diff --git a/.ai/lessons.md b/.ai/lessons.md index c8e986bd99..339fc7e60d 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -4,21 +4,27 @@ - Reproduce the reported failure on the unchanged baseline before adding a fix. - When a symptom matches an earlier fix, inspect that fix and prove why it no longer works before adding another workaround. - Test old reports against the current branch because later changes can make the report obsolete. +- Test serialized worker tasks with the real process driver. An in-process test does not prove that captured models or nested closures survive serialization. - Use the same regression test before and after the production change so the result shows the behavior difference. - Several dev instances can run from one checkout (see `./scripts/dev urls`). After you add a migration, run it on each running instance of that checkout, or those instances fail on the new code. - The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change. The dev instance of the checkout runs its live files, so a live repro also runs the uncommitted edits of other sessions: run `git diff` on the code under test before you trust the result. - Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail. - Files written on a server with `tee` get the server umask (027/077 on hardened hosts). Set the mode explicitly (`chmod 644`) for files that a non-root container user reads through a read-only bind mount. - `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior. +- Remote command batches run in `bash -se` (`sh -se` only when the host has no bash). A command that can fail without being an error (such as `cat` of an optional file) stops the batch unless it ends with `|| true`. `Process::fake()` cannot show this, so run such a command once in a real `bash -se` on the target host. - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Do not put `pkill -f "playwright run-server"` in the same shell command as other work; the pattern matches that shell and kills it. Run it as its own command. - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. -- Browser `click('Text')` can match a hidden element first (for example inactive modal steps) and wait forever; use `button:visible:has-text("Text")`. +- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. +- A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. - Do not symlink `vendor` into another worktree. Composer resolves the base path from the real `vendor` path, so the tests load the app classes of the other checkout. Run `composer install --ignore-platform-req=ext-zip` in the worktree. - To assert that the unsaved-changes bar shows, check `el.classList.contains('is-dirty')`. Playwright treats the hidden bar (`opacity-0`) as visible, and its class attribute always contains the text `is-dirty` from Tailwind variants. +- Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`. +- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`. - Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`. +- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely. ## Verify the complete user flow - Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure. @@ -110,9 +116,15 @@ - The parsers add sudo only to line starts (and after `&&`, `||`, `|`, `$(`). Put `if`/`else` branches on their own lines. When a redirect or `cd` must stay, make the whole line one `sh -c '...'` script; it runs as `sudo sh -c` without inner sudo or bash. - Tests that replace `DatabaseStartCommandExecutor` or use root servers miss non-root bugs. Test the parsed commands of a non-root server. +## Redact sensitive output before every sink +- Use one fail-closed redaction policy for database logs, framework logs, notifications, API output, exports, and MCP responses. +- Replace known resource secrets before storage; format detection is only a second layer. +- Keep read-time redaction for legacy records, but do not use it as the primary control. + ## Cached and stored state - `StandaloneDocker`/`SwarmDocker` `server` uses `Server::findCached()`, a per-process identity map that is flushed only when the `servers` row updates. A `ServerSetting` save does not flush it, so pass the current `Server` instance (`setRelation('server', $server)`) when generated output depends on just-saved settings. - Application deployments use the stored `custom_labels` when they exist. When a server-level setting changes the generated labels, regenerate the stored labels of read-only (`is_container_label_readonly_enabled`) applications, or a redeploy keeps the old labels. +- Tailwind `text-base` is a colour utility here (`--color-base`, near-black), not a font size. Use `text-[16px]` for size. ## Keep browser form restore away from app state - Firefox session restore writes saved input values back and fires a trusted `input` event. Add `autocomplete="off"` to inputs that only mirror client state (for example an Alpine-bound color picker), or their handlers run without a user action. diff --git a/.github/workflows/coolify-release.yml b/.github/workflows/coolify-release.yml index 9f83302ee9..70bb5d6af5 100644 --- a/.github/workflows/coolify-release.yml +++ b/.github/workflows/coolify-release.yml @@ -30,7 +30,8 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - name: Reject releases outside the production branch - if: ${{ github.ref_name != 'main' }} + # tmp: allow the 4.4.7 security release from fix/v4.4.7 + if: ${{ github.ref_name != 'main' && !startsWith(github.ref_name, 'fix/v') }} run: | echo "Stable releases must run from main, not ${{ github.ref_name }}." exit 1 diff --git a/.github/workflows/tmp-coolify-hotfix-release.yml b/.github/workflows/tmp-coolify-hotfix-release.yml new file mode 100644 index 0000000000..2ef14b3597 --- /dev/null +++ b/.github/workflows/tmp-coolify-hotfix-release.yml @@ -0,0 +1,159 @@ +# Temporary: releases the 4.0 to 4.3 security fixes from the v4.0.x to v4.3.x branches without moving latest. +# Delete this workflow once 4.5 ships. +name: TMP Release Coolify Hotfix +run-name: tmp ${{ inputs.branch }} + +on: + workflow_dispatch: + inputs: + branch: + description: Release line branch + required: true + type: choice + options: + - v4.3.x + - v4.2.x + - v4.1.x + - v4.0.x + +permissions: {} + +concurrency: + group: tmp-coolify-hotfix-release + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + DOCKER_REGISTRY: docker.io + IMAGE_NAME: coollabsio/coolify + +jobs: + validate: + runs-on: ubuntu-24.04 + permissions: + contents: read + outputs: + sha: ${{ steps.version.outputs.sha }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.branch }} + persist-credentials: false + + - name: Resolve version + id: version + env: + BRANCH: ${{ inputs.branch }} + GH_TOKEN: ${{ github.token }} + run: | + VERSION=$(docker run --rm -v "$(pwd):/app" -w /app php:8.2-alpine3.16 php bootstrap/getVersion.php) + if [[ "${VERSION}" != "${BRANCH:1:3}."* || ! "${VERSION}" =~ ^4\.[0-3]\.[0-9]+$ ]]; then + echo "Version ${VERSION} does not belong to ${BRANCH}." + exit 1 + fi + if gh release view "v${VERSION}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then + echo "Release v${VERSION} already exists." + exit 1 + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + build: + needs: validate + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.validate.outputs.sha }} + persist-credentials: false + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push image (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: . + file: docker/production/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + COOLIFY_VERSION=${{ needs.validate.outputs.version }} + labels: | + org.opencontainers.image.revision=${{ needs.validate.outputs.sha }} + tags: | + ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }} + ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:release-${{ needs.validate.outputs.version }}-${{ needs.validate.outputs.sha }}-${{ matrix.arch }} + + publish: + needs: [validate, build] + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to ${{ env.DOCKER_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.DOCKER_REGISTRY }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Publish the version tag + env: + VERSION: ${{ needs.validate.outputs.version }} + SHA: ${{ needs.validate.outputs.sha }} + run: | + for REGISTRY in "${GITHUB_REGISTRY}" "${DOCKER_REGISTRY}"; do + IMAGE="${REGISTRY}/${IMAGE_NAME}" + docker buildx imagetools create \ + "${IMAGE}:release-${VERSION}-${SHA}-amd64" \ + "${IMAGE}:release-${VERSION}-${SHA}-aarch64" \ + --tag "${IMAGE}:${VERSION}" + done + + - name: Publish the GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.validate.outputs.version }} + SHA: ${{ needs.validate.outputs.sha }} + run: | + gh release create "v${VERSION}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${SHA}" \ + --title "v${VERSION}" \ + --latest=false \ + --notes "Security fix release for Coolify ${VERSION%.*}. Apply it with: curl -fsSL https://cdn.coollabs.io/coolify/patch.sh | sudo bash" diff --git a/.github/workflows/tmp-php-base-image.yml b/.github/workflows/tmp-php-base-image.yml new file mode 100644 index 0000000000..f8c1839e33 --- /dev/null +++ b/.github/workflows/tmp-php-base-image.yml @@ -0,0 +1,134 @@ +# Temporary: builds PHP from the signed php.net release and rebuilds serversideup/php v4.5.1 on it for the 4.x security fixes. +# Delete this workflow and docker/tmp-php once 4.5 ships on serversideup/php v5. +name: TMP Build Patched PHP Base +run-name: tmp php ${{ inputs.php_version }} + +on: + workflow_dispatch: + inputs: + php_version: + description: Patched PHP 8.4 release, for example 8.4.27 + required: true + type: string + php_sha256: + description: SHA256 of php-.tar.xz from https://www.php.net/downloads.php + required: true + type: string + +permissions: + contents: read + packages: write + +concurrency: + group: tmp-php-base-image + cancel-in-progress: false + +env: + GITHUB_REGISTRY: ghcr.io + PHP_IMAGE: ghcr.io/coollabsio/php + BASE_IMAGE: ghcr.io/coollabsio/serversideup-php + # serversideup/docker-php v4.5.1 + SERVERSIDEUP_CONTEXT: https://github.com/serversideup/docker-php.git#92b16c9ee63bde4f75cdcc7ebe96aee36a896f88 + +jobs: + build: + strategy: + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: aarch64 + platform: linux/aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Validate inputs + env: + PHP_VERSION: ${{ inputs.php_version }} + PHP_SHA256: ${{ inputs.php_sha256 }} + run: | + if [[ ! "${PHP_VERSION}" =~ ^8\.4\.[0-9]+$ ]]; then + echo "Unsupported PHP version: ${PHP_VERSION}" + exit 1 + fi + if [[ ! "${PHP_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "Invalid SHA256: ${PHP_SHA256}" + exit 1 + fi + + - uses: actions/checkout@v5 + with: + persist-credentials: false + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build PHP from the signed release (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: docker/tmp-php + platforms: ${{ matrix.platform }} + push: true + build-args: | + PHP_VERSION=${{ inputs.php_version }} + PHP_SHA256=${{ inputs.php_sha256 }} + tags: ${{ env.PHP_IMAGE }}:${{ inputs.php_version }}-fpm-alpine3.24-${{ matrix.arch }} + + - name: Rebuild serversideup/php v4.5.1 on it (${{ matrix.arch }}) + uses: docker/build-push-action@v6 + with: + context: ${{ env.SERVERSIDEUP_CONTEXT }} + file: src/variations/fpm-nginx/Dockerfile + platforms: ${{ matrix.platform }} + push: true + build-args: | + BASE_IMAGE=${{ env.PHP_IMAGE }}:${{ inputs.php_version }}-fpm-alpine3.24-${{ matrix.arch }} + PHP_VERSION=8.4 + BASE_OS_VERSION=alpine3.24 + PHP_VARIATION=fpm-nginx + NGINX_VERSION=1.30.4-r1 + REPOSITORY_BUILD_VERSION=v4.5.1-php${{ inputs.php_version }} + tags: ${{ env.BASE_IMAGE }}:${{ inputs.php_version }}-fpm-nginx-alpine-v4.5.1-${{ matrix.arch }} + + - name: Check the PHP version (${{ matrix.arch }}) + env: + IMAGE: ${{ env.BASE_IMAGE }}:${{ inputs.php_version }}-fpm-nginx-alpine-v4.5.1-${{ matrix.arch }} + PHP_VERSION: ${{ inputs.php_version }} + run: | + BUILT_VERSION=$(docker run --rm --entrypoint php "${IMAGE}" -r 'echo PHP_VERSION;') + if [ "${BUILT_VERSION}" != "${PHP_VERSION}" ]; then + echo "The base image runs PHP ${BUILT_VERSION}, expected ${PHP_VERSION}." + exit 1 + fi + + publish: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: docker/setup-buildx-action@v3 + + - name: Login to ${{ env.GITHUB_REGISTRY }} + uses: docker/login-action@v3 + with: + registry: ${{ env.GITHUB_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish multi-arch images + env: + PHP_VERSION: ${{ inputs.php_version }} + run: | + docker buildx imagetools create \ + "${PHP_IMAGE}:${PHP_VERSION}-fpm-alpine3.24-amd64" \ + "${PHP_IMAGE}:${PHP_VERSION}-fpm-alpine3.24-aarch64" \ + --tag "${PHP_IMAGE}:${PHP_VERSION}-fpm-alpine3.24" + docker buildx imagetools create \ + "${BASE_IMAGE}:${PHP_VERSION}-fpm-nginx-alpine-v4.5.1-amd64" \ + "${BASE_IMAGE}:${PHP_VERSION}-fpm-nginx-alpine-v4.5.1-aarch64" \ + --tag "${BASE_IMAGE}:${PHP_VERSION}-fpm-nginx-alpine-v4.5.1" \ + --tag "${BASE_IMAGE}:8.4-fpm-nginx-alpine-v4.5.1-hotfix" diff --git a/AGENTS.md b/AGENTS.md index ce9efdde49..3016656c89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,22 @@ Because the "server" and the test share one PHP process, they share the phpunit - **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources. - **Proxy** — Traefik reverse proxy managed per server. +### Server transfer (keep it complete) + +A server transfer moves a server and every record that belongs to it to another Coolify instance: `ServerTransferExporter` writes the bundle, `ServerTransferImporter` reads it (`app/Services/ServerTransfer/`). `ServerTransferCoverage` records the transfer decision for every model and every column. + +- When you add a model, a column, or a relation that belongs to a server or to a resource on a server (application, preview, database, service, volume, environment variable, backup, scheduled task, tag, or setting), decide whether a transfer moves it: + - **Moved:** export and import it, and add a round-trip assertion to `tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php`. For a new model, also add it to `ServerTransferCoverage::TRANSFERRED`. + - **Not moved:** add the model to `ServerTransferCoverage::NOT_TRANSFERRED`, or the column to `NOT_EXPORTED_COLUMNS`, with the reason. If the data is lost for the user, also add an export warning. +- `tests/Unit/ServerTransfer/ServerTransferCoverageTest.php` exports a server with one record of every transferred model. It fails when a model or a column has no decision. When you add a transferred model, add a record of it to the fixture of that test. +- `docs/server-transfer-full-coverage.md` lists what to set up on a source server for a manual transfer test that covers every transferred model and setting. Add new transferred models and settings to it. +- `scripts/transfer-test/` has live end-to-end transfer tests between two dev instances (see its README). Run them after a change to the transfer flow. When you add transferred data that the unit tests cannot prove (remote commands, provider calls, queue behavior), add a check to `extras-e2e.sh`. +- Application, Service, and the standalone databases export their `$fillable` columns, plus hand-written lists for hidden columns. A column that is not fillable is lost unless you add it to those lists. +- Do not copy a foreign key as a raw ID. IDs are different on the target. Link the record by UUID, or leave the column out with a reason. +- Team credentials that a moved record needs (integration tokens, S3 storages, git sources, private keys, cloud provider tokens) move with it. +- When the user must do a step after the transfer (for example, change a webhook URL at GitHub), add an item type to `TransferFinishChecklist`: the "Finish the transfer" list on the server page of the instance that manages the server now. Give it a check where Coolify can verify the step, and a fix where it can do the step. Also add it to the `post_transfer_checklist` of the bundle (`{target_url}` stands for the URL of the target), which the source shows as text. +- On the source, a transferred server (`Server::isTransferredAway()`) is only an archive, because another instance manages the host. Do not run remote commands, cloud provider calls, or DNS provider calls for it, also not when the archive is deleted. + ### Instance sentinels (`id = 0`) Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id. diff --git a/DESIGN.md b/DESIGN.md index 1226dd0587..ef61480091 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,9 +1,415 @@ # Coolify UI design system -This document defines Coolify's UI design system for its Livewire + Blade + -Alpine + Tailwind v4 frontend. The visual system covers the global shell, -project and environment pages, application navigation, settings surfaces, -tables, modals, toasts, terminals, and metrics. +This file has two parts: + +- **Part 1, Portable design language:** the look and feel of the Coolify UI, + with no dependency on Coolify code. It has the principles, a copy-paste token + sheet in plain CSS, and the anatomy of each component with exact values. Use + it to give another app the same feel. It works with any stack (plain CSS, + Tailwind, React, Vue, Blade). +- **Part 2, Coolify implementation:** how Coolify applies the language with its + Livewire + Blade + Alpine + Tailwind v4 frontend: the components, routes, page + shells, and the rules for this repository. + +When you change a shared value in Coolify, update Part 1 and Part 2 in the same +change. + +--- + +# Part 1. Portable design language + +## P1. The feel + +- **Compact and calm.** 13–14px UI text, 32px controls, small radii, short + labels. The data is the hero, not the chrome. +- **Layers, not boxes.** Depth comes from a few surface steps (canvas, card, + well, control), 1px borders, and very soft shadows. Never use thick borders or + heavy floating shadows. +- **Card and well.** Content sits in a darker inset panel (the well) inside a + soft outer box with a small title row. +- **Tactile buttons.** Buttons and navigation icon tiles have a 2px bottom + edge. They lift 1px on hover and press down on click. +- **Dark gray, not black.** Dark mode is a pure neutral dark gray (page + canvas about sRGB 10,10,10). Light mode is pure neutral + gray. +- **One accent per theme.** Purple in light mode, yellow in dark mode (purple + has too little contrast on dark surfaces). Everything else is neutral. +- **Quiet text.** Sentence case. A clear text ladder: primary, dim, faint. + No em dashes in UI copy. + +## P2. Token sheet + +Copy this sheet into the app's global CSS. Put the dark values on the selector +the app uses for dark mode (here `.dark` on ``). Use the tokens in every +component; do not hard-code a gray in a component, because then it does not +follow the theme. + +```css +:root { + /* Type */ + --ui-font-sans: 'Geist Sans', Inter, system-ui, sans-serif; + --ui-font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + + /* Surfaces (light: pure neutral) */ + --ui-canvas: oklch(98.5% 0 0); /* page background and sidebar */ + --ui-panel: #ffffff; /* top header bar */ + --ui-card-bg: rgb(0 0 0 / 0.75%); /* card outer box */ + --ui-card-border: rgb(0 0 0 / 9%); + --ui-well-bg: #ffffff; /* card body (inset well) */ + --ui-well-border: rgb(0 0 0 / 8%); + --ui-well-shadow: 0 1px 2px rgb(0 0 0 / 6%); + --ui-control-bg: oklch(96% 0 0); /* inputs, select triggers */ + --ui-control-border: oklch(14.5% 0 0 / 0.1); + --ui-menu-bg: oklch(96% 0 0); /* dropdown, listbox, popover (opaque) */ + --ui-menu-border: oklch(14.5% 0 0 / 0.1); + --ui-hover: oklch(92.2% 0 0); /* menu option and row hover */ + --ui-divider: oklch(92.2% 0 0); + --ui-chrome-border: rgb(0 0 0 / 6%); /* sidebar and header edges */ + + /* Text */ + --ui-fg: oklch(14.5% 0 0); + --ui-fg-dim: oklch(40% 0 0); /* card titles */ + --ui-fg-faint: oklch(50% 0 0); /* labels, descriptions, meta */ + + /* Accent and status */ + --ui-accent: oklch(49.65% 0.2709 289.33); /* purple */ + --ui-accent-top: oklch(52.34% 0.287 289.16); /* primary button gradient */ + --ui-accent-bottom: oklch(43.76% 0.2369 289.82); + --ui-accent-edge: oklch(38.04% 0.2031 290.47); /* primary button depth edge */ + --ui-accent-fg: #ffffff; + --ui-success: oklch(48% 0.135 149.58); + --ui-warning: oklch(50% 0.105 80); + --ui-error: oklch(52% 0.2 27.33); + + /* Depth */ + --ui-card-shadow: 0 1px 2px rgb(0 0 0 / 5%); + --ui-menu-shadow: 0 4px 12px rgb(0 0 0 / 0.12), 0 2px 4px rgb(0 0 0 / 0.08); + --ui-modal-shadow: 0 24px 64px rgb(0 0 0 / 0.55), 0 4px 16px rgb(0 0 0 / 0.4); + --ui-depth-color: rgb(0 0 0 / 0.22); /* button and tile bottom edge */ + + /* Motion */ + --ui-ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ui-ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); +} + +.dark { + /* Surfaces (dark: pure neutral dark gray) */ + --ui-canvas: oklch(14.48% 0 0); /* sRGB 10,10,10 */ + --ui-panel: oklch(19.13% 0 0); /* sRGB 20,20,20: header bar, card and table headers */ + --ui-card-bg: rgb(255 255 255 / 6%); + --ui-card-border: rgb(255 255 255 / 8%); + --ui-well-bg: rgb(0 0 0 / 34%); + --ui-well-border: rgb(255 255 255 / 7%); + --ui-well-shadow: 0 1px 2px rgb(0 0 0 / 25%); + --ui-control-bg: rgb(255 255 255 / 3%); + --ui-control-border: rgb(255 255 255 / 8%); + --ui-menu-bg: oklch(25.2% 0 0); + --ui-menu-border: oklch(36% 0 0); + --ui-hover: oklch(29.31% 0 0); + --ui-divider: rgb(255 255 255 / 7%); + --ui-chrome-border: rgb(255 255 255 / 4%); + + /* Text */ + --ui-fg: oklch(96.12% 0 0); + --ui-fg-dim: oklch(76.99% 0 0); + --ui-fg-faint: oklch(65% 0 0); + + /* Accent and status: yellow accent in dark mode */ + --ui-accent: oklch(88.13% 0.1507 91.7); + --ui-success: oklch(72.27% 0.192 149.58); + --ui-warning: oklch(88.13% 0.1507 91.7); + --ui-error: oklch(66% 0.2 27.33); + + --ui-depth-color: rgb(255 255 255 / 0.08); +} +``` + +Rules for the tokens: + +- Translucent surfaces (`--ui-card-bg`, `--ui-well-bg`, `--ui-control-bg`) take + the color of what is below them. +- Floating surfaces (menus, popovers, tooltips, modals) must be opaque. Use + `--ui-menu-bg`, never a translucent value, or the content behind shows + through. +- Keep the dark surfaces pure neutral (chroma 0). A brand tint on the + surfaces was tried and removed. +- **Dark lightness level.** The canvas is at 14.48% and the header panel at + 19.13%. To + change the level, move every dark surface and border token by the same + amount (canvas, panel, menu, hover, borders, and the Coolify surface ladder), + so the steps between layers stay the same. If you move only some of them, a + hover fill can become darker than its menu. +- The primary buttons use purple in both themes. Only focus rings, active + navigation, and other accent states change to yellow in dark mode. + +## P3. Scales + +| Scale | Values | Use | +|---|---|---| +| Text | 11px | meta, section labels, card descriptions, header links | +| | 12px | badges, small descriptions, code snippets | +| | 13px | navigation, buttons, table cells, card titles (semibold) | +| | 14px | inputs, menu options, empty-state titles | +| | 24px | page titles (with a 13px faint summary below) | +| Weight | 500 / 600 | 500 for labels and buttons, 600 for titles | +| Height | 24px | badges | +| | 32px | buttons, inputs, select triggers, menu options | +| | 36px | navigation rows with tiles | +| | 40px / 48px | table header / table rows | +| Radius | 6px | menu options | +| | 8px | buttons, inputs, card wells, navigation tiles | +| | 10px | menus and popovers (6px options + 4px inset) | +| | 12px | card outer box | +| | full | badges, pills | +| Spacing | 6px | card outer padding | +| | 16px | well padding, page gutters on small screens | +| | 24px | gap between cards (use a flex or grid gap, not margins) | +| Motion | 80ms | button lift and press | +| | 120ms | color, border, and focus changes | +| | 150ms | menu swaps, row hover | + +Nested boxes normally follow `outer radius = inner radius + inset`. The card +(12px outer, 8px well, 6px inset) is an accepted exception. + +## P4. Components + +Class names below are examples. Keep the values and structure. + +### App shell + +- Three layers: the canvas (page and sidebar, `--ui-canvas`), the top header + bar (`--ui-panel`, 48px high), and the cards on the canvas. +- In dark mode, the header bar, the outer box of a top-level card, and the + header row of a table use the same color (`--ui-panel`). The card wells and + table rows are the darker content layer below them. +- The sidebar is the canvas color and has a 1px `--ui-chrome-border` right + edge. The brand block (product name and version) at the top of the sidebar + column also uses the canvas color, with a 1px bottom border. +- Keep the chrome borders faint. A strong border on near-black looks like a + line drawn on the page. + +### Sidebar navigation + +```css +.nav-section { padding: 4px 10px; font-size: 11px; font-weight: 500; color: var(--ui-fg-faint); } +.nav-item { + display: flex; align-items: center; gap: 10px; + height: 36px; padding: 0 10px 0 4px; + font-size: 13px; font-weight: 500; color: var(--ui-fg-dim); + background: transparent; /* the row itself never fills */ +} +.nav-item:hover, .nav-item.is-active { color: var(--ui-fg); } +.nav-tile { /* 30px tile: 16px icon + 6px padding + 1px border */ + box-sizing: content-box; width: 16px; height: 16px; padding: 6px; + border: 1px solid rgb(0 0 0 / 9%); border-radius: 8px; background: #fafafa; + box-shadow: 0 2px 0 var(--ui-depth-color); + transition: transform 80ms, box-shadow 80ms, border-color 120ms, background-color 120ms; +} +.dark .nav-tile { border-color: rgb(255 255 255 / 9%); background: rgb(255 255 255 / 4%); } +.nav-item:hover .nav-tile { transform: translateY(-1px); box-shadow: 0 3px 0 var(--ui-depth-color); } +.nav-item:active .nav-tile { transform: translateY(2px); box-shadow: none; } +.nav-item.is-active .nav-tile { + color: var(--ui-accent); + border-color: color-mix(in oklab, var(--ui-accent) 45%, transparent); + --ui-depth-color: color-mix(in oklab, var(--ui-accent) 45%, transparent); +} +``` + +- Use outline icons (1.5px stroke) at 16px. A brand item can use a one-color + brand mark when it is clearer than an outline icon. +- Rows with a submenu end in a 12px chevron: accent in light mode, faint gray + in dark mode. +- Group rows under 11px section labels, and separate groups with a 1px divider. + +### Card (outer box, title row, well) + +```css +.card { + display: flex; flex-direction: column; + border: 1px solid var(--ui-card-border); border-radius: 12px; padding: 6px; + background: var(--ui-card-bg); box-shadow: var(--ui-card-shadow); + overflow: visible; /* menus and tooltips must escape */ +} +.dark .card { background: var(--ui-panel); } /* top-level card: same as the header bar */ +.card-header { + display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; + gap: 16px; padding: 4px 8px 10px; +} +.card-title { font-size: 13px; font-weight: 600; line-height: 20px; color: var(--ui-fg-dim); } +.card-description { margin-top: 2px; font-size: 11px; line-height: 16px; color: var(--ui-fg-faint); } +.card-header a { font-size: 11px; font-weight: 500; color: var(--ui-fg-faint); } +.card-header a:hover { color: var(--ui-fg); } +.card-well { + position: relative; padding: 16px; + border: 1px solid var(--ui-well-border); border-radius: 8px; + background: var(--ui-well-bg); box-shadow: var(--ui-well-shadow); +} +.card-well.is-flush { padding: 0; } /* tables and lists that touch the edge */ +``` + +- Card actions (buttons, badges, a filter) go in the title row on the right. +- Keep one collection in one card: its toolbar at the top of a flush well, its + table below, and its pagination in the same well. + +### Inputs and select triggers + +```css +.input { + display: block; width: 100%; height: 32px; padding: 0 12px; + border: 1px solid var(--ui-control-border); border-radius: 8px; + background: var(--ui-control-bg); color: var(--ui-fg); font-size: 14px; + transition: border-color 120ms var(--ui-ease-out), box-shadow 120ms var(--ui-ease-out); +} +.input::placeholder { color: var(--ui-fg-faint); } +.input:focus-visible { outline: none; border-color: var(--ui-accent); } +.label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 500; color: var(--ui-fg-dim); } +``` + +- Use a custom listbox, not the native `) */ .listbox-trigger { display: flex; @@ -2514,6 +2706,14 @@ input[type="search"]::-webkit-search-results-decoration { padding: 0; } +/* The well does not clip (floating panels must escape it), so a filled table + header row needs the well's inner top radius, or its corners poke out. */ +.application-settings-section-body.is-flush > :first-child, +.application-settings-section-body.is-flush > :first-child > :first-child { + border-top-left-radius: calc(0.5rem - 1px); + border-top-right-radius: calc(0.5rem - 1px); +} + /* Empty states inside cards keep the same inset as other card content. */ .application-settings-section-body:has(> .empty-state:only-child), .application-settings-section-body.is-flush:has(> .empty-state:only-child), @@ -2521,8 +2721,54 @@ input[type="search"]::-webkit-search-results-decoration { padding: 1rem; } +/* Standalone empty state: the same outer box and inset well as a layer card */ .empty-state { - background: var(--coollabs-base); + border: 1px solid var(--card-border); + border-radius: 0.75rem; + padding: 0.375rem; + background: var(--card-bg); + box-shadow: 0 1px 2px rgb(0 0 0 / 5%); +} + +.empty-state-well { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 1px solid var(--well-border); + border-radius: 0.5rem; + background: var(--well-bg); + box-shadow: var(--well-shadow); + padding: 2rem 1.5rem; + text-align: center; +} + +.empty-state-title { + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + color: var(--card-fg); +} + +.empty-state-description { + margin-top: 0.25rem; + max-width: 28rem; + line-height: 1.25rem; + color: var(--coollabs-subtle); +} + +/* Inside a layer card or a well, that box is the frame: show only the centered text. */ +:is(.application-settings-section, .application-settings-section-body) .empty-state, +:is(.application-settings-section, .application-settings-section-body) .empty-state-well { + border: 0; + border-radius: 0; + padding-inline: 0; + background: none; + box-shadow: none; +} + +:is(.application-settings-section, .application-settings-section-body) .empty-state { + padding: 0; } .loading-state-card { @@ -2603,6 +2849,7 @@ input[type="search"]::-webkit-search-results-decoration { .dark .data-table-header { background: rgba(255, 255, 255, 0.02); + color: var(--color-fg-faint); } .data-table > .env-table-item { @@ -2634,6 +2881,44 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(14rem, 2.5fr) 4.8rem 6rem 4rem 4.5rem 4.8rem 4.2rem 3rem; } +/* File browser: icon, name, perms, size, modified, actions. + Every column is a fixed width except the flexible name column, so the + header grid and each row grid resolve identical tracks and stay aligned + (an `auto` column would size to its own content per row and drift). */ +.file-table-grid { + grid-template-columns: 1.75rem minmax(10rem, 1fr) 5rem 6rem 6rem 5rem 11rem 10rem; +} + +@media (max-width: 768px) { + .data-table-header.file-table-grid { + display: none; + } + + .data-table-row.file-table-grid { + grid-template-columns: 1.75rem minmax(0, 1fr) 10rem; + gap: 0.75rem; + } + + /* Hide perms, owner, group, size and modified columns on small screens */ + .data-table-row.file-table-grid > :nth-child(3), + .data-table-row.file-table-grid > :nth-child(4), + .data-table-row.file-table-grid > :nth-child(5), + .data-table-row.file-table-grid > :nth-child(6), + .data-table-row.file-table-grid > :nth-child(7) { + display: none; + } +} + +/* File browser Monaco editor container: fixed viewport-relative height with a + rounded, hairline-framed surface that matches the rest of the settings UI. */ +.file-editor-monaco { + height: 60vh; + min-height: 320px; + overflow: hidden; + border-radius: 8px; + box-shadow: inset 0 0 0 1px var(--coollabs-line); +} + .environment-table-scroll { overflow-x: auto; overscroll-behavior-x: contain; @@ -3136,8 +3421,20 @@ input[type="search"]::-webkit-search-results-decoration { } } +@media (min-width: 1536px) { + .deployment-table-embedded .deployment-table-grid { + grid-template-columns: 6.5rem minmax(0, 1fr) 6.5rem; + } + + .deployment-table-embedded .deployment-table-grid > :nth-child(2), + .deployment-table-embedded .deployment-table-grid > :nth-child(n + 5) { + display: none; + } +} + +/* Status and Started only fit a badge and a short "3 hours ago"; the names get the rest. */ .dashboard-deployment-table-grid { - grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(6.25rem, 0.75fr) 10rem 8rem; + grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 0.7fr) 6.5rem 6rem; } .team-members-table-grid { @@ -3156,6 +3453,26 @@ input[type="search"]::-webkit-search-results-decoration { grid-template-columns: minmax(9rem, 1.15fr) minmax(14rem, 1.9fr) 7rem 6.5rem 6.5rem 5.5rem; } +.fleet-servers-table-grid { + grid-template-columns: minmax(9rem, 1.4fr) 8rem 8rem 8rem 5rem 8rem 5.5rem; +} + +.fleet-containers-table-grid { + grid-template-columns: 2.25rem minmax(9rem, 1.7fr) minmax(6rem, 0.9fr) minmax(8.5rem, 1fr); +} + +.scheduled-executions-table-grid { + grid-template-columns: 5.5rem minmax(9rem, 1fr) minmax(7rem, 0.7fr) 8.5rem 5rem minmax(12rem, 1.5fr); +} + +.scheduler-runs-table-grid { + grid-template-columns: 9rem minmax(12rem, 1.5fr) 6rem 6rem 6rem; +} + +.skipped-jobs-table-grid { + grid-template-columns: 9rem 7rem minmax(10rem, 1fr) minmax(12rem, 1.4fr); +} + .server-resources-managed-table-grid { grid-template-columns: minmax(10rem, 1.3fr) minmax(8rem, 1fr) minmax(8rem, 1fr) 8rem 9.5rem; } @@ -3178,15 +3495,20 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-executions-table-grid { - grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 10rem 5rem 4rem minmax(8rem, 1fr) 5rem; + grid-template-columns: 6.5rem minmax(7rem, 0.8fr) minmax(14rem, 1.5fr) 10rem 5rem minmax(8rem, 1fr) 5rem; gap: 0.75rem; - min-width: 69rem; + min-width: 67.5rem; } .backup-executions-table-scroll { overflow-x: auto; } +/* Match the grid width so the log output spans the full scrolled row. */ +.backup-execution-row { + min-width: 67.5rem; +} + .backup-execution-row:last-child > .data-table-row { border-bottom: 0; } @@ -3239,11 +3561,10 @@ input[type="search"]::-webkit-search-results-decoration { } .backup-executions-table-grid { - grid-template-columns: 7.5rem minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) 10rem 6rem minmax(9rem, auto); + grid-template-columns: 7.5rem minmax(9rem, 0.8fr) minmax(14rem, 1.5fr) 10rem minmax(8rem, 1fr) 5rem; } - .backup-executions-table-grid > :nth-child(6), - .backup-executions-table-grid > :nth-child(7) { + .backup-executions-table-grid > :nth-child(5) { display: none; } } @@ -3323,15 +3644,18 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } + .backup-executions-table-grid, + .backup-execution-row { + min-width: 42rem; + } + .backup-executions-table-grid { grid-template-columns: 7.5rem 9rem minmax(14rem, 1fr) minmax(7rem, auto); - min-width: 42rem; } .backup-executions-table-grid > :nth-child(4), .backup-executions-table-grid > :nth-child(5), - .backup-executions-table-grid > :nth-child(6), - .backup-executions-table-grid > :nth-child(7) { + .backup-executions-table-grid > :nth-child(6) { display: none; } @@ -5166,3 +5490,522 @@ a.command-palette-item:focus-visible { height: 2.5rem; } } + +/* Navigation items: each sidebar icon sits in a framed 30px tile with the 3D + depth of .button, like the canvas tool rail. Only the tile reacts; the row + has no background. Applies to both navigation styles. Appearance → + Navigation items → Flat sets data-nav-items="flat" on and turns these + rules off, which brings back the plain rows. */ +html:where(:not([data-nav-items="flat"])) .menu-item:not(.sidebar-toggle) { + height: 2.25rem; + padding-left: 0.25rem; +} + +/* In-page section rows have no icon tile, so they stay compact. */ +html:where(:not([data-nav-items="flat"])) .menu-subitem { + height: 1.75rem; +} + +/* Only the icon tile reacts: no row background on hover or for the current + item; the current item shows through its tile and its brighter label. + In the utilities layer so it can beat the search row's !bg-* utilities + (a layered !important wins over an unlayered one). */ +@layer utilities { + /* The submenu Back row (navbar) keeps its fill. */ + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle, .menu-back), + html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle, .menu-back):hover { + background-color: transparent !important; + } + + /* The expanded Search field keeps its input fill (see .menu-search below). */ + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: var(--color-neutral-200) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:hover { + background-color: oklch(100% 0 0 / 0.07) !important; + } + + @media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: var(--color-neutral-100) !important; + } + + html.dark:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item { + background-color: oklch(100% 0 0 / 0.04) !important; + } + } +} + +/* The current item's tile takes the accent, like the active tab pill. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-coollabs); + border-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-coollabs) 45%, transparent) !important; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + color: var(--color-warning); + border-color: color-mix(in oklab, var(--color-warning) 40%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-warning) 35%, transparent) !important; +} + +/* Danger Zone icons () stay red, also when active. */ +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon.text-error { + color: var(--color-error); + border-color: color-mix(in oklab, var(--color-error) 45%, transparent) !important; + --button-depth-color: color-mix(in oklab, var(--color-error) 40%, transparent) !important; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible { + outline: none; +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):focus-visible .menu-item-icon { + outline: 1px solid var(--color-accent); + outline-offset: 2px; +} + +/* Sidebar menu swap: a deeper menu (submenu, resource menu) enters from the right, a lower one from the left. + CSS animations restart when an element is shown again, so x-show replays them on each swap. */ +@keyframes nav-swap-forward { + from { + opacity: 0; + transform: translateX(0.5rem); + } +} + +@keyframes nav-swap-back { + from { + opacity: 0; + transform: translateX(-0.5rem); + } +} + +.nav-swap-forward { + animation: nav-swap-forward 150ms ease-out; +} + +.nav-swap-back { + animation: nav-swap-back 150ms ease-out; +} + +@media (prefers-reduced-motion: reduce) { + .nav-swap-forward, + .nav-swap-back { + animation: none; + } +} + +/* Submenu Back chevron box: one icon wide (flat), or one icon tile wide (16px + padding + border). */ +.menu-back-icon { + width: 1rem; + height: 1rem; +} + +html:where(:not([data-nav-items="flat"])) .menu-back-icon { + width: calc(1rem + 0.75rem + 2px); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + box-sizing: content-box; + width: 1rem; + height: 1rem; + padding: 0.375rem; + border: 1px solid oklch(0% 0 0 / 0.09); + border-radius: 8px; + background: #fafafa; + opacity: 1; + /* Same 3D depth as .button: rests on a 2px ledge, lifts on hover, presses on click. */ + --button-depth-color: rgb(0 0 0 / 0.22); + box-shadow: 0 2px 0 var(--button-depth-color); + transition: border-color 120ms, background-color 120ms, box-shadow 80ms, transform 80ms; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + --button-depth-color: rgb(255 255 255 / 0.08); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon { + transform: translateY(-1px); + box-shadow: 0 3px 0 var(--button-depth-color); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):active .menu-item-icon { + transform: translateY(2px); + box-shadow: none; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.09); + background: oklch(100% 0 0 / 0.04); +} + +html:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(0% 0 0 / 0.18); + background: #fff; +} + +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item, .menu-subitem):not(.sidebar-toggle):hover .menu-item-icon, +html.dark:where(:not([data-nav-items="flat"])) :is(.menu-item-active, .menu-subitem-active) .menu-item-icon { + border-color: oklch(100% 0 0 / 0.18); + background: oklch(100% 0 0 / 0.08); +} + +/* Search keeps its original input look while the sidebar is expanded (always on + mobile, where the rail never collapses). Only the collapsed rail shows the 3D tile. + The icon resets use .menu-search.menu-item to beat the html.dark tile rules. */ +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search { + height: 2rem; + padding-left: 0.625rem; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item .menu-item-icon, +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; +} + +html:where(:not([data-nav-items="flat"])) nav:not(.sidebar-collapsed) .menu-search:focus-visible { + outline: 1px solid var(--color-accent); +} + +@media (max-width: 1023.98px) { + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search { + height: 2rem; + padding-left: 0.625rem; + } + + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item .menu-item-icon, + html:where(:not([data-nav-items="flat"])) nav.sidebar-collapsed .menu-search.menu-item:is(:hover, :active, :focus-visible) .menu-item-icon { + box-sizing: border-box; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; + transform: none; + outline: none; + opacity: 0.9; + } +} + +@media (min-width: 1024px) { + /* The collapsed rail is 64px wide with 40px of content: center the 30px tile. */ + html:where(:not([data-nav-items="flat"])) .sidebar-collapsed .menu-item:not(.sidebar-toggle) { + padding-left: 0.3125rem; + padding-right: 0.3125rem; + } +} + +/* Secondary navigation (settings rails): text-only rows with a filled pill for + the current item, like Sentry. Only the main sidebar keeps the icon tiles. */ +html .application-settings-navigation :is(.menu-item, .menu-subitem) { + height: 2rem; + padding-left: 0.625rem; +} + +html .application-settings-navigation .menu-item-icon { + display: none; +} + +/* In the utilities layer to beat the transparent row rule above. */ +@layer utilities { + html .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(0% 0 0 / 0.04) !important; + } + + html.dark .application-settings-navigation :is(.menu-item, .menu-subitem):hover { + background-color: oklch(100% 0 0 / 0.05) !important; + } + + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(0% 0 0 / 0.06) !important; + } + + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active), + html.dark .application-settings-navigation :is(.menu-item-active, .menu-subitem-active):hover { + background-color: oklch(100% 0 0 / 0.08) !important; + } +} + +/* Canvases at full zoom: the grid dots turn into small Coolify logos, one + per 20px tile. setCanvasDots() (environment-canvas.js) sets .canvas-zoom-max. */ +.canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(0 0 0 / 0.13)'/%3E%3C/svg%3E"); +} + +html.dark .canvas-zoom-max { + --canvas-dots-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath transform='translate(8 8) scale(0.0125)' d='M64 256H0V64h64Zm0-192V0h256v64Zm0 192h256v64H64Z' fill='rgb(255 255 255 / 0.09)'/%3E%3C/svg%3E"); +} + +.canvas-zoom-max [data-ec-viewport] { + background-image: var(--canvas-dots-image); +} + +/* "Floating" navigation (Appearance → Navigation style): the top bar, sidebar, + tab strip, and settings rail float as framed panels over a dotted backdrop, + like the canvas islands. Set on by applyStoredTheme() from + localStorage.navStyle; the default style keeps edge-to-edge bars. */ +@media (min-width: 1024px) { + html[data-nav-style="floating"] { + --floating-gap: 0.5rem; + --floating-radius: 8px; + --floating-line: oklch(0% 0 0 / 0.09); + /* Bottom edge of the top bar panel plus one gap. */ + --floating-top: calc(3rem + var(--floating-gap) * 2); + } + + html.dark[data-nav-style="floating"] { + --floating-line: oklch(100% 0 0 / 0.08); + --floating-dot: rgb(255 255 255 / 0.09); + } + + /* Dotted backdrop behind the floating panels and in the gaps between them, + like the canvas stage. Fixed so the dots stay still while content scrolls. */ + html[data-nav-style="floating"] .app-shell { + background-image: radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1.3px); + background-size: 20px 20px; + background-attachment: fixed; + } + + html[data-nav-style="floating"] .app-main { + background-color: transparent; + } + + html[data-nav-style="floating"] .app-topbar { + background-color: #fff; + } + + html.dark[data-nav-style="floating"] .app-topbar { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] :is(.app-topbar, .app-sidebar > div > nav) { + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* The top bar hugs its content (brand, breadcrumb, docked actions) + instead of spanning an empty full-width panel. */ + html[data-nav-style="floating"] .app-topbar { + top: var(--floating-gap); + left: var(--floating-gap); + right: auto; + max-width: calc(100vw - var(--floating-gap) * 2); + } + + /* The bar is opaque here, so it needs no blur. Without the backdrop filter + the bar is no longer the containing block for fixed children, which lets + the resource actions panel below sit against the screen edge. */ + html[data-nav-style="floating"] .app-topbar { + backdrop-filter: none; + } + + /* The panel border replaces the inner dividers of the top bar. */ + html[data-nav-style="floating"] .app-topbar > div { + border-bottom-width: 0; + } + + html[data-nav-style="floating"] .app-topbar > .app-topbar-brand { + border-radius: calc(var(--floating-radius) - 1px) 0 0 calc(var(--floating-radius) - 1px); + } + + html[data-nav-style="floating"] .app-sidebar { + top: var(--floating-top); + bottom: var(--floating-gap); + left: var(--floating-gap); + } + + html[data-nav-style="floating"] .app-sidebar > div > nav > .sidebar-footer { + border-radius: 0 0 var(--floating-radius) var(--floating-radius); + } + + html[data-nav-style="floating"] .app-main { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap)); + /* The content top lines up with the sidebar and settings rail panels. + The cards draw their frame as a 1px ring outside the box, while the + panels use a 1px border inside it, so add 1px to match the edges. */ + padding-top: calc(var(--floating-top) + 1px); + } + + /* Full-bleed pages (canvases): no top padding gap, and the same gap as the panels. */ + html[data-nav-style="floating"] .app-main.app-main-full { + margin-left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + padding: var(--floating-top) var(--floating-gap) var(--floating-gap) 0; + } + + /* Canvases fill the space under the top bar too, and the canvas root + paints the canvas dots (set by the canvas JS) behind the top bar, so + the dots are the same there and on the canvas. */ + html[data-nav-style="floating"] .app-main.app-main-full:has(:is([data-ec-root], .cv-root)) { + padding-top: 0; + } + + /* The resource canvases fill the whole screen behind the floating panels + (top bar, toolbar, sidebar), so the panels do not cut off the cards. */ + html[data-nav-style="floating"] .app-main.app-main-full:has([data-ec-root]) { + margin-left: 0; + padding: 0; + } + + html[data-nav-style="floating"] .app-main-full [data-ec-root] { + padding: 0 !important; + } + + html[data-nav-style="floating"] .app-main-full :is([data-ec-root], .cv-root) { + padding-top: calc(var(--floating-top) + 0.5rem); + background-image: var(--canvas-dots-image, radial-gradient(circle, var(--floating-dot, rgb(0 0 0 / 0.13)) 1px, transparent 1px)); + background-size: var(--canvas-dots-size, 20px 20px); + background-position: var(--canvas-dots-position, 0 0); + } + + /* The deploy canvas Steps panel starts at the same height as the sidebar. */ + html[data-nav-style="floating"] .app-main-full .cv-root { + padding-top: var(--floating-top); + } + + html[data-nav-style="floating"] .app-main-full [data-ec-viewport] { + background-image: none; + } + + /* Full page width: the content is one gap from the sidebar and the screen edge. */ + html[data-nav-style="floating"] .app-main.app-main-wide { + padding-right: var(--floating-gap); + padding-left: var(--floating-gap); + padding-bottom: var(--floating-gap); + } + + /* Dashboard canvas Deployments panel: one gap from the floating sidebar and the screen bottom. */ + /* Bottom-left zoom controls and deployments: the canvas fills the screen behind the sidebar, so start one gap after it. */ + html[data-nav-style="floating"] .app-main-full [data-ec-dock] { + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + bottom: var(--floating-gap); + } +} + +@media (min-width: 1280px) { + /* Resource actions (Links, Deploy, …) float as their own panel at the top + right. The top bar panel stops one gap before it (the header publishes + --resource-actions-w). */ + html[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + position: fixed; + top: var(--floating-gap); + right: var(--floating-gap); + height: 3rem; + padding: 0 0.5rem; + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + background-color: #fff; + } + + html.dark[data-nav-style="floating"] #resource-action-hud-slot:not(:empty) { + background-color: var(--color-panel); + } + + html[data-nav-style="floating"] .app-topbar:has(#resource-action-hud-slot:not(:empty)) { + max-width: calc(100vw - var(--floating-gap) * 3 - var(--resource-actions-w, 0px)); + } + + html[data-nav-style="floating"] .application-settings-navigation { + top: var(--floating-top); + left: calc(var(--sidebar-w, 14rem) + var(--floating-gap) * 2); + /* Hug the menu items; long menus scroll inside the viewport. */ + height: auto; + max-height: calc(100dvh - var(--floating-top) - var(--floating-gap)); + border: 1px solid var(--floating-line); + border-radius: var(--floating-radius); + } + + /* Full page width: the content starts one gap after the settings rail. */ + html[data-nav-style="floating"] .app-main-wide :has(> .application-settings-navigation) { + grid-template-columns: calc(210px + 2.5rem) minmax(0, 1fr); + column-gap: var(--floating-gap); + } + + /* The canvas fills the screen behind the rail too; the canvas JS keeps cards clear of it. */ + html[data-nav-style="floating"] .app-main-full .application-settings-navigation ~ [data-environment-canvas] { + margin-left: 0; + } +} + +/* Dark mode (both navigation styles): the floating dotted backdrop shows through + see-through surfaces (dark:bg-white/[x] with or without `!`, .button, …). Give the + outermost ones the same color as an opaque mix with the app background, like the + canvas cards. The normal navigation uses the same colors so both styles match. Nested + surfaces keep their tint on top, also inside opaque CSS-class panels + (settings sections, boxes, tables, …) that are not app-colored. Generated from the dark:bg-white|black/x + utilities used in resources/views and resources/js; in the utilities layer + with !important so it beats the !bg-* utilities. */ +@layer utilities { + html.dark .app-main [class*="dark:bg-black/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 10%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/15"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 15%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/20"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 20%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/30"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 30%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 40%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/55"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 55%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-black/60"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #000 60%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/40"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 40%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.02]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 2%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.045]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4.5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.09]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 9%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.14]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 14%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark .app-main :is(.button, .view-toggle):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *) { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/10"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.025]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 2.5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.035]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3.5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.03]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 3%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.04]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 4%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.05]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 5%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.06]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 6%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.07]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 7%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.08]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 8%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.12]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 12%, var(--color-app)) !important; } + html.dark .app-main [class*="dark:hover:bg-white/[0.1]"]:not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } + html.dark .app-main .button:not(:disabled):not(:is([class*="dark:bg-"]:not(.app-main), .button, .view-toggle, .application-settings-section, .application-settings-section-body, .box, .coolbox, .data-table, .resource-view, .empty-state, .loading-state-card, .runtime-log-shell, .logs-viewer) *):hover { background-color: color-mix(in oklab, #fff 10%, var(--color-app)) !important; } +} + +/* Resource menu in the collapsed main sidebar: icons only. */ +.sidebar-collapsed .nav-context-menu :is(.menu-item-label, .nav-children, .menu-item > :not(.menu-item-icon)) { + display: none !important; +} + +.sidebar-collapsed .nav-context-menu .menu-item { + justify-content: center; + padding-left: 0; + padding-right: 0; +} + +/* A page menu's own "Back to ..." item: the main sidebar copy has the back row instead (components/main-nav-menu). */ +.nav-context-menu [data-page-only] { + display: none !important; +} diff --git a/resources/css/utilities.css b/resources/css/utilities.css index 96511bbf79..a35cb76471 100644 --- a/resources/css/utilities.css +++ b/resources/css/utilities.css @@ -249,11 +249,6 @@ @apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none; } -/* Collapsible group header (accordion) for the resource settings sidebar. */ -@utility nav-section-toggle { - @apply w-full items-center justify-between gap-2 px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none rounded-md transition-colors cursor-pointer hover:text-nav-active; -} - /* Indented child rows in a collapsible nav group */ @utility menu-subitem { /* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */ @@ -435,6 +430,16 @@ padding-right: 0.625rem; } + /* Flat rows show their own fill, so make it a 2rem square (same as the h-8 + height) with the icon centered, in line with the footer controls. */ + html[data-nav-items="flat"] .sidebar-collapsed .menu-item:not(.sidebar-toggle) { + width: 2rem; + margin-left: 0.1875rem; + justify-content: center; + padding-left: 0; + padding-right: 0; + } + .sidebar-collapsed .sidebar-collapsed-label { display: none; } diff --git a/resources/js/app.js b/resources/js/app.js index dde95415a6..7e54193431 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,12 +1,32 @@ import { initializeCopyButtonComponent } from './copy-button.js'; import { initializeRuntimeLogsComponent } from './runtime-logs.js'; -import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js'; import { initializeTerminalComponent } from './terminal.js'; import './traffic-globe.js'; +import './traffic-table.js'; import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js'; import { parseSubmitAction } from './modal-confirmation.js'; +// The canvases (Svelte, Svelte Flow) load only on pages that have one. After the +// import, each canvas mounts itself, also after later Livewire SPA navigation. +const canvases = [ + { selector: '[data-deploy-canvas]', load: () => import('./canvas/deploy/index.js').then((m) => m.initializeDeployCanvas()) }, + { selector: '[data-environment-canvas]', load: () => import('./canvas/environment/index.js').then((m) => m.initializeEnvironmentCanvas()) }, +]; +const loadCanvases = () => { + for (const canvas of canvases) { + if (!canvas.loading && document.querySelector(canvas.selector)) { + canvas.loading = canvas.load(); + } + } +}; +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadCanvases); +} else { + loadCanvases(); +} +document.addEventListener('livewire:navigated', loadCanvases); + // Used by the modal-confirmation Blade component to call its submitAction. window.parseModalSubmitAction = parseSubmitAction; @@ -29,7 +49,6 @@ document.addEventListener('livewire:navigated', () => { document.addEventListener('alpine:init', initializeTerminalComponent); document.addEventListener('alpine:init', initializeCopyButtonComponent); document.addEventListener('alpine:init', initializeRuntimeLogsComponent); -document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent); /** * Smooth-scroll a settings section into view, then flash its border for 500ms @@ -38,6 +57,29 @@ document.addEventListener('alpine:init', initializeSettingsSidebarAccordionCompo * * @param {string|HTMLElement} target Section id or element */ +/** A resource favicon loaded: show it in place of the logo. */ +window.resourceLogoLoaded = function resourceLogoLoaded(img) { + img.closest('[data-resource-logo]')?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + img.removeAttribute('hidden'); +}; + +/** A resource logo failed to load: drop a favicon, try the fallback URL of a logo, then show the resource type icon instead. */ +window.resourceLogoError = function resourceLogoError(img) { + if (img.hasAttribute('data-resource-logo-favicon')) { + img.remove(); + return; + } + if (img.dataset.fallback && img.src !== img.dataset.fallback) { + img.src = img.dataset.fallback; + return; + } + const wrapper = img.closest('[data-resource-logo]'); + wrapper?.querySelector('[data-resource-logo-image]')?.setAttribute('hidden', ''); + if (!wrapper?.querySelector('[data-resource-logo-favicon]:not([hidden])')) { + wrapper?.querySelector('[data-resource-logo-icon]')?.removeAttribute('hidden'); + } +}; + window.scrollToSettingsSection = function scrollToSettingsSection(target) { const el = typeof target === 'string' ? document.getElementById(target) : target; if (!el) { diff --git a/resources/js/canvas-core.js b/resources/js/canvas-core.js new file mode 100644 index 0000000000..4f2127df1c --- /dev/null +++ b/resources/js/canvas-core.js @@ -0,0 +1,258 @@ +// Shared canvas core: API calls, pan/zoom and page mounting for the deploy +// canvas and the environment canvas (Svelte components in resources/js/canvas/). +// +// The top level stays DOM-free: `node --test` imports the canvases. + +export const MIN_ZOOM = 0.25; +export const MAX_ZOOM = 2; + +// ── Collisions ──────────────────────────────────────────────────────── + +/** Space that cards and frames keep between them. */ +export const BOX_GAP = 16; + +/** True when boxes `a` and `b` ({ x, y, w, h }) are at least `gap` apart. */ +export const apart = (a, b, gap = BOX_GAP) => a.x >= b.x + b.w + gap || a.x + a.w + gap <= b.x || a.y >= b.y + b.h + gap || a.y + a.h + gap <= b.y; + +/** + * The free spot nearest to `box` ({ x, y, w, h }) that keeps `gap` clear of + * every box in `others` (and passes `fits`, when given): the box itself, or it + * pushed against an edge (or a corner) of the boxes in the way. Null when no + * such spot is found. + */ +export function freeSpot(box, others, gap = BOX_GAP, fits = () => true) { + const free = b => others.every(o => apart(b, o, gap)) && fits(b); + if (free(box)) return box; + const xs = [box.x, ...others.flatMap(o => [o.x - box.w - gap, o.x + o.w + gap])]; + const ys = [box.y, ...others.flatMap(o => [o.y - box.h - gap, o.y + o.h + gap])]; + let best = null; + let bestDistance = Infinity; + for (const x of xs) { + for (const y of ys) { + const distance = Math.hypot(x - box.x, y - box.y); + if (distance < bestDistance && free({ ...box, x, y })) { + best = { ...box, x, y }; + bestDistance = distance; + } + } + } + return best; +} + +// ── API (Coolify's API controllers at /canvas/api, browser session) ─── + +/** The team of the open canvas page (data-team), fixed when the page was opened. */ +function canvasTeam() { + return document.querySelector('[data-environment-canvas], [data-deploy-canvas]')?.dataset.team || ''; +} + +export class ApiError extends Error { + constructor(status, body) { + const errors = body?.errors ? Object.values(body.errors).flat().map(String) : []; + super([body?.message || `Request failed (HTTP ${status}).`, ...errors.filter(e => e !== body?.message)].join(' ')); + this.status = status; + this.body = body; + } +} + +export async function api(method, path, body, options = {}) { + const response = await fetch(`/canvas/api${path}`, { + method, + credentials: 'same-origin', + keepalive: !!options.keepalive, + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + 'X-Canvas-Team': canvasTeam(), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { + data = await response.json(); + } catch { + // Empty body. + } + if (!response.ok) throw new ApiError(response.status, data); + return data; +} + +// ── Pan and zoom ────────────────────────────────────────────────────── + +/** Viewport position inside the root, so the root's dots (floating nav, see app.css) line up with the viewport's. */ +export function canvasDotsOffset(root, viewport) { + const r = root.getBoundingClientRect(); + const v = viewport.getBoundingClientRect(); + return { x: v.left - r.left, y: v.top - r.top }; +} + +export function setCanvasDots(root, size, x, y, atMaxZoom = false) { + root.style.setProperty('--canvas-dots-size', `${size}px ${size}px`); + root.style.setProperty('--canvas-dots-position', `${x}px ${y}px`); + // Fully zoomed in: the grid dots turn into small Coolify logos (app.css). + root.classList.toggle('canvas-zoom-max', atMaxZoom); +} + +/** + * The browser zoom shortcut in a keydown event (Cmd/Ctrl with +, - or 0), so the + * canvas can zoom itself instead: 'in', 'out', 'reset', or null for other keys. + */ +export function zoomShortcut(e) { + if (!(e.ctrlKey || e.metaKey) || e.altKey) return null; + if (e.key === '+' || e.key === '=') return 'in'; + if (e.key === '-' || e.key === '_') return 'out'; + if (e.key === '0') return 'reset'; + return null; +} + +/** + * Wheel zoom (ctrl/cmd) and pan, and drag-to-pan on the viewport background. + * getView() returns the current { x, y, z }, which is changed in place. + * canPan(event) decides which presses pan; onPanEnd(moved) runs on release. + */ +export function panZoom({ root, viewport, world, zoomLabel, getView, onChange, canPan, onPanStart, onPanEnd }) { + function apply() { + const { x, y, z } = getView(); + const offset = canvasDotsOffset(root, viewport); + world.style.transform = `translate(${x}px, ${y}px) scale(${z})`; + viewport.style.backgroundSize = `${20 * z}px ${20 * z}px`; + viewport.style.backgroundPosition = `${x}px ${y}px`; + setCanvasDots(root, 20 * z, x + offset.x, y + offset.y, z >= MAX_ZOOM); + zoomLabel.textContent = `${Math.round(z * 100)}%`; + onChange?.(); + } + function toWorld(cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + return { x: (cx - r.left - view.x) / view.z, y: (cy - r.top - view.y) / view.z }; + } + /** Zooms to z around a screen point (the viewport centre by default). */ + function zoomTo(z, cx, cy) { + const view = getView(); + const r = viewport.getBoundingClientRect(); + const px = cx ?? r.left + r.width / 2; + const py = cy ?? r.top + r.height / 2; + const before = toWorld(px, py); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z)); + view.x = px - r.left - before.x * view.z; + view.y = py - r.top - before.y * view.z; + apply(); + } + + viewport.addEventListener('wheel', e => { + e.preventDefault(); + const view = getView(); + if (e.ctrlKey || e.metaKey) { + zoomTo(view.z * Math.exp(-e.deltaY * 0.01), e.clientX, e.clientY); + } else { + view.x -= e.deltaX; + view.y -= e.deltaY; + apply(); + } + }, { passive: false }); + // Touch: two fingers pinch to zoom and pan, anywhere on the viewport (also on cards). + // Capture phase on the viewport, so the pinch fingers do not also move cards or pan with one finger. + const touches = new Map(); + const pinchFingers = new Set(); + let pinch = null; + const pinchState = () => { + const [a, b] = [...touches.values()]; + return { cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)) }; + }; + viewport.addEventListener('pointerdown', e => { + if (e.pointerType !== 'touch') return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (touches.size !== 2) return; + for (const id of touches.keys()) pinchFingers.add(id); + const start = pinchState(); + pinch = { ...start, z: getView().z, ...toWorld(start.cx, start.cy) }; + onPanStart?.(); + e.stopImmediatePropagation(); + }, true); + viewport.addEventListener('pointermove', e => { + if (!touches.has(e.pointerId)) return; + touches.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (!pinchFingers.has(e.pointerId)) return; + // Pinch fingers stay out of card drags and one-finger pans until they lift. + // Immediate: a one-finger pan listens on the viewport itself (capture listeners run first there). + e.stopImmediatePropagation(); + if (!pinch || touches.size < 2) return; + const now = pinchState(); + const view = getView(); + const r = viewport.getBoundingClientRect(); + view.z = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, pinch.z * now.d / pinch.d)); + view.x = now.cx - r.left - pinch.x * view.z; + view.y = now.cy - r.top - pinch.y * view.z; + apply(); + }, true); + // The release still reaches the drag that the first finger started, so that drag ends. + const liftFinger = e => { + touches.delete(e.pointerId); + pinchFingers.delete(e.pointerId); + if (touches.size < 2) pinch = null; + }; + viewport.addEventListener('pointerup', liftFinger, true); + viewport.addEventListener('pointercancel', liftFinger, true); + + viewport.addEventListener('pointerdown', e => { + if (e.button !== 0 || pinch || !canPan(e)) return; + onPanStart?.(); + const view = getView(); + const start = { cx: e.clientX, cy: e.clientY, x: view.x, y: view.y }; + let moved = false; + try { + viewport.setPointerCapture(e.pointerId); + } catch { + // Synthetic or already released pointer. + } + viewport.classList.add('panning'); + const move = ev => { + if (!moved && Math.hypot(ev.clientX - start.cx, ev.clientY - start.cy) < 4) return; + moved = true; + view.x = start.x + ev.clientX - start.cx; + view.y = start.y + ev.clientY - start.cy; + apply(); + }; + const up = () => { + viewport.classList.remove('panning'); + viewport.removeEventListener('pointermove', move); + viewport.removeEventListener('pointerup', up); + viewport.removeEventListener('pointercancel', up); + onPanEnd?.(moved); + }; + viewport.addEventListener('pointermove', move); + viewport.addEventListener('pointerup', up); + viewport.addEventListener('pointercancel', up); + }); + + return { apply, toWorld, zoomTo }; +} + +// ── Page mount (initial load and Livewire SPA navigation) ───────────── + +/** + * Mounts a canvas into the page's container unless it already holds the canvas root. + * mount(container) may return { root, unmount }, unmounted once its root leaves the page. + */ +export function mountOnPage(selector, rootSelector, mount) { + if (typeof document === 'undefined') return; + let active = null; + const run = () => { + if (active && !active.root.isConnected) { + active.unmount(); + active = null; + } + const container = document.querySelector(selector); + if (!container || container.querySelector(rootSelector)) return; + active?.unmount(); + active = mount(container) || null; + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } + document.addEventListener('livewire:navigated', run); +} diff --git a/resources/js/canvas/CanvasControls.svelte b/resources/js/canvas/CanvasControls.svelte new file mode 100644 index 0000000000..f4b2532891 --- /dev/null +++ b/resources/js/canvas/CanvasControls.svelte @@ -0,0 +1,35 @@ + + + + +
+ + + + + + {#if onrefresh} + + {/if} + {#if children} +
{@render children()}
+ {/if} +
diff --git a/resources/js/canvas/deploy/DeployCanvas.svelte b/resources/js/canvas/deploy/DeployCanvas.svelte new file mode 100644 index 0000000000..89dab86f61 --- /dev/null +++ b/resources/js/canvas/deploy/DeployCanvas.svelte @@ -0,0 +1,167 @@ + + +{#snippet header()} +
+ {#if cv.target} + + + {/if} + Beta +
+ +{/snippet} + +
+ {#if !desktop} +
{@render header()}
+ {/if} + + {#if noticeOpen} +
+ +

The canvas is not optimized for mobile yet. It works best on a larger screen.{#if cv.target}{' On a phone, use the '}New resource page.{/if}

+ +
+ {/if} + + + +
+
+
+ + {#each cv.flow.nodes as n (n.id)} + + {/each} + {#if resultNode} + +
+ + + {#if cv.result.urls.length} + {#each cv.result.urls.slice(0, 3) as url} + {url.replace(/^https?:\/\//, '')} + {/each} + {:else} + Open in Coolify + {/if} + +
+ {/if} +
+
+ + {#if cv.menu} +
+
Add the next step
+ {#each cv.menu.valid as type} + pickNext(type)} /> + {/each} +
+ {/if} + {#if cv.toast} +
{cv.toast.text}
+ {/if} +
+ + + els.pz.zoomTo(z)} onfit={fit} children={desktop ? header : null} /> + + {#if cv.dropdown} + {#key cv.dropdown?.trigger} + + {/key} + {/if} +
diff --git a/resources/js/canvas/deploy/Dropdown.svelte b/resources/js/canvas/deploy/Dropdown.svelte new file mode 100644 index 0000000000..8806ded1a5 --- /dev/null +++ b/resources/js/canvas/deploy/Dropdown.svelte @@ -0,0 +1,137 @@ + + +
+ {#if items.length > 8} + (active = 0)} /> + {/if} +
+ {#each shown as [value, text, badge], i} + + {:else} +

{q ? 'No match' : 'None yet'}

+ {/each} +
+ {#if add} +
+ {#if adding} +
+ + { if (e.key !== 'Escape') e.stopPropagation(); }} + /> + +
+

{problem}

+ {:else} + + {/if} +
+ {/if} +
diff --git a/resources/js/canvas/deploy/Edges.svelte b/resources/js/canvas/deploy/Edges.svelte new file mode 100644 index 0000000000..8eac07e5f6 --- /dev/null +++ b/resources/js/canvas/deploy/Edges.svelte @@ -0,0 +1,48 @@ + + + + {#each lines as line} + {#if line.edge} + + + removeEdge(line.edge)}>Click to remove this connection + {/if} + + {/each} + {#if dragged} + + {/if} + diff --git a/resources/js/canvas/deploy/Field.svelte b/resources/js/canvas/deploy/Field.svelte new file mode 100644 index 0000000000..293457c860 --- /dev/null +++ b/resources/js/canvas/deploy/Field.svelte @@ -0,0 +1,105 @@ + + +{#if f.kind === 'note'} +

{f.text}

+{:else} +
+ + + {#if f.kind === 'segmented'} +
+ {#each f.options as [option, text]} + + {/each} +
+ {:else if f.kind === 'select'} + + {#if error}{error}{/if} + {:else if f.kind === 'textarea'} + + {:else} + set(e.currentTarget.value)} + onpointerdown={stop} + onkeydown={stop} + /> + {/if} +
+{/if} diff --git a/resources/js/canvas/deploy/Icon.svelte b/resources/js/canvas/deploy/Icon.svelte new file mode 100644 index 0000000000..f81085b6da --- /dev/null +++ b/resources/js/canvas/deploy/Icon.svelte @@ -0,0 +1,25 @@ + + +{#if glyph} + {@html glyph} +{:else} + +{/if} diff --git a/resources/js/canvas/deploy/Node.svelte b/resources/js/canvas/deploy/Node.svelte new file mode 100644 index 0000000000..b6f36e6965 --- /dev/null +++ b/resources/js/canvas/deploy/Node.svelte @@ -0,0 +1,104 @@ + + +
+ +
dragNode(n, e)}> + + {#if logoSrc && !broken[logoSrc]} + (broken[logoSrc] = true)} /> + {#if logo.dark}{/if} + {:else} + + {/if} + +
{t.title}{t.sub}
+ {#if removable} + + {/if} +
+
+ {#each fields as f (f)} + + {/each} + {#if n.type === 'deploy'} + {#if !plan.lines.length} +

Every resource you connect to a Server shows here.

+ {/if} + {#each plan.lines as { source } (source.id)} + +
+ {/each} + +
+ + +
+ {/if} + {#if t.stage === 'config' && !sourceAbove(n.id, cv.flow)} +

Connect it after a source step.

+ {/if} +
+ {#if n.type !== 'start'}{/if} + {#if st?.message}
{st.message}
{/if} + {#if n.type !== 'deploy'} + + startConnect(n, e)}> + {/if} + {#if blank} +
Drag and drop to add a step
+ {/if} +
diff --git a/resources/js/canvas/deploy/PaletteItem.svelte b/resources/js/canvas/deploy/PaletteItem.svelte new file mode 100644 index 0000000000..f7928cfb04 --- /dev/null +++ b/resources/js/canvas/deploy/PaletteItem.svelte @@ -0,0 +1,21 @@ + + + diff --git a/resources/js/canvas/deploy/RunPanel.svelte b/resources/js/canvas/deploy/RunPanel.svelte new file mode 100644 index 0000000000..77d5fc249e --- /dev/null +++ b/resources/js/canvas/deploy/RunPanel.svelte @@ -0,0 +1,48 @@ + + + diff --git a/resources/js/canvas/deploy/index.js b/resources/js/canvas/deploy/index.js new file mode 100644 index 0000000000..876d4487fd --- /dev/null +++ b/resources/js/canvas/deploy/index.js @@ -0,0 +1,18 @@ +// Deploy canvas (Svelte): mounted on the /canvas page into [data-deploy-canvas], +// on the first load and after Livewire SPA navigation. + +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import DeployCanvas from './DeployCanvas.svelte'; +import { init } from './state.svelte.js'; +import { injectStyles } from './styles.js'; + +export function initializeDeployCanvas() { + mountOnPage('[data-deploy-canvas]', '.cv-root', container => { + injectStyles(); + init(container); + container.replaceChildren(); + const app = mount(DeployCanvas, { target: container }); + return { root: container.querySelector('.cv-root'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/canvas/deploy/state.svelte.js b/resources/js/canvas/deploy/state.svelte.js new file mode 100644 index 0000000000..2cfb6c3c24 --- /dev/null +++ b/resources/js/canvas/deploy/state.svelte.js @@ -0,0 +1,608 @@ +// Deploy canvas runtime: the state of the open canvas, what the steps do to +// it (add, connect, run…), and the Svelte actions around the imperative +// helpers of canvas-core.js (pan/zoom, top bar placement, card sizes). + +import { flushSync, tick } from 'svelte'; +import { api, freeSpot, MIN_ZOOM, panZoom } from '../../canvas-core.js'; +import { + canFollow, defaults, domainList, fresh, LOG_POLL_MS, lists, load, multiOut, nextOf, nodeById, outsOf, planOf, + resourceHref, save, sleep, targetFrom, TYPES, uid, uniqueLabels, withDeploy, withoutSecrets, withTarget, +} from '../../deploy-canvas.js'; + +export const PORT_Y = 26; // header centre (3.25rem / 2) +const CARD_W = 296; + +const emptyRun = () => ({ open: false, title: 'Deploy', sub: '', steps: [], panes: [], active: null, defaultHref: '', openHref: '', showOpen: false }); + +export const cv = $state({ + canAdd: false, // may create projects and environments (from the page) + target: null, // the environment that opened the canvas + flow: fresh(), + view: { x: 40, y: 40, z: 1 }, + runState: {}, // node id -> { state: running|done|failed, message } + invalid: {}, // node id -> true: a required field is empty + // The last successful run's addresses, shown as a pill after Deploy. + result: null, // { after: node id, urls: [], href } + running: false, // false, 'save' or 'deploy' + sizes: {}, // node id -> { w, h } of its card + listsVersion: 0, // bumped when an option list loads + menu: null, // the "Add the next step" menu: { prev, valid, p, left, top } + pendingLine: null, // the dragged line, kept while its menu is open + temp: null, // the line being dragged from a port + armed: null, // node id of the port being dragged + dropTarget: null, // node id the dragged line would connect to + toast: null, // { text, bad } + dropdown: null, // { trigger, items, current, onPick, add } + run: emptyRun(), // the run panel +}); + +// DOM of the open canvas (set by the actions below); mount counts the +// canvases, so a run of an earlier one stops writing here. +export const els = { root: null, stage: null, viewport: null, pz: null, nodeEls: new Map(), mount: 0, fitOnOpen: false }; + +lists.onChange = () => { cv.listsVersion++; }; + +export function init(container) { + els.mount++; + els.nodeEls.clear(); + cv.canAdd = container.dataset.canAdd === '1'; + if (load('deployed', false)) { + save('flow', null); + save('result', null); + save('deployed', false); + } + cv.target = targetFrom(location.search); + cv.flow = withTarget(withDeploy(load('flow', null) || fresh()), cv.target); + save('flow', withoutSecrets(cv.flow)); // drops secrets that older versions stored + // Small screens: the default view is made for desktop, so the first visit fits the steps instead. + els.fitOnOpen = !load('view', null) && !matchMedia('(min-width: 768px)').matches; + cv.view = load('view', { x: 40, y: 40, z: 1 }); + cv.result = load('result', null); + Object.assign(cv, { runState: {}, invalid: {}, running: false, sizes: {}, menu: null, pendingLine: null, temp: null, armed: null, dropTarget: null, toast: null, dropdown: null, run: emptyRun() }); +} + +/** The canvas left the page: its runs stop. */ +export function leave() { + els.mount++; +} + +// ── Actions ──────────────────────────────────────────────────────────── + +/** use:pan on the viewport: wheel and pinch zoom, drag to pan. */ +export function pan(viewport) { + const world = viewport.querySelector('.cv-world'); + const edges = viewport.querySelector('.cv-edges'); + els.viewport = viewport; + els.stage = viewport.parentElement; + els.root = viewport.closest('.cv-root'); + els.pz = panZoom({ + root: els.root, + viewport, + world, + zoomLabel: {}, // CanvasControls shows cv.view.z + getView: () => cv.view, + onChange: closeDropdown, + canPan: e => [viewport, world, edges].includes(e.target), + onPanStart: closeMenu, + }); + // The root paints the dots, so move them with the viewport when the Steps panel folds or the window resizes. + const sizes = new ResizeObserver(() => els.pz.apply()); + sizes.observe(viewport); + return { destroy: () => sizes.disconnect() }; +} + +/** use:track on a card: its element (for measuring) and its size (for the lines). */ +export function track(node, id) { + els.nodeEls.set(id, node); + const measure = () => { cv.sizes[id] = { w: node.offsetWidth, h: node.offsetHeight }; }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(node); + return { + destroy() { + observer.disconnect(); + if (els.nodeEls.get(id) === node) els.nodeEls.delete(id); + delete cv.sizes[id]; + }, + }; +} + +// ── Canvas: geometry, fit ────────────────────────────────────────────── +// Port positions in world coordinates. +export const outPoint = n => ({ x: n.x + (cv.sizes[n.id]?.w || CARD_W), y: n.y + PORT_Y }); +export const inPoint = n => ({ x: n.x, y: n.y + PORT_Y }); +export const curve = (a, b) => { + const dx = Math.max(40, Math.abs(b.x - a.x) / 2); + return `M ${a.x} ${a.y} C ${a.x + dx} ${a.y}, ${b.x - dx} ${b.y}, ${b.x} ${b.y}`; +}; +const toWorld = (cx, cy) => els.pz.toWorld(cx, cy); +const width = n => els.nodeEls.get(n.id)?.offsetWidth || CARD_W; +const height = n => els.nodeEls.get(n.id)?.offsetHeight || 160; +// A card's box in world units (its rendered size, or a usual card size). +const boxOf = n => ({ x: n.x, y: n.y, w: width(n), h: height(n) }); + +export function fit() { + if (!els.nodeEls.size) return; + let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; + for (const n of cv.flow.nodes) { + const node = els.nodeEls.get(n.id); + minX = Math.min(minX, n.x); + minY = Math.min(minY, n.y); + maxX = Math.max(maxX, n.x + (node?.offsetWidth || 280)); + maxY = Math.max(maxY, n.y + (node?.offsetHeight || 120)); + } + const pill = els.root.querySelector('.cv-result'); + if (pill) { + maxX = Math.max(maxX, pill.offsetLeft + pill.offsetWidth); + maxY = Math.max(maxY, pill.offsetTop + pill.offsetHeight); + } + const r = els.viewport.getBoundingClientRect(); + const pad = 60; + const view = cv.view; + view.z = Math.min(1.2, Math.max(MIN_ZOOM, Math.min((r.width - pad * 2) / (maxX - minX), (r.height - pad * 2) / (maxY - minY)))); + view.x = (r.width - (maxX - minX) * view.z) / 2 - minX * view.z; + view.y = (r.height - (maxY - minY) * view.z) / 2 - minY * view.z; + els.pz.apply(); +} + +// ── Toast, menus, dropdown ───────────────────────────────────────────── +let toastTimer = 0; +export function toast(text, bad = false) { + cv.toast = { text, bad }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { cv.toast = null; }, bad ? 4200 : 2600); +} + +export function closeMenu() { + cv.menu = null; + cv.pendingLine = null; +} +// Close an older menu but keep the line being dropped. +function openNextMenu(prev, cx, cy, p) { + cv.menu = null; + const valid = Object.keys(TYPES).filter(type => canFollow(prev, type, null, cv.flow)); + if (!valid.length) return toast('Nothing can follow this step.', true); + const r = els.stage.getBoundingClientRect(); + cv.menu = { prev: prev.id, valid, p, left: Math.min(cx - r.left, r.width - 220), top: Math.min(cy - r.top, r.height - 40 * valid.length - 40) }; +} +export function pickNext(type) { + const { prev, p } = cv.menu; + closeMenu(); + const n = addNode(type, p.x + 8, p.y - PORT_Y, true); + connect(prev, n.id); +} + +// Our dropdown panel (fixed in the canvas root, so zoom never shrinks it). +export function closeDropdown() { + cv.dropdown = null; +} +export function openDropdown(trigger, items, current, onPick, add = null) { + if (cv.dropdown?.trigger === trigger) return closeDropdown(); + cv.dropdown = { trigger, items, current, onPick, add }; +} + +// ── Flow edits ───────────────────────────────────────────────────────── +// A structural change redraws the cards, which clears their invalid marks. +const redraw = () => { cv.invalid = {}; }; + +export function connect(from, to) { + const a = nodeById(from, cv.flow); + cv.flow.edges = cv.flow.edges.filter(e => !(e.from === from && e.to === to) && (multiOut(a) || e.from !== from) && e.to !== to); + cv.flow.edges.push({ from, to }); + redraw(); +} +export function removeEdge(edge) { + if (cv.running) return; + cv.flow.edges = cv.flow.edges.filter(e => e !== edge); + redraw(); + toast('Connection removed.'); +} +export function addNode(type, x, y, quiet) { + cv.flow.nodes.push({ id: uid(), type, x: Math.round(x), y: Math.round(y), data: defaults(type) }); + const n = cv.flow.nodes.at(-1); + redraw(); + flushSync(); // render the card, to measure it + settle(n); + if (!quiet) toast(`${TYPES[type].title} added. Connect it from the dot of the step before.`); + return n; +} +export function removeNode(id) { + if (cv.running) return; + cv.flow.nodes = cv.flow.nodes.filter(n => n.id !== id); + cv.flow.edges = cv.flow.edges.filter(e => e.from !== id && e.to !== id); + delete cv.runState[id]; + redraw(); +} +export function resetCanvas() { + if (cv.running) return; + cv.flow = withTarget(fresh(), cv.target); + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + redraw(); +} +export function hideResult() { + cv.result = null; + save('result', null); +} +// A field change. Fields loaded from the one changed (`rerender`) start empty again. +export function setField(n, f, value) { + const fields = TYPES[n.type].fields; + n.data[f.key] = value; + f.after?.(n.data); + if (f.rerender) { + for (const other of fields) if (other.load && other !== f && other.key !== f.key && fields.indexOf(other) > fields.indexOf(f)) delete n.data[other.key]; + } + delete cv.invalid[n.id]; +} + +// Move a card to the nearest spot where it does not cover another card. +function settle(n) { + const spot = freeSpot(boxOf(n), cv.flow.nodes.filter(m => m !== n).map(boxOf)); + if (!spot || (spot.x === n.x && spot.y === n.y)) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); +} +// Click on a step in the list: add it after the step it most likely follows. +function appendToChain(type) { + const flow = cv.flow; + const plan = planOf(flow); + // The newest step in the tree this one can follow, with room for it. + const inTree = [plan.start, ...plan.servers, ...plan.lines.flatMap(l => [l.source, ...l.settings])]; + const parent = inTree.reverse().find(n => canFollow(n, type, null, flow) && (multiOut(n) || !nextOf(n.id, flow))); + if (!parent) return addNode(type, ...centre()); + const siblings = multiOut(parent) ? outsOf(parent.id, flow).map(id => nodeById(id, flow)).filter(Boolean) : []; + const y = siblings.length ? Math.max(...siblings.map(n => n.y + height(n))) + 40 : parent.y; + const n = addNode(type, parent.x + width(parent) + 80, y, true); + connect(parent.id, n.id); +} +function centre() { + const r = els.viewport.getBoundingClientRect(); + const p = toWorld(r.left + r.width / 2, r.top + r.height / 2); + return [p.x - 140, p.y - 60]; +} + +// ── Pointer drags ────────────────────────────────────────────────────── + +/** A palette step: drag it to the canvas, or click to append it to the chain. */ +export function dragFromPalette(type, event) { + if (event.button !== 0) return; + const item = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + let ghost = null; + const move = e => { + if (!ghost && Math.hypot(e.clientX - startX, e.clientY - startY) < 5) return; + if (!ghost) { + ghost = item.cloneNode(true); + Object.assign(ghost.style, { position: 'fixed', zIndex: 2147483001, width: `${item.offsetWidth}px`, pointerEvents: 'none', opacity: 0.9, background: 'var(--cv-ghost)', borderRadius: '8px', boxShadow: '0 8px 24px -8px rgb(0 0 0 / 0.35)' }); + ghost.style.setProperty('--cv-ghost', 'light-dark(#fff, #222)'); + els.root.append(ghost); + } + ghost.style.left = `${e.clientX - 20}px`; + ghost.style.top = `${e.clientY - 18}px`; + }; + const stop = () => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', cancel); + }; + // Touch: a sideways swipe scrolls the step strip (small screens) and cancels the pointer. + const cancel = () => { + stop(); + ghost?.remove(); + }; + const up = e => { + stop(); + if (!ghost) return appendToChain(type); + ghost.remove(); + const r = els.viewport.getBoundingClientRect(); + if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return; + const p = toWorld(e.clientX, e.clientY); + addNode(type, p.x - 30, p.y - 16); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', cancel); +} + +/** + * Drag a card by its header. Pointer capture keeps the drag even when the + * pointer outruns the card. + */ +export function dragNode(n, event) { + if (event.button !== 0 || event.target.closest('button, input, select, textarea')) return; + const handle = event.currentTarget; + event.stopPropagation(); + event.preventDefault(); + closeMenu(); + try { handle.setPointerCapture(event.pointerId); } catch { /* not capturable */ } + const ox = event.clientX / cv.view.z - n.x; + const oy = event.clientY / cv.view.z - n.y; + // The other cards stay put during the drag, so measure them once. + const others = cv.flow.nodes.filter(m => m !== n).map(boxOf); + const move = e => { + if (e.pointerId !== event.pointerId) return; + const wanted = { x: e.clientX / cv.view.z - ox, y: e.clientY / cv.view.z - oy, w: width(n), h: height(n) }; + // Cards cannot cover each other: stop at the edge of a card in the way. + const spot = freeSpot(wanted, others); + if (!spot) return; + n.x = Math.round(spot.x); + n.y = Math.round(spot.y); + }; + const up = e => { + if (e.pointerId !== event.pointerId) return; + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + removeEventListener('pointercancel', up); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); + addEventListener('pointercancel', up); +} + +// Drag from an output port: drop on a node to connect, on empty space to pick. +export function startConnect(n, event) { + event.stopPropagation(); + event.preventDefault(); + if (cv.running) return; + cv.armed = n.id; + const a = outPoint(n); + let target = null; + const move = e => { + cv.temp = { a, b: toWorld(e.clientX, e.clientY) }; + const hit = document.elementFromPoint(e.clientX, e.clientY)?.closest?.('.cv-node'); + const id = hit?.dataset.id; + const candidate = id && id !== n.id ? nodeById(id, cv.flow) : null; + target = candidate && canFollow(n, candidate.type, candidate, cv.flow) ? candidate : null; + cv.dropTarget = target?.id ?? null; + }; + const up = e => { + removeEventListener('pointermove', move); + removeEventListener('pointerup', up); + cv.armed = null; + cv.temp = null; + cv.dropTarget = null; + if (target) return connect(n.id, target.id); + const over = document.elementFromPoint(e.clientX, e.clientY); + if (over?.closest?.('.cv-node')) return toast('That step cannot follow this one.', true); + const p = toWorld(e.clientX, e.clientY); + if (Math.hypot(p.x - a.x, p.y - a.y) < 12) return; + cv.pendingLine = { a, b: p }; + openNextMenu(n, e.clientX, e.clientY, p); + }; + addEventListener('pointermove', move); + addEventListener('pointerup', up); +} + +// ── Run ──────────────────────────────────────────────────────────────── +// Log panes: "all" holds the steps; each deployment gets its own tab and pane. +const paneOf = key => cv.run.panes.find(pane => pane.key === key); +function openPane(key, label) { + if (!paneOf(key)) cv.run.panes.push({ key, label, href: '', state: '', status: '', lines: [] }); + return paneOf(key); +} +export function showPane(key) { + const r = cv.run; + r.active = key; + const href = paneOf(key)?.href || r.defaultHref; + if (href) r.openHref = href; +} +function setPaneState(key, state, status) { + const pane = paneOf(key); + if (!pane) return; + pane.state = state; + pane.status = status; +} +function logLine(text, cls = '', key = 'all') { + const pane = paneOf(key) || paneOf('all'); + const box = els.root?.querySelector(`.cv-logs[data-key="${pane.key}"]`); + const atEnd = !box || box.scrollTop + box.clientHeight >= box.scrollHeight - 30; + pane.lines.push({ text, cls }); + if (box && atEnd) tick().then(() => { box.scrollTop = box.scrollHeight; }); +} +function setStep(n, state, message) { + cv.runState[n.id] = { state, message }; + delete cv.invalid[n.id]; + const row = cv.run.steps.find(s => s.id === n.id); + if (row) { + row.state = state; + row.message = message || ''; + } +} + +function validate(plan) { + const problems = []; + if (!plan.servers.length) problems.push('Connect a Server right after Start.'); + else if (!plan.lines.length) problems.push('Connect a source step (Git repository, Docker image, Dockerfile, Service or Database) to a Server.'); + for (const n of [plan.start, ...new Set(plan.lines.map(l => l.server)), ...plan.lines.flatMap(l => [l.source, ...l.settings])]) { + const missing = TYPES[n.type].fields.filter(f => f.required && (!f.show || f.show(n.data)) && !String(n.data[f.key] ?? '').trim()).map(f => f.label); + if (missing.length) { + problems.push(`${TYPES[n.type].title}: fill in ${missing.join(', ')}.`); + cv.invalid[n.id] = true; + } + } + return problems; +} + +export async function runFlow(action = 'deploy') { + if (cv.running) return; + closeMenu(); + const plan = planOf(cv.flow); + const problems = validate(plan); + if (problems.length) { + toast(problems[0], true); + return; + } + cv.running = action; + cv.runState = {}; + cv.result = null; + save('result', null); + save('deployed', false); + const servers = [...new Set(plan.lines.map(l => l.server))]; + const list = [plan.start, ...servers, ...plan.lines.flatMap(l => [l.source, ...l.settings]), plan.deploy]; + const names = uniqueLabels(plan.lines.map(l => l.source)); + const labelOf = n => names.get(n.id) || TYPES[n.type].title; + const r = cv.run; + r.open = true; + r.title = action === 'save' ? 'Saving' : 'Deploying'; + r.sub = `${plan.lines.length} resource${plan.lines.length === 1 ? '' : 's'}`; + r.panes = []; + r.active = null; + r.defaultHref = ''; + openPane('all', 'Steps'); + showPane('all'); + r.showOpen = false; + r.steps = list.map(n => ({ id: n.id, label: labelOf(n), state: '', message: 'Waiting' })); + redraw(); + // The page can be left (and the canvas mounted again) during a run. + const owner = els.mount; + const live = () => els.mount === owner; + // Run one step; false when it failed. + const step = async (n, work) => { + setStep(n, 'running', 'Running…'); + try { + const message = await work(); + if (!live()) return false; + setStep(n, 'done', message); + logLine(`✓ ${labelOf(n)}: ${message}`, 'dim'); + return true; + } catch (error) { + if (!live()) return false; + setStep(n, 'failed', error.message); + logLine(`✗ ${labelOf(n)}: ${error.message}`, 'err'); + return false; + } + }; + const base = {}; + let ok = await step(plan.start, () => TYPES.start.run(base, plan.start.data)); + for (const server of servers) { + if (!ok) break; + ok = await step(server, async () => 'Server picked.'); + } + const shipped = []; // { line, ctx, label } per created resource + for (const line of plan.lines) { + if (!ok) break; + const ctx = { ...base }; + await TYPES.server.run(ctx, line.server.data); + const domainNode = line.settings.find(n => n.type === 'domain'); + if (domainNode) ctx.domains = domainList(domainNode.data.domains); + for (const n of [line.source, ...line.settings]) { + ok = await step(n, () => TYPES[n.type].run(ctx, n.data)); + if (!ok) break; + } + if (ctx.resource) shipped.push({ line, ctx, label: names.get(line.source.id) }); + } + if (!live()) return; + const href = () => shipped.length === 1 ? resourceHref(shipped[0].ctx) : `/project/${base.project}/environment/${base.environment}`; + if (shipped.length) { + r.showOpen = true; + r.openHref = r.defaultHref = href(); + } + if (ok) { + ok = await step(plan.deploy, async () => action === 'save' + ? `Saved ${shipped.length} resource${shipped.length === 1 ? '' : 's'}. Not deployed.` + : deployAll(shipped, live)); + } + if (!live()) return; + if (shipped.length) r.defaultHref = href(); + showPane(r.active); + if (ok) { + const urls = []; + for (const { ctx } of action === 'save' ? [] : shipped) { + let found = String(ctx.domains || ctx.createdDomains || '').split(',').map(v => v.trim()).filter(Boolean); + if (!found.length && ctx.resource.kind === 'application') { + try { + const app = await api('GET', `/applications/${ctx.resource.uuid}`); + found = String(app?.fqdn || '').split(',').map(v => v.trim()).filter(Boolean); + } catch { /* keep none */ } + } + urls.push(...found); + } + cv.result = { after: plan.deploy.id, urls, href: href() }; + save('result', cv.result); + // The next visit starts with an empty canvas. + if (action === 'deploy') save('deployed', true); + } + r.title = ok ? 'Done' : 'Stopped'; + r.sub = ok ? (action === 'save' ? 'Saved in Coolify. Deploy them from there or from here.' : 'Every step finished.') : 'A step failed. Fix it and try again.'; + cv.running = false; +} + +// Deploy every resource at once and follow them. +async function deployAll(shipped, live) { + const many = shipped.length > 1; + // A tab per resource, in order; the first one opens. + for (const { line, ctx, label } of shipped) openPane(line.source.id, label).href = resourceHref(ctx); + if (shipped.length) showPane(shipped[0].line.source.id); + const results = await Promise.allSettled(shipped.map(async ({ line, ctx, label }) => { + const key = line.source.id; + setPaneState(key, 'running', 'Starting'); + try { + const res = await api('POST', '/deploy', { uuid: ctx.resource.uuid }); + if (!live()) return 'Left the canvas.'; + const item = res?.deployments?.[0]; + let message = item?.message || 'Started.'; + if (item?.deployment_uuid) { + ctx.deployment = item.deployment_uuid; + paneOf(key).href = resourceHref(ctx); + if (cv.run.active === key) showPane(key); + message = await followDeployment(label, item.deployment_uuid, key, live); + if (!live()) return message; + } else { + logLine(message, '', key); + } + setPaneState(key, 'done', message); + logLine(`✓ ${label}: ${message}`, 'dim'); + return `${label}: ${message}`; + } catch (error) { + if (live()) { + setPaneState(key, 'failed', error.message.replace(`${label}: `, '')); + logLine(error.message, 'err', key); + } + throw error; + } + })); + const failed = results.filter(res => res.status === 'rejected').map(res => res.reason.message); + if (failed.length) throw new Error(failed.join(' · ')); + return many ? `${results.length} resources deployed.` : results[0].value; +} + +// Poll the deployment (Coolify's own log view also polls every 2 s). +// Logs go to the resource's own pane (key). +async function followDeployment(label, deploymentUuid, key, live) { + logLine(`Deployment ${deploymentUuid}`, 'dim', key); + let shown = 0; + let warned = false; + for (;;) { + if (!live()) return 'Left the canvas.'; + let dep; + try { + dep = await api('GET', `/deployments/${deploymentUuid}`); + } catch (error) { + if (!live()) return 'Left the canvas.'; + logLine(`(could not read the deployment: ${error.message})`, 'err', key); + await sleep(LOG_POLL_MS * 2); + continue; + } + if (!live()) return 'Left the canvas.'; + let entries = []; + if (typeof dep.logs === 'string') { + try { entries = JSON.parse(dep.logs) || []; } catch { entries = []; } + } else if (dep.logs === undefined && !warned) { + warned = true; + logLine('Logs are hidden: only team admins can see deployment logs.', 'err', key); + } + const visible = entries.filter(entry => !entry.hidden); + for (const entry of visible.slice(shown)) { + for (const line of String(entry.output ?? '').split('\n')) if (line.trim()) logLine(line, entry.type === 'stderr' ? 'err' : '', key); + } + shown = visible.length; + setPaneState(key, 'running', String(dep.status || '').replace(/_/g, ' ')); + if (dep.status === 'finished') return 'Deployed.'; + if (dep.status === 'failed') throw new Error(`${label}: deployment failed. See the logs.`); + if (dep.status === 'cancelled-by-user') throw new Error(`${label}: deployment cancelled.`); + await sleep(LOG_POLL_MS); + } +} diff --git a/resources/js/canvas/deploy/styles.js b/resources/js/canvas/deploy/styles.js new file mode 100644 index 0000000000..869e76d236 --- /dev/null +++ b/resources/js/canvas/deploy/styles.js @@ -0,0 +1,327 @@ +// The deploy canvas look: islands on a backdrop. The colors are CSS variables +// on .cv-root, so a custom color scheme can replace them. Injected once as a +// diff --git a/resources/js/canvas/environment/Icon.svelte b/resources/js/canvas/environment/Icon.svelte new file mode 100644 index 0000000000..8a715697fa --- /dev/null +++ b/resources/js/canvas/environment/Icon.svelte @@ -0,0 +1,10 @@ + + + diff --git a/resources/js/canvas/environment/NodeIcon.svelte b/resources/js/canvas/environment/NodeIcon.svelte new file mode 100644 index 0000000000..920d30b42a --- /dev/null +++ b/resources/js/canvas/environment/NodeIcon.svelte @@ -0,0 +1,37 @@ + + +{#if faviconShown} + +{:else} + {#if !logo?.src || broken} + + {:else} + + + {#if logo.dark} + + {/if} + + {/if} + {#if logo?.favicon && !failed[logo.favicon]} + (loadedFavicon = logo.favicon)} onerror={() => (failed[logo.favicon] = true)} /> + {/if} +{/if} diff --git a/resources/js/canvas/environment/Panel.svelte b/resources/js/canvas/environment/Panel.svelte new file mode 100644 index 0000000000..b4fd9c549c --- /dev/null +++ b/resources/js/canvas/environment/Panel.svelte @@ -0,0 +1,316 @@ + + +{#snippet section(title, content)} +
+

{title}

+ {@render content()} +
+{/snippet} + +{#snippet note(content)} +

{content}

+{/snippet} + +{#snippet shell(title, iconSource, body, footer)} + +{/snippet} + +{#snippet meta(n)} +
+ {TYPE_LABELS[n.type] || n.type} + {statusLabel(n.status)} +
+{/snippet} + +{#snippet openButton(n, cls, label)} + {label} +{/snippet} + +{#snippet removeButton(l)} + {#if canEdit} + + {/if} +{/snippet} + +{#if connectView && node && isSource(node)} + {#snippet body()} +
+ {#if node.reference}{node.reference}{/if} + {#if node.missing}Deleted{/if} +
+ {#if node.missing} + {@render note(`This shared variable was deleted. The resources below still reference it and get the text ${node.reference} instead of a value. Add the variable again, or remove the reference.`)} + {:else if node.subtitle} + {#snippet description()}

{node.subtitle}

{/snippet} + {@render section('Description', description)} + {/if} + {#snippet used()} +
    + {#if !usedBy.length}
  • {text.noTargets}
  • {/if} + {#each usedBy as l (l.id)} + {@const resource = nodeById(l.target)} +
  • + + {@render removeButton(l)} +
  • + {/each} +
+ {/snippet} + {@render section(`${text.usedBy} (${usedBy.length})`, used)} + {#if canEdit && !node.missing} + {@const linked = new Set(usedBy.map(l => l.target))} + !isSource(n) && !linked.has(n.id)), + resource => ({ title: `Add ${node.name} to ${resource.name}`, sub: resource.context || '' }), + resource => oncreate(node.id, resource.id))} /> + {/if} + {#if !node.missing}{@render note(canEdit ? text.note(node) : text.viewOnly)}{/if} + {/snippet} + {#snippet footer()}{@render openButton(node, '', `Open ${TYPE_LABELS[node.type].toLowerCase()}`)}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && node} + {#snippet body()} + {@render meta(node)} + {#if node.context} + {#snippet context()}{node.context}{/snippet} + {@render section('Project / environment', context)} + {/if} + {#snippet list()} +
    + {#if !sources.length}
  • {text.noSources}
  • {/if} + {#each sources as l (l.id)} + {@const source = nodeById(l.source)} +
  • + + {@render removeButton(l)} +
  • + {/each} +
+ {/snippet} + {@render section(`${text.sources} (${sources.length})`, list)} + {#if canEdit} + {@const linked = new Set(sources.map(l => l.source))} + isSource(n) && !n.missing && !linked.has(n.id)), + source => ({ title: `Add ${source.name} to ${node.name}`, sub: source.type === 'variable' ? source.subtitle || '' : '' }), + source => oncreate(source.id, node.id))} /> + {/if} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if connectView && link} + {@const variable = nodeById(link.source)} + {@const resource = nodeById(link.target)} + {#snippet body()} + {#snippet ends()} +
+ {#each [[text.source, variable], ['Resource', resource]] as [endLabel, end] (endLabel)} + + {/each} +
+ {/snippet} + {@render section('Ends', ends)} + {#if link.keys?.length} + {#snippet keys()} +
+ {#each link.keys as key (key)}{key}{/each} +
+ {/snippet} + {@render section(`Variables on ${resource?.name || 'the resource'}`, keys)} + {/if} + {#if link.missing}{@render note(`The shared variable was deleted. ${resource?.name} gets the text ${variable?.reference} instead of a value.`)}{/if} + {#if canEdit && !link.removable}{@render note(removeHint(link))}{/if} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(`${variable?.name || text.source} → ${resource?.name || 'Resource'}`, 'link', body, canEdit ? footer : null)} +{:else if node} + {#snippet body()} + {@render meta(node)} + {#if node.subtitle} + {#snippet details()}

{node.subtitle}

{/snippet} + {@render section('Details', details)} + {/if} + {#if node.urls?.length} + {#snippet urls()} + + {/snippet} + {@render section(node.urls.length === 1 ? 'URL' : 'URLs', urls)} + {/if} + {#snippet serverList()}

{servers.length ? servers.join(', ') : NO_SERVER}

{/snippet} + {@render section(servers.length > 1 ? 'Servers' : 'Server', serverList)} + {#if node.children?.length} + {#snippet containers()} +
    + {#each node.children as child, i (i)} +
  • + + {child.name} + {TYPE_LABELS[child.type] || child.type || ''} +
  • + {/each} +
+ {/snippet} + {@render section('Containers', containers)} + {/if} + {#snippet linkList()} +
    + {#if !nodeLinks.length}
  • {canEdit ? 'No links. Drag the dot on the right edge of a card to another card.' : 'No links.'}
  • {/if} + {#each nodeLinks as l (l.id)} +
  • + +
  • + {/each} +
+ {/snippet} + {@render section('Links', linkList)} + {/snippet} + {#snippet footer()}{@render openButton(node, 'button-highlighted', 'Open')}{/snippet} + {@render shell(node.name, node, body, node.link ? footer : null)} +{:else if link} + {#snippet body()} + {#snippet ends()} +
+ {#each [['From', link.source], ['To', link.target]] as [endLabel, id] (endLabel)} + {@const end = nodeById(id)} + + {/each} +
+ {/snippet} + {@render section('Resources', ends)} + {#snippet labelField()} + {#if canEdit} + { + if (e.key === 'Enter') { + e.preventDefault(); + labelInput.blur(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + label = saved; + labelInput.blur(); + } + }} + onblur={saveLabel} + /> + {:else} +

{link.label || 'No label'}

+ {/if} + {/snippet} + {@render section('Label', labelField)} + {@render note('Links are for the diagram only. They do not change your resources.')} + {/snippet} + {#snippet footer()} + + {/snippet} + {@render shell(link.label || 'Link', 'link', body, canEdit ? footer : null)} +{/if} diff --git a/resources/js/canvas/environment/StatusDot.svelte b/resources/js/canvas/environment/StatusDot.svelte new file mode 100644 index 0000000000..0778c28dbb --- /dev/null +++ b/resources/js/canvas/environment/StatusDot.svelte @@ -0,0 +1,13 @@ + + + + {#if tone === 'busy'} + + {/if} + + diff --git a/resources/js/canvas/environment/Toolbar.svelte b/resources/js/canvas/environment/Toolbar.svelte new file mode 100644 index 0000000000..ee53dcb078 --- /dev/null +++ b/resources/js/canvas/environment/Toolbar.svelte @@ -0,0 +1,39 @@ + + +
+
+ + Beta + + View only +
+ +
+ + + +
+
diff --git a/resources/js/canvas/environment/Zone.svelte b/resources/js/canvas/environment/Zone.svelte new file mode 100644 index 0000000000..a4f65a3b7e --- /dev/null +++ b/resources/js/canvas/environment/Zone.svelte @@ -0,0 +1,100 @@ + + +
+ +
diff --git a/resources/js/canvas/environment/index.js b/resources/js/canvas/environment/index.js new file mode 100644 index 0000000000..5cb79c380a --- /dev/null +++ b/resources/js/canvas/environment/index.js @@ -0,0 +1,11 @@ +// Environment canvas page mount (initial load and Livewire SPA navigation). +import { mount, unmount } from 'svelte'; +import { mountOnPage } from '../../canvas-core.js'; +import EnvironmentCanvas from './EnvironmentCanvas.svelte'; + +export function initializeEnvironmentCanvas() { + mountOnPage('[data-environment-canvas]', '[data-ec-root]', container => { + const app = mount(EnvironmentCanvas, { target: container, props: { container } }); + return { root: container.querySelector('[data-ec-root]'), unmount: () => unmount(app) }; + }); +} diff --git a/resources/js/deploy-canvas.js b/resources/js/deploy-canvas.js new file mode 100644 index 0000000000..82d28ccd52 --- /dev/null +++ b/resources/js/deploy-canvas.js @@ -0,0 +1,510 @@ +/** + * Deploy canvas: build a deploy workflow (project → server → source → + * settings → deploy) from nodes, run it through Coolify's API controllers + * (at /canvas/api, with the browser session) and follow the deployment logs. + * + * Mounted on the /canvas page into [data-deploy-canvas] by + * canvas/deploy/index.js (Svelte components). This file keeps the DOM-free + * parts: step types, flow rules and storage; `node --test` imports it. + */ + +import { api } from './canvas-core.js'; + +// ── Settings ─────────────────────────────────────────────────────────── +export const LOG_POLL_MS = 2000; // Deployment log refresh (Coolify's own log view uses 2 s too). +export const STORE = 'coolify-canvas'; // localStorage prefix (flow, view). + +// ── Small helpers ────────────────────────────────────────────────────── +// Icons: the app's reicon glyphs (rendered by the page into +// [data-deploy-canvas-icons]), or a few local paths in the same style. +export const LOCAL_ICONS = { + minus: ['M5 12h14'], + fit: ['M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5'], + back: ['M19 12H5', 'M11 6l-6 6 6 6'], // the environment canvas back arrow + fold: ['M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z', 'M9 4v16'], // the sidebar toggle in the navbar +}; +export const uid = () => Math.random().toString(36).slice(2, 10); +const b64 = text => btoa(unescape(encodeURIComponent(text))); +export const load = (key, fallback) => { + try { return JSON.parse(localStorage.getItem(`${STORE}:${key}`)) ?? fallback; } catch { return fallback; } +}; +export const save = (key, value) => localStorage.setItem(`${STORE}:${key}`, JSON.stringify(value)); +export const sleep = ms => new Promise(r => setTimeout(r, ms)); + +export const ICONS = { + start: 'projects', + server: 'servers', + git: 'sources', + image: 'cloud', + file: 'file-content', + layers: 'layers', + db: 'database', + sliders: 'sliders', + variables: 'variables', + globe: 'globe', + deploy: 'play-circle', + x: 'x', + plus: 'plus', + minus: 'minus', + fit: 'fit', + out: 'external-link', + fold: 'fold', + check: 'check', + chevron: 'chevron-down', + info: 'info-circle', +}; + +// Cached option lists for the node fields (key → array; loading state). +// onChange runs when a list loads or fails (the canvas redraws its fields). +export const lists = { cache: {}, pending: {}, errors: {}, onChange: () => {} }; +function options(key, fetcher) { + const { cache, pending, errors } = lists; + if (cache[key]) return cache[key]; + if (!pending[key]) { + pending[key] = fetcher().then(list => { + cache[key] = list; + delete errors[key]; + }).catch(error => { + errors[key] = error.message; + }).finally(() => { + delete pending[key]; + lists.onChange(); + }); + } + return null; +} +// The cache key a select loads from (to show its error). +export function loaderKey(f, d) { + const map = { project: 'projects', environment: `envs:${d.project}`, server: 'servers', destination: `dest:${d.server}`, ghApp: 'gh', ghRepo: `repos:${d.ghApp}`, ghBranch: `br:${d.ghApp}:${d.ghRepo}`, key: 'keys' }; + return map[f.key]; +} +const LOADERS = { + projects: () => options('projects', async () => (await api('GET', '/projects')).map(p => [p.uuid, p.name])), + envs: project => project && options(`envs:${project}`, async () => (await api('GET', `/projects/${project}/environments`)).map(e => [e.uuid, e.name])), + // Build-only servers cannot run resources (as on the New resource page). + servers: () => options('servers', async () => (await api('GET', '/servers')).filter(s => s.settings?.server_role !== 'build').map(s => [s.uuid, `${s.name}${s.is_reachable === false ? ' (unreachable)' : ''}`])), + destinations: server => server && options(`dest:${server}`, async () => (await api('GET', `/servers/${server}/destinations`)).map(d => [d.uuid, `${d.name} · ${d.network}`])), + ghApps: () => options('gh', async () => (await api('GET', '/github-apps')).filter(a => !a.is_public && a.app_id).map(a => [`${a.id}|${a.uuid}`, a.name])), + repos: app => app && options(`repos:${app}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories`)).repositories.map(r => [r.full_name, r.full_name])), + branches: (app, repo) => app && repo && options(`br:${app}:${repo}`, async () => (await api('GET', `/github-apps/${app.split('|')[0]}/repositories/${repo}/branches`)).branches.map(b => [b.name, b.name])), + keys: () => options('keys', async () => (await api('GET', '/security/keys')).map(k => [k.uuid, k.name])), +}; + +// ── Node types ───────────────────────────────────────────────────────── +// stage: start → infra → source → config → ship. kind: what a source creates. +// Select options are [value, label, badge?]; the badge shows next to the label. +const BUILD_PACKS = [['railpack', 'Railpack'], ['nixpacks', 'Nixpacks', 'Deprecated'], ['static', 'Static'], ['dockerfile', 'Dockerfile'], ['dockercompose', 'Docker Compose']]; +// The PostgreSQL images of the New resource page (project/new/select.blade.php). +const POSTGRES_IMAGES = [ + ['postgres:18-alpine', 'PostgreSQL 18'], + ['postgres:17-alpine', 'PostgreSQL 17'], + ['postgres:16-alpine', 'PostgreSQL 16'], + ['supabase/postgres:17.4.1.032', 'Supabase PostgreSQL'], + ['postgis/postgis:17-3.5-alpine', 'PostGIS'], + ['pgvector/pgvector:pg18', 'PGVector 18'], + ['pgvector/pgvector:pg17', 'PGVector 17'], +]; +const DATABASES = [['postgresql', 'PostgreSQL'], ['mysql', 'MySQL'], ['mariadb', 'MariaDB'], ['mongodb', 'MongoDB'], ['redis', 'Redis'], ['keydb', 'KeyDB'], ['dragonfly', 'Dragonfly'], ['clickhouse', 'ClickHouse']]; +// One-click service templates ([key, name]), from the page (deploy-canvas.blade.php). +let serviceTemplates = null; +const templateOptions = () => { + if (!serviceTemplates) { + try { serviceTemplates = JSON.parse(document.querySelector('[data-deploy-canvas-services]')?.textContent || '[]'); } catch { serviceTemplates = []; } + } + return serviceTemplates; +}; + +// The logo of a source step: the one resource_logo() (bootstrap/helpers/services.php) and the +// New resource page show for what it creates. Service logos come from the page +// (deploy-canvas.blade.php). Null keeps the step's glyph. +// The page gives versioned URLs (resource_logo_url()), so a changed logo is not loaded from the cache. +let resourceLogos = null; +const resourceLogoUrl = name => { + if (!resourceLogos) { + try { resourceLogos = JSON.parse(globalThis.document?.querySelector('[data-deploy-canvas-resource-logos]')?.textContent || '{}'); } catch { resourceLogos = {}; } + } + return resourceLogos[name] ?? `/svgs/resources/${name}.svg`; +}; +const resourceLogo = (name, hasDark = false) => ({ + src: resourceLogoUrl(name), dark: hasDark ? resourceLogoUrl(`${name}-dark`) : null, fallback: null, padded: false, +}); +const DATABASE_LOGOS = { postgresql: 'postgres' }; +let serviceLogos = null; +const serviceLogo = key => { + if (!serviceLogos) { + try { serviceLogos = JSON.parse(document.querySelector('[data-deploy-canvas-service-logos]')?.textContent || '{}'); } catch { serviceLogos = {}; } + } + const logo = serviceLogos[key]; + return logo ? { src: logo[0], dark: null, fallback: logo[1], padded: true } : null; +}; +export function logoOf(n) { + const d = n.data || {}; + switch (n.type) { + case 'git': + if (d.buildPack === 'dockerfile') return resourceLogo('dockerfile'); + if (d.buildPack === 'dockercompose') return resourceLogo('docker-compose'); + if (d.mode === 'github') return resourceLogo('github-app', true); + return resourceLogo(d.mode === 'key' ? 'deploy-key' : 'public-repo'); + case 'image': return resourceLogo('docker-image'); + case 'dockerfile': return resourceLogo('dockerfile'); + case 'service': return d.mode === 'compose' ? resourceLogo('docker-compose') : (d.type ? serviceLogo(d.type) : null); + case 'database': return d.engine ? resourceLogo(DATABASE_LOGOS[d.engine] ?? d.engine, ['keydb', 'dragonfly'].includes(d.engine)) : null; + default: return null; + } +} + +export const TYPES = { + start: { + title: 'Start', sub: 'Which project and environment', stage: 'start', icon: ICONS.start, + fields: [ + // `add`: a "New …" row at the end of the list creates one and selects it. + { key: 'project', label: 'Project', kind: 'select', load: () => LOADERS.projects(), rerender: true, required: true, + add: { label: 'New project', placeholder: 'Project name', list: () => 'projects', create: async name => (await api('POST', '/projects', { name })).uuid } }, + { key: 'environment', label: 'Environment', kind: 'select', load: d => LOADERS.envs(d.project), required: true, only: true, + add: { label: 'New environment', placeholder: 'Environment name', list: d => `envs:${d.project}`, create: async (name, d) => (await api('POST', `/projects/${d.project}/environments`, { name })).uuid } }, + ], + run: async (ctx, d) => { + ctx.project = d.project; + ctx.environment = d.environment; + return 'Project and environment set.'; + }, + }, + server: { + title: 'Server', sub: 'Where it runs', stage: 'infra', icon: ICONS.server, + fields: [ + { key: 'server', label: 'Server', kind: 'select', load: () => LOADERS.servers(), rerender: true, required: true, only: true }, + { key: 'destination', label: 'Network', kind: 'select', load: d => LOADERS.destinations(d.server), optional: 'Default' }, + ], + run: async (ctx, d) => { + ctx.server = d.server; + ctx.destination = d.destination || undefined; + return 'Server picked.'; + }, + }, + git: { + title: 'Git repository', sub: 'Build from source', stage: 'source', kind: 'application', icon: ICONS.git, + fields: [ + { key: 'mode', label: 'Access', kind: 'segmented', options: [['public', 'Public'], ['github', 'GitHub app'], ['key', 'Deploy key']], rerender: true, value: 'public' }, + { key: 'repository', label: 'Repository URL', kind: 'text', placeholder: 'https://github.com/coollabsio/coolify-examples', show: d => d.mode !== 'github', required: true, secret: v => /^\w+:\/\/[^/]*@/.test(v) }, + { key: 'ghApp', label: 'GitHub app', kind: 'select', load: () => LOADERS.ghApps(), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'ghRepo', label: 'Repository', kind: 'select', load: d => LOADERS.repos(d.ghApp), show: d => d.mode === 'github', rerender: true, required: true }, + { key: 'key', label: 'Deploy key', kind: 'select', load: () => LOADERS.keys(), show: d => d.mode === 'key', required: true }, + { key: 'branch', label: 'Branch', kind: 'text', placeholder: 'main', value: 'main', show: d => d.mode !== 'github', required: true }, + { key: 'ghBranch', label: 'Branch', kind: 'select', load: d => LOADERS.branches(d.ghApp, d.ghRepo), show: d => d.mode === 'github', required: true }, + { key: 'buildPack', label: 'Build pack', kind: 'select', options: BUILD_PACKS, value: 'railpack', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '3000', value: '3000' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const base = common(ctx, d); + Object.assign(base, { build_pack: d.buildPack, ports_exposes: d.ports || undefined }); + let res; + if (d.mode === 'github') { + res = await api('POST', '/applications/private-github-app', { ...base, github_app_uuid: d.ghApp.split('|')[1], git_repository: d.ghRepo, git_branch: d.ghBranch }); + } else if (d.mode === 'key') { + res = await api('POST', '/applications/private-deploy-key', { ...base, private_key_uuid: d.key, git_repository: d.repository, git_branch: d.branch }); + } else { + res = await api('POST', '/applications/public', { ...base, git_repository: d.repository, git_branch: d.branch }); + } + ctx.resource = { kind: 'application', sub: 'git', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + image: { + title: 'Docker image', sub: 'Run a prebuilt image', stage: 'source', kind: 'application', icon: ICONS.image, + fields: [ + { key: 'image', label: 'Image', kind: 'text', placeholder: 'nginx', required: true }, + { key: 'tag', label: 'Tag', kind: 'text', placeholder: 'latest, or the tag in the image' }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const { name, tag } = splitImage(d.image, d.tag); + const res = await api('POST', '/applications/dockerimage', { ...common(ctx, d), docker_registry_image_name: name, docker_registry_image_tag: tag, ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'image', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + dockerfile: { + title: 'Dockerfile', sub: 'Build from a Dockerfile', stage: 'source', kind: 'application', icon: ICONS.file, + fields: [ + { key: 'dockerfile', label: 'Dockerfile', kind: 'textarea', placeholder: 'FROM nginx:alpine\nCOPY . /usr/share/nginx/html', required: true }, + { key: 'ports', label: 'Port', kind: 'text', placeholder: '80', value: '80' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + ctx.applicationSource = true; + const res = await api('POST', '/applications/dockerfile', { ...common(ctx, d), dockerfile: b64(d.dockerfile), ports_exposes: d.ports || undefined }); + ctx.resource = { kind: 'application', sub: 'dockerfile', uuid: res.uuid }; + ctx.createdDomains = res.domains && domainList(res.domains); + return 'Application created.'; + }, + }, + service: { + title: 'Service', sub: 'One-click template or compose', stage: 'source', kind: 'service', icon: ICONS.layers, + fields: [ + { key: 'mode', label: 'From', kind: 'segmented', options: [['template', 'Template'], ['compose', 'Compose']], rerender: true, value: 'template' }, + { key: 'type', label: 'Template', kind: 'select', load: () => templateOptions(), show: d => d.mode !== 'compose', required: true }, + { key: 'compose', label: 'docker-compose.yml', kind: 'textarea', placeholder: 'services:\n app:\n image: nginx', show: d => d.mode === 'compose', required: true, secret: true }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.mode === 'compose') body.docker_compose_raw = b64(d.compose); + else body.type = d.type.trim(); + const res = await api('POST', '/services', body); + ctx.resource = { kind: 'service', uuid: res.uuid }; + return 'Service created.'; + }, + }, + database: { + title: 'Database', sub: 'A managed database', stage: 'source', kind: 'database', icon: ICONS.db, + fields: [ + // A new engine starts with its default image (PostgreSQL: PostgreSQL 18). + { key: 'engine', label: 'Engine', kind: 'select', options: DATABASES, value: 'postgresql', required: true, + after: d => { d.image = d.engine === 'postgresql' ? POSTGRES_IMAGES[0][0] : ''; } }, + { key: 'image', label: 'Image', kind: 'select', options: POSTGRES_IMAGES, value: POSTGRES_IMAGES[0][0], show: d => d.engine === 'postgresql', required: true }, + { key: 'image', label: 'Image', kind: 'text', placeholder: 'Default for the engine', show: d => d.engine !== 'postgresql' }, + { key: 'name', label: 'Name', kind: 'text', placeholder: 'Optional' }, + ], + run: async (ctx, d) => { + const body = common(ctx, d); + if (d.image) body.image = d.image; + const res = await api('POST', `/databases/${d.engine}`, body); + ctx.resource = { kind: 'database', uuid: res.uuid }; + return 'Database created.'; + }, + }, + build: { + title: 'Build settings', sub: 'Directories and commands', stage: 'config', icon: ICONS.sliders, for: ['git'], + fields: [ + { key: 'base_directory', label: 'Base directory', kind: 'text', placeholder: '/' }, + { key: 'publish_directory', label: 'Publish directory', kind: 'text', placeholder: '/dist' }, + { key: 'install_command', label: 'Install command', kind: 'text', placeholder: 'Detected' }, + { key: 'build_command', label: 'Build command', kind: 'text', placeholder: 'Detected' }, + { key: 'start_command', label: 'Start command', kind: 'text', placeholder: 'Detected' }, + ], + run: async (ctx, d) => { + const body = Object.fromEntries(['base_directory', 'publish_directory', 'install_command', 'build_command', 'start_command'].filter(k => d[k]).map(k => [k, d[k]])); + if (!Object.keys(body).length) return 'Nothing to change.'; + await api('PATCH', `/applications/${ctx.resource.uuid}`, body); + return 'Build settings saved.'; + }, + }, + env: { + title: 'Environment variables', sub: 'KEY=value, one per line', stage: 'config', icon: ICONS.variables, for: ['application', 'service'], + fields: [ + { key: 'vars', label: 'Variables', kind: 'textarea', placeholder: 'NODE_ENV=production\nAPI_URL=https://api.example.com', required: true, mono: true, secret: true }, + ], + run: async (ctx, d) => { + const data = parseEnvVars(d.vars); + if (!data.length) return 'No variables.'; + const kind = ctx.resource.kind === 'service' ? 'services' : 'applications'; + await api('PATCH', `/${kind}/${ctx.resource.uuid}/envs/bulk`, { data }); + return `${data.length} variable${data.length === 1 ? '' : 's'} saved.`; + }, + }, + domain: { + title: 'Domains', sub: 'Your own public addresses', stage: 'config', icon: ICONS.globe, for: ['git', 'image', 'dockerfile'], + fields: [ + { key: 'domains', label: 'Domains', kind: 'textarea', placeholder: 'https://app.example.com', required: true, mono: true, rows: 2 }, + { key: 'note', kind: 'note', text: 'Without this step, Coolify makes an address from the server’s wildcard domain (or sslip.io).' }, + ], + run: async (ctx, d) => { + const domains = domainList(d.domains); + if (ctx.createdDomains === domains) return `Set at creation: ${domains.replace(/,/g, ', ')}.`; + const res = await api('PATCH', `/applications/${ctx.resource.uuid}`, { domains }); + return `Domains saved${res?.uuid ? '' : ''}: ${domains.replace(/,/g, ', ')}.`; + }, + }, + deploy: { + title: 'Deploy', sub: 'Save or deploy every resource', stage: 'ship', icon: ICONS.deploy, + fields: [], + }, +}; +// "nginx:alpine" → nginx + alpine; "ghcr.io:443/a/b@sha256:…" → digest tag +// (Coolify's "sha256-…" form). A tag in the image wins over the Tag field. +export function splitImage(image, tagField) { + let name = String(image || '').trim(); + let tag = ''; + const at = name.indexOf('@sha256:'); + if (at >= 0) { + tag = `sha256-${name.slice(at + 8)}`; + name = name.slice(0, at); + } else { + const colon = name.lastIndexOf(':'); + if (colon > name.lastIndexOf('/')) { + tag = name.slice(colon + 1); + name = name.slice(0, colon); + } + } + return { name, tag: tag || String(tagField || '').trim() || 'latest' }; +} +// Fields common to every create call. Domains from a Domains step later in +// the chain go in at creation (with no generated domain), as Coolify's +// create endpoints accept them. +function common(ctx, d) { + const body = { project_uuid: ctx.project, environment_uuid: ctx.environment, server_uuid: ctx.server, instant_deploy: false }; + if (ctx.destination) body.destination_uuid = ctx.destination; + if (d.name) body.name = d.name; + if (ctx.domains && ctx.applicationSource) { + body.domains = ctx.domains; + body.autogenerate_domain = false; + } + return body; +} +// "KEY=value" lines → [{ key, value }]; skips blanks and # comments, strips quotes. +export function parseEnvVars(text) { + return String(text || '').split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#') && line.includes('=')).map(line => { + const i = line.indexOf('='); + return { key: line.slice(0, i).trim(), value: line.slice(i + 1).trim().replace(/^(['"])(.*)\1$/, '$2') }; + }); +} +export const domainList = text => String(text || '').split(/[\n,]/).map(v => v.trim()).filter(Boolean).join(','); +export const PALETTE = [ + ['Infrastructure', ['server']], + ['Source', ['git', 'image', 'dockerfile', 'service', 'database']], + ['Configure', ['build', 'env', 'domain']], +]; + +// ── Flow state ───────────────────────────────────────────────────────── +// Every deploy needs a server, so a new canvas has one, already connected +// (id 'server': it cannot be removed, nor its connection to Start). +export const fresh = () => withDeploy({ + nodes: [{ id: 'start', type: 'start', x: 80, y: 160, data: {} }, { id: 'server', type: 'server', x: 456, y: 160, data: {} }], + edges: [{ from: 'start', to: 'server' }], +}); +// A select with `only: true` takes its option when it has exactly one. +export const onlyOption = (list, value) => (!value && list?.length === 1 ? list[0][0] : value); +// Every flow ends in one Deploy step. Resource lines reach it on their own, so +// edges into it (from older saved flows) are dropped. +export function withDeploy(f) { + if (!f.nodes.some(n => n.type === 'deploy')) f.nodes.push({ id: 'deploy', type: 'deploy', x: 1240, y: 160, data: {} }); + const deployIds = new Set(f.nodes.filter(n => n.type === 'deploy').map(n => n.id)); + f.edges = f.edges.filter(e => !deployIds.has(e.to) && !deployIds.has(e.from)); + return f; +} +// The environment that opened the canvas (?project=…&environment=…, from the +// New resource page), or null. Both values must look like UUIDs. +export function targetFrom(search) { + const params = new URLSearchParams(search); + const project = params.get('project'); + const environment = params.get('environment'); + const valid = value => /^[A-Za-z0-9_-]+$/.test(value ?? ''); + return valid(project) && valid(environment) ? { project, environment } : null; +} +// The Start step targets that environment, also in a flow restored from an earlier visit. +export function withTarget(f, t) { + const start = f.nodes.find(n => n.type === 'start'); + if (t && start) start.data = { project: t.project, environment: t.environment }; + return f; +} +// Secret fields (`secret: true`, or a test on the value) stay in memory only; +// they are never written to localStorage. +export function withoutSecrets(f) { + const nodes = f.nodes.map(n => { + const data = { ...n.data }; + for (const { key, secret } of TYPES[n.type]?.fields || []) { + if (secret === true || (typeof secret === 'function' && data[key] && secret(data[key]))) delete data[key]; + } + return { ...n, data }; + }); + return { ...f, nodes }; +} +// The flow is a tree: Start → Servers → one line per resource (source → +// settings…), and every line ends in the one Deploy step. +export const nodeById = (id, f) => f.nodes.find(n => n.id === id); +export const outsOf = (id, f) => f.edges.filter(e => e.from === id).map(e => e.to); +export const nextOf = (id, f) => outsOf(id, f)[0]; +export const prevOf = (id, f) => f.edges.find(e => e.to === id)?.from; +// Start and Server fan out; only Deploy takes more than one line in. +export const multiOut = n => ['start', 'infra'].includes(TYPES[n.type].stage); +// The source node above a node in its line (what the settings apply to). +export function sourceAbove(id, f) { + const seen = new Set(); + for (let cur = id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (n && TYPES[n.type].stage === 'source') return n; + } + return null; +} +// Can a `type` step come after `prev`? With `target`, also: the link makes no loop. +export function canFollow(prev, type, target, f) { + const t = TYPES[type]; + const p = TYPES[prev.type]; + if (type === 'start' || type === 'deploy' || prev.type === 'deploy') return false; + if (target) { + const seen = new Set(); + for (let cur = prev.id; cur && !seen.has(cur); cur = prevOf(cur, f)) { + if (cur === target.id) return false; + seen.add(cur); + } + } + if (p.stage === 'start') return type === 'server'; + if (p.stage === 'infra') return t.stage === 'source'; + const src = sourceAbove(prev.id, f); + if (!src) return false; + if (t.stage === 'ship') return true; + if (t.stage !== 'config') return false; + const sub = src.type === 'git' || src.type === 'image' || src.type === 'dockerfile' ? src.type : null; + return t.for.includes(TYPES[src.type].kind) || (sub !== null && t.for.includes(sub)); +} +// What a run does: each resource line under each Server under Start. +export function planOf(f) { + const servers = outsOf('start', f).map(id => nodeById(id, f)).filter(n => n?.type === 'server'); + const lines = []; + for (const server of servers) { + for (const id of outsOf(server.id, f)) { + const source = nodeById(id, f); + if (!source || TYPES[source.type].stage !== 'source') continue; + const settings = []; + const seen = new Set([source.id]); + for (let cur = nextOf(source.id, f); cur && !seen.has(cur); cur = nextOf(cur, f)) { + seen.add(cur); + const n = nodeById(cur, f); + if (!n) break; + settings.push(n); + } + lines.push({ server, source, settings, end: settings.at(-1) || source }); + } + } + return { start: nodeById('start', f), servers, lines, deploy: f.nodes.find(n => n.type === 'deploy') || null }; +} +// Source id → label; same labels get a number ("Docker image · nginx #2"). +export function uniqueLabels(sources) { + const labels = sources.map(resourceLabel); + const seen = {}; + return new Map(sources.map((src, i) => { + const label = labels[i]; + if (labels.filter(l => l === label).length < 2) return [src.id, label]; + seen[label] = (seen[label] || 0) + 1; + return [src.id, `${label} #${seen[label]}`]; + })); +} +// "Docker image · nginx": what a source step makes, for lists and logs. +export function resourceLabel(src) { + const d = src.data; + const detail = d.name || { + git: d.mode === 'github' ? d.ghRepo : d.repository, + image: d.image, + service: d.mode === 'compose' ? 'compose' : d.type, + database: d.engine, + }[src.type]; + return detail ? `${TYPES[src.type].title} · ${detail}` : TYPES[src.type].title; +} +export function defaults(type) { + const data = {}; + for (const f of TYPES[type].fields) if (f.value !== undefined) data[f.key] = f.value; + return data; +} +// The Coolify page of a created resource (its deployment, while one runs). +export function resourceHref(ctx) { + const base = `/project/${ctx.project}/environment/${ctx.environment}`; + const kind = { application: 'application', service: 'service', database: 'database' }[ctx.resource.kind]; + return ctx.deployment && kind === 'application' ? `${base}/application/${ctx.resource.uuid}/deployment/${ctx.deployment}` : `${base}/${kind}/${ctx.resource.uuid}`; +} diff --git a/resources/js/deploy-canvas.test.js b/resources/js/deploy-canvas.test.js new file mode 100644 index 0000000000..a8fbecc2d7 --- /dev/null +++ b/resources/js/deploy-canvas.test.js @@ -0,0 +1,189 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { freeSpot, zoomShortcut } from './canvas-core.js'; +import { canFollow, domainList, fresh, logoOf, onlyOption, parseEnvVars, planOf, splitImage, targetFrom, uniqueLabels, withDeploy, withoutSecrets, withTarget } from './deploy-canvas.js'; + +test('splits the tag out of an image name', () => { + assert.deepEqual(splitImage('nginx:alpine', 'latest'), { name: 'nginx', tag: 'alpine' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', ''), { name: 'ghcr.io:443/org/app', tag: 'latest' }); + assert.deepEqual(splitImage('ghcr.io:443/org/app', 'v2'), { name: 'ghcr.io:443/org/app', tag: 'v2' }); +}); + +test('turns an image digest into the sha256- tag form', () => { + assert.deepEqual(splitImage('redis@sha256:abc123', 'latest'), { name: 'redis', tag: 'sha256-abc123' }); +}); + +test('parses KEY=value lines and skips comments and blanks', () => { + assert.deepEqual(parseEnvVars('# comment\n\nNODE_ENV=production\nURL="https://a.b/?x=1"\nNOPE\n'), [ + { key: 'NODE_ENV', value: 'production' }, + { key: 'URL', value: 'https://a.b/?x=1' }, + ]); +}); + +test('joins domains from lines and commas', () => { + assert.equal(domainList(' https://a.com\nhttps://b.com, https://c.com \n'), 'https://a.com,https://b.com,https://c.com'); +}); + +// Start → Server → (Service, Dockerfile → Env), and the one Deploy step. +const node = (id, type, data = {}) => ({ id, type, x: 0, y: 0, data }); +const fanOut = () => ({ + nodes: [ + node('start', 'start'), + node('srv', 'server'), + node('svc', 'service', { mode: 'template', type: 'umami' }), + node('df', 'dockerfile'), + node('env', 'env'), + node('dep', 'deploy'), + ], + edges: [ + { from: 'start', to: 'srv' }, + { from: 'srv', to: 'svc' }, + { from: 'srv', to: 'df' }, + { from: 'df', to: 'env' }, + ], +}); + +test('plans one line per resource under a server, each ending at its last step', () => { + const plan = planOf(fanOut()); + assert.equal(plan.deploy.id, 'dep'); + assert.deepEqual(plan.lines.map((l) => [l.server.id, l.source.id, l.settings.map((n) => n.id), l.end.id]), [ + ['srv', 'svc', [], 'svc'], + ['srv', 'df', ['env'], 'env'], + ]); +}); + +test('adds the deploy step to a flow without one and drops edges into it', () => { + const flow = withDeploy({ + nodes: [node('start', 'start'), node('srv', 'server'), node('img', 'image'), node('old', 'deploy')], + edges: [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }, { from: 'img', to: 'old' }], + }); + assert.deepEqual(flow.edges, [{ from: 'start', to: 'srv' }, { from: 'srv', to: 'img' }]); + assert.equal(flow.nodes.filter((n) => n.type === 'deploy').length, 1); + + const empty = withDeploy({ nodes: [node('start', 'start')], edges: [] }); + assert.equal(empty.nodes.filter((n) => n.type === 'deploy').length, 1); +}); + +test('allows only steps that fit after a step', () => { + const flow = fanOut(); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('srv'), 'image', null, flow), true); + assert.equal(canFollow(byId('start'), 'image', null, flow), false); + assert.equal(canFollow(byId('env'), 'deploy', byId('dep'), flow), false); // Deploy is connected on its own + assert.equal(canFollow(byId('svc'), 'build', null, flow), false); // build settings are for git only + assert.equal(canFollow(byId('dep'), 'env', null, flow), false); +}); + +test('refuses a link that would make a loop', () => { + const flow = fanOut(); + flow.nodes.push(node('env2', 'env')); + flow.edges.push({ from: 'env', to: 'env2' }); + const byId = (id) => flow.nodes.find((n) => n.id === id); + assert.equal(canFollow(byId('env2'), 'env', byId('env'), flow), false); +}); + +test('numbers resources that would have the same name', () => { + const names = uniqueLabels([ + node('a', 'image', { image: 'nginx' }), + node('b', 'image', { image: 'nginx' }), + node('c', 'service', { mode: 'template', type: 'umami' }), + ]); + assert.deepEqual([...names.values()], ['Docker image · nginx #1', 'Docker image · nginx #2', 'Service · umami']); +}); + +test('keeps secrets out of the stored flow', () => { + const flow = { + nodes: [ + node('env', 'env', { vars: 'TOKEN=abc' }), + node('svc', 'service', { mode: 'compose', compose: 'services: {}', name: 'web' }), + node('git', 'git', { repository: 'https://user:tok@github.com/a/b', branch: 'main' }), + node('pub', 'git', { repository: 'https://github.com/a/b' }), + ], + edges: [], + }; + const stored = withoutSecrets(flow); + assert.deepEqual(stored.nodes.map((n) => n.data), [{}, { mode: 'compose', name: 'web' }, { branch: 'main' }, { repository: 'https://github.com/a/b' }]); + assert.equal(flow.nodes[0].data.vars, 'TOKEN=abc'); // the open canvas keeps them +}); + +test('reads the environment that opened the canvas from the query string', () => { + assert.deepEqual(targetFrom('?project=p1a2&environment=e_3-x'), { project: 'p1a2', environment: 'e_3-x' }); + assert.equal(targetFrom(''), null); + assert.equal(targetFrom('?project=p1a2'), null); + assert.equal(targetFrom('?project=../servers&environment=e1'), null); + assert.equal(targetFrom('?project=p1&environment='), null); +}); + +test('points the Start step of a restored flow at the opening environment', () => { + const flow = { + nodes: [node('start', 'start', { project: 'old', environment: 'old-env' }), node('srv', 'server', { server: 's1' })], + edges: [{ from: 'start', to: 'srv' }], + }; + withTarget(flow, { project: 'p1', environment: 'e1' }); + assert.deepEqual(flow.nodes.map((n) => n.data), [{ project: 'p1', environment: 'e1' }, { server: 's1' }]); + assert.deepEqual(withTarget(flow, null).nodes[0].data, { project: 'p1', environment: 'e1' }); +}); + +test('a new canvas starts with a Server step connected to Start', () => { + const plan = planOf(fresh()); + assert.deepEqual(plan.servers.map((n) => n.id), ['server']); + assert.equal(plan.deploy?.type, 'deploy'); + assert.deepEqual(plan.lines, []); +}); + +test('selects the only option, and only when nothing is selected', () => { + assert.equal(onlyOption([['srv-1', 'localhost']], ''), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], undefined), 'srv-1'); + assert.equal(onlyOption([['srv-1', 'localhost']], 'srv-old'), 'srv-old'); + assert.equal(onlyOption([['srv-1', 'a'], ['srv-2', 'b']], ''), ''); + assert.equal(onlyOption([], ''), ''); + assert.equal(onlyOption(null, ''), ''); // still loading +}); + +test('keeps a dragged card off the other cards', () => { + const other = { x: 0, y: 0, w: 100, h: 100 }; + const free = { x: 300, y: 0, w: 100, h: 100 }; + assert.deepEqual(freeSpot(free, [other]), free); + // Overlaps from the right: pushed to the right edge, plus the gap. + assert.deepEqual(freeSpot({ x: 90, y: 10, w: 100, h: 100 }, [other], 16), { x: 116, y: 10, w: 100, h: 100 }); + // Overlaps from below: pushed under it. + assert.deepEqual(freeSpot({ x: 5, y: 80, w: 100, h: 100 }, [other], 16), { x: 5, y: 116, w: 100, h: 100 }); +}); + +test('finds a corner spot when two cards block the way', () => { + const cards = [{ x: 0, y: 0, w: 100, h: 100 }, { x: 116, y: 0, w: 100, h: 100 }]; + const spot = freeSpot({ x: 60, y: 20, w: 100, h: 100 }, cards, 16); + for (const c of cards) { + const apart = spot.x >= c.x + c.w + 16 || spot.x + spot.w + 16 <= c.x || spot.y >= c.y + c.h + 16 || spot.y + spot.h + 16 <= c.y; + assert.ok(apart, 'the spot does not touch a card'); + } + assert.deepEqual(spot, { x: 60, y: 116, w: 100, h: 100 }); +}); + +test('shows the logo of what a source step creates, like the rest of Coolify', () => { + const src = (type, data) => logoOf(node('n', type, data))?.src ?? null; + assert.equal(src('image', {}), '/svgs/resources/docker-image.svg'); + assert.equal(src('dockerfile', {}), '/svgs/resources/dockerfile.svg'); + assert.equal(src('database', { engine: 'postgresql' }), '/svgs/resources/postgres.svg'); + assert.equal(src('database', { engine: 'mysql' }), '/svgs/resources/mysql.svg'); + assert.equal(logoOf(node('n', 'database', { engine: 'keydb' })).dark, '/svgs/resources/keydb-dark.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'railpack' }), '/svgs/resources/public-repo.svg'); + assert.equal(src('git', { mode: 'github', buildPack: 'railpack' }), '/svgs/resources/github-app.svg'); + assert.equal(src('git', { mode: 'key', buildPack: 'railpack' }), '/svgs/resources/deploy-key.svg'); + assert.equal(src('git', { mode: 'public', buildPack: 'dockerfile' }), '/svgs/resources/dockerfile.svg'); + assert.equal(src('service', { mode: 'compose' }), '/svgs/resources/docker-compose.svg'); + // No engine or template yet, or a step that creates nothing: the step keeps its glyph. + assert.equal(src('database', {}), null); + assert.equal(src('server', {}), null); +}); + +test('reads the browser zoom shortcut so the canvas can zoom instead', () => { + const key = (key, mods = {}) => zoomShortcut({ key, ctrlKey: false, metaKey: false, altKey: false, ...mods }); + assert.equal(key('=', { metaKey: true }), 'in'); + assert.equal(key('+', { ctrlKey: true }), 'in'); + assert.equal(key('-', { ctrlKey: true }), 'out'); + assert.equal(key('0', { metaKey: true }), 'reset'); + assert.equal(key('-'), null); + assert.equal(key('-', { ctrlKey: true, altKey: true }), null); + assert.equal(key('s', { metaKey: true }), null); +}); diff --git a/resources/js/environment-canvas.js b/resources/js/environment-canvas.js new file mode 100644 index 0000000000..3e4311726b --- /dev/null +++ b/resources/js/environment-canvas.js @@ -0,0 +1,727 @@ +// Environment canvas (Beta): pure helpers and constants. The Svelte +// components live in canvas/environment/. +// +// DOM-free: `node --test` imports this file. + +import { apart, BOX_GAP, freeSpot, MAX_ZOOM, MIN_ZOOM } from './canvas-core.js'; + +export const CARD_WIDTH = 260; +export const COLUMN_GAP = 48; +export const ROW_GAP = 24; +export const ZONE_GAP = 72; +export const ZONE_PADDING = 24; +export const ZONE_HEADER = 40; +export const SERVER_ZONE_HEADER = 68; // server canvas: a second header row with host facts, proxy and usage +export const SERVER_ZONE_MIN_WIDTH = 560; // server canvas: room for the header (name, IP, status, host facts) above one card +export const MAX_CHILDREN = 5; +export const MAX_ROWS = 6; // auto placement wraps a type into another column after this many cards +export const NO_SERVER = 'No server'; +export const DOUBLE_CLICK_MS = 400; +export const SOURCES_ZONE = 'sources'; +export const CONNECTED_ZONE = 'connected'; + +const COLUMNS = { application: 0, service: 1, database: 2 }; +/** Connect view: shared variable and tag cards are link sources; every other card is a resource. */ +export const isSource = node => node?.type === 'variable' || node?.type === 'tag'; +const columnOf = node => COLUMNS[node?.type] ?? 0; +const columnLeft = (originX, column) => originX + column * (CARD_WIDTH + COLUMN_GAP); +const isPoint = p => !!p && Number.isFinite(Number(p.x)) && Number.isFinite(Number(p.y)); +/** Header height of a zone frame: server canvas zones show a second row of server details. */ +export const zoneHeader = zone => (zone?.details ? SERVER_ZONE_HEADER : ZONE_HEADER); +const storedPositions = graph => (graph?.positions && !Array.isArray(graph.positions) ? graph.positions : {}); + +// ── Pure helpers (tested) ───────────────────────────────────────────── + +/** Estimated card height in px; matches the rendered card closely enough for auto placement. */ +export function estimateNodeHeight(node) { + let height = 58; // padding + icon/name row + if (node?.urls?.length) height += 20; + if (node?.extra_zones?.length) height += 28; + const children = node?.children?.length || 0; + if (children) height += 12 + Math.min(children, MAX_CHILDREN) * 20 + (children > MAX_CHILDREN ? 20 : 0); + return height; +} + +/** Zones in display order (graph.zones order, then "No server"), each with its nodes. Empty zones are skipped. */ +export function zoneGroups(graph) { + const zones = graph?.zones || []; + const known = new Set(zones.map(z => z.id)); + const groups = zones.map(zone => ({ id: zone.id, name: zone.name, zone, nodes: [] })); + const none = { id: null, name: NO_SERVER, zone: null, nodes: [] }; + for (const node of graph?.nodes || []) { + if (node.zone !== null && node.zone !== undefined && known.has(node.zone)) { + groups.find(g => g.id === node.zone).nodes.push(node); + } else { + none.nodes.push(node); + } + } + return [...groups, none].filter(g => g.nodes.length); +} + +/** + * Card positions (top-left, world px) for every node. Stored positions win; + * other nodes are placed in their zone: applications left, services middle, + * databases right. Zones without stored cards stack below everything else. + */ +export function layoutGraph(graph) { + if (graph?.layout === 'server') return layoutServerView(graph); + if (graph?.layout === 'connect') return layoutConnectView(graph); + const stored = storedPositions(graph); + const out = {}; + const placedBottom = []; + const fresh = []; + for (const group of zoneGroups(graph)) { + const fixed = group.nodes.filter(n => isPoint(stored[n.id])); + if (!fixed.length) { + fresh.push(group); + continue; + } + for (const n of fixed) out[n.id] = { x: Number(stored[n.id].x), y: Number(stored[n.id].y) }; + const originX = Math.min(...fixed.map(n => out[n.id].x)); + const fixedBottom = Math.max(...fixed.map(n => out[n.id].y + estimateNodeHeight(n))); + // New cards go in a grid below the saved ones, so they never cover a saved card. + const bottom = placeGrid(group.nodes.filter(n => !out[n.id]), originX, fixedBottom + ROW_GAP, out); + placedBottom.push(Math.max(fixedBottom, bottom)); + } + let y = placedBottom.length ? Math.max(...placedBottom) + ZONE_PADDING + ZONE_GAP : 0; + for (const group of fresh) { + y = placeGrid(group.nodes, 0, y + ZONE_HEADER + ZONE_PADDING, out) + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Places nodes in a grid from (originX, top): applications, then services, then + * databases, each type in its own column(s) that wrap after MAX_ROWS cards. + * Returns the bottom of the lowest card (top when there are no nodes). + */ +function placeGrid(nodes, originX, top, out) { + let firstColumn = 0; + let bottom = top; + for (const column of [0, 1, 2]) { + const ofType = nodes.filter(n => columnOf(n) === column); + const nextY = []; + ofType.forEach((n, i) => { + const sub = Math.floor(i / MAX_ROWS); + nextY[sub] ??= top; + out[n.id] = { x: columnLeft(originX, firstColumn + sub), y: nextY[sub] }; + nextY[sub] += estimateNodeHeight(n) + ROW_GAP; + }); + firstColumn += nextY.length; + bottom = Math.max(bottom, ...nextY.map(v => v - ROW_GAP)); + } + return bottom; +} + +// ── Server view: project / environment groups, linked cards side by side ── + +export const GROUP_PADDING = 16; +export const GROUP_HEADER = 32; +export const GROUP_GAP = 32; +export const GROUPS_PER_ROW = 3; +export const SINGLE_COLUMNS = 3; + +const contextGroups = nodes => { + const groups = new Map(); + for (const n of nodes) { + const key = n.context || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(n); + } + // Server view: the unmanaged containers group comes first, so it is not lost below many projects. + const unmanaged = ([, members]) => (members[0]?.type === 'container' ? 0 : 1); + return [...groups.entries()].sort((a, b) => unmanaged(a) - unmanaged(b) || a[0].localeCompare(b[0])); +}; +const byTypeAndName = (a, b) => columnOf(a) - columnOf(b) || String(a.name).localeCompare(String(b.name)); + +/** + * Card positions inside one group, from (0, 0). Cards with a position saved on their environment + * canvas (node.saved) keep that arrangement; the other cards follow below them in the grid the environment + * canvas uses, so both canvases show the group the same way. Otherwise linked + * cards form chains from left to right (source before target, one column per step); every chain + * gets its own rows. Cards without links follow in a grid of SINGLE_COLUMNS. + */ +export function layoutBlock(nodes, links = []) { + const saved = nodes.filter(n => isPoint(n.saved)); + if (saved.length) { + const left = Math.min(...saved.map(n => Number(n.saved.x))); + const top = Math.min(...saved.map(n => Number(n.saved.y))); + const out = {}; + for (const n of saved) out[n.id] = { x: Number(n.saved.x) - left, y: Number(n.saved.y) - top }; + const width = Math.max(...saved.map(n => out[n.id].x)) + CARD_WIDTH; + const bottom = Math.max(...saved.map(n => out[n.id].y + estimateNodeHeight(n))); + const rest = nodes.filter(n => !out[n.id]); + if (!rest.length) return { positions: out, width, height: bottom }; + // Same grid as the environment canvas (layoutGraph) puts below the saved cards, so both canvases match. + const height = placeGrid(rest, 0, bottom + ROW_GAP, out); + return { positions: out, width: Math.max(...Object.values(out).map(p => p.x)) + CARD_WIDTH, height }; + } + const ids = new Set(nodes.map(n => n.id)); + const inside = links.filter(l => ids.has(l.source) && ids.has(l.target) && l.source !== l.target); + const parent = new Map(nodes.map(n => [n.id, n.id])); + const find = id => (parent.get(id) === id ? id : find(parent.get(id))); + for (const l of inside) parent.set(find(l.source), find(l.target)); + const order = [...nodes].sort(byTypeAndName); + const components = new Map(); + for (const n of order) { + const root = find(n.id); + if (!components.has(root)) components.set(root, []); + components.get(root).push(n); + } + + const out = {}; + const step = CARD_WIDTH + COLUMN_GAP; + let top = 0; + let columns = 0; + const singles = []; + for (const members of components.values()) { + if (members.length === 1) { + singles.push(members[0]); + continue; + } + // Depth: the longest path from a card without incoming links (cycles stop at the size of the chain). + const own = new Set(members.map(n => n.id)); + const edges = inside.filter(l => own.has(l.source)); + const depth = new Map(members.filter(n => !edges.some(l => l.target === n.id)).map(n => [n.id, 0])); + if (!depth.size) depth.set(members[0].id, 0); + for (let changed = true, rounds = 0; changed && rounds < members.length; rounds++) { + changed = false; + for (const l of edges) { + const next = (depth.get(l.source) ?? -1) + 1; + if (next > 0 && next > (depth.get(l.target) ?? -1)) { + depth.set(l.target, next); + changed = true; + } + } + } + const nextY = []; + for (const n of members) { + const d = depth.get(n.id) ?? 0; + nextY[d] ??= top; + out[n.id] = { x: d * step, y: nextY[d] }; + nextY[d] += estimateNodeHeight(n) + ROW_GAP; + } + columns = Math.max(columns, nextY.length); + top = Math.max(...nextY.filter(v => v !== undefined)) + ROW_GAP; + } + let rowHeight = 0; + singles.forEach((n, i) => { + const column = i % SINGLE_COLUMNS; + if (column === 0 && i > 0) { + top += rowHeight + ROW_GAP; + rowHeight = 0; + } + out[n.id] = { x: column * step, y: top }; + rowHeight = Math.max(rowHeight, estimateNodeHeight(n)); + }); + if (singles.length) columns = Math.max(columns, Math.min(singles.length, SINGLE_COLUMNS)); + const bottom = singles.length ? top + rowHeight : Math.max(0, top - ROW_GAP); + return { positions: out, width: columns * step - COLUMN_GAP, height: bottom }; +} + +/** + * Server view: key of a project / environment group on one server (":"). + * Groups of an empty environment (placeholder card) have none, so they cannot be moved. + */ +export const groupKey = (nodes, zoneId) => { + const environment = nodes.find(n => n.type !== 'empty' && n.environment?.uuid)?.environment?.uuid; + return environment && zoneId ? `${environment}:${zoneId}` : null; +}; + +/** + * Server view: zones stack downwards. A group with cards saved on its environment canvas (node.saved) sits where + * those cards are, so the server canvas and the environment canvas share one position (a group that would + * leave the zone or cover another group moves to the nearest free spot). Other groups follow below, + * GROUPS_PER_ROW per row. When `groups` is given, it gets per group key its frame top-left, the zone's inner + * top-left, and `shift`: environment canvas position = server view position - shift. + */ +export function layoutServerView(graph, groups = null, { compact = false } = {}) { + const out = {}; + let y = 0; + for (const zone of zoneGroups(graph)) { + const inner = { x: 0, y: y + zoneHeader(zone.zone) + ZONE_PADDING }; + const blocks = contextGroups(zone.nodes).map(([, nodes]) => { + const saved = nodes.filter(n => isPoint(n.saved)); + const block = layoutBlock(nodes, graph.links || []); + return { + key: groupKey(nodes, zone.id), + nodes, + block, + size: { w: block.width + GROUP_PADDING * 2, h: GROUP_HEADER + GROUP_PADDING * 2 + block.height }, + at: saved.length ? { x: Math.min(...saved.map(n => Number(n.saved.x))), y: Math.min(...saved.map(n => Number(n.saved.y))) } : null, + }; + }); + // Empty environments (placeholder cards only) go next to the other groups after these are placed. + const isEmpty = b => b.nodes.every(n => n.type === 'empty'); + const pinned = blocks.filter(b => b.at).sort((a, b) => a.at.y - b.at.y || a.at.x - b.at.x); + // The environment canvas puts its first cards below a zone header; those land at the zone's top-left here. + const shift = { x: inner.x + GROUP_PADDING, y: inner.y + GROUP_HEADER + GROUP_PADDING - ZONE_HEADER - ZONE_PADDING }; + const frames = []; + const place = ({ key, block, size }, left, top) => { + frames.push({ x: left, y: top }); + for (const [id, p] of Object.entries(block.positions)) { + out[id] = { x: left + GROUP_PADDING + p.x, y: top + GROUP_HEADER + GROUP_PADDING + p.y }; + } + if (groups && key) groups[key] = { x: left, y: top, inner, shift }; + return top + size.h; + }; + const taken = []; + const inside = b => b.x >= inner.x && b.y >= inner.y; + let bottom = inner.y; + for (const b of pinned) { + // Environment canvas positions can be left of or above the zone (negative coordinates): keep the group inside. + const wanted = { + x: Math.max(inner.x, b.at.x + shift.x - GROUP_PADDING), + y: Math.max(inner.y, b.at.y + shift.y - GROUP_HEADER - GROUP_PADDING), + ...b.size, + }; + const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; + taken.push({ ...spot, project: b.nodes[0]?.project?.uuid }); + bottom = Math.max(bottom, place(b, spot.x, spot.y)); + } + let x = 0; + let rowTop = pinned.length ? bottom + GROUP_GAP : inner.y; + let rowBottom = rowTop; + blocks.filter(b => !b.at && !isEmpty(b)).forEach((b, i) => { + if (i > 0 && i % GROUPS_PER_ROW === 0) { + x = 0; + rowTop = rowBottom + GROUP_GAP; + } + taken.push({ x, y: rowTop, ...b.size, project: b.nodes[0]?.project?.uuid }); + rowBottom = Math.max(rowBottom, place(b, x, rowTop)); + x += b.size.w + GROUP_GAP; + }); + bottom = Math.max(bottom, rowBottom); + // Right of the last group of the same project (or of the zone), at the nearest free spot. + for (const b of blocks.filter(b => !b.at && isEmpty(b))) { + const project = b.nodes[0]?.project?.uuid; + const anchor = taken.filter(t => t.project === project).at(-1) || taken.at(-1); + const wanted = anchor ? { x: anchor.x + anchor.w + GROUP_GAP, y: anchor.y, ...b.size } : { ...inner, ...b.size }; + const spot = freeSpot(wanted, taken, GROUP_GAP, inside) || wanted; + taken.push({ ...spot, project }); + bottom = Math.max(bottom, place(b, spot.x, spot.y)); + } + if (compact && frames.length) { + // The groups keep their arrangement, but start at the zone's top-left (no empty space from saved positions). + const dx = inner.x - Math.min(...frames.map(f => f.x)); + const dy = inner.y - Math.min(...frames.map(f => f.y)); + for (const n of zone.nodes) if (out[n.id]) out[n.id] = { x: out[n.id].x + dx, y: out[n.id].y + dy }; + bottom += dy; + } + y = bottom + ZONE_PADDING + ZONE_GAP; + } + return out; +} + +/** + * Shared variables view: variable cards in one column on the left (by name). Resources that use a + * variable sit next to them in one column, grouped by project / environment, so every link runs + * straight from left to right. Other resources follow below in the server view grid. + */ +export function layoutConnectView(graph) { + const out = {}; + const groups = zoneGroups(graph); + const variables = groups.find(g => g.id === SOURCES_ZONE); + const connected = groups.find(g => g.id === CONNECTED_ZONE); + let y = ZONE_HEADER + ZONE_PADDING; + for (const n of [...(variables?.nodes || [])].sort((a, b) => String(a.name).localeCompare(String(b.name)))) { + out[n.id] = { x: 0, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + const offset = variables ? CARD_WIDTH + ZONE_PADDING * 2 + ZONE_GAP * 2 : 0; + let top = 0; + if (connected) { + y = ZONE_HEADER + ZONE_PADDING; + for (const [, nodes] of contextGroups(connected.nodes)) { + y += GROUP_HEADER + GROUP_PADDING; + for (const n of [...nodes].sort(byTypeAndName)) { + out[n.id] = { x: offset + GROUP_PADDING, y }; + y += estimateNodeHeight(n) + ROW_GAP; + } + y += GROUP_PADDING - ROW_GAP + GROUP_GAP; + } + top = y - GROUP_GAP + ZONE_PADDING + ZONE_GAP; + } + const others = groups.filter(g => g.id !== SOURCES_ZONE && g.id !== CONNECTED_ZONE); + // Compact: groups cannot be moved here, so positions saved on the environment canvas must not leave gaps. + const placed = layoutServerView({ zones: others.map(g => g.zone || { id: g.id }), nodes: others.flatMap(g => g.nodes) }, null, { compact: true }); + for (const [id, p] of Object.entries(placed)) out[id] = { x: p.x + offset, y: p.y + top }; + return out; +} + +/** + * Shared variables view: moves the resources that use a shown variable into the "connected" zone, + * so they show apart from the other resources. Runs after filterGraph and after every link change. + */ +export function groupByUse(graph) { + if (graph?.layout !== 'connect') return graph; + const used = new Set((graph.links || []).map(l => l.target)); + return { + ...graph, + nodes: (graph.nodes || []).map(n => (!isSource(n) && used.has(n.id) ? { ...n, zone: CONNECTED_ZONE } : n)), + }; +} + +/** Frames of the project / environment groups of the given cards (server view). */ +export function groupFrames(nodes, positions, sizes = {}) { + const frames = []; + for (const [name, members] of contextGroups(nodes)) { + const rects = members.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + frames.push({ + name, + key: groupKey(members, members[0].zone), + zone: members[0].zone, + link: members[0].context_link, + x: minX - GROUP_PADDING, + y: minY - GROUP_PADDING - GROUP_HEADER, + width: Math.max(...rects.map(r => r.x + r.width)) - minX + GROUP_PADDING * 2, + height: Math.max(...rects.map(r => r.y + r.height)) - minY + GROUP_PADDING * 2 + GROUP_HEADER, + }); + } + return frames; +} + +/** Card rect from positions and measured sizes (falls back to the estimate). */ +export function cardRect(node, positions, sizes = {}) { + const p = positions?.[node.id]; + if (!p) return null; + return { + x: p.x, + y: p.y, + width: sizes[node.id]?.width ?? CARD_WIDTH, + height: sizes[node.id]?.height ?? estimateNodeHeight(node), + }; +} + +/** Frame per zone: bbox of its cards (or of its group frames) + padding, with room for the header on top. */ +export function zoneBoxes(graph, positions, sizes = {}) { + const boxes = []; + for (const group of zoneGroups(graph)) { + const rects = group.nodes.some(n => n.context) + ? groupFrames(group.nodes, positions, sizes) + : group.nodes.map(n => cardRect(n, positions, sizes)).filter(Boolean); + if (!rects.length) continue; + const minX = Math.min(...rects.map(r => r.x)); + const minY = Math.min(...rects.map(r => r.y)); + const maxX = Math.max(...rects.map(r => r.x + r.width)); + const maxY = Math.max(...rects.map(r => r.y + r.height)); + boxes.push({ + id: group.id, + name: group.name, + zone: group.zone, + nodes: group.nodes.map(n => n.id), + x: minX - ZONE_PADDING, + y: minY - ZONE_PADDING - zoneHeader(group.zone), + width: Math.max(maxX - minX + ZONE_PADDING * 2, group.zone?.details ? SERVER_ZONE_MIN_WIDTH : 0), + height: maxY - minY + ZONE_PADDING * 2 + zoneHeader(group.zone), + }); + } + return boxes; +} + +const toBox = r => ({ x: r.x, y: r.y, w: r.width, h: r.height }); +// A frame reaches `side` past its cards left, right and below, and `top` above them. +const grow = (b, side, top) => ({ x: b.x - side, y: b.y - top, w: b.w + side * 2, h: b.h + side + top }); +const frameAround = (boxes, side, top) => { + const x = Math.min(...boxes.map(b => b.x)); + const y = Math.min(...boxes.map(b => b.y)); + return grow({ x, y, w: Math.max(...boxes.map(b => b.x + b.w)) - x, h: Math.max(...boxes.map(b => b.y + b.h)) - y }, side, top); +}; + +/** + * What a dragged card must keep clear of, for freeSpot(): `others` are the + * other cards and the other zone frames (and, server view, the other project / + * environment frames in its zone), grown by the room the card's own frames + * take around it. `fits` checks the card's own frames, which also span its + * other cards, against those frames. + */ +export function dragRules(graph, positions, sizes, id) { + const node = graph.nodes.find(n => n.id === id); + const own = zoneGroups(graph).find(z => z.nodes.some(n => n.id === id)); + const grouped = !!own?.nodes.some(n => n.context); + const context = n => n.context || ''; + const rects = nodes => nodes.filter(n => n.id !== id).map(n => cardRect(n, positions, sizes)).filter(Boolean).map(toBox); + const groupSide = grouped ? GROUP_PADDING : 0; + const groupTop = grouped ? GROUP_PADDING + GROUP_HEADER : 0; + const side = ZONE_PADDING + groupSide; + const top = ZONE_PADDING + zoneHeader(own?.zone) + groupTop; + const zones = zoneBoxes(graph, positions, sizes).filter(z => !z.nodes.includes(id)).map(toBox); + const groups = grouped ? groupFrames(own.nodes.filter(n => context(n) !== context(node)), positions, sizes).map(toBox) : []; + const zoneMates = rects(own?.nodes || []); + const groupMates = grouped ? rects(own.nodes.filter(n => context(n) === context(node))) : []; + // The other box must keep the frame's room on the side that faces the card. + const room = (b, s, t) => ({ x: b.x - s, y: b.y - s, w: b.w + s * 2, h: b.h + s + t }); + return { + others: [...rects(graph.nodes), ...zones.map(z => room(z, side, top)), ...groups.map(g => room(g, groupSide, groupTop))], + fits: box => zones.every(z => apart(frameAround([...zoneMates, box], side, top), z, BOX_GAP)) + // Same gap between groups as layoutServerView, so a reload does not move the group. + && (!grouped || groups.every(g => apart(frameAround([...groupMates, box], groupSide, groupTop), g, GROUP_GAP))), + }; +} + +/** + * SVG path between two card rects. Target right of source: right edge → + * left edge; target left of source: left edge → right edge; overlapping + * columns (stacked): bottom → top (or top → bottom when the target is above). + */ +export function linkPath(a, b) { + let side; + let from; + let to; + let c1; + let c2; + if (b.x >= a.x + a.width) { + side = 'right'; + from = { x: a.x + a.width, y: a.y + a.height / 2 }; + to = { x: b.x, y: b.y + b.height / 2 }; + } else if (b.x + b.width <= a.x) { + side = 'left'; + from = { x: a.x, y: a.y + a.height / 2 }; + to = { x: b.x + b.width, y: b.y + b.height / 2 }; + } else if (b.y + b.height / 2 >= a.y + a.height / 2) { + side = 'down'; + from = { x: a.x + a.width / 2, y: a.y + a.height }; + to = { x: b.x + b.width / 2, y: b.y }; + } else { + side = 'up'; + from = { x: a.x + a.width / 2, y: a.y }; + to = { x: b.x + b.width / 2, y: b.y + b.height }; + } + if (side === 'right' || side === 'left') { + const dx = Math.max(40, Math.abs(to.x - from.x) / 2) * (side === 'right' ? 1 : -1); + c1 = { x: from.x + dx, y: from.y }; + c2 = { x: to.x - dx, y: to.y }; + } else { + const dy = Math.max(40, Math.abs(to.y - from.y) / 2) * (side === 'down' ? 1 : -1); + c1 = { x: from.x, y: from.y + dy }; + c2 = { x: to.x, y: to.y - dy }; + } + const mid = { + x: (from.x + 3 * c1.x + 3 * c2.x + to.x) / 8, + y: (from.y + 3 * c1.y + 3 * c2.y + to.y) / 8, + }; + const r = v => Math.round(v * 10) / 10; + const d = `M ${r(from.x)} ${r(from.y)} C ${r(c1.x)} ${r(c1.y)}, ${r(c2.x)} ${r(c2.y)}, ${r(to.x)} ${r(to.y)}`; + return { d, side, from, to, mid }; +} + +const BUSY = ['starting', 'restarting', 'created', 'removing', 'in_progress', 'queued', 'deploying', 'building']; +const BAD = ['exited', 'stopped', 'dead', 'failed', 'error']; + +/** Coolify status string → 'ok' | 'warn' | 'bad' | 'idle' | 'busy'. */ +export function statusTone(status) { + const value = String(status ?? '').trim().toLowerCase(); + if (!value) return 'idle'; + const [state, health = ''] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + if (BUSY.includes(state)) return 'busy'; + if (BAD.includes(state)) return 'bad'; + if (state === 'degraded') return 'warn'; + if (state === 'running' || state === 'healthy') return health === 'unhealthy' ? 'warn' : 'ok'; + return 'idle'; +} + +export const READABLE_ZOOM = 0.6; +const FIT_PAD = 48; + +/** + * View ({x, y, z}) that shows the content bounds in the free part of the viewport ({left, top, width, height}). + * `readable`: never below READABLE_ZOOM; content that does not fit then starts at its top-left, + * so the cards can be read and the user scrolls to the rest. + */ +export function fitView(bounds, area, readable = false) { + const w = bounds.maxX - bounds.minX; + const h = bounds.maxY - bounds.minY; + const room = { w: area.width - FIT_PAD * 2, h: area.height - FIT_PAD * 2 }; + const whole = Math.min(room.w / w, room.h / h); + const z = Math.min(1.1, Math.max(readable ? READABLE_ZOOM : MIN_ZOOM, whole)); + const x = w * z <= room.w ? area.left + (area.width - w * z) / 2 : area.left + FIT_PAD; + const y = h * z <= room.h ? area.top + (area.height - h * z) / 2 : area.top + FIT_PAD; + return { z, x: x - bounds.minX * z, y: y - bounds.minY * z }; +} + +/** Resource cards per status tone ({ok, warn, bad, busy, idle}); unmanaged containers and placeholder cards are not counted. */ +export function statusSummary(nodes) { + const out = { ok: 0, warn: 0, bad: 0, busy: 0, idle: 0 }; + for (const n of nodes || []) if (n.type !== 'container' && n.type !== 'empty' && !isSource(n)) out[statusTone(n.status)]++; + return out; +} + +/** "running:healthy" → "Running": the state word for the status text on a card. */ +export function statusWord(status) { + return statusLabel(status).replace(/\s*\(.*$/, ''); +} + +/** 1007820800 → "1 GB", 17179869184 → "16 GB". */ +export function formatMemory(bytes) { + if (!bytes) return null; + const gb = bytes / 1024 ** 3; + return `${gb < 10 ? Math.round(gb * 10) / 10 : Math.round(gb)} GB`; +} + +/** Time since a "YYYY-MM-DD HH:MM:SS" (UTC) or ISO date, as "45m", "5h", "12d". */ +export function formatUptime(since, now = Date.now()) { + const at = Date.parse(String(since || '').replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(since || '') ? '' : 'Z')); + if (!Number.isFinite(at)) return null; + const minutes = Math.max(0, Math.floor((now - at) / 60000)); + if (minutes < 60) return `${minutes}m`; + if (minutes < 60 * 24) return `${Math.floor(minutes / 60)}h`; + return `${Math.floor(minutes / 1440)}d`; +} + +/** + * Ids of the server zones whose server is not reachable. The saved status of their resources is stale, + * so the canvas shows it as unknown. Zones with an icon (projects, connect view) are not servers. + */ +export function unreachableZones(graph) { + return new Set((graph?.zones || []).filter(z => !z.icon && z.is_reachable === false).map(z => z.id)); +} + +/** "running:healthy" → "Running (healthy)". */ +export function statusLabel(status) { + const value = String(status ?? '').trim(); + if (!value) return 'Unknown'; + const [state, health] = value.replace(/\)$/, '').split(/\s*[:(]\s*/); + const word = state.charAt(0).toUpperCase() + state.slice(1).replace(/_/g, ' '); + return health ? `${word} (${health})` : word; +} + +/** + * Connect and server views: projects of the resources, each with its environments, sorted by name. + * @returns {{uuid: string, name: string, environments: {uuid: string, name: string}[]}[]} + */ +export function filterOptions(graph) { + const projects = new Map(); + for (const n of graph?.nodes || []) { + if (!n.project || !n.environment) continue; + if (!projects.has(n.project.uuid)) projects.set(n.project.uuid, { ...n.project, environments: new Map() }); + projects.get(n.project.uuid).environments.set(n.environment.uuid, n.environment); + } + const byName = (a, b) => String(a.name).localeCompare(String(b.name)); + return [...projects.values()] + .map(p => ({ uuid: p.uuid, name: p.name, environments: [...p.environments.values()].sort(byName) })) + .sort(byName); +} + +/** + * Connect and server views: only the resources of the chosen project / environment and their links. + * Variables always stay, so they can be added to the shown resources. + */ +export function filterGraph(graph, filter = {}) { + if (!graph || (!filter.project && !filter.environment)) return graph; + const keep = n => isSource(n) + || ((!filter.project || n.project?.uuid === filter.project) && (!filter.environment || n.environment?.uuid === filter.environment)); + const nodes = (graph.nodes || []).filter(keep); + const ids = new Set(nodes.map(n => n.id)); + return { ...graph, nodes, links: (graph.links || []).filter(l => ids.has(l.source) && ids.has(l.target)) }; +} + +/** + * Connect view: links go from a shared variable (not a deleted one) or a tag to a resource. + * Server view: links are saved on an environment canvas, so both cards must be in the same environment. + */ +export function canConnect(graph, source, target) { + if (graph?.layout === 'server') { + const environment = graph.nodes?.find(n => n.id === source)?.environment?.uuid; + return !!environment && source !== target && graph.nodes.find(n => n.id === target)?.environment?.uuid === environment; + } + if (graph?.layout !== 'connect') return !!source && !!target && source !== target; + const from = graph.nodes?.find(n => n.id === source); + const to = graph.nodes?.find(n => n.id === target); + return isSource(from) && !from.missing && !!to && !isSource(to); +} + +/** A link may be drawn between two different nodes that are not linked yet (either direction). */ +export function canLink(links, source, target) { + if (!source || !target || source === target) return false; + return !(links || []).some(l => (l.source === source && l.target === target) || (l.source === target && l.target === source)); +} + +// ── Card and panel constants (used by the Svelte components) ────────── + +// Application and service events go to the team channel; database events go to the user channel. +export const STATUS_CHANNELS = [['team', ['ApplicationStatusChanged', 'ServiceStatusChanged']], ['user', ['DatabaseStatusChanged']]]; +export const TYPE_LABELS = { application: 'Application', service: 'Service', database: 'Database', container: 'Unmanaged container', variable: 'Shared variable', tag: 'Tag' }; +// Wording of the connect view, per data-mode (shared variables or tags). +export const CONNECT_TEXT = { + variables: { + source: 'Variable', + added: (source, target) => `Added ${source} to ${target}. Redeploy it to use the variable.`, + removed: (source, target) => `Removed ${source} from ${target}. Redeploy it to apply.`, + already: (source, target) => `${target} already uses ${source}.`, + usedBy: 'Used by', + noTargets: 'No resource uses this variable.', + allTargets: 'Every resource already uses this variable.', + port: 'Drag to a resource to add this variable to it', + noSources: 'This resource uses no shared variable of this scope.', + sources: 'Shared variables', + addSources: 'Add shared variables', + searchSources: 'Search variables', + noMoreSources: 'No other shared variable to add.', + note: node => `Adding creates a ${node.name} variable with the value ${node.reference} on the resource. Redeploy or restart the resource to apply changes.`, + viewOnly: 'You can look around. Only team admins can add shared variables to resources.', + empty: 'No shared variables or resources yet', + }, + tags: { + source: 'Tag', + added: (source, target) => `Tagged ${target} with ${source}.`, + removed: (source, target) => `Removed the ${source} tag from ${target}.`, + already: (source, target) => `${target} already has the ${source} tag.`, + usedBy: 'Tagged', + noTargets: 'No resource has this tag.', + allTargets: 'Every resource already has this tag.', + port: 'Drag to a resource to tag it', + noSources: 'This resource has no tag.', + sources: 'Tags', + addSources: 'Add tags', + searchSources: 'Search tags', + noMoreSources: 'No other tag to add. Create tags on the Tags page.', + note: () => 'The deploy webhook of a tag deploys every tagged application and service.', + viewOnly: 'You can look around. Only team admins can tag resources.', + empty: 'No tags or resources yet', + }, +}; +export const TONE_DOT = { + ok: 'bg-emerald-500', + warn: 'bg-warning', + bad: 'bg-red-500', + idle: 'bg-neutral-400 dark:bg-neutral-500', + busy: 'bg-warning', +}; +// Outline icons, drawn from local paths. +export const ICONS = { + chevron: ['M6 15l6-6 6 6'], + application: ['M3 6.5A2.5 2.5 0 0 1 5.5 4h13A2.5 2.5 0 0 1 21 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-13A2.5 2.5 0 0 1 3 17.5z', 'M3 9h18', 'M9.5 13l-2 2 2 2', 'M14.5 13l2 2-2 2'], + service: ['M12 4 3 8.5l9 4.5 9-4.5z', 'M3 12.5l9 4.5 9-4.5', 'M3 16.5l9 4.5 9-4.5'], + database: ['M5 6c0-1.66 3.13-3 7-3s7 1.34 7 3-3.13 3-7 3-7-1.34-7-3z', 'M5 6v12c0 1.66 3.13 3 7 3s7-1.34 7-3V6', 'M5 12c0 1.66 3.13 3 7 3s7-1.34 7-3'], + container: ['M12 3 4 7.5v9L12 21l8-4.5v-9z', 'M4 7.5 12 12l8-4.5', 'M12 12v9'], + server: ['M4 4h16v6H4z', 'M4 14h16v6H4z', 'M7.5 7h.01', 'M7.5 17h.01'], + refresh: ['M20 11a8 8 0 0 0-14.9-3.9', 'M4 4v4h4', 'M4 13a8 8 0 0 0 14.9 3.9', 'M20 20v-4h-4'], + plus: ['M12 5v14', 'M5 12h14'], + minus: ['M5 12h14'], + fit: ['M4 9V4h5', 'M20 9V4h-5', 'M4 15v5h5', 'M20 15v5h-5'], + x: ['M6 6l12 12', 'M18 6 6 18'], + external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4'], + link: ['M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1 1', 'M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1-1'], + variable: ['M8 4H7a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2 2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1', 'M16 4h1a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2 2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-1'], + search: ['M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14z', 'M20 20l-4-4'], + tag: ['M3 12V4a1 1 0 0 1 1-1h8l9 9-9 9z', 'M7.5 7.5h.01'], + info: ['M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z', 'M12 11v5', 'M12 8h.01'], +}; + +/** A saved { x, y, z } canvas view, or null when it is missing or not valid. */ +export function readView(key, storage = globalThis.localStorage) { + try { + const v = JSON.parse(storage?.getItem(key) ?? 'null'); + const ok = v && [v.x, v.y, v.z].every(Number.isFinite) && v.z >= MIN_ZOOM && v.z <= MAX_ZOOM; + return ok ? { x: v.x, y: v.y, z: v.z } : null; + } catch { + return null; + } +} + +// Small screens: a notice that the canvas is not optimized for mobile. Shown below 768px (md); +// closed once, it stays closed on every canvas (same key as the deploy canvas notice). +export const MOBILE_NOTICE_KEY = 'coolify-canvas:mobile-notice-dismissed'; diff --git a/resources/js/environment-canvas.test.js b/resources/js/environment-canvas.test.js new file mode 100644 index 0000000000..e88dcd9904 --- /dev/null +++ b/resources/js/environment-canvas.test.js @@ -0,0 +1,670 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CARD_WIDTH, + GROUP_GAP, + MAX_ROWS, + COLUMN_GAP, + ROW_GAP, + SERVER_ZONE_MIN_WIDTH, + ZONE_HEADER, + ZONE_PADDING, + ZONE_GAP, + canConnect, + canLink, + dragRules, + filterGraph, + groupByUse, + filterOptions, + fitView, + formatMemory, + formatUptime, + SERVER_ZONE_HEADER, + statusSummary, + statusWord, + READABLE_ZOOM, + groupFrames, + groupKey, + layoutBlock, + estimateNodeHeight, + layoutGraph, + layoutServerView, + linkPath, + statusLabel, + statusTone, + zoneBoxes, + readView, + unreachableZones, +} from './environment-canvas.js'; +import { freeSpot } from './canvas-core.js'; + +const node = (id, type, zone, extra = {}) => ({ id, type, zone, name: id, status: 'running:healthy', extra_zones: [], urls: [], children: [], ...extra }); +const graph = (nodes, extra = {}) => ({ + zones: [{ id: 's1', name: 'One' }, { id: 's2', name: 'Two' }], + nodes, + links: [], + positions: {}, + ...extra, +}); +const column = n => n * (CARD_WIDTH + COLUMN_GAP); + +test('places applications, services, and databases in their own columns', () => { + const p = layoutGraph(graph([node('db', 'database', 's1'), node('app', 'application', 's1'), node('svc', 'service', 's1')])); + assert.equal(p.app.x, column(0)); + assert.equal(p.svc.x, column(1)); + assert.equal(p.db.x, column(2)); + const top = ZONE_HEADER + ZONE_PADDING; + assert.deepEqual([p.app.y, p.svc.y, p.db.y], [top, top, top]); +}); + +test('stacks rows of the same type inside a column', () => { + const first = node('a1', 'application', 's1', { urls: ['https://a.test'] }); + const p = layoutGraph(graph([first, node('a2', 'application', 's1')])); + assert.equal(p.a2.x, p.a1.x); + assert.equal(p.a2.y, p.a1.y + estimateNodeHeight(first) + ROW_GAP); +}); + +test('wraps a long list into another column and shifts later types right', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`a${i}`, 'application', 's1')); + const p = layoutGraph(graph([...apps, node('db', 'database', 's1')])); + assert.equal(p[`a${MAX_ROWS - 1}`].x, column(0)); + assert.deepEqual(p[`a${MAX_ROWS}`], { x: column(1), y: ZONE_HEADER + ZONE_PADDING }); + assert.equal(p.db.x, column(2)); +}); + +test('stacks zones in graph order with the "No server" zone last', () => { + const p = layoutGraph(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')])); + assert.ok(p.a.y < p.b.y, 'first server zone on top'); + assert.ok(p.b.y < p.orphan.y, 'no-server zone at the bottom'); + const boxes = zoneBoxes(graph([node('orphan', 'application', null), node('b', 'application', 's2'), node('a', 'application', 's1')]), p); + assert.deepEqual(boxes.map(b => b.name), ['One', 'Two', 'No server']); + for (let i = 1; i < boxes.length; i++) assert.ok(boxes[i].y > boxes[i - 1].y + boxes[i - 1].height, 'zones do not overlap'); +}); + +test('treats a zone id that is not in the zone list as no server', () => { + const g = graph([node('x', 'database', 'gone')]); + assert.deepEqual(zoneBoxes(g, layoutGraph(g)).map(b => b.id), [null]); +}); + +test('stored positions win over auto placement', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: { app: { x: 500, y: -40 } } })); + assert.deepEqual(p.app, { x: 500, y: -40 }); +}); + +test('treats an empty positions list from PHP as nothing stored', () => { + const p = layoutGraph(graph([node('app', 'application', 's1')], { positions: [] })); + assert.equal(p.app.x, 0); +}); + +test('places new nodes in a wrapped grid below the stored cards of their zone', () => { + const stored = node('a1', 'application', 's1'); + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => node(`n${i}`, 'application', 's1')); + const p = layoutGraph(graph([stored, ...apps, node('d1', 'database', 's1')], { positions: { a1: { x: 100, y: 200 } } })); + const top = 200 + estimateNodeHeight(stored) + ROW_GAP; + assert.deepEqual(p.n0, { x: 100, y: top }); + assert.deepEqual(p[`n${MAX_ROWS}`], { x: 100 + column(1), y: top }, 'wraps after MAX_ROWS cards'); + assert.deepEqual(p.d1, { x: 100 + column(2), y: top }, 'databases go right of the application columns'); +}); + +test('auto-placed zones go below zones with stored cards', () => { + const p = layoutGraph(graph([node('a', 'application', 's1'), node('b', 'application', 's2')], { positions: { a: { x: 0, y: 1000 } } })); + assert.ok(p.b.y > 1000 + estimateNodeHeight(node('a', 'application', 's1')) + ZONE_PADDING + ZONE_HEADER); +}); + +test('estimates taller cards for urls, chips, and children', () => { + const plain = estimateNodeHeight(node('a', 'service', 's1')); + const rich = estimateNodeHeight(node('b', 'service', 's1', { urls: ['https://x'], extra_zones: ['s2'], children: [{ name: 'c' }, { name: 'd' }] })); + assert.ok(rich > plain); + const capped = estimateNodeHeight(node('c', 'service', 's1', { children: Array.from({ length: 20 }, (_, i) => ({ name: `c${i}` })) })); + const six = estimateNodeHeight(node('d', 'service', 's1', { children: Array.from({ length: 6 }, (_, i) => ({ name: `c${i}` })) })); + assert.equal(capped, six, 'children beyond the cap add one "+N more" row'); +}); + +test('zone boxes wrap their cards with padding and header', () => { + const g = graph([node('a', 'application', 's1'), node('d', 'database', 's1')]); + const positions = { a: { x: 0, y: 0 }, d: { x: 400, y: 50 } }; + const sizes = { a: { width: 260, height: 100 }, d: { width: 260, height: 80 } }; + const [box] = zoneBoxes(g, positions, sizes); + assert.deepEqual( + { id: box.id, x: box.x, y: box.y, width: box.width, height: box.height, nodes: box.nodes }, + { id: 's1', x: -ZONE_PADDING, y: -ZONE_PADDING - ZONE_HEADER, width: 660 + ZONE_PADDING * 2, height: 130 + ZONE_PADDING * 2 + ZONE_HEADER, nodes: ['a', 'd'] }, + ); +}); + +test('server canvas zone boxes are wide enough for their header, also with one card', () => { + const g = graph([node('a', 'application', 's1')], { zones: [{ id: 's1', name: 'One', details: {} }] }); + const [box] = zoneBoxes(g, { a: { x: 0, y: 0 } }, { a: { width: 260, height: 100 } }); + assert.equal(box.x, -ZONE_PADDING); + assert.equal(box.width, SERVER_ZONE_MIN_WIDTH); +}); + +test('zone boxes skip zones without cards', () => { + assert.deepEqual(zoneBoxes(graph([node('a', 'application', 's2')]), { a: { x: 0, y: 0 } }).map(b => b.id), ['s2']); +}); + +const rect = (x, y) => ({ x, y, width: 260, height: 100 }); + +test('links go from the right edge to the left edge when the target is on the right', () => { + const path = linkPath(rect(0, 0), rect(400, 200)); + assert.equal(path.side, 'right'); + assert.deepEqual(path.from, { x: 260, y: 50 }); + assert.deepEqual(path.to, { x: 400, y: 250 }); + assert.match(path.d, /^M 260 50 C /); +}); + +test('links go from the left edge to the right edge when the target is on the left', () => { + const path = linkPath(rect(400, 0), rect(0, 0)); + assert.equal(path.side, 'left'); + assert.deepEqual(path.from, { x: 400, y: 50 }); + assert.deepEqual(path.to, { x: 260, y: 50 }); +}); + +test('links run vertically between stacked cards', () => { + const down = linkPath(rect(0, 0), rect(40, 300)); + assert.equal(down.side, 'down'); + assert.deepEqual([down.from, down.to], [{ x: 130, y: 100 }, { x: 170, y: 300 }]); + const up = linkPath(rect(0, 300), rect(0, 0)); + assert.equal(up.side, 'up'); + assert.deepEqual([up.from, up.to], [{ x: 130, y: 300 }, { x: 130, y: 100 }]); +}); + +test('link midpoint sits between both ends', () => { + const { mid } = linkPath(rect(0, 0), rect(600, 0)); + assert.deepEqual(mid, { x: 430, y: 50 }); +}); + +test('maps Coolify statuses to tones', () => { + const cases = { + 'running:healthy': 'ok', + 'running:unknown': 'ok', + running: 'ok', + 'running (healthy)': 'ok', + 'running:unhealthy': 'warn', + 'degraded:unhealthy': 'warn', + 'exited:unhealthy': 'bad', + exited: 'bad', + stopped: 'bad', + restarting: 'busy', + 'starting:unknown': 'busy', + paused: 'idle', + '': 'idle', + null: 'idle', + }; + for (const [status, tone] of Object.entries(cases)) { + assert.equal(statusTone(status === 'null' ? null : status), tone, status); + } +}); + +test('formats status labels', () => { + assert.equal(statusLabel('running:healthy'), 'Running (healthy)'); + assert.equal(statusLabel('exited'), 'Exited'); + assert.equal(statusLabel(''), 'Unknown'); +}); + +test('refuses self links and duplicates in either direction', () => { + const links = [{ id: 'l1', source: 'a', target: 'b', label: null }]; + assert.equal(canLink(links, 'a', 'a'), false); + assert.equal(canLink(links, 'a', 'b'), false); + assert.equal(canLink(links, 'b', 'a'), false); + assert.equal(canLink(links, 'a', 'c'), true); +}); + +test('a server view card has the same height as on the environment canvas', () => { + const card = { id: 'a', type: 'application' }; + // The group frame names the project / environment, so the card has no extra line for it. + assert.equal(estimateNodeHeight({ ...card, context: 'Acme / production' }), estimateNodeHeight(card)); +}); + +// ── Server view ── +const srv = (id, type, context, extra = {}) => ({ id, type, name: id, zone: 'z1', context, ...extra }); + +test('server view: a chain of linked cards runs left to right, source before target', () => { + const nodes = [srv('db', 'database', 'A / prod'), srv('web', 'application', 'A / prod'), srv('api', 'application', 'A / prod')]; + const { positions } = layoutBlock(nodes, [{ source: 'web', target: 'api' }, { source: 'api', target: 'db' }]); + assert.equal(positions.web.y, positions.api.y); + assert.equal(positions.api.y, positions.db.y); + assert.ok(positions.web.x < positions.api.x && positions.api.x < positions.db.x); + assert.equal(positions.api.x - positions.web.x, CARD_WIDTH + COLUMN_GAP); +}); + +test('server view: cards without links go below the chains, in a grid', () => { + const nodes = [srv('a', 'application', 'g'), srv('b', 'application', 'g'), srv('c', 'service', 'g'), srv('d', 'database', 'g'), srv('e', 'database', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'a', target: 'd' }]); + assert.equal(positions.a.y, positions.d.y); // linked pair side by side + for (const id of ['b', 'c', 'e']) assert.ok(positions[id].y > positions.a.y, `${id} is below the chain`); + assert.equal(positions.b.y, positions.c.y); // one grid row +}); + +test('server view: a link cycle still gets every card a position', () => { + const nodes = [srv('x', 'application', 'g'), srv('y', 'application', 'g')]; + const { positions } = layoutBlock(nodes, [{ source: 'x', target: 'y' }, { source: 'y', target: 'x' }]); + assert.ok(positions.x && positions.y); + assert.notDeepEqual(positions.x, positions.y); +}); + +test('server view: one frame per project / environment, and the frames do not overlap', () => { + const graph = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [{ id: 'l', source: 'a', target: 'b' }], + positions: {}, + }; + const positions = layoutGraph(graph); + const frames = groupFrames(graph.nodes, positions); + assert.deepEqual(frames.map(f => f.name), ['Blog / prod', 'Shop / prod']); + const [f1, f2] = frames; + assert.ok(f1.x + f1.width <= f2.x || f2.x + f2.width <= f1.x || f1.y + f1.height <= f2.y || f2.y + f2.height <= f1.y); + assert.equal(positions.a.y, positions.b.y); +}); + +const envNode = (id, type, zone, environment, extra = {}) => srv(id, type, `Shop / ${environment}`, { zone, project: { uuid: 'p' }, environment: { uuid: environment }, ...extra }); + +test('server view: cards link only to cards of the same environment', () => { + const g = { + layout: 'server', + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z2', 'prod'), envNode('c', 'application', 'z1', 'dev'), { id: 'container:1', type: 'container', zone: 'z1' }], + }; + assert.equal(canConnect(g, 'a', 'b'), true); + assert.equal(canConnect(g, 'a', 'c'), false); + assert.equal(canConnect(g, 'a', 'a'), false); + assert.equal(canConnect(g, 'a', 'container:1'), false); + assert.equal(canConnect(g, 'container:1', 'a'), false); +}); + +test('server view: positions saved on an environment canvas are ignored', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'database', 'z1', 'prod')], + links: [], + positions: {}, + }; + const auto = layoutServerView(g); + assert.deepEqual(layoutServerView({ ...g, positions: { a: { x: 900, y: 700 } } }), auto); +}); + +test('server view: cards keep the arrangement saved on their environment canvas, new cards go below', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + envNode('a', 'application', 'z1', 'prod', { saved: { x: 900, y: 700 } }), + envNode('b', 'database', 'z1', 'prod', { saved: { x: 600, y: 900 } }), + envNode('c', 'service', 'z1', 'prod'), + ], + links: [], + }; + const p = layoutServerView(g); + assert.equal(p.a.x - p.b.x, 300); + assert.equal(p.b.y - p.a.y, 200); + assert.equal(p.c.x, p.b.x); + assert.ok(p.c.y >= p.b.y + estimateNodeHeight(g.nodes[1]) + ROW_GAP); +}); + +test('server view: a group with one saved card matches the environment canvas arrangement', () => { + const apps = Array.from({ length: MAX_ROWS + 1 }, (_, i) => envNode(`n${i}`, 'application', 'z1', 'prod')); + const nodes = [envNode('a', 'application', 'z1', 'prod', { saved: { x: 1000, y: 697 } }), ...apps, envNode('d', 'database', 'z1', 'prod')]; + const env = layoutGraph({ zones: [{ id: 'z1', name: 'web' }], nodes, links: [], positions: { a: { x: 1000, y: 697 } } }); + const server = layoutServerView({ layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [{ source: 'n0', target: 'd' }] }); + for (const n of nodes) { + assert.deepEqual({ x: server[n.id].x - server.a.x, y: server[n.id].y - server.a.y }, { x: env[n.id].x - env.a.x, y: env[n.id].y - env.a.y }, n.id); + } +}); + +test('server view: a moved card saved with its group on the environment canvas stays where it was dropped', () => { + const nodes = [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'service', 'z1', 'prod'), envNode('d', 'database', 'z1', 'prod')]; + const g = { layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [] }; + const groups = {}; + const before = layoutServerView(g, groups); + const { shift } = groups['prod:z1']; + const dropped = { ...before, b: { x: before.b.x + 120, y: before.b.y + 400 } }; + const saved = Object.fromEntries(nodes.map(n => [n.id, { x: dropped[n.id].x - shift.x, y: dropped[n.id].y - shift.y }])); + const after = layoutServerView({ ...g, nodes: nodes.map(n => ({ ...n, saved: saved[n.id] })) }); + assert.deepEqual(after, dropped); +}); + +test('server view: zones with server details get the taller header', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web', details: { os: 'Ubuntu' } }], + nodes: [envNode('a', 'application', 'z1', 'prod')], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + const [box] = zoneBoxes(g, positions); + assert.equal(box.y, 0); + assert.ok(positions.a.y > SERVER_ZONE_HEADER); +}); + +test('server view: groups sit where their cards are saved on the environment canvas', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + envNode('a', 'application', 'z1', 'prod', { saved: { x: 0, y: ZONE_HEADER + ZONE_PADDING } }), + envNode('b', 'application', 'z1', 'stage', { saved: { x: 600, y: ZONE_HEADER + ZONE_PADDING } }), + envNode('c', 'application', 'z1', 'dev'), + ], + links: [], + positions: {}, + }; + const groups = {}; + const positions = layoutServerView(g, groups); + // A card at the start of the environment canvas sits at the zone's top-left. + assert.equal(groups['prod:z1'].x, 0); + assert.equal(groups['prod:z1'].y, groups['prod:z1'].inner.y); + assert.equal(positions.b.x - positions.a.x, 600); + // The environment canvas position comes back from the server view position. + const { shift } = groups['prod:z1']; + assert.deepEqual({ x: positions.b.x - shift.x, y: positions.b.y - shift.y }, { x: 600, y: ZONE_HEADER + ZONE_PADDING }); + // A group without saved cards goes below. + assert.ok(positions.c.y > positions.a.y + estimateNodeHeight(g.nodes[0])); +}); + +test('server view: a group saved left of the zone stays inside it', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [envNode('a', 'application', 'z1', 'prod', { saved: { x: -236, y: 251 } })], + links: [], + positions: {}, + }; + const groups = {}; + layoutServerView(g, groups); + assert.equal(groups['prod:z1'].x, groups['prod:z1'].inner.x); +}); + +test('server view: saved groups of different environments that would overlap are moved apart', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + envNode('a', 'application', 'z1', 'prod', { saved: { x: 0, y: 0 } }), + envNode('b', 'application', 'z1', 'stage', { saved: { x: 0, y: 0 } }), + ], + links: [], + positions: {}, + }; + const groups = {}; + layoutServerView(g, groups); + const [p, s] = [groups['prod:z1'], groups['stage:z1']]; + assert.ok(Math.abs(p.x - s.x) >= CARD_WIDTH || Math.abs(p.y - s.y) >= estimateNodeHeight(g.nodes[0]), 'the groups do not cover each other'); +}); + +test('status summary counts resources by tone, without unmanaged containers and placeholder cards', () => { + const nodes = [ + node('a', 'application', 'z', { status: 'running:healthy' }), + node('b', 'database', 'z', { status: 'exited:unhealthy' }), + node('c', 'service', 'z', { status: 'exited' }), + node('container:1', 'container', 'z', { status: 'exited' }), + node('empty:1', 'empty', 'z', { status: null }), + ]; + assert.deepEqual(statusSummary(nodes), { ok: 1, warn: 0, bad: 2, busy: 0, idle: 0 }); + assert.equal(statusWord('exited:unhealthy'), 'Exited'); +}); + +test('server details: memory and uptime formats', () => { + assert.equal(formatMemory(1007820800), '0.9 GB'); + assert.equal(formatMemory(17179869184), '16 GB'); + assert.equal(formatMemory(null), null); + const now = Date.parse('2026-10-14T12:00:00Z'); + assert.equal(formatUptime('2026-10-14 11:15:00', now), '45m'); + assert.equal(formatUptime('2026-10-14 07:00:00', now), '5h'); + assert.equal(formatUptime('2026-10-02T12:00:00+00:00', now), '12d'); + assert.equal(formatUptime('nonsense', now), null); +}); + +test('unreachable server zones: only server zones whose server is not reachable', () => { + const zones = [ + { id: 'up', is_reachable: true }, + { id: 'down', is_reachable: false }, + { id: 'project', icon: 'projects' }, + ]; + assert.deepEqual([...unreachableZones({ zones })], ['down']); + assert.equal(unreachableZones(null).size, 0); +}); + +test('fit: shows everything when it stays readable, centered', () => { + const v = fitView({ minX: 0, minY: 0, maxX: 400, maxY: 200 }, { left: 0, top: 0, width: 1000, height: 600 }, true); + assert.equal(v.z, 1.1); + assert.equal(v.x, (1000 - 440) / 2); +}); + +test('fit: the first fit of a large canvas keeps the cards readable and starts at the top-left', () => { + const bounds = { minX: 100, minY: 50, maxX: 3100, maxY: 4050 }; + const area = { left: 0, top: 0, width: 1280, height: 520 }; + assert.ok(fitView(bounds, area).z < READABLE_ZOOM, 'the fit button still shows everything'); + const v = fitView(bounds, area, true); + assert.equal(v.z, READABLE_ZOOM); + assert.equal(v.x + bounds.minX * v.z, 48); + assert.equal(v.y + bounds.minY * v.z, 48); +}); + +test('fit: a tall, narrow canvas keeps its column centered', () => { + const v = fitView({ minX: 0, minY: 0, maxX: 300, maxY: 5000 }, { left: 0, top: 0, width: 1280, height: 520 }, true); + assert.equal(v.z, READABLE_ZOOM); + assert.equal(v.x, (1280 - 300 * READABLE_ZOOM) / 2); + assert.equal(v.y, 48); +}); + +test('variables view: variables stack on the left by name, resources sit to their right', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Team variables', icon: 'variable' }, { id: 'resources', name: 'Resources', icon: 'service' }], + nodes: [ + node('variable:2', 'variable', 'sources', { name: 'TOKEN' }), + node('variable:1', 'variable', 'sources', { name: 'API_URL' }), + node('app', 'application', 'resources', { context: 'Shop / prod' }), + ], + links: [{ id: 'variable:1|app', source: 'variable:1', target: 'app' }], + positions: {}, + }; + const p = layoutGraph(g); + + assert.equal(p['variable:1'].x, 0); + assert.equal(p['variable:2'].x, 0); + assert.ok(p['variable:1'].y < p['variable:2'].y); + assert.ok(p.app.x > CARD_WIDTH + ZONE_PADDING * 2); + const [variables, resources] = zoneBoxes(g, p); + assert.ok(variables.x + variables.width < resources.x, 'zones do not overlap'); +}); + +test('variables view: links only go from an existing shared variable to a resource', () => { + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('variable:2', 'variable', 'sources'), + node('missing:OLD', 'variable', 'sources', { missing: true }), + node('app', 'application', 'resources'), + ], + }; + + assert.equal(canConnect(g, 'variable:1', 'app'), true); + assert.equal(canConnect(g, 'app', 'variable:1'), false); + assert.equal(canConnect(g, 'variable:1', 'variable:2'), false); + assert.equal(canConnect(g, 'missing:OLD', 'app'), false); + assert.equal(canConnect({ nodes: g.nodes }, 'app', 'variable:1'), true, 'other canvases link any two cards'); +}); + +test('variables view: filter resources by project and environment, keep every variable', () => { + const shop = { uuid: 'p1', name: 'Shop' }; + const blog = { uuid: 'p2', name: 'Blog' }; + const g = { + layout: 'connect', + nodes: [ + node('variable:1', 'variable', 'sources'), + node('a', 'application', 'resources', { project: shop, environment: { uuid: 'e1', name: 'production' } }), + node('b', 'application', 'resources', { project: shop, environment: { uuid: 'e2', name: 'staging' } }), + node('c', 'application', 'resources', { project: blog, environment: { uuid: 'e3', name: 'production' } }), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'a' }, { id: 'l2', source: 'variable:1', target: 'c' }], + }; + + assert.deepEqual(filterOptions(g).map(p => [p.name, p.environments.map(e => e.name)]), [['Blog', ['production']], ['Shop', ['production', 'staging']]]); + assert.equal(filterGraph(g, {}), g); + const shopOnly = filterGraph(g, { project: 'p1' }); + assert.deepEqual(shopOnly.nodes.map(n => n.id), ['variable:1', 'a', 'b']); + assert.deepEqual(shopOnly.links.map(l => l.id), ['l1']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).nodes.map(n => n.id), ['variable:1', 'b']); + assert.deepEqual(filterGraph(g, { environment: 'e2' }).links, []); +}); + +test('variables view: resources that use a variable get their own zone, in one column', () => { + const ctx = { context: 'Shop / prod' }; + const g = groupByUse({ + layout: 'connect', + zones: [{ id: 'sources', icon: 'variable' }, { id: 'connected', icon: 'link' }, { id: 'resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources'), + node('app', 'application', 'resources', ctx), + node('db', 'database', 'resources', ctx), + node('idle', 'application', 'resources', ctx), + ], + links: [{ id: 'l1', source: 'variable:1', target: 'app' }, { id: 'l2', source: 'variable:1', target: 'db' }], + }); + assert.deepEqual(g.nodes.map(n => n.zone), ['sources', 'connected', 'connected', 'resources']); + + const p = layoutGraph(g); + assert.equal(p.app.x, p.db.x, 'connected cards share one column, so no link runs behind a card'); + assert.ok(p.db.y > p.app.y); + const boxes = zoneBoxes(g, p); + const connected = boxes.find(b => b.id === 'connected'); + const others = boxes.find(b => b.id === 'resources'); + assert.ok(connected.y + connected.height < others.y, 'other resources sit below the connected zone'); +}); + +test('readView restores a saved zoom and pan, and ignores bad data', () => { + const store = values => ({ getItem: key => values[key] ?? null }); + assert.deepEqual(readView('k', store({ k: '{"x":-120.5,"y":40,"z":0.6}' })), { x: -120.5, y: 40, z: 0.6 }); + assert.equal(readView('k', store({})), null); + assert.equal(readView('k', store({ k: 'not json' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2}' })), null); + assert.equal(readView('k', store({ k: '{"x":1,"y":2,"z":9}' })), null); +}); + +test('server view: the unmanaged containers group comes first in its zone', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [ + node('a', 'application', 'z1', { context: 'Alpha / prod' }), + node('container:1', 'container', 'z1', { context: 'Unmanaged containers' }), + ], + links: [], + positions: {}, + }; + const positions = layoutServerView(g); + assert.ok(positions['container:1'].x < positions.a.x); +}); + +const apart = (a, b) => a.x + a.width <= b.x || b.x + b.width <= a.x || a.y + a.height <= b.y || b.y + b.height <= a.y; +// Drag card `id` to the top-left of `onto`, as the canvas does: stop at the nearest free spot. +const dragOnto = (g, positions, id, onto) => { + const { others, fits } = dragRules(g, positions, {}, id); + const card = g.nodes.find(n => n.id === id); + const start = { ...positions[id], w: CARD_WIDTH, h: estimateNodeHeight(card) }; + assert.deepEqual(freeSpot(start, others, undefined, fits), start, 'the laid out card is already free, so it does not jump'); + const spot = freeSpot({ ...start, ...positions[onto] }, others, undefined, fits); + return { ...positions, [id]: { x: spot.x, y: spot.y } }; +}; + +test('a dragged card stops before it covers a card or another zone', () => { + const g = graph([node('a', 'application', 's1'), node('b', 'database', 's1'), node('c', 'application', 's2')]); + const before = layoutGraph(g); + for (const [id, onto] of [['a', 'b'], ['a', 'c'], ['c', 'a']]) { + const positions = dragOnto(g, before, id, onto); + const rects = Object.fromEntries(g.nodes.map(n => [n.id, ({ ...positions[n.id], width: CARD_WIDTH, height: estimateNodeHeight(n) })])); + for (const n of g.nodes) if (n.id !== id) assert.ok(apart(rects[id], rects[n.id]), `${id} does not cover ${n.id}`); + const [z1, z2] = zoneBoxes(g, positions); + assert.ok(apart(z1, z2), 'the zones do not overlap'); + } +}); + +test('server view: a dragged card stops before its group covers another group', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('b', 'database', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const positions = dragOnto(g, layoutGraph(g), 'a', 'c'); + const [f1, f2] = groupFrames(g.nodes, positions); + assert.ok(apart(f1, f2), 'the project / environment frames do not overlap'); +}); + +test('server view: a group dropped as close to another group as a drag allows stays there after a reload', () => { + const nodes = [envNode('a', 'application', 'z1', 'prod'), envNode('b', 'application', 'z1', 'staging')]; + const g = { layout: 'server', zones: [{ id: 'z1', name: 'web' }], nodes, links: [] }; + const groups = {}; + const before = layoutServerView(g, groups); + const [prod, staging] = groupFrames(nodes, before); + // Drag the staging group to the left of the prod group: it stops at the gap the drag keeps. + const box = { x: staging.x, y: staging.y, w: staging.width, h: staging.height }; + const others = [{ x: prod.x, y: prod.y, w: prod.width, h: prod.height }]; + const { inner } = groups[groupKey([nodes[1]], 'z1')]; + const spot = freeSpot({ ...box, x: prod.x + 40 }, others, GROUP_GAP, b => b.x >= inner.x && b.y >= inner.y); + const dropped = { ...before, b: { x: before.b.x + spot.x - box.x, y: before.b.y + spot.y - box.y } }; + const shift = id => groups[groupKey([nodes.find(n => n.id === id)], 'z1')].shift; + const saved = Object.fromEntries(nodes.map(n => [n.id, { x: dropped[n.id].x - shift(n.id).x, y: dropped[n.id].y - shift(n.id).y }])); + const after = layoutServerView({ ...g, nodes: nodes.map(n => ({ ...n, saved: saved[n.id] })) }); + assert.deepEqual(after, dropped); +}); + +test('server view: a dragged card keeps its group as far from another group as the layout does', () => { + const g = { + layout: 'server', + zones: [{ id: 'z1', name: 'web' }], + nodes: [srv('a', 'application', 'Shop / prod'), srv('c', 'application', 'Blog / prod')], + links: [], + positions: {}, + }; + const before = layoutServerView(g); + const { others, fits } = dragRules(g, before, {}, 'a'); + const start = { ...before.a, w: CARD_WIDTH, h: estimateNodeHeight(g.nodes[0]) }; + // The layout puts the groups GROUP_GAP apart: a card cannot move its group closer to the other one. + assert.equal(freeSpot({ ...start, x: start.x - 10 }, others, undefined, fits), null); +}); + +test('groupKey: a group with only an empty-environment placeholder has no key, so it cannot be moved', () => { + const environment = { uuid: 'env', name: 'production' }; + assert.equal(groupKey([{ id: 'empty:env', type: 'empty', environment }], 'empty'), null); + assert.equal(groupKey([{ id: 'a', type: 'application', environment }], 'srv'), 'env:srv'); +}); + +test('layoutServerView: an empty environment sits right of the other groups of its project, not below them', () => { + const shop = { uuid: 'shop', name: 'Shop' }; + const node = (id, context, extra = {}) => ({ id, type: 'application', zone: 'srv', context, project: shop, environment: { uuid: context }, ...extra }); + const graph = { + layout: 'server', + zones: [{ id: 'srv', name: 'srv' }], + nodes: [ + node('a', 'Shop / staging', { saved: { x: 0, y: 0 } }), + node('empty:prod', 'Shop / production', { type: 'empty' }), + ], + links: [], + }; + const out = layoutServerView(graph); + assert.equal(out['empty:prod'].y, out.a.y); + assert.ok(out['empty:prod'].x >= out.a.x + CARD_WIDTH); +}); + +test('variables view: saved environment canvas positions do not push resources away from the variables', () => { + const g = { + layout: 'connect', + zones: [{ id: 'sources', name: 'Server variables', icon: 'variable' }, { id: 'resources', name: 'Other resources', icon: 'service' }], + nodes: [ + node('variable:1', 'variable', 'sources', { name: 'COOLIFY_SERVER_NAME' }), + node('app', 'application', 'resources', { context: 'Shop / prod', environment: { uuid: 'e1' }, saved: { x: 900, y: 1200 } }), + ], + links: [], + positions: {}, + }; + const [variables, resources] = zoneBoxes(g, layoutGraph(g)); + + assert.equal(resources.y, variables.y, 'both zones start at the top'); + assert.ok(resources.x - (variables.x + variables.width) <= ZONE_GAP * 2, 'resources sit next to the variables'); +}); diff --git a/resources/js/passkeys.js b/resources/js/passkeys.js new file mode 100644 index 0000000000..060e1ad00d --- /dev/null +++ b/resources/js/passkeys.js @@ -0,0 +1,22 @@ +import { Passkeys } from '@laravel/passkeys'; + +window.Passkeys = Passkeys; + +window.passkeyDeviceName = () => { + const ua = navigator.userAgent; + const browser = /Edg\//.test(ua) ? 'Edge' + : /Firefox\//.test(ua) ? 'Firefox' + : /Chrome\//.test(ua) ? 'Chrome' + : /Safari\//.test(ua) ? 'Safari' + : 'Browser'; + const os = /iPhone|iPad/.test(ua) ? 'iOS' + : /Android/.test(ua) ? 'Android' + : /Mac/.test(ua) ? 'Mac' + : /Windows/.test(ua) ? 'Windows' + : /Linux/.test(ua) ? 'Linux' + : 'device'; + + return `${browser} on ${os}`; +}; + +window.dispatchEvent(new CustomEvent('passkeys:ready')); diff --git a/resources/js/runtime-logs.js b/resources/js/runtime-logs.js index 0dad173ada..3b34fce197 100644 --- a/resources/js/runtime-logs.js +++ b/resources/js/runtime-logs.js @@ -348,9 +348,9 @@ export function initializeRuntimeLogsComponent() { }); }, - visibleText() { + visibleText(source = filtered) { const withTime = this.$wire.showTimeStamps; - return filtered + return source .map((line) => (withTime && line.ts ? `${formatTimestamp(line.ts)} ${line.text}` : line.text)) .join('\n'); }, @@ -406,9 +406,21 @@ export function initializeRuntimeLogsComponent() { }); }, - downloadLogs() { + // The browser holds unredacted lines, so fetch the redacted copy from the server + // and apply the same level and search filters before downloading it. + async downloadLogs() { + let logs; + try { + logs = await this.$wire.copyLogs(); + } catch { + window.Livewire.dispatch('error', ['Failed to prepare logs for download.']); + return; + } + if (!logs) return; + let id = 0; + const exported = filterLogLines(parseLogLines(logs, () => ++id), this.logFilters, this.searchQuery); const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-'); - downloadText(this.visibleText() + '\n', `${this.containerName}-logs-${timestamp}.txt`); + downloadText(this.visibleText(exported) + '\n', `${this.containerName}-logs-${timestamp}.txt`); }, makeFullscreen() { diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js deleted file mode 100644 index 11dec0c320..0000000000 --- a/resources/js/settings-sidebar-accordion.js +++ /dev/null @@ -1,42 +0,0 @@ -// Alpine data provider for the collapsible resource settings sidebar -// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })"). -// -// Every group is open by default; each group can be collapsed/expanded and the -// choice is remembered per resource type. The active group is always forced open -// on load so the current page stays reachable. -export function initializeSettingsSidebarAccordionComponent() { - window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({ - activeGroup: config.activeGroup || '', - storageKey: config.storageKey || 'coolify.settings-sidebar', - groups: {}, - init() { - let stored = {}; - try { - stored = JSON.parse(localStorage.getItem(this.storageKey)) || {}; - } catch (e) { - stored = {}; - } - this.groups = stored && typeof stored === 'object' ? stored : {}; - }, - isOpen(group) { - // The current page must stay visible, even when this group was - // previously stored as collapsed on another page. - if (group === this.activeGroup) { - return true; - } - - if (Object.prototype.hasOwnProperty.call(this.groups, group)) { - return this.groups[group]; - } - return true; - }, - toggle(group) { - this.groups = { ...this.groups, [group]: !this.isOpen(group) }; - try { - localStorage.setItem(this.storageKey, JSON.stringify(this.groups)); - } catch (e) { - // ignore storage errors (private mode, quota, etc.) - } - }, - })); -} diff --git a/resources/js/terminal.browser.test.js b/resources/js/terminal.browser.test.js new file mode 100644 index 0000000000..29f607e82e --- /dev/null +++ b/resources/js/terminal.browser.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; + +test('pty-ready keeps the console input focused after the 500 ms retry', async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.route('http://terminal.test/', route => route.fulfill({ contentType: 'text/html', body: '
Container output
' })); + await page.goto('http://terminal.test/'); + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + // terminal.js imports these helpers; load them as plain declarations. + const connectionSource = readFileSync(new URL('./terminal-connection.js', import.meta.url), 'utf8') + .replace(/^export /gm, ''); + await page.evaluate(([connectionSource, source]) => { + window.Alpine = { data: (_name, factory) => { window.terminalFactory = factory; } }; + window.applicationTerminalThemes = { system: {} }; + window.MAX_TERMINAL_SESSION_SECONDS = 28800; + window.logTerminal = () => {}; + // Register the actual Alpine component, without its Vite imports. + new Function(`${connectionSource}\nfunction initializeTerminalComponent${source}\ninitializeTerminalComponent();`)(); + const component = window.terminalFactory(); + component.$wire = { terminalMode: 'attach', dispatch() {} }; + component.$refs = { consoleInput: document.querySelector('#consoleInput') }; + component.term = { _initialized: true, focus: () => document.querySelector('.xterm-viewport').focus() }; + component.startTerminalSessionCountdown = () => {}; + component.resizeTerminal = () => {}; + component.handleSocketMessage({ data: 'pty-ready' }); + window.terminalComponent = component; + }, [connectionSource, source]); + await page.waitForTimeout(650); + assert.equal(await page.locator('#consoleInput').evaluate((input) => document.activeElement === input), true); + await page.screenshot({ path: '/tmp/pr11538-console-focus.png' }); + await page.evaluate(() => { + window.terminalComponent.$wire.terminalMode = 'shell'; + window.terminalComponent.handleSocketMessage({ data: 'pty-ready' }); + }); + await page.waitForTimeout(650); + assert.equal(await page.locator('.xterm-viewport').evaluate((terminal) => document.activeElement === terminal), true); + } finally { + await browser.close(); + } +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js index 420e20fb11..93d196286b 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -205,6 +205,15 @@ export function initializeTerminalComponent() { wasConnectedBeforeHidden: false, mobileToolbarCollapsed: false, terminalModifier: null, + // Console (attach) mode — line-oriented command input, like a game-server panel. + consoleCommand: '', + consoleHistory: [], + consoleHistoryIndex: -1, + // Lines from a multi-line paste, waiting for the user to confirm with Enter. + consolePendingLines: [], + consoleSentFlash: false, + consoleSentFlashTimeout: null, + consoleForegroundColor: '#ffffff', keyboardInset: 0, keyboardAnchorTop: 0, keyboardViewportHeight: 0, @@ -312,6 +321,7 @@ export function initializeTerminalComponent() { logTerminal('warn', '[Terminal] Ignoring terminal token after authentication was rejected.'); return; } + this.applyConsoleMode(); this.beginTerminalSessionStart(); this.sendCommandWhenReady({ terminalToken: token }); }); @@ -330,7 +340,7 @@ export function initializeTerminalComponent() { // Wait for terminal to be ready, then focus const focusWhenReady = () => { if (this.terminalActive && this.term) { - this.term.focus(); + this.focusActiveInput(); } else { setTimeout(focusWhenReady, 100); } @@ -515,6 +525,7 @@ export function initializeTerminalComponent() { const cursorBlink = this.term.options.cursorBlink; this.term.options.cursorBlink = false; this.term.options.theme = { ...applicationTerminalThemes[themeName] }; + this.syncConsoleForeground(); this.term.refresh(0, Math.max(0, this.term.rows - 1)); requestAnimationFrame(() => { @@ -524,7 +535,7 @@ export function initializeTerminalComponent() { this.term.options.cursorBlink = cursorBlink; this.term.refresh(0, Math.max(0, this.term.rows - 1)); - this.term.focus(); + this.focusActiveInput(); logTerminal('log', '[Terminal Theme] Theme applied', this.terminalThemeDebugSnapshot(themeName)); }); } @@ -580,7 +591,7 @@ export function initializeTerminalComponent() { // Force a refresh this.$nextTick(() => { this.resizeTerminal(); - this.term.focus(); + this.focusActiveInput(); }); } }, @@ -607,6 +618,7 @@ export function initializeTerminalComponent() { ? applicationTerminalThemes[this.selectedTheme] ?? applicationTerminalThemes.system : undefined }); + this.syncConsoleForeground(); this.fitAddon = new FitAddon(); this.term.loadAddon(this.fitAddon); this.$nextTick(() => { @@ -851,6 +863,169 @@ export function initializeTerminalComponent() { } }, + isConsoleMode() { + return this.$wire?.terminalMode === 'attach'; + }, + + // Focus the console command box in attach mode, otherwise the xterm. + focusActiveInput() { + if (this.isConsoleMode()) { + this.$refs.consoleInput?.focus(); + } else { + this.term?.focus(); + } + }, + + // Keep the xterm read-only while in console mode and load its command history. + applyConsoleMode() { + const consoleMode = this.isConsoleMode(); + if (this.term) { + this.term.options.disableStdin = consoleMode; + this.term.options.cursorBlink = !consoleMode; + // The output cannot take input, so do not draw a cursor in it. + this.term.options.cursorInactiveStyle = consoleMode ? 'none' : 'outline'; + } + if (consoleMode) { + this.loadConsoleHistory(); + this.$nextTick(() => this.$refs.consoleInput?.focus()); + } + // Let the header mode selector reflect the container's current state. + window.dispatchEvent(new CustomEvent('terminal-mode-updated', { + detail: { + attachAvailable: Boolean(this.$wire?.attachAvailable), + hasShell: this.$wire?.hasShell ?? true, + mode: this.$wire?.terminalMode ?? 'shell', + }, + })); + }, + + loadConsoleHistory() { + // Keep commands in this connection only: names can be reused, and + // localStorage is shared by users and teams on the same browser. + this.consoleHistory = []; + this.consoleHistoryIndex = -1; + this.consoleCommand = ''; + this.consolePendingLines = []; + try { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (key?.startsWith('coolify-console-history:')) { + localStorage.removeItem(key); + } + } + } catch (_) { + // History does not depend on browser storage being available. + } + }, + + // Text colour for the command box: the active terminal theme's foreground. + syncConsoleForeground() { + this.consoleForegroundColor = this.term?.options.theme?.foreground || '#ffffff'; + }, + + canSubmitConsoleCommand() { + return this.consolePendingLines.length > 0 || this.consoleCommand.trim() !== ''; + }, + + // Send one printable command line to the process stdin, never terminal control bytes. + sendConsoleLine(command) { + if (command.trim() === '' || /[\x00-\x1f\x7f]/.test(command)) { + return false; + } + // Send the whole line plus a newline to the process stdin. + this.sendMessage({ message: `${command}\n` }); + + if (this.consoleHistory[this.consoleHistory.length - 1] !== command) { + this.consoleHistory.push(command); + this.consoleHistory = this.consoleHistory.slice(-50); + } + + return true; + }, + + submitConsoleCommand() { + if (!this.terminalActive || !this.isConsoleMode()) { + return; + } + + const lines = this.consolePendingLines.length > 0 ? this.consolePendingLines : [this.consoleCommand]; + const sent = lines.filter((line) => this.sendConsoleLine(line)).length; + + this.consolePendingLines = []; + this.consoleHistoryIndex = -1; + if (sent > 0) { + this.consoleCommand = ''; + this.flashConsoleSent(); + // Show the response even when the user scrolled up to read older output. + this.term?.scrollToBottom(); + } + // Clicking Send moves focus to the button; return it to the input. + this.$refs.consoleInput?.focus(); + }, + + flashConsoleSent() { + clearTimeout(this.consoleSentFlashTimeout); + this.consoleSentFlash = true; + this.consoleSentFlashTimeout = setTimeout(() => { + this.consoleSentFlash = false; + }, 300); + }, + + // A multi-line paste waits for confirmation, so pasted logs are not sent to the process by mistake. + pasteConsoleText(event) { + const text = event.clipboardData?.getData('text') ?? ''; + if (!/[\r\n]/.test(text)) { + return; + } + event.preventDefault(); + + const lines = text.split(/\r?\n|\r/).filter((line) => line.trim() !== ''); + if (lines.length === 1) { + this.consoleCommand += lines[0]; + return; + } + if (this.consoleCommand.trim() !== '') { + lines[0] = this.consoleCommand + lines[0]; + } + this.consolePendingLines = lines; + this.consoleCommand = ''; + }, + + // Esc cancels a pending paste first, then clears the input. + cancelConsoleInput() { + if (this.consolePendingLines.length > 0) { + this.consolePendingLines = []; + return; + } + this.consoleCommand = ''; + this.consoleHistoryIndex = -1; + }, + + // Ctrl+L clears the visible output only; nothing is sent to the process. + clearConsoleOutput() { + this.term?.clear(); + }, + + // Clicking the read-only output moves focus to the command box, unless the user is selecting text. + focusConsoleFromOutput() { + if (this.isConsoleMode() && !this.term?.hasSelection()) { + this.$refs.consoleInput?.focus(); + } + }, + + recallConsoleCommand(direction) { + if (this.consoleHistory.length === 0) { + return; + } + if (this.consoleHistoryIndex === -1) { + this.consoleHistoryIndex = this.consoleHistory.length; + } + let index = this.consoleHistoryIndex + direction; + index = Math.max(0, Math.min(this.consoleHistory.length, index)); + this.consoleHistoryIndex = index; + this.consoleCommand = index >= this.consoleHistory.length ? '' : this.consoleHistory[index]; + }, + handleSocketMessage(event) { // Handle pong responses if (event.data === 'pong') { @@ -882,7 +1057,9 @@ export function initializeTerminalComponent() { } this.terminalActive = true; this.startTerminalSessionCountdown(); - this.term.focus(); + // In console (attach) mode the user types into the command box, not the + // read-only xterm, so keep focus on that input instead of the terminal. + this.focusActiveInput(); document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm'); // Initial resize after terminal is ready @@ -893,13 +1070,13 @@ export function initializeTerminalComponent() { this.resizeTerminal(); }, 200); - // Ensure terminal gets focus after connection with multiple attempts + // Ensure focus after connection with multiple attempts setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 100); - + setTimeout(() => { - this.term.focus(); + this.focusActiveInput(); }, 500); // Notify parent component that terminal is connected @@ -972,6 +1149,11 @@ export function initializeTerminalComponent() { if (!this.term) return; this.term.onData((data) => { + // Console (attach) mode is read-only: commands go through the input box, + // so raw keystrokes (including Ctrl-C) are never sent to the process. + if (this.isConsoleMode()) { + return; + } this.sendMessage({ message: data }); if (data === '\r') { this.commandBuffer = ''; @@ -982,6 +1164,14 @@ export function initializeTerminalComponent() { // Copy and paste functionality this.term.attachCustomKeyEventHandler((arg) => { + // The console output is read-only: move typed characters to the command box. + if (this.isConsoleMode() && arg.type === 'keydown' && arg.key.length === 1 + && !arg.ctrlKey && !arg.metaKey && !arg.altKey) { + // Moving focus during keydown makes the browser type this key into the input. + this.$refs.consoleInput?.focus(); + return false; + } + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { return false; } @@ -1012,6 +1202,11 @@ export function initializeTerminalComponent() { return; } + // Console (attach) mode only accepts commands from the input box. + if (this.isConsoleMode()) { + return; + } + this.sendMessage({ message: data }); }, diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js index e0a4fb852d..ed6f9a696d 100644 --- a/resources/js/terminal.test.js +++ b/resources/js/terminal.test.js @@ -1,9 +1,12 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; import { MAX_TERMINAL_SESSION_SECONDS, formatTerminalSessionRemainingTime, } from './terminal-session-timer.js'; +import * as terminalConnection from './terminal-connection.js'; test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => { assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60); @@ -13,3 +16,78 @@ test('formatTerminalSessionRemainingTime formats the eight hour terminal limit c assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s'); assert.equal(formatTerminalSessionRemainingTime(0), 'expired'); }); + +function terminalComponent() { + let factory; + const storage = new Map([['coolify-console-history:server:container', '["secret"]']]); + const localStorage = { + get length() { return storage.size; }, + key: (index) => [...storage.keys()][index], + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => storage.set(key, value), + removeItem: (key) => storage.delete(key), + }; + const source = readFileSync(new URL('./terminal.js', import.meta.url), 'utf8') + .split('export function initializeTerminalComponent')[1]; + const componentSource = `function initializeTerminalComponent${source}`; + vm.runInNewContext(`${componentSource}\ninitializeTerminalComponent();`, { + window: { Alpine: { data: (_name, value) => { factory = value; } } }, + localStorage, console, + setTimeout, clearTimeout, + MAX_TERMINAL_SESSION_SECONDS, applicationTerminalThemes: { system: {} }, + ...terminalConnection, + }); + const component = factory(); + component.$wire = { terminalMode: 'attach', currentIdentifier: 'container', currentServerUuid: 'server', dispatch() {} }; + component.$refs = { consoleInput: { focus() {} } }; + component.$nextTick = (callback) => callback(); + component.term = { options: {}, focus() {} }; + component.terminalActive = true; + return { component, storage }; +} + +test('console history does not persist sensitive commands or load previous users history', (t) => { + const { component, storage } = terminalComponent(); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); + component.consoleCommand = 'secret command'; + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + component.submitConsoleCommand(); + assert.deepEqual(messages, ['secret command\n']); + assert.equal(component.consoleHistory.length, 1); + assert.equal(storage.size, 0); + component.loadConsoleHistory(); + assert.equal(component.consoleHistory.length, 0); +}); + +test('console startup and theme focus stay on the command input', (t) => { + const { component } = terminalComponent(); + let consoleFocus = 0; + let terminalFocus = 0; + component.$refs.consoleInput.focus = () => { consoleFocus++; }; + component.term.focus = () => { terminalFocus++; }; + component.focusActiveInput(); + assert.equal(consoleFocus, 1); + assert.equal(terminalFocus, 0); + component.$wire.terminalMode = 'shell'; + component.focusActiveInput(); + assert.equal(terminalFocus, 1); +}); + +test('console input cannot forward control sequences or send into a shell session', () => { + const { component } = terminalComponent(); + const messages = []; + component.sendMessage = (message) => messages.push(message.message); + for (const command of ['\x03', 'list\nstop', '\x10\x11']) { + component.consoleCommand = command; + component.submitConsoleCommand(); + } + component.$wire.terminalMode = 'shell'; + component.consoleCommand = 'stop'; + component.submitConsoleCommand(); + component.$wire.terminalMode = 'attach'; + component.terminalActive = false; + component.submitConsoleCommand(); + assert.equal(messages.length, 0); +}); diff --git a/resources/js/traffic-globe.js b/resources/js/traffic-globe.js index 49f52ac9ac..ca8fd6a175 100644 --- a/resources/js/traffic-globe.js +++ b/resources/js/traffic-globe.js @@ -24,16 +24,26 @@ const THEMES = { }; /** - * Turn country-breakdown rows into cobe markers. Marker size scales with the - * square root of request volume so a single dominant country doesn't dwarf the rest. + * Turn marker rows into cobe markers. A row is placed at its own `lat`/`lon` + * (a city) or at the centroid of its ISO-A2 `code` (a country). Marker size + * scales with the square root of request volume so one dominant place doesn't + * dwarf the rest. City markers are smaller, because cities are close together. * - * @param {Array<{code: string, requests: number}>} data + * @param {Array<{code?: string, lat?: number, lon?: number, requests: number}>} data * @returns {Array<{location: [number, number], size: number}>} */ function buildMarkers(data) { const rows = (data || []) - .map((r) => ({ code: String(r.code || '').toUpperCase(), requests: Number(r.requests || 0) })) - .filter((r) => r.requests > 0 && CENTROIDS[r.code]); + .map((r) => { + const city = Number.isFinite(r.lat) && Number.isFinite(r.lon); + + return { + city, + location: city ? [r.lat, r.lon] : CENTROIDS[String(r.code || '').toUpperCase()], + requests: Number(r.requests || 0), + }; + }) + .filter((r) => r.requests > 0 && r.location); if (rows.length === 0) { return []; @@ -41,14 +51,29 @@ function buildMarkers(data) { const max = Math.max(...rows.map((r) => r.requests)); - return rows.map((r) => ({ - location: CENTROIDS[r.code], - size: Math.max(0.03, Math.min(0.11, Math.sqrt(r.requests / max) * 0.11)), - })); + return rows.map((r) => { + const [min, cap] = r.city ? [0.02, 0.07] : [0.03, 0.11]; + + return { + location: r.location, + size: Math.max(min, Math.min(cap, Math.sqrt(r.requests / max) * cap)), + }; + }); } const TWO_PI = Math.PI * 2; +const SPIN_SPEED = 0.15; // rad/s +// Ambient spin is drawn at 30fps. A little under two 60Hz frames, so timestamp +// jitter does not skip to the third frame. +const SPIN_FRAME_MS = 1000 / 30 - 4; +const SETTLE_EPSILON = 0.0005; + +// Signed shortest angular distance from `current` to `target`. +function angleDelta(current, target) { + return ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; +} + // cobe orientation for a lat/lng so the point faces the viewer (cobe's own // focus example formula). Returns [phi, theta]. function locationToAngles(lat, lng) { @@ -58,23 +83,21 @@ function locationToAngles(lat, lng) { // Shortest-path angular interpolation, so easing across the 0/2π seam never // spins the long way around. function lerpAngle(current, target, t) { - let delta = ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; - - return current + delta * t; + return current + angleDelta(current, target) * t; } /** * Mount an interactive, drag-to-rotate dotted globe onto a canvas. Returns a - * controller with `update(data, dark)`, `focus(code)`, `resume()` and + * controller with `update(data, dark)`, `focus(target)`, `resume()` and * `destroy()`. The globe auto-rotates, pauses while grabbed or while a country * is hover-focused, and eases smoothly toward whatever it's pointed at. * * cobe v2 has no internal render loop or `onRender` callback: createGlobe draws * a single frame and returns `{ update, destroy }`. We drive our own rAF loop, - * calling `globe.update({...})` each frame for rotation and to swap markers/theme. + * which draws only while something moves and stops when the canvas is off-screen. * * @param {HTMLCanvasElement} canvas - * @param {Array<{code: string, requests: number}>} data + * @param {Array<{code?: string, lat?: number, lon?: number, requests: number}>} data * @param {boolean} dark */ function mountTrafficGlobe(canvas, data, dark) { @@ -82,6 +105,9 @@ function mountTrafficGlobe(canvas, data, dark) { let width = 0; let destroyed = false; let rafId = 0; + let lastDraw = 0; + let dirty = true; + let visible = true; let markers = buildMarkers(data); // Ambient spin is decorative; honor reduced-motion by not auto-rotating @@ -103,6 +129,7 @@ function mountTrafficGlobe(canvas, data, dark) { dragStartPhi = targetPhi; autoRotate = false; canvas.style.cursor = 'grabbing'; + wake(); }; const onPointerUp = () => { if (dragging === null) { @@ -111,10 +138,12 @@ function mountTrafficGlobe(canvas, data, dark) { dragging = null; autoRotate = ! prefersReduced; canvas.style.cursor = 'grab'; + wake(); }; const onPointerMove = (e) => { if (dragging !== null) { targetPhi = dragStartPhi + (e.clientX - dragging) / 150; + wake(); } }; @@ -127,7 +156,7 @@ function mountTrafficGlobe(canvas, data, dark) { const theme = isDark ? THEMES.dark : THEMES.light; globe = createGlobe(canvas, { - devicePixelRatio: 2, + devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2), width: width, height: width, phi: currentPhi, @@ -144,20 +173,45 @@ function mountTrafficGlobe(canvas, data, dark) { }); }; - // Self-driven animation loop (cobe v2 draws only when we call update()). - const tick = () => { - if (destroyed) { + const schedule = () => { + if (! rafId && ! destroyed && globe && visible) { + rafId = requestAnimationFrame(tick); + } + }; + + // Request a draw; a no-op while one is already queued. + function wake() { + dirty = true; + schedule(); + } + + // Draws only while the globe spins or eases toward a target, then goes idle. + const tick = (now) => { + rafId = 0; + if (destroyed || ! globe || width === 0) { return; } - if (globe && width > 0) { - if (autoRotate && dragging === null) { - targetPhi += 0.0025; - } - currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); - currentTheta += (targetTheta - currentTheta) * 0.12; - globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + const spinning = autoRotate && dragging === null; + const elapsed = now - lastDraw; + if (spinning && ! dirty && elapsed < SPIN_FRAME_MS) { + schedule(); + + return; + } + if (spinning) { + targetPhi += (SPIN_SPEED * Math.min(elapsed, 100)) / 1000; + } + currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); + currentTheta += (targetTheta - currentTheta) * 0.12; + globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + lastDraw = now; + dirty = false; + + const settled = Math.abs(angleDelta(currentPhi, targetPhi)) < SETTLE_EPSILON + && Math.abs(targetTheta - currentTheta) < SETTLE_EPSILON; + if (spinning || ! settled) { + schedule(); } - rafId = requestAnimationFrame(tick); }; // cobe needs a non-zero canvas width at creation; inside a freshly-rendered @@ -170,17 +224,26 @@ function mountTrafficGlobe(canvas, data, dark) { } width = next; create(dark); - rafId = requestAnimationFrame(tick); + wake(); }; const resizeObserver = new ResizeObserver(() => { if (globe) { width = canvas.offsetWidth || width; + wake(); } else { ensure(); } }); resizeObserver.observe(canvas); + + const intersectionObserver = new IntersectionObserver((entries) => { + visible = entries[entries.length - 1].isIntersecting; + if (visible) { + wake(); + } + }); + intersectionObserver.observe(canvas); requestAnimationFrame(ensure); return { @@ -203,19 +266,22 @@ function mountTrafficGlobe(canvas, data, dark) { }); } }, - focus(code) { - const c = CENTROIDS[String(code || '').toUpperCase()]; - if (!c) { + // `target` is an ISO-A2 code or a [lat, lon] pair. + focus(target) { + const c = Array.isArray(target) ? target : CENTROIDS[String(target || '').toUpperCase()]; + if (!c || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) { return; } const [phi, theta] = locationToAngles(c[0], c[1]); targetPhi = phi; targetTheta = theta; autoRotate = false; + wake(); }, resume() { if (dragging === null) { autoRotate = ! prefersReduced; + wake(); } }, destroy() { @@ -225,6 +291,7 @@ function mountTrafficGlobe(canvas, data, dark) { rafId = 0; } resizeObserver.disconnect(); + intersectionObserver.disconnect(); canvas.removeEventListener('pointerdown', onPointerDown); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointermove', onPointerMove); diff --git a/resources/js/traffic-table.js b/resources/js/traffic-table.js new file mode 100644 index 0000000000..0012b65d52 --- /dev/null +++ b/resources/js/traffic-table.js @@ -0,0 +1,112 @@ +// Client-side sort and paging for the traffic analytics lists. Each row carries +// its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard +// and screen-reader order match the visual order. A Livewire morph puts the rows +// back in server order (they are keyed) and can add or remove rows, so the rows +// are sorted again after it. +function compareValues(a, b) { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); +} + +function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) { + const ranks = new WeakMap(); + let observer = null; + let removeMorphHook = null; + + return { + page: 0, + per, + total: 0, + key, + dir, + tick: 0, + + init() { + this.resort(); + observer = new MutationObserver(() => this.resort()); + observer.observe(this.$root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-row'], + }); + if (window.Livewire) { + removeMorphHook = window.Livewire.hook('morphed', ({ el }) => { + if (el.contains(this.$root)) { + this.resort(); + } + }); + } + }, + + destroy() { + if (observer) { + observer.disconnect(); + observer = null; + } + if (removeMorphHook) { + removeMorphHook(); + removeMorphHook = null; + } + }, + + // Text columns start ascending, number columns descending. + sortBy(next, firstDir = 'desc') { + if (this.key === next) { + this.dir = this.dir === 'desc' ? 'asc' : 'desc'; + } else { + this.key = next; + this.dir = firstDir; + } + this.page = 0; + this.resort(); + }, + + resort() { + const sign = this.dir === 'asc' ? 1 : -1; + const rows = [...this.$root.querySelectorAll('[data-row]')].map((el, index) => { + let values = {}; + try { + values = JSON.parse(el.dataset.row) || {}; + } catch { + values = {}; + } + + const value = values[this.key] ?? null; + + return { el, index, pinned: 'pinned' in el.dataset ? 1 : 0, missing: value === null ? 1 : 0, value }; + }); + + // Pinned rows (the "Other" remainder) stay last in each sort, then rows without a value. + rows.sort((a, b) => a.pinned - b.pinned || a.missing - b.missing || compareValues(a.value, b.value) * sign || a.index - b.index); + rows.forEach((row, rank) => ranks.set(row.el, rank)); + // All rows share one parent. Move them only when the order changed: the mutation + // that the move causes then finds them sorted, and a focused link is not blurred. + if (rows.some((row, rank) => row.index !== rank)) { + rows.forEach((row) => row.el.parentElement.appendChild(row.el)); + observer?.takeRecords(); + } + + this.total = rows.length; + this.page = Math.min(this.page, Math.max(0, Math.ceil(this.total / this.per) - 1)); + this.tick++; + }, + + shown(el) { + this.tick; + const rank = ranks.get(el) ?? 0; + + return rank >= this.page * this.per && rank < (this.page + 1) * this.per; + }, + + // 'asc', 'desc', or null when the list is sorted by another column. + sorted(column) { + return this.key === column ? this.dir : null; + }, + }; +} + +window.trafficTable = trafficTable; diff --git a/resources/views/auth/confirm-password.blade.php b/resources/views/auth/confirm-password.blade.php index 9d2bc7be54..ee5cbb0858 100644 --- a/resources/views/auth/confirm-password.blade.php +++ b/resources/views/auth/confirm-password.blade.php @@ -28,6 +28,11 @@ {{ __('auth.confirm_password') }} + + @if ($can_confirm_with_passkey) + + @endif diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 476b153985..0788e43f13 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -22,12 +22,14 @@
@csrf @env('local') - @else - @@ -78,6 +80,10 @@ + @if ($passkeys_available) + + @endif + @if ($enabled_oauth_providers->isNotEmpty())
Or continue with
diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php index 6924f0198d..29f56af40d 100644 --- a/resources/views/components/application/configuration-sidebar.blade.php +++ b/resources/views/components/application/configuration-sidebar.blade.php @@ -1,4 +1,4 @@ -@props(['application', 'currentRoute', 'flush' => false]) +@props(['application', 'currentRoute', 'flush' => false, 'navOnly' => false]) @php $applicationRouteParameters = [ @@ -51,6 +51,12 @@ 'navigate' => false, 'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'), ], + [ + 'label' => 'Files', + 'route' => 'project.application.files', + 'active' => $currentRoute === 'project.application.files', + 'visible' => ! $application->destination->server->isSwarm() && auth()->user()?->can('canAccessTerminal'), + ], [ 'label' => 'Deployment Logs', 'route' => 'project.application.deployment.index', @@ -147,12 +153,13 @@ 'Persistent Storage' => 'storages', 'Backups' => 'database', 'Terminal' => 'browser-terminal', + 'Files' => 'folder', 'Deployment Logs' => 'time-back', 'Runtime Logs' => 'unordered-list', 'Git Source' => 'sources', 'Servers' => 'servers', 'Scheduled Tasks' => 'calendar', - 'Webhooks' => 'notifications', + 'Webhooks' => 'webhook', 'Preview Deployments' => 'eye', 'Healthcheck' => 'feedback', 'Rollback' => 'time-back', @@ -161,16 +168,17 @@ 'Metrics' => 'graph', 'Analytics' => 'analytics', 'Tags' => 'tags', - 'Danger Zone' => 'shield-alert', + 'Danger Zone' => 'danger', ]; // Discord-style groups for the settings sidebar $menuGroups = [ 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage', 'Advanced', 'Swarm', 'Healthcheck'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Metrics', 'Analytics'], + 'Observe & troubleshoot' => ['Runtime Logs', 'Deployment Logs', 'Terminal', 'Files', 'Metrics', 'Analytics'], 'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'], 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'], - 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags', 'Danger Zone'], + 'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags'], + 'Danger Zone' => ['Danger Zone'], ]; $groupedMenuItems = collect($menuGroups) ->map(fn (array $labels) => collect($labels) @@ -179,9 +187,6 @@ ->values()) ->filter(fn ($items) => $items->isNotEmpty()); - // Group that holds the current page — always kept open, even if collapsed before. - $activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); - // In-page sections (cards) shown as sub-items under the active page $isComposeApp = $application->build_pack === 'dockercompose'; $pageSections = [ @@ -247,32 +252,38 @@ $settingsSearchItems = settingsSearchItems($groupedMenuItems, $applicationRouteParameters, $pageSections); @endphp +@php + // The main sidebar shows this menu on wide screens (see components/navbar.blade.php). + registerMainNavContext(0, 'Dashboard', 'Back to the main menu'); +@endphp + +{{-- Rendered twice: in the page (below xl), and teleported into the main sidebar (xl and up). + navOnly renders only the sidebar copy, as the parent menu of a deeper page (e.g. a backup). --}} +@foreach ($navOnly ? ['main-nav'] : ['page', 'main-nav'] as $placement) +@if ($placement === 'main-nav') +@teleport('#main-nav-context-0') + +@endteleport +@else + +@endif +@endforeach diff --git a/resources/views/components/auth/passkey-verify.blade.php b/resources/views/components/auth/passkey-verify.blade.php new file mode 100644 index 0000000000..793e5998b6 --- /dev/null +++ b/resources/views/components/auth/passkey-verify.blade.php @@ -0,0 +1,55 @@ +@props([ + 'optionsRoute' => 'passkey.login-options', + 'submitRoute' => 'passkey.login', + 'label' => 'Sign in with a passkey', + 'autofill' => false, +]) + +@vite('resources/js/passkeys.js') +
+ + {{ $label }} + + +
diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php index be7148c98e..900a0be0b6 100644 --- a/resources/views/components/backup-sidebar.blade.php +++ b/resources/views/components/backup-sidebar.blade.php @@ -9,32 +9,24 @@ 'application' => [ 'back' => 'project.application.backup.index', 'general' => 'project.application.backup.show', - 's3' => 'project.application.backup.s3', - 'retention' => 'project.application.backup.retention', 'executions' => 'project.application.backup.executions', 'danger' => 'project.application.backup.danger', ], 'service' => [ 'back' => 'project.service.volume-backups.index', 'general' => 'project.service.database.backup.show', - 's3' => 'project.service.database.backup.s3', - 'retention' => 'project.service.database.backup.retention', 'executions' => 'project.service.database.backup.executions', 'danger' => 'project.service.database.backup.danger', ], 'service-volume' => [ 'back' => 'project.service.volume-backups.index', 'general' => 'project.service.volume-backups.show', - 's3' => 'project.service.volume-backups.s3', - 'retention' => 'project.service.volume-backups.retention', 'executions' => 'project.service.volume-backups.executions', 'danger' => 'project.service.volume-backups.danger', ], default => [ 'back' => 'project.database.configuration', 'general' => 'project.database.backup.execution', - 's3' => 'project.database.backup.s3', - 'retention' => 'project.database.backup.retention', 'executions' => 'project.database.backup.executions', 'danger' => 'project.database.backup.danger', ], @@ -42,12 +34,16 @@ $items = [ ['key' => 'general', 'label' => 'General', 'icon' => 'settings'], - ['key' => 's3', 'label' => 'S3 storage', 'icon' => 'storages'], - ['key' => 'retention', 'label' => 'Retention', 'icon' => 'unordered-list'], ['key' => 'executions', 'label' => 'Executions', 'icon' => 'browser-terminal'], - ['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'shield-alert'], + ['key' => 'danger', 'label' => 'Danger Zone', 'icon' => 'danger'], ]; $backLabel = $context === 'database' ? 'Back to database' : 'Back to backups'; + // The resource menu that the page renders one Back away in the main sidebar. + $parentMenu = match ($context) { + 'application' => 'Application', + 'database' => 'Database', + default => 'Service', + }; $backParameters = match ($context) { 'database' => collect($parameters)->except('backup_uuid')->all(), 'service' => collect($parameters)->except(['stack_service_uuid', 'backup_uuid'])->all(), @@ -55,17 +51,19 @@ }; @endphp - + diff --git a/resources/views/components/backup-tabs.blade.php b/resources/views/components/backup-tabs.blade.php index 4898a29ed5..d1c436e09b 100644 --- a/resources/views/components/backup-tabs.blade.php +++ b/resources/views/components/backup-tabs.blade.php @@ -8,21 +8,15 @@ $routes = match ($context) { 'service-schedule' => [ 'general' => true, - 's3' => true, - 'retention' => true, 'danger' => true, ], 'service' => [ 'general' => 'project.service.database.backup.show', - 's3' => 'project.service.database.backup.s3', - 'retention' => 'project.service.database.backup.retention', 'executions' => 'project.service.database.backup.executions', 'danger' => 'project.service.database.backup.danger', ], 'service-volume' => [ 'general' => 'project.service.volume-backups.show', - 's3' => 'project.service.volume-backups.s3', - 'retention' => 'project.service.volume-backups.retention', 'executions' => 'project.service.volume-backups.executions', 'danger' => 'project.service.volume-backups.danger', ], @@ -30,8 +24,6 @@ $items = collect([ ['key' => 'general', 'label' => 'General'], - ['key' => 's3', 'label' => 'S3 storage'], - ['key' => 'retention', 'label' => 'Retention'], ['key' => 'executions', 'label' => 'Executions'], ['key' => 'danger', 'label' => 'Danger Zone'], ])->filter(fn (array $item): bool => isset($routes[$item['key']])); diff --git a/resources/views/components/beta-badge.blade.php b/resources/views/components/beta-badge.blade.php index 56c12a3771..66581433e4 100644 --- a/resources/views/components/beta-badge.blade.php +++ b/resources/views/components/beta-badge.blade.php @@ -1,6 +1,10 @@ merge(['class' => 'inline-flex items-center']) }}> - - Beta + + diff --git a/resources/views/components/canvas-view-toggle.blade.php b/resources/views/components/canvas-view-toggle.blade.php new file mode 100644 index 0000000000..f27e7bab5a --- /dev/null +++ b/resources/views/components/canvas-view-toggle.blade.php @@ -0,0 +1,49 @@ +@props(['listUrl' => null, 'canvasUrl' => null, 'onCanvas' => false, 'views' => null]) +@php + $idle = 'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $canvasItem = 'relative flex h-7.5 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $views ??= ['table' => ['Table view', 'unordered-list'], 'grid' => ['Grid view', 'grid']]; + // One list view (no table / grid choice): nothing to switch to on the list page. + $singleList = count($views) === 1; +@endphp +{{-- Table / grid / canvas switcher. On the list page, table and grid switch the + parent's Alpine viewMode; the canvas page shows one "List view" link back + (?view= makes the list the remembered view again, see RemembersCanvasView). + Pages with one list view pass :views="['list' => [...]]". Without a + canvasUrl it is only a table / grid switcher. --}} +@if ($onCanvas || ! $singleList || filled($canvasUrl)) +
merge(['class' => 'view-toggle']) }}> + @if ($onCanvas) + {{-- On the canvas only the way back: one labelled link to the list (its first view). --}} + @php [$label, $icon] = reset($views); @endphp + + + List view + + @else + {{-- Only the views you can switch to, with their names; the current one is not shown. --}} + @unless ($singleList) + @foreach ($views as $mode => [$label, $icon]) + + @endforeach + @endunless + @if (filled($canvasUrl)) + {{-- A label and a purple "Try it" badge, so people notice the canvas view. --}} + + + Canvas + + Try it + + + @endif + @endif +
+@endif diff --git a/resources/views/components/dashboard/navbar.blade.php b/resources/views/components/dashboard/navbar.blade.php index db6242f56a..8e7e390b81 100644 --- a/resources/views/components/dashboard/navbar.blade.php +++ b/resources/views/components/dashboard/navbar.blade.php @@ -109,7 +109,7 @@ @if ($showNav)
@endif -
@if ($searchable) @@ -329,6 +345,14 @@
+ @if ($searchable && $allowCustom) + + @endif @if ($searchable)
@endif diff --git a/resources/views/components/forms/repository-paths.blade.php b/resources/views/components/forms/repository-paths.blade.php new file mode 100644 index 0000000000..1b5c6f94f9 --- /dev/null +++ b/resources/views/components/forms/repository-paths.blade.php @@ -0,0 +1,47 @@ +@props([ + 'base', + 'file' => null, + 'fileLabel' => null, + 'defaultFile' => null, + 'disabled' => 'false', +]) + +
class('grid gap-4') }} x-data="{ + base: $wire.entangle('{{ $base }}'), + file: {{ $file ? "\$wire.entangle('{$file}')" : 'null' }}, + normalize(path) { + path = (path ?? '').trim().replace(/\/+$/, ''); + return path === '' || path.startsWith('/') ? path : '/' + path; + }, + get directory() { + return this.normalize(this.base); + }, + get location() { + return this.normalize(this.file) || '{{ $defaultFile }}'; + }, + get repeatsBase() { + return this.directory !== '' && this.location.startsWith(this.directory + '/'); + }, + get suggestion() { + return this.location.slice(this.directory.length); + }, +}"> + + @if ($file) + +

+ Resolved file: + +

+ + {{ $fileLabel }} is relative to the base directory, so it should not start with + again. + + + @endif + {{ $slot }} +
diff --git a/resources/views/components/git-icon.blade.php b/resources/views/components/git-icon.blade.php index 47ae8f8bbe..ca7196b52b 100644 --- a/resources/views/components/git-icon.blade.php +++ b/resources/views/components/git-icon.blade.php @@ -15,6 +15,16 @@ +@elseif($git === 'App\Models\GiteaApp') + merge(['class' => 'w-6 h-6']) }} viewBox="0 0 24 24" + stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> + + + + + + + @else merge(['class' => 'w-6 h-6']) }} viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> diff --git a/resources/views/components/hostinger-icon.blade.php b/resources/views/components/hostinger-icon.blade.php new file mode 100644 index 0000000000..9616c8d965 --- /dev/null +++ b/resources/views/components/hostinger-icon.blade.php @@ -0,0 +1,7 @@ + + Hostinger + + diff --git a/resources/views/components/main-nav-menu.blade.php b/resources/views/components/main-nav-menu.blade.php new file mode 100644 index 0000000000..3dc4687d2e --- /dev/null +++ b/resources/views/components/main-nav-menu.blade.php @@ -0,0 +1,18 @@ +@props(['backLabel', 'backTitle' => 'Back to the main menu', 'depth' => 0, 'navOnly' => false]) +@php + // The main sidebar replaces its menu with this one on wide screens (see components/navbar.blade.php). + registerMainNavContext($depth, $backLabel, $backTitle); +@endphp +{{-- A page menu, rendered twice: in the page (below xl, CSS hides it on xl), and teleported into the + main sidebar (xl and up). The slot must not contain Livewire components (they would render twice). + navOnly renders only the sidebar copy: a deeper page uses it to keep its parent menu one Back away. --}} +@unless ($navOnly) + +@endunless +@teleport('#main-nav-context-'.$depth) + +@endteleport diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index 58f70ab206..b026094ca2 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -60,7 +60,7 @@ 'sm:h-[40rem]' => $fixedHeight, ]) style="box-shadow: 0 0 0 1px var(--coollabs-hairline), var(--shadow-modal)"> -
+

{{ $title }}

@if ($subtitle) diff --git a/resources/views/components/navbar.blade.php b/resources/views/components/navbar.blade.php index e60f1246fc..90f281712e 100644 --- a/resources/views/components/navbar.blade.php +++ b/resources/views/components/navbar.blade.php @@ -1,6 +1,10 @@ -@props(['deploymentsIndicator' => false]) +@props(['desktop' => false]) -
@endif -
    + - {{-- Sticky sidebar footer (desktop only; mobile uses a temporary slide-over) --}} - + +
    +
    +
    +

    Navigation style

    +

    Choose how the top bar and sidebars sit on the page.

    +
    +
    +
    + @foreach ([ + ['value' => 'default', 'label' => 'Default', 'description' => 'Top bar and sidebars run edge to edge.'], + ['value' => 'floating', 'label' => 'Floating', 'description' => 'Framed panels float on a dotted backdrop, like the canvas.'], + ] as $option) + + @endforeach +
    +
    + +
    +
    +
    +

    Navigation items

    +

    Choose how the main sidebar shows its items.

    +
    +
    +
    + @foreach ([ + ['value' => '3d', 'label' => '3D', 'description' => 'Each icon sits in a raised tile that lifts on hover.'], + ['value' => 'flat', 'label' => 'Flat', 'description' => 'Plain rows with a highlight for the current page.'], + ] as $option) + + @endforeach +
    +
    @endif diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 8a5fba1e2b..6acaab08ae 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -65,7 +65,8 @@ $currentStorage = $storages->firstWhere('uuid', $storageUuid); $githubAppUuid = request()->route('github_app_uuid'); $gitlabAppUuid = request()->route('gitlab_app_uuid'); - $sourceUuid = $githubAppUuid ?? $gitlabAppUuid; + $giteaAppUuid = request()->route('gitea_app_uuid'); + $sourceUuid = $githubAppUuid ?? $gitlabAppUuid ?? $giteaAppUuid; $sources = $sourceUuid && $team ? $team->sources()->sortBy('name')->values() : collect(); $currentSource = $sources->firstWhere('uuid', $sourceUuid); $destinationUuid = request()->route('destination_uuid'); @@ -85,7 +86,7 @@ $currentTag = $tags->firstWhere('name', $tagName); $dashboardContext = match (true) { request()->routeIs('dashboard') => 'Dashboard', - request()->routeIs('project.index') => 'Projects', + request()->routeIs('project.index', 'project.canvas.index') => 'Projects', request()->routeIs('terminal') => 'Terminal', request()->routeIs('server.*') => 'Servers', request()->routeIs('source.*') => 'Sources', @@ -102,33 +103,6 @@ request()->routeIs('admin.*') => 'Admin', default => null, }; - // Workspace destinations require an active plan on cloud; unsubscribed users - // only keep profile/appearance and must not see this switcher. - $canUseWorkspaceNav = isSubscribed() || ! isCloud(); - $pageDestinations = $canUseWorkspaceNav - ? collect([ - ['label' => 'Dashboard', 'href' => url('/')], - ['label' => 'Projects', 'href' => url('/projects')], - auth()->user()?->can('canAccessTerminal') - ? ['label' => 'Terminal', 'href' => route('terminal')] - : null, - ['label' => 'Servers', 'href' => url('/servers')], - ['label' => 'Sources', 'href' => route('source.all')], - ['label' => 'Destinations', 'href' => route('destination.index')], - auth()->user()?->isAdmin() - ? ['label' => 'Registries', 'href' => route('registries.index')] - : null, - ['label' => 'S3 Storage', 'href' => route('storage.index')], - ['label' => 'Shared Variables', 'href' => route('shared-variables.index')], - ['label' => 'Team', 'href' => route('team.index')], - ['label' => 'Notifications', 'href' => route('notifications.email')], - ['label' => 'Keys & Tokens', 'href' => route('security.private-key.index')], - ['label' => 'Tags', 'href' => route('tags.show')], - isInstanceAdmin() - ? ['label' => 'Settings', 'href' => route('settings.index')] - : null, - ])->filter() - : collect(); @endphp
    {{-- Team --}} @@ -136,33 +110,7 @@
    - @if (!$currentProject && $dashboardContext && $canUseWorkspaceNav) - / -
    - -
    -
    - Pages -
    - @foreach ($pageDestinations as $destination) - - {{ $destination['label'] }} - - @endforeach -
    -
    - @elseif (!$currentProject && $dashboardContext) - {{-- Static context label only — no links to paid workspace pages. --}} + @if (!$currentProject && $dashboardContext) / {{ $dashboardContext }} @@ -193,9 +141,11 @@ @endphp @@ -247,7 +197,7 @@ {{-- Project switcher --}}
    -
    diff --git a/resources/views/emails/application-deployment-failed.blade.php b/resources/views/emails/application-deployment-failed.blade.php index a97c650b2a..937c8cdb8e 100644 --- a/resources/views/emails/application-deployment-failed.blade.php +++ b/resources/views/emails/application-deployment-failed.blade.php @@ -6,5 +6,6 @@ Failed to deploy a pull request #{{ $pull_request_id }} of {{ $name }} at [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/emails/application-deployment-success.blade.php b/resources/views/emails/application-deployment-success.blade.php index bc09a24796..2a1b6321d8 100644 --- a/resources/views/emails/application-deployment-success.blade.php +++ b/resources/views/emails/application-deployment-success.blade.php @@ -6,6 +6,7 @@ Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/emails/server-patches.blade.php b/resources/views/emails/server-patches.blade.php index 37644eaa2e..21e455e6d8 100644 --- a/resources/views/emails/server-patches.blade.php +++ b/resources/views/emails/server-patches.blade.php @@ -6,13 +6,19 @@ - Operating System: {{ ucfirst($osId) }} - Package Manager: {{ $package_manager }} - Total Updates: {{ $total_updates }} +@if (isset($security_updates)) +- Security Updates: {{ $security_updates }} +@endif +@if ($reboot_required ?? false) +- A reboot is required to apply installed updates. +@endif ## Available Updates @if ($total_updates > 0) @foreach ($updates as $update) -Package: {{ $update['package'] }} ({{ $update['architecture'] }}), from version {{ $update['current_version'] }} to {{ $update['new_version'] }} at repository {{ $update['repository'] ?? 'Unknown' }} +Package: {{ $update['package'] }}{{ ($update['security'] ?? false) ? ' [security]' : '' }} ({{ $update['architecture'] ?? 'unknown' }}), from version {{ $update['current_version'] }} to {{ $update['new_version'] }} at repository {{ $update['repository'] ?? 'Unknown' }} @endforeach ## Security Considerations @@ -21,12 +27,7 @@ Some of these updates may include important security patches. We recommend revie ### Critical packages that may require container/server/service restarts: @php -$criticalPackages = collect($updates)->filter(function ($update) { - return str_contains(strtolower($update['package']), 'docker') || - str_contains(strtolower($update['package']), 'kernel') || - str_contains(strtolower($update['package']), 'openssh') || - str_contains(strtolower($update['package']), 'ssl'); - }); +$criticalPackages = collect($updates)->filter(fn ($update) => filled($update['risk'] ?? null)); @endphp @if ($criticalPackages->count() > 0) diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 51bc6a8640..ed88a038f7 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -9,60 +9,36 @@ @auth {{-- ============ DESKTOP SIDEBAR (below top bar) ============ --}} - @@ -181,13 +160,23 @@
    {{-- ============ MAIN ============ --}} -
    -
    - {{ $slot }} -
    -
    + @if ($fullBleed ?? false) + {{-- Full-bleed pages (canvases) fill the space between the top bar and the sidebar. --}} +
    +
    + {{ $slot }} +
    +
    + @else +
    +
    + {{ $slot }} +
    +
    + @endif @endauth @endsection diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index f1f303ad61..4ff572da81 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -32,6 +32,8 @@ const theme = localStorage.theme === 'purple' ? 'custom' : (localStorage.theme || 'dark'); const themeColor = localStorage.themeColor || '#6b16ed'; const customMode = localStorage.customMode || 'dark'; + document.documentElement.dataset.navStyle = localStorage.navStyle === 'floating' ? 'floating' : 'default'; + document.documentElement.dataset.navItems = localStorage.navItems === 'flat' ? 'flat' : '3d'; const isDark = theme === 'dark' || (theme === 'custom' && customMode === 'dark') || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches); @@ -49,7 +51,9 @@ theme: localStorage.getItem('theme') === 'purple' ? 'custom' : (localStorage.getItem('theme') || 'dark'), themeColor: localStorage.getItem('themeColor') || '#6b16ed', customMode: localStorage.getItem('customMode') || 'dark', - pageWidth: localStorage.getItem('pageWidth') || 'full', + pageWidth: localStorage.getItem('pageWidth') || 'centered', + navStyle: localStorage.getItem('navStyle') === 'floating' ? 'floating' : 'default', + navItems: localStorage.getItem('navItems') === 'flat' ? 'flat' : '3d', themeColorFrame: null, pickerOpen: false, init() { @@ -79,6 +83,16 @@ localStorage.setItem('pageWidth', width); window.dispatchEvent(new CustomEvent('page-width-changed', { detail: width })); }, + setNavStyle(style) { + this.navStyle = style; + localStorage.setItem('navStyle', style); + document.documentElement.dataset.navStyle = style; + }, + setNavItems(style) { + this.navItems = style; + localStorage.setItem('navItems', style); + document.documentElement.dataset.navItems = style; + }, previewThemeColor(color) { this.themeColor = color; if (this.theme !== 'custom') { diff --git a/resources/views/livewire/analytics-placeholder.blade.php b/resources/views/livewire/analytics-placeholder.blade.php index 767f0f46ac..e33bf19353 100644 --- a/resources/views/livewire/analytics-placeholder.blade.php +++ b/resources/views/livewire/analytics-placeholder.blade.php @@ -52,8 +52,16 @@ - - + + + + +
    +
    + +
    + +
    @endif diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index c326ac29d5..f383f56506 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -254,14 +254,15 @@ $appListboxOptions = array_merge( icon-name="unordered-list" /> @else @php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp -
    -
    - Application / service - - Requests - +
    +
    + @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Application / service', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent'])
    +
    @foreach ($topApps as $row) @php $appHref = $row['link'] ?? null; @@ -269,11 +270,9 @@ $appListboxOptions = array_merge( @endphp <{{ $appHref ? 'a' : 'div' }} wire:key="analytics-app-{{ $row['uuid'] }}" @if ($appHref) href="{{ $appHref }}" {{ wireNavigate() }} @endif - x-show="{{ $loop->index }} >= page * per && {{ $loop->index }} < (page + 1) * per" - @class([ - 'flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]', - 'transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]' => $appHref, - ])> + data-row="{{ json_encode(['name' => (string) $row['name'], 'requests' => (int) $row['requests'], 'bytesOut' => (int) $row['bandwidth']]) }}" + x-show="shown($el)" + class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]"> {{ $row['name'] }} @if (! empty($row['isService'])) @@ -299,6 +298,7 @@ $appListboxOptions = array_merge( @endif @endforeach +
    @include('livewire.traffic._pager')
    @endif @@ -312,14 +312,12 @@ $appListboxOptions = array_merge( @include('livewire.traffic._paths-list', ['paths' => $topPaths]) - {{-- Countries --}} - - @include('livewire.traffic._geo', [ - 'countries' => data_get($breakdowns, 'country', []), - 'attribution' => $attribution, - ]) - + {{-- Locations (countries and cities) --}} + @include('livewire.traffic._geo', [ + 'countries' => data_get($breakdowns, 'country', []), + 'cities' => trafficCityRows(data_get($breakdowns, 'city', [])), + 'attribution' => $attribution, + ]) {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
    diff --git a/resources/views/livewire/boarding/index.blade.php b/resources/views/livewire/boarding/index.blade.php index bc22715931..71c065dac3 100644 --- a/resources/views/livewire/boarding/index.blade.php +++ b/resources/views/livewire/boarding/index.blade.php @@ -207,6 +207,23 @@ + + +
    +
    + +
    +

    Hostinger

    +

    + Deploy servers directly from your Hostinger account. +

    +
    +
    +
    +
    + +
    @endif
    diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 5de545df33..352060701a 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -12,6 +12,38 @@ $dashboardProjects = $projects->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); $dashboardServers = $servers->sortBy('name', SORT_NATURAL)->take($dashboardItemLimit); $hasTrafficAnalytics = $servers->contains(fn ($server) => $server->isTrafficAnalyticsEnabled()); + $projectResourceCounts = $dashboardProjects->mapWithKeys(fn ($project) => [$project->id => collect([ + $project->applications_count, + $project->services_count, + $project->postgresqls_count, + $project->redis_count, + $project->keydbs_count, + $project->dragonflies_count, + $project->clickhouses_count, + $project->mongodbs_count, + $project->mysqls_count, + $project->mariadbs_count, + $project->sqlites_count, + ])->sum()]); + $serverStatuses = $dashboardServers->mapWithKeys(function ($server) { + $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); + $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; + + return [$server->id => match (true) { + $server->settings->force_disabled => ['Disabled', 'error'], + ! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'], + ! $server->settings->is_reachable => ['Unreachable', 'error'], + ! $server->settings->is_usable => ['Not ready', 'warning'], + $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'], + default => ['Ready', 'success'], + }]; + }); + $iconButton = 'flex size-6.5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + // Same look as $iconButton, sized to the 20px section title it sits next to. + $titleIconButton = 'flex size-5 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'; + $tableShell = 'overflow-x-auto rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]'; + $tableHead = 'border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint'; + $tableRow = 'min-h-14 items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]'; @endphp
    @@ -32,38 +64,59 @@
    @endif + {{-- Wide screens: activity takes the left third, projects and servers the other two thirds. + It stays one column while the left side has no section (no deployments, no traffic). --}} +
    + -
    - +
    +
    + + {{-- Projects has no sidebar entry, so the dashboard is where new projects start. --}} + @can('createAnyResource') + + + + + + + + + @endcan + {{-- The dashboard canvas link lives here, so it never adds a row above the page. --}} + + + + @if ($dashboardProjects->isEmpty()) @else -
    + + + @endif
    -
    - +
    + + @can('createAnyResource') + + + + + + @endcan + @if ($dashboardServers->isNotEmpty()) + + + + @endif + @if ($dashboardServers->isEmpty()) @if ($privateKeys->isEmpty()) @@ -177,20 +319,11 @@ @endif @else -
    +
    @foreach ($dashboardServers as $server) @php - $proxyNeedsAttention = $server->proxySet() && ($server->proxy->status !== 'running' || $server->hasCurrentTraefikOutdatedInfo()); - $sentinelNeedsAttention = $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'; - - [$serverStatus, $serverStatusType] = match (true) { - $server->settings->force_disabled => ['Disabled', 'error'], - ! $server->settings->is_reachable && ! $server->settings->is_usable => ['Unavailable', 'error'], - ! $server->settings->is_reachable => ['Unreachable', 'error'], - ! $server->settings->is_usable => ['Not ready', 'warning'], - $proxyNeedsAttention || $sentinelNeedsAttention => ['Attention required', 'warning'], - default => ['Ready', 'success'], - }; + [$serverStatus, $serverStatusType] = $serverStatuses[$server->id]; @endphp @endforeach
    + @endif
    +
    +
    diff --git a/resources/views/livewire/dashboard/active-deployments.blade.php b/resources/views/livewire/dashboard/active-deployments.blade.php index cea0489a20..ff7897b42a 100644 --- a/resources/views/livewire/dashboard/active-deployments.blade.php +++ b/resources/views/livewire/dashboard/active-deployments.blade.php @@ -24,17 +24,18 @@ $hasAnyDeployments = $hasActiveDeployments || $hasRecentDeployments; @endphp -
    $hasAnyDeployments, 'hidden' => ! $hasAnyDeployments])> +
    $hasAnyDeployments, 'hidden' => ! $hasAnyDeployments])> @if ($hasAnyDeployments) -
    - + {{-- Container breakpoints: the table needs room, which the dashboard column may not have. --}} +
    +
    @if ($hasActiveDeployments)
    -

    +

    Active

    @@ -43,7 +44,7 @@

    + +
    diff --git a/resources/views/livewire/profile/passkeys.blade.php b/resources/views/livewire/profile/passkeys.blade.php new file mode 100644 index 0000000000..86df71597d --- /dev/null +++ b/resources/views/livewire/profile/passkeys.blade.php @@ -0,0 +1,104 @@ +
    + @if ($enabled) + @assets + @vite('resources/js/passkeys.js') + @endassets +
    +
    +
    +

    Passkeys

    +

    Sign in with your fingerprint, face, or a security key. A passkey sign-in skips two-factor authentication.

    +
    + @if ($available && ! $show_form) + Add passkey + @endif +
    +
    + @unless ($available) + + Passkeys need HTTPS on the instance domain, or localhost. An IP address does not work. + + @endunless + + @if ($available && $show_form) + +
    + + Create passkey + Cancel +
    +

    + + @endif + + @forelse ($passkeys as $passkey) +
    +
    +
    {{ $passkey->name }}
    +
    + + Save + +

    + {{ $passkey->authenticator ?? 'Passkey' }} + · Added {{ $passkey->created_at->diffForHumans() }} + · {{ $passkey->last_used_at ? 'Last used '.$passkey->last_used_at->diffForHumans() : 'Not used yet' }} +

    +
    +
    + + + +
    + +
    +
    +
    + @empty + @if ($available && ! $show_form) + + @endif + @endforelse + + @error('name') +

    {{ $message }}

    + @enderror +
    +
    + @endif +
    diff --git a/resources/views/livewire/project/application/advanced.blade.php b/resources/views/livewire/project/application/advanced.blade.php index c5e5aa89c3..9ce4bc1ae9 100644 --- a/resources/views/livewire/project/application/advanced.blade.php +++ b/resources/views/livewire/project/application/advanced.blade.php @@ -203,6 +203,30 @@ @if ($application->build_pack !== 'dockercompose') + + @php($canRefreshGpus = $gpuServers->contains(fn ($gpuServer) => $gpuServer->isFunctional())) + + + Refresh GPUs + + + + @foreach ($gpuServers as $gpuServer) +
    ! $loop->first])> +

    + {{ $gpuServer->name }} + @if (data_get($gpuServer->server_metadata, 'collected_at')) + · checked + {{ \Illuminate\Support\Carbon::parse(data_get($gpuServer->server_metadata, 'collected_at'))->diffForHumans() }} + @endif +

    + +
    + @endforeach +
    -
    - @endif diff --git a/resources/views/livewire/project/application/analytics-placeholder.blade.php b/resources/views/livewire/project/application/analytics-placeholder.blade.php index 1cb6cffc70..6c8df5d82c 100644 --- a/resources/views/livewire/project/application/analytics-placeholder.blade.php +++ b/resources/views/livewire/project/application/analytics-placeholder.blade.php @@ -25,8 +25,16 @@ - - + + + + +
    +
    + +
    + +
    diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php index b2458f4cf8..53c09e5629 100644 --- a/resources/views/livewire/project/application/analytics.blade.php +++ b/resources/views/livewire/project/application/analytics.blade.php @@ -157,13 +157,11 @@ $approxBadge = fn (string $tooltip) => ' - @include('livewire.traffic._geo', [ - 'countries' => data_get($breakdowns, 'country', []), - 'attribution' => $attribution, - ]) - + @include('livewire.traffic._geo', [ + 'countries' => data_get($breakdowns, 'country', []), + 'cities' => trafficCityRows(data_get($breakdowns, 'city', [])), + 'attribution' => $attribution, + ]) {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
    diff --git a/resources/views/livewire/project/application/backup/index.blade.php b/resources/views/livewire/project/application/backup/index.blade.php index f842ce95d0..bab72d8ec4 100644 --- a/resources/views/livewire/project/application/backup/index.blade.php +++ b/resources/views/livewire/project/application/backup/index.blade.php @@ -181,6 +181,7 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; + $s3StorageNames = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->pluck('name'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -208,9 +209,9 @@ {{ $backup->targetType() }} {{ $backup->frequency }} - - + + {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} diff --git a/resources/views/livewire/project/application/backup/show.blade.php b/resources/views/livewire/project/application/backup/show.blade.php index 982160794d..0b9b31bfb6 100644 --- a/resources/views/livewire/project/application/backup/show.blade.php +++ b/resources/views/livewire/project/application/backup/show.blade.php @@ -7,11 +7,14 @@
    + {{-- The application menu, one Back away from the backup menu in the main sidebar. --}} +
    diff --git a/resources/views/livewire/project/application/configuration.blade.php b/resources/views/livewire/project/application/configuration.blade.php index 11d6400bf6..e223ed8123 100644 --- a/resources/views/livewire/project/application/configuration.blade.php +++ b/resources/views/livewire/project/application/configuration.blade.php @@ -14,6 +14,7 @@ @elseif ($currentRoute === 'project.application.domains') + @elseif ($currentRoute === 'project.application.swarm' && $application->destination->server->isSwarm()) @elseif ($currentRoute === 'project.application.advanced') @@ -29,8 +30,6 @@ @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/deployment/index.blade.php b/resources/views/livewire/project/application/deployment/index.blade.php index bfee536891..049bc21cb6 100644 --- a/resources/views/livewire/project/application/deployment/index.blade.php +++ b/resources/views/livewire/project/application/deployment/index.blade.php @@ -169,7 +169,7 @@ -
    +
    $embedded])>
    Status Source diff --git a/resources/views/livewire/project/application/deployment/show.blade.php b/resources/views/livewire/project/application/deployment/show.blade.php index a10e0b6640..29d5cb7b42 100644 --- a/resources/views/livewire/project/application/deployment/show.blade.php +++ b/resources/views/livewire/project/application/deployment/show.blade.php @@ -8,7 +8,7 @@
    -
    +
    @@ -273,7 +273,7 @@ this.deploymentFinishedCleanup = null; } } - }" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full flex-col overflow-hidden xl:h-[32rem] xl:min-h-0 xl:flex-none"> + }" class="flex h-[calc(100dvh-8rem)] min-h-[32rem] w-full min-w-0 flex-col overflow-hidden xl:h-[32rem] xl:min-h-0 xl:flex-none 2xl:sticky 2xl:top-4 2xl:h-[calc(100dvh-10rem)] 2xl:min-h-[32rem]">
    where('is_suggested', false)->count(); - $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $composeDomainGroups = collect($domainRows) @@ -81,25 +79,16 @@ @endcannot {{-- Toolbar --}} -
    -
    -

    Domains

    -

    - {{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} - @if ($suggestedCount > 0) - · {{ $suggestedCount }} not added - @endif -

    -
    +
    + @if ($hasRows) +
    + + +
    + @endif
    - @if ($hasRows) -
    - - -
    - @endif @can('update', $application) diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index c2e9f500d5..8d6c6e22ef 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -1,3 +1,4 @@ +@php use App\Enums\HttpBasicAuthHashAlgorithm; use App\Enums\ProxyTypes; @endphp
    @if ($buildPack === 'dockercompose')
    -
    - - -
    +
    @else -
    - - @if ($buildPack === 'dockerfile' && !$application->dockerfile) - - @endif - - @if ($buildPack === 'dockerfile') - - @endif + @if ($application->could_set_build_commands()) @if ($application->settings->is_static) @endif @endif - -
    + @if ($this->application->is_github_based() && !$this->application->is_public_repository())
    + @if ($buildPack === 'dockerfile') + + @endif
    + @php + $server = $application->destination->server; + $supportsArgon2id = $server->caddySupportsArgon2idBasicAuth(); + $usesArgon2id = $httpBasicAuthHashAlgorithm === HttpBasicAuthHashAlgorithm::ARGON2ID->value; + @endphp +
    +

    Password hashing

    +
    + + @if ($usesArgon2id) +
    + +
    +
    + +
    + @else +
    + +
    + @endif +
    +
    @endif @endif diff --git a/resources/views/livewire/project/application/rollback.blade.php b/resources/views/livewire/project/application/rollback.blade.php index c159af392c..9a32c1a411 100644 --- a/resources/views/livewire/project/application/rollback.blade.php +++ b/resources/views/livewire/project/application/rollback.blade.php @@ -22,7 +22,7 @@ + helper="Rollback uses an existing image without rebuilding the application. Images in the Docker registry are pulled to the server first." flush> @can('view', $application) @@ -41,6 +41,7 @@ $isPrTag = preg_match('/^pr-\d+$/', $tag); $isRollbackable = $isCommitSha || $isPrTag; $isCurrent = data_get($image, 'is_current'); + $isRegistryImage = data_get($image, 'source') === 'registry'; @endphp
    @@ -58,9 +59,12 @@ @elseif (!$isRollbackable) @endif + @if ($isRegistryImage) + + @endif

    - Built {{ $createdAt->diffForHumans() }} + {{ $isRegistryImage ? 'Deployed' : 'Built' }} {{ $createdAt->diffForHumans() }} · {{ $date }}

    @@ -84,7 +88,7 @@
    @empty @endforelse
    diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index bf1b4bdccc..c2dbc5836b 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -74,11 +74,14 @@ $currentSourceMorph = $currentSource?->getMorphClass(); $currentSourceIsGithub = $currentSourceMorph === \App\Models\GithubApp::class; $currentSourceIsGitlab = $currentSourceMorph === \App\Models\GitlabApp::class; + $currentSourceIsGitea = $currentSourceMorph === \App\Models\GiteaApp::class; $currentSourceSubtitle = match (true) { $currentSourceIsGithub && filled(data_get($currentSource, 'organization')) => 'GitHub · '.$currentSource->organization, $currentSourceIsGithub => data_get($currentSource, 'is_public') ? 'Public GitHub' : 'GitHub · Personal account', $currentSourceIsGitlab && filled(data_get($currentSource, 'group_name')) => 'GitLab · '.$currentSource->group_name, $currentSourceIsGitlab => 'GitLab · Personal account', + $currentSourceIsGitea && filled(data_get($currentSource, 'organization')) => 'Gitea · '.$currentSource->organization, + $currentSourceIsGitea => 'Gitea · Personal account', default => 'Connected source', }; @endphp @@ -113,11 +116,14 @@ $sourceMorph = $source->getMorphClass(); $sourceIsGithub = $sourceMorph === \App\Models\GithubApp::class; $sourceIsGitlab = $sourceMorph === \App\Models\GitlabApp::class; + $sourceIsGitea = $sourceMorph === \App\Models\GiteaApp::class; $sourceSubtitle = match (true) { $sourceIsGithub && filled($source->organization) => 'GitHub · '.$source->organization, $sourceIsGithub => data_get($source, 'is_public') ? 'Public GitHub' : 'GitHub · Personal account', $sourceIsGitlab && filled($source->group_name) => 'GitLab · '.$source->group_name, $sourceIsGitlab => 'GitLab · Personal account', + $sourceIsGitea && filled($source->organization) => 'Gitea · '.$source->organization, + $sourceIsGitea => 'Gitea · Personal account', default => 'Git source', }; @endphp diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index d85d506a0b..90543e817b 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -1,13 +1,5 @@
    - @if ($backup->database_id === 0) - @include('livewire.project.database.backup-edit.general') - @include('livewire.project.database.backup-edit.s3') - @include('livewire.project.database.backup-edit.retention') - @elseif ($section === 'retention') - @include('livewire.project.database.backup-edit.retention') - @elseif ($section === 's3') - @include('livewire.project.database.backup-edit.s3') - @elseif ($section === 'danger') + @if ($section === 'danger' && $backup->database_id !== 0) @include('livewire.project.database.backup-edit.danger') @else @include('livewire.project.database.backup-edit.general') diff --git a/resources/views/livewire/project/database/backup-edit/general.blade.php b/resources/views/livewire/project/database/backup-edit/general.blade.php index 49385b0258..dfe465e07a 100644 --- a/resources/views/livewire/project/database/backup-edit/general.blade.php +++ b/resources/views/livewire/project/database/backup-edit/general.blade.php @@ -1,10 +1,11 @@ -
    + -
    + {{-- Below xl the header bar is hidden, so keep the buttons here. --}} +
    $actionsInHeader])> @if (! $backupEnabled) @@ -98,4 +99,7 @@
    + + @include('livewire.project.database.backup-edit.s3') + @include('livewire.project.database.backup-edit.retention') diff --git a/resources/views/livewire/project/database/backup-edit/retention.blade.php b/resources/views/livewire/project/database/backup-edit/retention.blade.php index 8825b3c26d..d340216537 100644 --- a/resources/views/livewire/project/database/backup-edit/retention.blade.php +++ b/resources/views/livewire/project/database/backup-edit/retention.blade.php @@ -1,34 +1,30 @@ -
    - - - -
    -
    -

    Local backups

    -
    - - - -
    -
    - -
    -

    S3 backups

    -
    - - - -
    + +
    +
    +

    Local backups

    +
    + + +
    - - + +
    +

    S3 backups

    +
    + + + +
    +
    +
    +
    diff --git a/resources/views/livewire/project/database/backup-edit/s3.blade.php b/resources/views/livewire/project/database/backup-edit/s3.blade.php index dcfb7c3815..586c3617cf 100644 --- a/resources/views/livewire/project/database/backup-edit/s3.blade.php +++ b/resources/views/livewire/project/database/backup-edit/s3.blade.php @@ -10,32 +10,29 @@ @else -
    - - - - - @if (! $saveS3) - Enable S3 - @else - Disable S3 - @endif - -
    - - -
    -
    - + + + @if (! $saveS3) + Enable S3 + @else + Disable S3 + @endif + +
    + + +
    +
    @endif diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index f731b005f8..66a9d2c863 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -1,10 +1,12 @@ -
    +
    + @if (count($pendingDownloads) > 0) + + @endif @isset($backup)

    Executions

    -

    Review generated archives, storage availability, and backup output.

    Clean failed backups @@ -37,9 +39,8 @@ class="data-table-header backup-executions-table-grid h-auto rounded-none px-4 py-2.5 text-[11px]"> Status Database - Backup path - Finished - Duration + Archive + Time Size Availability Actions @@ -58,6 +59,11 @@ 'failed' => ['Failed', 'error'], default => [str($executionStatus)->headline(), 'neutral'], }; + $finishedAt = \Carbon\Carbon::parse(data_get($execution, 'finished_at') ?? data_get($execution, 'updated_at')); + $s3Replicas = $execution->s3Replicas; + $hasLiveS3Copies = $s3Replicas->isNotEmpty() + ? $s3Replicas->contains(fn ($replica) => $replica->s3_uploaded === true && ! $replica->s3_storage_deleted) + : data_get($execution, 's3_uploaded') === true && ! data_get($execution, 's3_storage_deleted', false); $executionCheckboxes = []; $deleteActions = []; @@ -65,11 +71,10 @@ $deleteActions[] = 'This backup will be permanently deleted from local storage.'; } - if (data_get($execution, 's3_uploaded') === true - && ! data_get($execution, 's3_storage_deleted', false)) { + if ($hasLiveS3Copies) { $executionCheckboxes[] = [ 'id' => 'delete_backup_s3', - 'label' => 'Delete the selected backup permanently from S3 Storage', + 'label' => 'Delete the selected backup permanently from every S3 storage', ]; } @@ -82,48 +87,52 @@
    - @if ($executionStatus === 'running') - - @endif + :type="$executionStatusType" :pulse="$executionStatus === 'running'" />
    {{ data_get($execution, 'database_name', 'N/A') }}
    -
    - {{ data_get($execution, 'filename', 'N/A') }} - +
    +
    -
    @if ($executionStatus === 'running') - Running now + Running for {{ calculateDuration(data_get($execution, 'created_at'), now()) }} @else
    - {{ formatDateInServerTimezone(data_get($execution, 'finished_at'), $server) }} + {{ formatDateInServerTimezone($finishedAt, $server) }}
    -
    {{ \Carbon\Carbon::parse(data_get($execution, 'finished_at'))->diffForHumans() }}
    +
    {{ $finishedAt->diffForHumans() }} · {{ calculateDuration(data_get($execution, 'created_at'), $finishedAt) }}
    @endif
    -
    - {{ calculateDuration( - data_get($execution, 'created_at'), - $executionStatus === 'running' ? now() : data_get($execution, 'finished_at'), - ) }} -
    -
    +
    {{ data_get($execution, 'size') ? formatBytes(data_get($execution, 'size')) : '-' }}
    - - @if (data_get($execution, 's3_uploaded') !== null) - + @endforeach + @if ($s3Replicas->isEmpty() && data_get($execution, 's3_uploaded') !== null) + @@ -189,7 +198,11 @@ @script @endscript diff --git a/resources/views/livewire/project/database/backup/execution.blade.php b/resources/views/livewire/project/database/backup/execution.blade.php index 911f403db8..7fc0c715c4 100644 --- a/resources/views/livewire/project/database/backup/execution.blade.php +++ b/resources/views/livewire/project/database/backup/execution.blade.php @@ -5,8 +5,27 @@ + @can('manageBackups', $database) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan +
    + {{-- The database menu, one Back away from the backup menu in the main sidebar. --}} +
    @@ -15,7 +34,7 @@ @else + :actions-in-header="true" wire:key="database-backup-{{ $backup->uuid }}-{{ $section }}" /> @endif
    diff --git a/resources/views/livewire/project/database/scheduled-backups.blade.php b/resources/views/livewire/project/database/scheduled-backups.blade.php index 793cd2ff47..aba61ec48c 100644 --- a/resources/views/livewire/project/database/scheduled-backups.blade.php +++ b/resources/views/livewire/project/database/scheduled-backups.blade.php @@ -3,7 +3,7 @@ backups: @js($database->scheduledBackups->map(fn ($backup) => [ 'name' => strtolower($database->name), 'frequency' => strtolower($backup->frequency), - 's3_storage' => strtolower($backup->s3?->name ?? ''), + 's3_storage' => strtolower(($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->pluck('name')->join(', ')), ])->values()), hasMatches() { const query = this.search.toLowerCase(); @@ -67,6 +67,7 @@ @foreach ($database->scheduledBackups as $backup) @php $latestStatus = data_get($backup->latest_log, 'status'); + $s3StorageNames = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->pluck('name')->join(', '); [$statusLabel, $statusType] = match ($latestStatus) { 'success' => ['Success', 'success'], 'running' => ['In progress', 'warning'], @@ -83,7 +84,7 @@
    @@ -96,8 +97,8 @@ @endif
    -
    - {{ $backup->save_s3 ? ($backup->s3?->name ?? 'Unavailable') : 'Local only' }} +
    + {{ $backup->save_s3 ? ($s3StorageNames ?: 'Unavailable') : 'Local only' }}
    -
    + +
    +
    +
    +

    Project details

    +

    Icon, name, and description shown across the dashboard.

    +
    +
    +
    +
    -
    -
    -

    Project icon

    -

    Upload a JPG, PNG, or WebP image. It will appear in the projects list.

    -
    -
    -
    -
    - Project icon preview - @if ($project->icon_path) - {{ $project->name }} icon - @else - - @endif -
    -
    -
    - - - - - @if ($project->icon_path) - Remove - @endif +
    Icon
    +
    +
    + Project icon preview + @if ($project->icon_path) + {{ $project->name }} icon + @else + + @endif +
    +
    +
    + + + + + @if ($project->icon_path) + Remove + @endif +
    +

    JPG, PNG, or WebP. Shown in the projects list.

    +

    + @error('icon') +

    {{ $message }}

    + @enderror +
    +
    -

    - @error('icon')

    {{ $message }}

    @enderror -
    -
    -
    - -
    - -
    -
    -
    -

    Project details

    -

    Name and describe this project across the dashboard.

    -
    -
    -
    diff --git a/resources/views/livewire/project/environment-canvas.blade.php b/resources/views/livewire/project/environment-canvas.blade.php new file mode 100644 index 0000000000..35535969e4 --- /dev/null +++ b/resources/views/livewire/project/environment-canvas.blade.php @@ -0,0 +1,38 @@ +
    + + {{ $environment->name }} > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar; the page actions come last, at the right end. --}} +
    + + + + Shared variables + + @can('update', $project) + + + Settings + + @endcan + @can('createAnyResource') + {{-- A full page load: the deploy canvas uses the minimal layout. --}} + + + New resource + + @endcan +
    +
    +
    diff --git a/resources/views/livewire/project/index.blade.php b/resources/views/livewire/project/index.blade.php index 1351424f70..7a476c1c0c 100644 --- a/resources/views/livewire/project/index.blade.php +++ b/resources/views/livewire/project/index.blade.php @@ -48,7 +48,7 @@
    -
    +
    - -
    +
    @@ -244,7 +225,7 @@ search: '', sortBy: 'name-asc', sortOpen: false, - viewMode: localStorage.getItem('projects-view') || 'grid', + viewMode: @js($this->listView('projects', 'grid')), page: 1, pageSize: 12, projects: @js($projectsJs), @@ -312,7 +293,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('projects-view', mode); + this.$wire.saveListView('projects', mode); }, previousPage() { this.page = Math.max(1, this.page - 1); diff --git a/resources/views/livewire/project/new/gitea-private-repository.blade.php b/resources/views/livewire/project/new/gitea-private-repository.blade.php new file mode 100644 index 0000000000..6ed15d6312 --- /dev/null +++ b/resources/views/livewire/project/new/gitea-private-repository.blade.php @@ -0,0 +1,146 @@ +
    + @if ($gitea_apps->isEmpty()) +
    +
    +
    +

    Gitea App

    +

    Connect a Gitea App before selecting a private repository.

    +
    +
    +
    + + + + + + + +
    +
    + @elseif ($current_step === 'gitea_apps') +
    +
    +
    +

    Choose Gitea App

    +

    Select the Gitea App that can access the repository you want to deploy.

    +
    + + + +
    +
    + @foreach ($gitea_apps as $gtapp) + + @endforeach +
    +
    + @elseif ($current_step === 'repository') +
    +
    +
    +

    Choose repository

    +

    Search repositories available through the selected Gitea App.

    +
    + @if ($gitea_app_id) + + Refresh + + @endif +
    +
    + @if ($repositories->isNotEmpty()) +
    + + + + Load repository + +
    + @else + + @endif +
    +
    + + @if ($branches->isNotEmpty()) + +
    +
    +
    +

    Build configuration

    +

    Choose the branch and build strategy for this application.

    +
    + Continue +
    +
    +
    + + + @if ($show_is_static) + + + @endif + @if ($is_static) + + @endif +
    + + @if ($build_pack === 'dockercompose') + + @else + + @endif +
    +
    + + @endif + @endif +
    diff --git a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php index 86eb652392..6ee5dfe990 100644 --- a/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php +++ b/resources/views/livewire/project/new/github-private-repository-deploy-key.blade.php @@ -81,32 +81,32 @@ @endif @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else @endif + + @include('livewire.project.new.partials.smart-scan-summary') + + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif
    diff --git a/resources/views/livewire/project/new/github-private-repository.blade.php b/resources/views/livewire/project/new/github-private-repository.blade.php index e2b691f929..c89472dd7a 100644 --- a/resources/views/livewire/project/new/github-private-repository.blade.php +++ b/resources/views/livewire/project/new/github-private-repository.blade.php @@ -104,6 +104,8 @@ Continue
    + @include('livewire.project.new.partials.smart-scan-summary') +
    @endif + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/gitlab-private-repository.blade.php b/resources/views/livewire/project/new/gitlab-private-repository.blade.php index a088844ec0..0a0e7e152d 100644 --- a/resources/views/livewire/project/new/gitlab-private-repository.blade.php +++ b/resources/views/livewire/project/new/gitlab-private-repository.blade.php @@ -135,28 +135,9 @@ @endif @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/partials/env-import-modal.blade.php b/resources/views/livewire/project/new/partials/env-import-modal.blade.php new file mode 100644 index 0000000000..bb8136e2b4 --- /dev/null +++ b/resources/views/livewire/project/new/partials/env-import-modal.blade.php @@ -0,0 +1,31 @@ + + + {{ $envImported ? 'Edit' : 'Review' }} + + +
    + @if (count($detectedEnvFiles) > 1) + + @endif +
    + @foreach ($envExampleVars as $key => $value) +
    + {{ $key }} + +
    + @endforeach +
    +
    + + + @if ($envImported) + Remove import + @endif + + {{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }} + + +
    diff --git a/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php new file mode 100644 index 0000000000..690bba4303 --- /dev/null +++ b/resources/views/livewire/project/new/partials/smart-scan-summary.blade.php @@ -0,0 +1,73 @@ +{{-- + Smart Scan result inside the build configuration, as a compact divided list: the build pack, + the Dockerfile port, and the env file import. The scan button sends the current base directory, + so the user can scan a subdirectory after changing it. +--}} +@php + $buildPackLabels = ['dockercompose' => 'Docker Compose', 'dockerfile' => 'Dockerfile']; + $suggestedFile = match ($suggestedBuildPack) { + 'dockercompose' => $detectedDockerComposeFiles[0] ?? null, + 'dockerfile' => $detectedDockerfiles[0] ?? null, + default => null, + }; + $nestedFiles = collect([ + count($detectedDockerfiles) ? count($detectedDockerfiles).' '.Str::plural('Dockerfile', count($detectedDockerfiles)) : null, + count($detectedDockerComposeFiles) ? count($detectedDockerComposeFiles).' '.Str::plural('Compose file', count($detectedDockerComposeFiles)) : null, + ])->filter()->join(' and '); + $showPort = $detectionRan && $build_pack === 'dockerfile' && in_array($selectedDockerfile, $detectedDockerfiles, true); +@endphp + +
    +
    +

    + Smart Scan +

    + + + {{ $detectionRan ? 'Scan again' : 'Scan repository' }} + +
    + +
    + +
    + +
    + @if (! $detectionRan) + + @elseif ($detectionFailed) + + @elseif ($suggestedBuildPack) + + @elseif ($nestedFiles !== '') + + @else + + @endif + + @if ($showPort) + @if ($detectedPort) + + @else + + @endif + @endif + + @if ($detectionRan && count($envExampleVars) > 0) +
    + + @include('livewire.project.new.partials.env-import-modal') +
    + @endif +
    +
    diff --git a/resources/views/livewire/project/new/public-git-repository.blade.php b/resources/views/livewire/project/new/public-git-repository.blade.php index 8e0b7a1bfe..8825ea3ee5 100644 --- a/resources/views/livewire/project/new/public-git-repository.blade.php +++ b/resources/views/livewire/project/new/public-git-repository.blade.php @@ -56,6 +56,8 @@ @endif + @include('livewire.project.new.partials.smart-scan-summary') +
    @endif + {{-- Dockerfile: pick a detected file or type a path --}} + @if ($build_pack === 'dockerfile') + + @endif + + {{-- Docker Compose file selector: pick a detected file or type a path --}} + @if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0) + + @endif + @if ($build_pack === 'dockercompose') -
    - - -

    - Resolved file: - -

    -
    + @else diff --git a/resources/views/livewire/project/new/select.blade.php b/resources/views/livewire/project/new/select.blade.php index 1e33c43d8a..2c20adb3a5 100644 --- a/resources/views/livewire/project/new/select.blade.php +++ b/resources/views/livewire/project/new/select.blade.php @@ -35,7 +35,7 @@ :aria-selected="resourceType === option.value" @click="resourceType = option.value; close()"> - @@ -71,7 +71,7 @@ :aria-selected="selectedCategory === ''" @click="selectedCategory = ''; categorySearch = ''; categoryOpen = false"> All categories - @@ -92,6 +92,29 @@
    + {{-- A full page load: the canvas uses the minimal layout. --}} + +
    + +
    +
    +
    +

    Deploy canvas

    + +
    +

    + Connect a server, a source, and its settings step by step, then deploy every resource at once. +

    +
    + + Try it + + +
    +
    diff --git a/resources/views/livewire/project/partials/new-environment-form.blade.php b/resources/views/livewire/project/partials/new-environment-form.blade.php new file mode 100644 index 0000000000..b81572efa6 --- /dev/null +++ b/resources/views/livewire/project/partials/new-environment-form.blade.php @@ -0,0 +1,11 @@ +
    + + +
    + + Create environment + +
    + diff --git a/resources/views/livewire/project/project-canvas.blade.php b/resources/views/livewire/project/project-canvas.blade.php new file mode 100644 index 0000000000..8083cdc921 --- /dev/null +++ b/resources/views/livewire/project/project-canvas.blade.php @@ -0,0 +1,38 @@ +
    + + {{ $project->name }} > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so buttons open the modals below with a window event. --}} +
    + + @can('update', $project) + + + Settings + + + @endcan +
    +
    + @can('update', $project) + + + @include('livewire.project.partials.new-environment-form') + + @endcan +
    diff --git a/resources/views/livewire/project/projects-canvas.blade.php b/resources/views/livewire/project/projects-canvas.blade.php new file mode 100644 index 0000000000..b909d6a39a --- /dev/null +++ b/resources/views/livewire/project/projects-canvas.blade.php @@ -0,0 +1,31 @@ +
    + + Projects > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so buttons open the modals below with a window event. --}} +
    + + @can('createAnyResource') + + @endcan +
    +
    + @can('createAnyResource') + + + + + @endcan +
    diff --git a/resources/views/livewire/project/resource/create.blade.php b/resources/views/livewire/project/resource/create.blade.php index 2821dbfc25..ec58c27455 100644 --- a/resources/views/livewire/project/resource/create.blade.php +++ b/resources/views/livewire/project/resource/create.blade.php @@ -8,6 +8,8 @@ @elseif ($type === 'private-gitlab-app') + @elseif ($type === 'private-gitea-app') + @elseif ($type === 'private-deploy-key') @elseif ($type === 'dockerfile') diff --git a/resources/views/livewire/project/resource/index.blade.php b/resources/views/livewire/project/resource/index.blade.php index f2a874527e..57dcd456d6 100644 --- a/resources/views/livewire/project/resource/index.blade.php +++ b/resources/views/livewire/project/resource/index.blade.php @@ -5,13 +5,31 @@
    -

    {{ $environment->name }}

    +
    +

    {{ $environment->name }}

    + @can('update', $project) + + + + @endcan +

    - in {{ $project->name }} + in {{ $project->name }}

    -
    +
    + @if ($environment->isEmpty()) + {{-- The list toolbar (with its canvas switch) is hidden when there are no resources. --}} + + @endif Shared variables - @can('update', $project) - - - Settings - - @endcan @can('createAnyResource')
    -
    +
    - -
    +
    @@ -193,16 +183,8 @@ {{ wireNavigate() }} class="absolute inset-0" :aria-label="`Open ${item.name}`">
    - - - + class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim"> +
    @@ -287,16 +269,8 @@
    - - - + class="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim"> +
    @@ -353,7 +327,7 @@ serverFilters: [], statusFilters: [], sortBy: 'name-asc', - viewMode: localStorage.getItem('environment-resource-view') || 'table', + viewMode: @js($this->listView('environment', 'table')), filterOpen: false, sortOpen: false, page: 1, @@ -522,7 +496,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('environment-resource-view', mode); + this.$wire.saveListView('environment', mode); }, statusState(item) { if (item.restartLimitReached) { diff --git a/resources/views/livewire/project/service/backup-executions.blade.php b/resources/views/livewire/project/service/backup-executions.blade.php index 61fd3e9d0b..761b2e2dce 100644 --- a/resources/views/livewire/project/service/backup-executions.blade.php +++ b/resources/views/livewire/project/service/backup-executions.blade.php @@ -1,4 +1,10 @@ -
    +
    + @if (count($pendingDownloads) > 0) + + @endif + @if ($executions->onFirstPage()) + + @endif @php $server = $service->server; @endphp @@ -66,7 +72,12 @@ {{ $execution['size'] ? formatBytes($execution['size']) : '-' }} - @if ($execution['download_url']) + @if ($execution['download_url'] && $s3Downloads) + + @elseif ($execution['download_url']) diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php index 379c6f9f4f..6b33ce5c45 100644 --- a/resources/views/livewire/project/service/configuration.blade.php +++ b/resources/views/livewire/project/service/configuration.blade.php @@ -6,51 +6,6 @@ @php - $serviceRouteParameters = [ - 'project_uuid' => $project->uuid, - 'environment_uuid' => $environment->uuid, - 'service_uuid' => $service->uuid, - ]; - - $configurationItems = collect([ - ['label' => 'General', 'route' => 'project.service.configuration', 'icon' => 'settings'], - ['label' => 'Domains', 'route' => 'project.service.domains', 'icon' => 'globe'], - ['label' => 'Environment Variables', 'route' => 'project.service.environment-variables', 'icon' => 'variables', 'hasWarning' => ! $service->isDeployable], - ['label' => 'Persistent Storage', 'route' => 'project.service.storages', 'icon' => 'storages'], - ['label' => 'Backups', 'route' => 'project.service.volume-backups.index', 'icon' => 'database'], - ['label' => 'Import Backup', 'route' => 'project.service.import-backup', 'icon' => 'upload', 'navigate' => false], - ['label' => 'Runtime Logs', 'route' => 'project.service.logs', 'icon' => 'unordered-list', 'navigate' => false], - ['label' => 'Terminal', 'route' => 'project.service.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')], - ['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'], - ['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'], - ['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'], - ['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'], - ['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'], - ['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'shield-alert'], - ])->filter(fn (array $item): bool => $item['visible'] ?? true)->map(fn (array $item): array => [ - ...$item, - 'active' => $currentRoute === $item['route'] - || ($item['route'] === 'project.service.scheduled-tasks.show' - && str($currentRoute)->startsWith('project.service.scheduled-tasks')), - ]); - - $menuGroups = [ - 'Settings' => ['General', 'Domains', 'Environment Variables', 'Persistent Storage'], - 'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Analytics'], - 'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'], - 'Operations' => ['Resource Operations', 'Tags', 'Danger Zone'], - ]; - - $groupedItems = collect($menuGroups) - ->map(fn (array $labels) => collect($labels) - ->map(fn (string $label) => $configurationItems->firstWhere('label', $label)) - ->filter() - ->values()) - ->filter(fn ($items) => $items->isNotEmpty()); - - // Group that holds the current page — always kept open, even if collapsed before. - $activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false)); - $storageSections = $applications ->concat($databases) ->map(fn ($resource): array => [ @@ -61,57 +16,8 @@
    - +
    @if ($currentRoute === 'project.service.configuration') @@ -124,79 +30,56 @@ localStorage.setItem('service-compose-resources-view', mode); } }"> -
    -
    -

    Compose resources

    -

    - Applications and databases defined in this service. -

    -
    -
    -
    - - -
    + + + Documentation + + +
    + @if ($applications->isNotEmpty() || $databases->isNotEmpty()) +
    +
    Resource
    +
    Image
    +
    Status
    +
    +
    + @endif + + @if ($applications->isEmpty() && $databases->isEmpty()) +
    + +
    + @endif + + @foreach ($applications as $application) + + @endforeach + @foreach ($databases as $database) + + @endforeach
    -
    - -
    - @if ($applications->isNotEmpty() || $databases->isNotEmpty()) -
    -
    Resource
    -
    Image
    -
    Status
    -
    -
    - @endif - - @if ($applications->isEmpty() && $databases->isEmpty()) -
    - -
    - @endif - - @foreach ($applications as $application) - - @endforeach - @foreach ($databases as $database) - - @endforeach -
    +
    @elseif ($currentRoute === 'project.service.analytics') @elseif ($currentRoute === 'project.service.domains') + @elseif ($currentRoute === 'project.service.environment-variables') @@ -217,8 +100,6 @@
    @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/domains.blade.php b/resources/views/livewire/project/service/domains.blade.php index e40ab95145..293ff827d2 100644 --- a/resources/views/livewire/project/service/domains.blade.php +++ b/resources/views/livewire/project/service/domains.blade.php @@ -1,6 +1,5 @@ @php $configuredCount = collect($domainRows)->where('is_suggested', false)->count(); - $suggestedCount = collect($domainRows)->where('is_suggested', true)->count(); $hasRows = count($domainRows) > 0; $hasDnsChecksInProgress = collect($domainRows)->contains(fn ($row) => $row['dns_status'] === 'checking'); $serviceAppCount = count($serviceApps); @@ -69,25 +68,16 @@ @endcannot {{-- Toolbar --}} -
    -
    -

    Domains

    -

    - {{ $configuredCount }} domain{{ $configuredCount === 1 ? '' : 's' }} across {{ $domainGroups->count() }} service{{ $domainGroups->count() === 1 ? '' : 's' }} - @if ($suggestedCount > 0) - · {{ $suggestedCount }} not added - @endif -

    -
    +
    + @if ($domainGroups->isNotEmpty()) +
    + + +
    + @endif
    - @if ($domainGroups->isNotEmpty()) -
    - - -
    - @endif @can('update', $service) @if ($configuredCount > 0) ! $embedded])>
    ! $embedded])> + @unless ($embedded) + {{-- The service menu, one Back away from this compose resource menu in the main sidebar. --}} + + @endunless @if (! $embedded && $resourceType === 'database') @elseif (! $embedded) - + @endif
    @if ($resourceType === 'application') diff --git a/resources/views/livewire/project/service/volume-backup/index.blade.php b/resources/views/livewire/project/service/volume-backup/index.blade.php index 61c4657924..e3605b7a7f 100644 --- a/resources/views/livewire/project/service/volume-backup/index.blade.php +++ b/resources/views/livewire/project/service/volume-backup/index.blade.php @@ -76,7 +76,7 @@ - @foreach (['general', 's3', 'retention', 'danger'] as $modalSection) + @foreach (['general', 'danger'] as $modalSection)
    @if ($selectedDatabaseBackup) 'neutral', }; $databaseBackupId = 'database:'.$databaseBackup->id; - $databaseS3 = $databaseBackup->s3?->team_id === $serviceTeamId ? $databaseBackup->s3 : null; - $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3 ? 'S3 storage: '.$databaseS3->name.' (bucket: '.$databaseS3->bucket.')' : 'S3 storage: Unavailable'); + $databaseS3s = ($databaseBackup->s3Storages->isNotEmpty() ? $databaseBackup->s3Storages : collect([$databaseBackup->s3])->filter())->where('team_id', $serviceTeamId); + $databaseS3Tooltip = ! $databaseBackup->save_s3 ? 'S3 storage: Not configured' : ($databaseS3s->isNotEmpty() ? 'S3 storage: '.$databaseS3s->map(fn ($s3) => $s3->name.' (bucket: '.$s3->bucket.')')->join(', ') : 'S3 storage: Unavailable'); @endphp
    {{ $databaseBackup->frequency }} - {{ $latestExecution?->finished_at?->diffForHumans() ?? ($status === 'running' ? 'Running now' : 'Never') }} @@ -308,8 +308,8 @@ @foreach ($backups as $backup) @php $latestExecution = $backup->latestExecution; - $volumeS3 = $backup->s3?->team_id === $serviceTeamId ? $backup->s3 : null; - $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3 ? 'S3 storage: '.$volumeS3->name.' (bucket: '.$volumeS3->bucket.')' : 'S3 storage: Unavailable'); + $volumeS3s = ($backup->s3Storages->isNotEmpty() ? $backup->s3Storages : collect([$backup->s3])->filter())->where('team_id', $serviceTeamId); + $volumeS3Tooltip = ! $backup->save_s3 ? 'S3 storage: Not configured' : ($volumeS3s->isNotEmpty() ? 'S3 storage: '.$volumeS3s->map(fn ($s3) => $s3->name.' (bucket: '.$s3->bucket.')')->join(', ') : 'S3 storage: Unavailable'); $status = $latestExecution?->status; $statusLabel = match ($status) { 'running' => 'In progress', @@ -338,8 +338,8 @@ {{ $backup->frequency }} - diff --git a/resources/views/livewire/project/shared/danger.blade.php b/resources/views/livewire/project/shared/danger.blade.php index faeff1aed2..5aaf304ef1 100644 --- a/resources/views/livewire/project/shared/danger.blade.php +++ b/resources/views/livewire/project/shared/danger.blade.php @@ -17,6 +17,23 @@ @endif + @if (in_array($resource?->type(), ['standalone-redis', 'standalone-keydb', 'standalone-dragonfly'])) + +

    + Permanently remove every key from + {{ $resourceName }}. + The database keeps running. +

    + + + +
    + @endif

    Permanently delete diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 15731c51fb..6c4dd401dd 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -28,28 +28,41 @@ @endcan @if ($view === 'normal') -

    +
    @if ($resourceClass === 'App\Models\Application') - @if (data_get($resource, 'build_pack') !== 'dockercompose') - + @if (data_get($resource, 'build_pack') !== 'dockercompose') + + @endif + - @endif - +
    @endif + @if ($this->hasLogRedactionSetting()) +
    + +
    + @endif
    @else
    diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index b3c5977341..dc4436267e 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -88,6 +88,12 @@ @endforeach @endif
    + @if ($this->connectLink) + + + + @endif @if (! $isLocked && ! $isValueHidden) @endif @@ -270,6 +276,12 @@ @endcan
    + @if ($this->connectLink) + + + Connect to resources + + @endif @if ($canUpdate && !$isLocked && !$isMagicVariable) Lock @endif diff --git a/resources/views/livewire/project/shared/execute-container-command.blade.php b/resources/views/livewire/project/shared/execute-container-command.blade.php index 2d974c0bb1..166086743c 100644 --- a/resources/views/livewire/project/shared/execute-container-command.blade.php +++ b/resources/views/livewire/project/shared/execute-container-command.blade.php @@ -66,11 +66,16 @@
    +
    + + +
    diff --git a/resources/views/livewire/project/shared/file-browser.blade.php b/resources/views/livewire/project/shared/file-browser.blade.php new file mode 100644 index 0000000000..7611ac6967 --- /dev/null +++ b/resources/views/livewire/project/shared/file-browser.blade.php @@ -0,0 +1,434 @@ +
    + @if (count($pendingDownloads) > 0) + + @endif + + {{ data_get_str($resource, 'name')->limit(10) }} > Files | Coolify + + + @if ($type === 'application') + + @elseif ($type === 'service') + + @else + + @endif + + {{-- Standard resource settings workspace (matches Logs/Configuration) so the + heading spacing and sidebar behave identically to every other page. --}} +
    +
    + @if ($type === 'application') + + @elseif ($type === 'service') + + @else + + @endif + +
    + + + + + + + + @if ($containerRunning && $filesLoaded) + + + Upload + + +
    + + New + + + +
    + + + Refresh + + @endif +
    + + @if ($loadFailed) + +
    + Retry +
    + @elseif (! $filesLoaded) +
    + +
    + @elseif (! $containerRunning) + + + + + + @else + {{-- Keep the current listing visible while an action runs: blur it + and show the action label (from run()) on top of it. --}} +
    +
    +
    +
    + + +
    +
    +
    +
    + {{-- Container picker (services / multi-container) --}} + @if (count($availableContainers) > 1) +
    + Container + @foreach ($availableContainers as $name) + + @endforeach +
    + @endif + + {{-- Breadcrumb. The root crumb renders as "/" and doubles as the + first separator, so we only draw a slash between named segments + (index >= 2) - avoids the "/ /" duplicate. --}} +
    + @foreach ($this->breadcrumbs() as $crumb) + @if ($loop->index > 1) + / + @endif + + @endforeach +
    + + {{-- Drag-and-drop file list --}} +
    + @if (count($entries) === 0) + + + + + + @else +
    +
    + + Name + Perms + Owner + Group + Size + Modified + Actions +
    + @foreach ($entries as $entry) +
    +
    + +
    +
    + @if ($entry['type'] === 'dir') + + @else + {{ $entry['name'] }} + @endif +
    + {{-- Permissions (click to chmod) --}} +
    + + + + + +

    + Set the octal permission mode for + {{ $entry['name'] }}. +

    + +
    + Apply +
    + +
    +
    +
    + {{ $entry['owner'] ?: '-' }} +
    +
    + {{ $entry['group'] ?: '-' }} +
    +
    + {{ $entry['type'] === 'dir' ? '-' : formatBytes($entry['size']) }} +
    +
    + {{ $entry['mtime'] ? \Illuminate\Support\Carbon::createFromTimestamp($entry['mtime'])->toDateTimeString() : '-' }} +
    + {{-- Actions kebab. The entry name lives in this + row's x-data as a JS string so the menu can + call $wire.* with it directly - passing @js() + through a component attribute double-encodes + the quotes and breaks the handler. --}} +
    +
    + + +
    +
    +
    + @endforeach +
    + @endif +
    +
    +
    + + {{-- Editor modal (opens when a text file is loaded) --}} + + + + +
    +
    +

    {{ $editingPath }}

    + @if ($editorLanguage) + {{ $editorLanguage }} + @endif +
    + {{-- Monaco editor, kept mounted across opens (wire:ignore) and + preloaded on page init so the first Edit click is instant. + openEditor dispatches load-file-editor with the content and + Monaco language id; we setValue + setModelLanguage without + recreating, so highlighting applies and there is no flash. + Inline Alpine (like x-forms.monaco-editor) means this works + without a JS rebuild. --}} +
    +
    +
    +
    + Cancel + Save changes +
    +
    +
    + @endif +
    +
    +
    +
    +
    diff --git a/resources/views/livewire/project/shared/get-logs.blade.php b/resources/views/livewire/project/shared/get-logs.blade.php index 3f855abea3..3249e8cf4b 100644 --- a/resources/views/livewire/project/shared/get-logs.blade.php +++ b/resources/views/livewire/project/shared/get-logs.blade.php @@ -194,6 +194,7 @@
    +

    Copied and downloaded logs hide secrets. The live view shows the raw output.

    + @endforeach + + + - @if ($resource->isRunning()) @can('update', $resource) @@ -16,13 +28,6 @@ {{ $isEnabled ? 'Disable' : 'Enable' }} - @endcan @@ -47,8 +52,28 @@ - + + @can('update', $resource) +
    + + +

    The task stops running and cannot be restored.

    + + + +
    +
    +
    + @endcan
    diff --git a/resources/views/livewire/project/shared/secret-manager-links.blade.php b/resources/views/livewire/project/shared/secret-manager-links.blade.php index 84e21a3054..447184d12e 100644 --- a/resources/views/livewire/project/shared/secret-manager-links.blade.php +++ b/resources/views/livewire/project/shared/secret-manager-links.blade.php @@ -7,8 +7,14 @@ @if (! $link && $availableTokens->isEmpty()) + description="Add a Doppler, Infisical, or HashiCorp Vault token first." + icon-name="keys" size="sm"> + + + Integration tokens + + + @else @can('update', $resource)
    diff --git a/resources/views/livewire/project/shared/storages/volume-backups.blade.php b/resources/views/livewire/project/shared/storages/volume-backups.blade.php index cc354e88d5..0a395fa08e 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups.blade.php @@ -1,9 +1,25 @@ -
    - @if ($section === 'retention') - @include('livewire.project.shared.storages.volume-backups.retention') - @elseif ($section === 's3') - @include('livewire.project.shared.storages.volume-backups.s3') - @elseif ($section === 'executions') +
    + @if (count($pendingDownloads) > 0) + + @endif + @if ($actionsInHeader && $backup) + @can('update', $resource) + @teleport('#resource-action-hud-slot') + + @endteleport + @endcan + @endif + @if ($section === 'executions') + @if ($backup && $executions->onFirstPage()) + + @endif @include('livewire.project.shared.storages.volume-backups.executions') @elseif ($section === 'danger') @include('livewire.project.shared.storages.volume-backups.danger') @@ -14,7 +30,11 @@ @script @endscript diff --git a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php index 46077e4f52..3d29c960e6 100644 --- a/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php +++ b/resources/views/livewire/project/shared/storages/volume-backups/executions.blade.php @@ -49,15 +49,19 @@ 'failed' => 'error', default => 'neutral', }; + $s3Replicas = $execution->s3Replicas; + $hasLiveS3Copies = $s3Replicas->isNotEmpty() + ? $s3Replicas->contains(fn ($replica) => $replica->s3_uploaded === true && ! $replica->s3_storage_deleted) + : $execution->s3_uploaded === true && ! $execution->s3_storage_deleted; $executionCheckboxes = []; $deleteActions = []; if (! $execution->local_storage_deleted) { $deleteActions[] = 'This backup will be permanently deleted from local storage.'; } - if ($execution->s3_uploaded === true && ! $execution->s3_storage_deleted) { + if ($hasLiveS3Copies) { $executionCheckboxes[] = [ 'id' => 'delete_backup_s3', - 'label' => 'Delete the selected backup permanently from S3 Storage', + 'label' => 'Delete the selected backup permanently from every S3 storage', ]; } if (empty($deleteActions)) { @@ -76,7 +80,7 @@
    - + @@ -100,7 +104,22 @@ - @if ($execution->s3_uploaded !== null) + @foreach ($s3Replicas as $replica) + + @endforeach + @if ($s3Replicas->isEmpty() && $execution->s3_uploaded !== null) - @if ($execution->status === 'success' && ! $execution->local_storage_deleted) + @if ($execution->status === 'success' && $execution->isDownloadable()) +
    +
    + +
    diff --git a/resources/views/livewire/project/show.blade.php b/resources/views/livewire/project/show.blade.php index 20eb67595e..d682ee0033 100644 --- a/resources/views/livewire/project/show.blade.php +++ b/resources/views/livewire/project/show.blade.php @@ -5,7 +5,18 @@
    -

    {{ $project->name }}

    +
    +

    {{ $project->name }}

    + @can('update', $project) + + + + @endcan +

    @@ -15,15 +26,6 @@ @can('update', $project)

    - - - Settings - -
    @endcan @@ -68,7 +60,7 @@
    -
    +
    - -
    +
    @@ -239,7 +213,7 @@ search: '', sortBy: 'name-asc', sortOpen: false, - viewMode: localStorage.getItem('project-environments-view') || 'grid', + viewMode: @js($this->listView('project', 'grid')), page: 1, pageSize: 12, environments: @js($environmentsJs), @@ -299,7 +273,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('project-environments-view', mode); + this.$wire.saveListView('project', mode); }, previousPage() { this.page = Math.max(1, this.page - 1); diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index 271ad7367d..d6f14e188d 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -9,6 +9,7 @@ get providerConsoleUrl() { if (this.selectedProvider === 'hetzner') return 'https://console.hetzner.com/projects'; if (this.selectedProvider === 'vultr') return 'https://console.vultr.com/user/apiaccess/'; + if (this.selectedProvider === 'hostinger') return 'https://hpanel.hostinger.com/profile/api'; return 'https://cloud.digitalocean.com/account/api/tokens'; } }"> @@ -18,6 +19,7 @@ ['value' => 'hetzner', 'label' => 'Hetzner'], ['value' => 'digitalocean', 'label' => 'DigitalOcean'], ['value' => 'vultr', 'label' => 'Vultr'], + ['value' => 'hostinger', 'label' => 'Hostinger'], ]" /> @else diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index ed7d108aed..46882ba513 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -34,10 +34,15 @@ Capabilities
    + :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

    Manage Cloudflare DNS records.

    + +

    + Create and manage tunnels for server SSH. Needs DNS. +

    @error('capabilities') {{ $message }} @@ -71,8 +76,11 @@
    • Zone - DNS - Edit
    • Zone - Zone - Read
    • + @if (in_array('tunnel', $capabilities, true)) +
    • Account - Cloudflare Tunnel - Edit
    • + @endif
    - Create a replacement token in Cloudflare diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index a085ebbe1a..004c642f03 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -42,10 +42,15 @@ Capabilities
    + :disabled="in_array('tunnel', $capabilities, true)" wire:model.live="capabilities" />

    Manage Cloudflare DNS records.

    + +

    + Create and manage tunnels for server SSH. Needs DNS. +

    @error('capabilities') {{ $message }} @@ -79,9 +84,12 @@
    • Zone - DNS - Edit
    • Zone - Zone - Read
    • + @if (in_array('tunnel', $capabilities, true)) +
    • Account - Cloudflare Tunnel - Edit
    • + @endif

    Limit zone resources to the zones Coolify should manage.

    -
    Create token at Cloudflare diff --git a/resources/views/livewire/server/canvas.blade.php b/resources/views/livewire/server/canvas.blade.php new file mode 100644 index 0000000000..f8e236770c --- /dev/null +++ b/resources/views/livewire/server/canvas.blade.php @@ -0,0 +1,34 @@ + diff --git a/resources/views/livewire/server/charts.blade.php b/resources/views/livewire/server/charts.blade.php index 442fc1979f..1d889a2ac6 100644 --- a/resources/views/livewire/server/charts.blade.php +++ b/resources/views/livewire/server/charts.blade.php @@ -59,15 +59,34 @@ - -
    -
    + {{-- CPU is the primary signal, so it spans full width; the other four + charts fall into a 2x2 below (mirrors the fleet /metrics layout). --}} +
    + +
    +
    - -
    -
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    @script diff --git a/resources/views/livewire/server/cloud-provider-token/show.blade.php b/resources/views/livewire/server/cloud-provider-token/show.blade.php index 5c4fe5d98f..e4fe7c19d1 100644 --- a/resources/views/livewire/server/cloud-provider-token/show.blade.php +++ b/resources/views/livewire/server/cloud-provider-token/show.blade.php @@ -10,7 +10,7 @@
    - @if ($server->hetzner_server_id || $server->vultr_instance_id) + @if ($server->hetzner_server_id || $server->vultr_instance_id || $server->digitalocean_droplet_id || $server->hostinger_virtual_machine_id) @@ -67,7 +67,7 @@ @endif diff --git a/resources/views/livewire/server/cloudflare-tunnel.blade.php b/resources/views/livewire/server/cloudflare-tunnel.blade.php index 255260ca18..7ae00efde6 100644 --- a/resources/views/livewire/server/cloudflare-tunnel.blade.php +++ b/resources/views/livewire/server/cloudflare-tunnel.blade.php @@ -22,31 +22,60 @@ - + Documentation + + + @endif + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional() && auth()->user()->can('update', $server)) + - @else - + @endif + @if ($isCloudflareTunnelsEnabled) + @endif @if ($isCloudflareTunnelsEnabled) + @if (filled($server->settings->cloudflare_tunnel_id)) +

    + Coolify manages this tunnel through the Cloudflare API (tunnel + {{ \App\Services\CloudflareTunnelService::tunnelName($server) }}). +

    + @endif The server IP must be restored to its direct address after disabling the tunnel. + @can('update', $server) +
    + +
    + @endcan @elseif (!$server->isFunctional()) Automated configuration requires a validated server, a Cloudflare token, and an SSH domain. @@ -56,25 +85,6 @@ . @else -

    - Choose automated setup to install and configure cloudflared, or confirm that you already - configured the tunnel manually. -

    - @endif -
    - - @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) - - - - Documentation - - - - @cannot('update', $server) You do not have permission to configure Cloudflare Tunnel for this server. @@ -86,34 +96,88 @@ -
    -
    - - -
    -
    - Configure tunnel -
    -
    + + @if ($tunnelTokens->isEmpty()) + + @if ($hasCloudflareTokens) + Enable the Cloudflare Tunnel capability on a Cloudflare token + @else + Add a Cloudflare token with the DNS and Cloudflare Tunnel capabilities + @endif + in + Integration tokens. + The token needs Account - Cloudflare Tunnel - Edit, Zone - DNS - Edit, and Zone - Zone - Read. + + @can('create', App\Models\IntegrationToken::class) +
    + + + + + + +
    + @endcan + @else +
    +
    + + +
    +
    + Set up tunnel +
    +
    + @if ($dnsConflict) +
    + + {{ $dnsConflict['hostname'] }} already points to {{ $dnsConflict['current'] }}. + Replace it with the tunnel record to continue. + +
    + +
    +
    + @endif + @endif + +
    + +

    + For a tunnel you created in the Cloudflare dashboard. Coolify does not need a + Cloudflare API token for this. +

    +
    +
    + + +
    +
    + Configure tunnel +
    +
    +
    +
    @endcannot -
    - - - @can('update', $server) - - @endcan - + @endif + + @if (!$isCloudflareTunnelsEnabled && $server->isFunctional()) @script - @endscript
    diff --git a/resources/views/livewire/server/security/terminal-access.blade.php b/resources/views/livewire/server/security/terminal-access.blade.php index 88d46e6469..88f80276b7 100644 --- a/resources/views/livewire/server/security/terminal-access.blade.php +++ b/resources/views/livewire/server/security/terminal-access.blade.php @@ -7,16 +7,11 @@
    - +
    - - - -
    - +
    - + @if ($server->isFunctional())
    diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index acfd8cec6a..cfbad137c9 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,5 +1,5 @@
    + x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif @if ($server->hostinger_virtual_machine_id && $server->cloudProviderToken && !$hostingerVirtualMachineStatus) $wire.checkHostingerVirtualMachineStatus(); @endif"> {{ data_get_str($server, 'name')->limit(24) }} | Server | Coolify @@ -18,23 +18,26 @@ filled($server->hetzner_server_id) => 'Hetzner', filled($server->digitalocean_droplet_id) => 'DigitalOcean', filled($server->vultr_instance_id) => 'Vultr', + filled($server->hostinger_virtual_machine_id) => 'Hostinger', default => null, }; $providerStatus = match ($provider) { 'Hetzner' => $hetznerServerStatus, 'DigitalOcean' => $digitalOceanDropletStatus, 'Vultr' => $vultrInstanceStatus, + 'Hostinger' => $hostingerVirtualMachineStatus, default => null, }; $providerStatusType = match (true) { in_array($providerStatus, ['running', 'active']) => 'success', - in_array($providerStatus, ['starting', 'initializing', 'pending', 'new']) => 'warning', - in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted']) => 'error', + in_array($providerStatus, ['starting', 'initializing', 'pending', 'new', 'initial', 'creating', 'unsuspending', 'recreating', 'restoring']) => 'warning', + in_array($providerStatus, ['off', 'stopped', 'suspended', 'archive', 'deleted', 'destroyed', 'error']) => 'error', default => 'neutral', }; $hasLinkableCloudProviders = (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty()) || (!$server->vultr_instance_id && $availableVultrTokens->isNotEmpty()) - || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()); + || (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) + || (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()); $hetznerMatch = $matchedHetznerServer ? [ 'name' => $matchedHetznerServer['name'] ?? 'Hetzner server', @@ -56,6 +59,13 @@ 'status' => $matchedVultrInstance['status'] ?? null, ] : null; + $hostingerMatch = $matchedHostingerVirtualMachine + ? [ + 'name' => $matchedHostingerVirtualMachine['hostname'] ?? 'Hostinger VPS', + 'id' => $matchedHostingerVirtualMachine['id'] ?? null, + 'status' => $matchedHostingerVirtualMachine['state'] ?? null, + ] + : null; @endphp
    @@ -65,6 +75,14 @@ + + @if ($server->isTransferredAway()) + + @else + + @endif + @if ($hasLinkableCloudProviders)
    @@ -107,6 +125,18 @@ :searchError="$vultrSearchError" :noMatch="$vultrNoMatchFound" :matched="$vultrMatch" /> @endif + @if (!$server->hostinger_virtual_machine_id && $availableHostingerTokens->isNotEmpty()) + + @endif
    @endif @@ -125,64 +155,49 @@ @endif - @if (isDev()) -
    - @if ($server->isTransferredAway()) - - - - - Enable management - - - - @else - - - - - Disable management - - - - @endif -
    - @endif - @if (isDev() && $server->isTransferredAway()) - @unless ($server->isManagementDisabled()) - - @endunless - @else - + @if ($server->isTransferredAway() && ! $server->isLocalhost()) + @php + $transferTarget = data_get($server->server_metadata, 'transfer.target_instance_url') ?: 'another Coolify instance'; + $transferredAt = data_get($server->server_metadata, 'transfer.transferred_at'); + $transferredAt = $transferredAt ? \Illuminate\Support\Carbon::parse($transferredAt)->toDayDateTimeString().' UTC' : 'the transfer'; + @endphp + + + + + Enable management + + + @endif - @if (isDev() && $server->isManagementDisabled()) - - Management of this server is disabled on this Coolify instance. You can now - transfer it to another instance, or enable - management again to manage it here. - - @elseif (isDev() && $server->isTransferredAway()) - - This server was migrated away from this Coolify instance. It cannot be revalidated or - managed here. Use the target instance, or delete this server when you no longer need the - archive. + @if ($server->isTransferredAway()) + + This server was transferred to + {{ data_get($server->server_metadata, 'transfer.target_instance_url') ?: 'another Coolify instance' }}, + which manages it now. Use that instance. To undo the transfer, use + Transfer back. Keep this archive until you are sure: it is the copy + that an undo uses. @endif + + @if ($this->limaStartCommand) + @elseif ($provider === 'Hostinger') + + + @endif @if ($server->cloudProviderToken) @if ($provider === 'Hetzner' && !$server->isFunctional() && $hetznerServerStatus === 'off') @@ -292,12 +313,12 @@ @endif @endif @endif - @if ($server->server_metadata) - - - - @endif + + + {{ $server->server_metadata ? 'Refresh details' : 'Fetch server details' }} +
    @@ -310,9 +331,7 @@ {{ $server->name }}

    - @if (isDev() && $server->isManagementDisabled()) - Management is disabled on this Coolify instance. You can transfer this server to another instance. - @elseif (isDev() && $server->isTransferredAway()) + @if ($server->isTransferredAway()) This server was migrated away from this Coolify instance and cannot be managed here. @elseif ($server->isFunctional()) The server is reachable, validated, and ready to host resources. @@ -325,13 +344,6 @@ @if ($server->server_metadata) @include('livewire.server.partials.server-details', ['server' => $server]) - @else -

    - - - Fetch server details - -
    @endif diff --git a/resources/views/livewire/server/traffic-analytics-settings.blade.php b/resources/views/livewire/server/traffic-analytics-settings.blade.php index 09ee724eeb..177bb1a59a 100644 --- a/resources/views/livewire/server/traffic-analytics-settings.blade.php +++ b/resources/views/livewire/server/traffic-analytics-settings.blade.php @@ -61,7 +61,7 @@ ['value' => 'anonymized', 'label' => 'Anonymized (/24 IPv4, /48 IPv6)'], ['value' => 'off', 'label' => 'Disabled'], ]" - helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." /> + helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs or visitor cities. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." /> diff --git a/resources/views/livewire/server/transfer-finish.blade.php b/resources/views/livewire/server/transfer-finish.blade.php new file mode 100644 index 0000000000..aaeb37869a --- /dev/null +++ b/resources/views/livewire/server/transfer-finish.blade.php @@ -0,0 +1,171 @@ +
    + @if ($finish && $openCount > 0) + {{-- Not on the root element: Livewire starts a poll that a later render adds only on a new element. --}} + + @endif + @if ($finish) + @php + $doneCount = $totalCount - $openCount; + $lastCheckedAt = data_get($finish, 'last_checked_at'); + $badges = [ + 'action' => ['Needs action', 'warning'], + 'unchecked' => ['Cannot check', 'neutral'], + 'pending' => ['Not checked yet', 'neutral'], + ]; + @endphp +
    +
    +
    +

    Finish the transfer

    +

    + {{ $doneCount }} of {{ $totalCount }} done. + @if ($openCount > 0) + Coolify checks the open steps every 30 seconds. + @endif +

    +
    +
    + @if ($openCount > 0) + + {{ $lastCheckedAt ? 'Checked '.\Illuminate\Support\Carbon::parse($lastCheckedAt)->format('H:i') : 'Checking…' }} + + @can('update', $server) + + @endcan + @else + + Finish the transfer + + @endif +
    +
    +
    +
    +
    + + @foreach ($openGroups as $group) +
    +

    {{ $group['label'] }}

    +
      + @foreach ($group['items'] as $step) + @php + $fixLabel = $checklist->fixLabel($step); + $manualOnly = ! $checklist->canCheck($step); + [$link, $linkLabel] = $checklist->linkOf($step) ?? [null, null]; + $linkAttributes = $link && str_starts_with($link, 'http') ? 'target="_blank" rel="noopener noreferrer"' : wireNavigate(); + $hasMenu = ! $manualOnly || $link; + $hasDetails = filled($step['hint'] ?? null) || count($step['details'] ?? []) > 0 || filled($step['error'] ?? null); + [$badgeLabel, $badgeType] = $badges[$step['status']] ?? ['Not checked yet', 'neutral']; + @endphp +
    • +
      + +
      +

      {{ $step['label'] }}

      +

      + {{ $step['message'] ?? $step['hint'] }} + @if ($hasDetails) + + @endif +

      +
      + @can('update', $server) +
      + @if ($hasMenu) +
      + + +
      + @endif + @if ($fixLabel) + {{ $fixLabel }} + @elseif ($link) + {{ $linkLabel }} + @elseif ($manualOnly) + Mark as done + @endif +
      + @endcan +
      + @if ($hasDetails) +
      + @if (filled($step['hint'] ?? null) && filled($step['message'] ?? null)) +

      {{ $step['hint'] }}

      + @endif + @foreach ($step['details'] ?? [] as $detail) +
      + {{ $detail['label'] }} + {{ $detail['value'] }} + +
      + @endforeach + @if (filled($step['error'] ?? null)) +

      {{ $step['error'] }}

      + @endif +
      + @endif +
    • + @endforeach +
    +
    + @endforeach + + @if ($doneCount > 0) +
    + +
      + @foreach ($doneItems as $step) +
    • + + {{ $step['label'] }} + {{ $step['message'] }} +
    • + @endforeach +
    +
    + @endif + + @if ($openCount > 0) + @can('update', $server) +
    + + + + + +
    + @endcan + @endif +
    + @endif +
    diff --git a/resources/views/livewire/server/transfer-import.blade.php b/resources/views/livewire/server/transfer-import.blade.php deleted file mode 100644 index d55a4d320b..0000000000 --- a/resources/views/livewire/server/transfer-import.blade.php +++ /dev/null @@ -1,128 +0,0 @@ -
    - - Import server | Coolify - - -
    -
    -
    -

    Import server

    - -
    -

    - Import a server that you downloaded from another Coolify instance. This instance then manages it. -

    -
    - - Back to servers - -
    - -
    - - - - - Dry run - Checking… - - - - - - Import server - Importing… - - - - - -
    - - -
    - -
    -
    -
    - - -
    - - -
    -
    - - @if (count($lastWarnings) > 0) - -
      - @foreach ($lastWarnings as $warning) -
    • {{ $warning }}
    • - @endforeach -
    -
    - @endif - - @if ($lastResult) - @php - $createdCounts = collect(data_get($lastResult, 'created', [])) - ->filter(fn ($count) => (int) $count > 0); - @endphp - - - @if (data_get($lastResult, 'dry_run')) - - @elseif (data_get($lastResult, 'claimed')) - - @else - - @endif - @if ($importedServerUuid) - - Open server - - - @endif - - -
    - @if (filled(data_get($lastResult, 'server_uuid'))) -

    - Server UUID - {{ data_get($lastResult, 'server_uuid') }} -

    - @endif - - @if ($createdCounts->isNotEmpty()) -
    - @foreach ($createdCounts as $type => $count) -
    -
    - {{ $count }}
    -
    - {{ str($type)->replace('_', ' ')->ucfirst() }}
    -
    - @endforeach -
    - @else -

    No resources in this file.

    - @endif -
    -
    - @endif -
    -
    diff --git a/resources/views/livewire/server/transfer.blade.php b/resources/views/livewire/server/transfer.blade.php index 0b322fc6ca..900e04f8be 100644 --- a/resources/views/livewire/server/transfer.blade.php +++ b/resources/views/livewire/server/transfer.blade.php @@ -11,29 +11,74 @@
    @if ($this->isLocalhost) - The Coolify host (localhost) cannot be transferred between instances. @else - - @if ($server->isManagementDisabled()) - + @php + $exportTooltip = $exportId ? "Export ID: {$exportId}" : null; + @endphp + @if (data_get($this->migration, 'status') === 'running') + @elseif ($server->isTransferredAway()) - + @else - - @endif - @if ($exportId) - export {{ $exportId }} + @endif
    + @if ($server->isTransferredAway()) +
    +

    Transfer back

    +

    + Undo the transfer. The instance that manages the server now transfers it back here, + with all changes made there. This archive is replaced only when the import here + succeeds. If the other instance is lost, use Enable management on the server page. +

    +
    +
    + + + +
    +
    + + + + Transfer back + Running checks… + + + +
    + @if ($transferBackAt = data_get($server->server_metadata, 'transfer.transfer_back.requested_at')) + + {{ data_get($server->server_metadata, 'transfer.transfer_back.from') }} started the transfer back at + {{ \Illuminate\Support\Carbon::parse($transferBackAt)->format('H:i') }}. Reload this page in a minute. + When the transfer finishes, this instance manages the server again. If it does not, read + the transfer log of the server on the other instance. + + @endif + @else

    Transfer to another instance

    @@ -45,16 +90,17 @@
    + placeholder="https://coolify.example.com" + helper="Base URL of the other Coolify instance (no trailing path). It must use https." /> + helper="Create a token on the target with root (or write + deploy). It is only used for this request." />
    @@ -62,11 +108,83 @@ Transfer server - Transferring… + Running checks…
    + @endif + @php $migration = $this->migration; @endphp + @if (data_get($migration, 'status') === 'running') +
    + + The server is being transferred to {{ data_get($migration, 'target_url') }}. You can + leave this page; the transfer continues in the background. + +
    + @elseif (data_get($migration, 'status') === 'failed') + + @if (count((array) data_get($migration, 'log', [])) > 0) + See the transfer log below for details. + @else + {{ data_get($migration, 'error') }} + @endif + + @elseif (data_get($migration, 'status') === 'unknown') + + The transfer stopped before this instance could confirm the result. Check whether + {{ data_get($migration, 'target_url') }} manages this server. If it does, call + POST /api/v1/servers/{{ $server->uuid }}/transfer/complete on this instance. + If it does not, transfer again. + + @elseif (data_get($migration, 'status') === 'succeeded') + + {{ data_get($migration, 'target_url') }} now manages this server. Automations are + disabled on this instance. + + @php $checklist = $this->postTransferChecklist; @endphp + @if (count($checklist) > 0 || data_get($migration, 'target_server_url')) +
    +

    Finish the transfer on the target

    +

    + The target shows these steps on the server page, checks them where it can, and keeps + their state. + @if ($targetServerUrl = data_get($migration, 'target_server_url')) + Open the server on the target. + @endif +

    + @if (count($checklist) > 0) +
      + @foreach ($checklist as $step) +
    • {{ $step }}
    • + @endforeach +
    + @endif +
    + @endif + @endif + @if (count((array) data_get($migration, 'log', [])) > 0) +
    +

    Transfer log

    +
      + @foreach ((array) data_get($migration, 'log', []) as $entry) +
    1. data_get($entry, 'level') === 'success', + 'text-error' => data_get($entry, 'level') === 'error', + 'text-warning' => data_get($entry, 'level') === 'warning', + 'text-neutral-600 dark:text-fg-dim' => data_get($entry, 'level') === 'info', + ])> + {{ \Illuminate\Support\Carbon::parse(data_get($entry, 'at'))->format('H:i:s') }} + {{ data_get($entry, 'message') }} +
    2. + @endforeach +
    +
    + @endif @if (count($lastWarnings) > 0)
      @@ -76,41 +194,9 @@
    @endif - @if ($lastResultJson) -
    -
    Result
    -
    {{ $lastResultJson }}
    -
    - @endif
    - -
    -

    - Import the file on the target via Servers → Import server, then click Disable management - on this server. -

    -
    - -
    -
    - - Download JSON - Exporting… - - - Import page (this instance) - -
    -
    -
    @endif
    diff --git a/resources/views/livewire/settings-backup.blade.php b/resources/views/livewire/settings-backup.blade.php index 1d4bc85e13..93c2269d65 100644 --- a/resources/views/livewire/settings-backup.blade.php +++ b/resources/views/livewire/settings-backup.blade.php @@ -8,7 +8,8 @@ @if ($server->isFunctional()) @if (isset($database) && isset($backup)) - + {{-- Exclude is_backup_before_update_enabled (instantSave) so the bar does not flash. --}} +
    @@ -25,6 +26,18 @@ + +
    + +
    +
    + @else diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 1f35c83555..ee957bd461 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -13,8 +13,10 @@ - + + + {{ $oauth_setting['label'] }} @endforeach diff --git a/resources/views/livewire/settings/advanced.blade.php b/resources/views/livewire/settings/advanced.blade.php index dc18b41010..52e26ce9a9 100644 --- a/resources/views/livewire/settings/advanced.blade.php +++ b/resources/views/livewire/settings/advanced.blade.php @@ -32,6 +32,12 @@ ['value' => false, 'label' => 'Require two-step confirmation'], ['value' => true, 'label' => 'Skip two-step confirmation'], ]" /> +
    @@ -168,6 +174,22 @@
    @endif + + +
    + +
    + + When you select an S3 storage, Coolify adds a lifecycle rule that deletes objects under + coolify-file-downloads/ after 1 day, as a safety net. The selected + storage must be reachable from your servers, and the S3 provider must support lifecycle rules. + +
    diff --git a/resources/views/livewire/settings/index.blade.php b/resources/views/livewire/settings/index.blade.php index 6815fb9ee9..963c8d1070 100644 --- a/resources/views/livewire/settings/index.blade.php +++ b/resources/views/livewire/settings/index.blade.php @@ -15,7 +15,7 @@ 'lg:col-span-2' => !str_starts_with(strtolower($fqdn ?? ''), 'https://'), ])>
    diff --git a/resources/views/livewire/settings/updates.blade.php b/resources/views/livewire/settings/updates.blade.php index aaea04e655..73ebedac72 100644 --- a/resources/views/livewire/settings/updates.blade.php +++ b/resources/views/livewire/settings/updates.blade.php @@ -8,25 +8,26 @@ {{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}} - - - - - + Check now - - - -
    + + + + @if (!is_null(config('constants.coolify.autoupdate', null))) -
    - -
    -
    diff --git a/resources/views/livewire/shared-variables/canvas.blade.php b/resources/views/livewire/shared-variables/canvas.blade.php new file mode 100644 index 0000000000..d01113327e --- /dev/null +++ b/resources/views/livewire/shared-variables/canvas.blade.php @@ -0,0 +1,31 @@ +
    + + {{ str($scope)->headline() }} Variables > Canvas | Coolify + + {{-- Page search items; the menu is in the main sidebar. --}} + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so the button opens the modal below with a window event. --}} +
    + + @if ($canAddVariables) + + @endif +
    +
    + @if ($canAddVariables) + + + + + @endif +
    diff --git a/resources/views/livewire/shared-variables/index.blade.php b/resources/views/livewire/shared-variables/index.blade.php index 4cf74d6c8a..25baec1002 100644 --- a/resources/views/livewire/shared-variables/index.blade.php +++ b/resources/views/livewire/shared-variables/index.blade.php @@ -3,66 +3,114 @@ Shared Variables | Coolify + @php + $scopeCards = [ + 'team' => ['Team wide', 'teams', route('shared-variables.team.index'), 'Every resource of this team.', null], + 'project' => ['Project wide', 'projects', route('shared-variables.project.index'), 'Every environment of a project.', 'project'], + 'environment' => ['Environment wide', 'layers', route('shared-variables.environment.index'), 'Resources in one environment.', 'environment'], + 'server' => ['Server wide', 'servers', route('shared-variables.server.index'), 'Resources deployed on one server.', 'server'], + ]; + $panel = 'rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]'; + $panelTitle = 'flex items-center justify-between gap-2 border-b border-neutral-200 px-3 py-2.5 text-[12px] font-semibold text-black dark:border-white/[0.06] dark:text-fg'; + $row = 'flex items-center gap-2 border-b border-neutral-100 px-3 py-2 last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.04] dark:hover:bg-white/[0.03]'; + $scopeBadge = 'shrink-0 rounded-full border border-neutral-200 bg-neutral-50 px-1.5 text-[10px] leading-4 font-medium text-neutral-500 dark:border-white/[0.1] dark:bg-white/[0.05] dark:text-fg-dim'; + @endphp + +
    + {{-- Scopes, with how many variables each has and on how many owners. --}} + -
    -
    - -
    - -
    -
    -

    Team wide

    -

    - Available to every resource owned by this team. -

    -
    -
    + @if ($overview['missing'] > 0) +
    + +

    + {{ $overview['missing'] }} {{ str('reference')->plural($overview['missing']) }} in resource variables + {{ $overview['missing'] === 1 ? 'points' : 'point' }} to a shared variable that does not exist. Open a scope canvas to find + {{ $overview['missing'] === 1 ? 'it' : 'them' }}. +

    +
    + @endif - -
    - -
    -
    -

    Project wide

    -

    - Shared by every environment inside a project. -

    -
    -
    +
    + {{-- Most used: links open the variable on its scope canvas. --}} +
    +

    Most used

    + @forelse ($overview['used'] as $variable) + + + {{ $variable['key'] }} + {{ $variable['owner'] }} + + + {{ $variable['resources'] }} {{ str('resource')->plural($variable['resources']) }} + + + @empty +

    No resource uses a shared variable yet.

    + @endforelse +
    - -
    - -
    -
    -

    Environment wide

    -

    - Reused by resources in one environment. -

    -
    -
    +
    +

    Recently changed

    + @forelse ($overview['recent'] as $variable) + + + {{ $variable['key'] }} + {{ $variable['owner'] }} + + {{ $variable['updated_at']?->diffForHumans(short: true) }} + + @empty +

    No shared variables yet.

    + @endforelse +
    - - -
    -

    Server wide

    -

    - Available to resources deployed on one server. -

    -
    - -
    -
    +
    diff --git a/resources/views/livewire/shared/list-search-controls.blade.php b/resources/views/livewire/shared/list-search-controls.blade.php index d14ddc8f4b..1a99c6352e 100644 --- a/resources/views/livewire/shared/list-search-controls.blade.php +++ b/resources/views/livewire/shared/list-search-controls.blade.php @@ -4,11 +4,5 @@ clear-when="search" clear-action="search = ''" class="h-8! rounded-lg! border-neutral-200! bg-white! py-0! pr-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint" />
    -
    - -
    - - -
    -
    +
    diff --git a/resources/views/livewire/source/gitea/change.blade.php b/resources/views/livewire/source/gitea/change.blade.php new file mode 100644 index 0000000000..a59fb7abf4 --- /dev/null +++ b/resources/views/livewire/source/gitea/change.blade.php @@ -0,0 +1,306 @@ +
    + + {{ $name ?: 'Gitea App' }} | Sources | Coolify + + + @if ($isConnected) +
    +
    +
    +

    + {{ $name ?: 'Gitea App' }} +

    + +
    +

    + {{ filled($organization) ? 'Gitea App for '.$organization : 'Private Gitea source' }} +

    +
    +
    + @can('view', $gitea_app) + + Test connection + + @endcan + @can('delete', $gitea_app) + + @endcan +
    +
    + +
    + + + +
    + + + @if (! isCloud()) +
    + +
    + @if ($isSystemWide) +
    + + System-wide Gitea Apps are shared across all teams on this Coolify instance. Prefer team-specific apps when you need repository isolation. + +
    + @endif + @endif +
    +
    + + +
    + + + +
    +
    + + +
    + + + + +
    + +
    +
    +
    + + +
    +
    + +
    + +
    +
    + + +
    + + @if ($applications->isEmpty()) + + @else +
    +
    + + +
    +
    +
    +
    +
    Project
    +
    Environment
    +
    Resource
    +
    Repository
    +
    + @foreach ($applications->sortBy('name', SORT_NATURAL) as $application) + @php + $projectName = (string) data_get($application, 'environment.project.name'); + $environmentName = (string) data_get($application, 'environment.name'); + $resourceName = (string) $application->name; + $repoLabel = $application->git_repository.':'.$application->git_branch; + $searchValue = strtolower( + $projectName.' '.$environmentName.' '.$resourceName.' '.$repoLabel, + ); + @endphp + + {{ $projectName }} + {{ $environmentName }} + {{ $resourceName }} + {{ $repoLabel }} + + @endforeach +
    + @endif +
    +
    + @else +
    +
    +

    + {{ $name ?: 'Gitea App' }} +

    +

    + Finish connecting this Gitea App before using it as a source +

    +
    + @can('delete', $gitea_app) +
    + +
    + @endcan +
    + +
    + +
    + + {{ rtrim($htmlUrl, '/') }}/user/settings/applications + + +
      +
    • + Set Redirect URI to: + {{ $redirectUri }} +
    • +
    • + Keep Confidential client checked +
    • +
    +
    +
    + +
    + + + Save + + +
    + + + + +
    + + @if (! isCloud() || isDev()) +
    +
    + Gitea will redirect back to this Coolify URL. It must match the Redirect URI on your Gitea OAuth2 application exactly. +
    +
    + +
    +
    + +
    +
    + +
    +
    + @endif + +
    + +
    + + + + + @if (! isCloud()) +
    + +
    + @endif +
    +
    +
    +
    + + @if ($clientId) + + + Connect to Gitea + + + + @endif +
    + @endif +
    diff --git a/resources/views/livewire/source/gitea/create.blade.php b/resources/views/livewire/source/gitea/create.blade.php new file mode 100644 index 0000000000..a605c8dd9d --- /dev/null +++ b/resources/views/livewire/source/gitea/create.blade.php @@ -0,0 +1,59 @@ +@can('createAnyResource') +
    +

    + Connect a Gitea OAuth2 application for private repositories, webhooks, and deployments. +

    + +
    + + +
    + + @if (! isCloud()) +
    +
    + +
    +
    + + System-wide Gitea Apps are available to every team on this instance. Prefer team-specific apps when you need repository isolation. + +
    +
    + @endif + +
    + +
    +
    + + + + +
    +
    +
    + + + Continue + +
    +@else + + You don't have permission to create new Gitea Apps. Contact your team administrator. + +@endcan diff --git a/resources/views/livewire/source/github/change.blade.php b/resources/views/livewire/source/github/change.blade.php index 9567a3f7fa..6a74d5fb0b 100644 --- a/resources/views/livewire/source/github/change.blade.php +++ b/resources/views/livewire/source/github/change.blade.php @@ -34,7 +34,7 @@ 'label' => 'Danger Zone', 'route' => 'source.github.danger', 'active' => $activeTab === 'danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -47,12 +47,14 @@ @if ($showSettingsSidebar)
    - +
    @if (!data_get($github_app, 'installation_id') && $activeTab === 'general') diff --git a/resources/views/livewire/source/gitlab/change.blade.php b/resources/views/livewire/source/gitlab/change.blade.php index 2ba6bb1d63..a773769aa0 100644 --- a/resources/views/livewire/source/gitlab/change.blade.php +++ b/resources/views/livewire/source/gitlab/change.blade.php @@ -95,7 +95,7 @@ + description="Configure this webhook URL in your GitLab project settings (Settings → Webhooks). On self-managed GitLab, an admin can instead add the same URL and token once as a system hook (Admin Area → System hooks) with Push events and Merge request events enabled.">
    String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-s3-storages-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages', 'singular' => 'storage', 'plural' => 'storages']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search S3 storages'])
    @foreach ($s3 as $storage) id === 0) { + $resourceLink = route('settings.backup'); } elseif ($database) { $environment = $database->environment; $project = $environment?->project; @@ -103,10 +105,10 @@
    - + :confirmWithPassword="false" step2ButtonText="Remove storage" + :actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']"> @@ -148,10 +150,10 @@
    - + :confirmWithPassword="false" step2ButtonText="Remove storage" + :actions="['Backups from this schedule will no longer be uploaded to this storage. S3 backup turns off when no other storage remains.']"> diff --git a/resources/views/livewire/storage/show.blade.php b/resources/views/livewire/storage/show.blade.php index 93fa1150ee..aacc03706a 100644 --- a/resources/views/livewire/storage/show.blade.php +++ b/resources/views/livewire/storage/show.blade.php @@ -23,7 +23,7 @@ 'label' => 'Danger Zone', 'route' => 'storage.danger', 'active' => $currentRoute === 'storage.danger', - 'icon' => 'shield-alert', + 'icon' => 'danger', ], ]; @endphp @@ -36,12 +36,14 @@ @if ($showSettingsSidebar)
    - +
    @if ($currentRoute === 'storage.show') diff --git a/resources/views/livewire/switch-team.blade.php b/resources/views/livewire/switch-team.blade.php index 31ea8d3086..2346b4efe7 100644 --- a/resources/views/livewire/switch-team.blade.php +++ b/resources/views/livewire/switch-team.blade.php @@ -1,6 +1,5 @@ @php $currentTeam = auth()->user()->currentTeam(); - $teamInitial = strtoupper(mb_substr($currentTeam->name, 0, 1)); @endphp
    {{-- Expanded: inline switcher (team name + up/down chevron) --}} @@ -9,6 +8,7 @@ @endforeach @@ -55,8 +56,8 @@ } }">
    user()->teams as $team) @endforeach diff --git a/resources/views/livewire/tags/canvas.blade.php b/resources/views/livewire/tags/canvas.blade.php new file mode 100644 index 0000000000..246addc1e6 --- /dev/null +++ b/resources/views/livewire/tags/canvas.blade.php @@ -0,0 +1,29 @@ +
    + + Tags > Canvas | Coolify + +
    + {{-- The canvas moves this group into its toolbar (page actions last, at the right end), + which can be outside this component, + so the button opens the modal below with a window event. --}} +
    + + @can('create', App\Models\Tag::class) + + @endcan +
    +
    + @can('create', App\Models\Tag::class) + + + @include('livewire.tags.partials.new-tag-form') + + @endcan +
    diff --git a/resources/views/livewire/tags/partials/new-tag-form.blade.php b/resources/views/livewire/tags/partials/new-tag-form.blade.php new file mode 100644 index 0000000000..214ecd0d18 --- /dev/null +++ b/resources/views/livewire/tags/partials/new-tag-form.blade.php @@ -0,0 +1,7 @@ +
    + +
    + Create tag +
    + diff --git a/resources/views/livewire/tags/show.blade.php b/resources/views/livewire/tags/show.blade.php index 44ce541f45..65904408df 100644 --- a/resources/views/livewire/tags/show.blade.php +++ b/resources/views/livewire/tags/show.blade.php @@ -19,11 +19,26 @@ @endif

    + @unless (isset($tag)) +
    + @can('create', App\Models\Tag::class) + + + + + @include('livewire.tags.partials.new-tag-form') + + @endcan +
    + @endunless
@if ($tags->isEmpty()) @elseif (! isset($tag))
@@ -41,7 +56,7 @@
-
+
- -
+
@@ -176,7 +172,7 @@ search: '', sortBy: 'name-asc', sortOpen: false, - viewMode: localStorage.getItem('tags-view') || 'grid', + viewMode: @js($this->listView('tags', 'grid')), page: 1, pageSize: 12, tags: @js($tagsJs), @@ -231,7 +227,7 @@ setViewMode(mode) { this.viewMode = mode; this.page = 1; - localStorage.setItem('tags-view', mode); + this.$wire.saveListView('tags', mode); }, previousPage() { this.page = Math.max(1, this.page - 1); @@ -284,6 +280,56 @@ + {{-- Two ways to connect: a list in a modal, or the canvas (wide screens only). --}} + + + + + @forelse ($connectableResources as $context => $resources) +
+

{{ $context }}

+
+ @foreach ($resources as $resource) + + @endforeach +
+
+ @empty +
+ +
+ @endforelse +
+
+ @can('delete', $tag) + + @endcan @if ($resourceCount === 0) @else
@@ -311,8 +357,8 @@ class="group flex min-h-24 flex-col rounded-lg border border-neutral-200 bg-neutral-50/70 p-3 transition-colors hover:border-neutral-300 hover:no-underline dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]">
- + class="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim"> +

- + class="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim"> +

+
+
Avatar
+
+ + @can('update', $team) +
+
+ + + + + @if ($team->avatar_path) + Remove + @endif +
+

JPG, PNG, or WebP. Shown in the team switcher.

+

+ @error('avatar')

{{ $message }}

@enderror +
+ @endcan +
+
diff --git a/resources/views/livewire/team/member.blade.php b/resources/views/livewire/team/member.blade.php index 724256e904..4b1261a07a 100644 --- a/resources/views/livewire/team/member.blade.php +++ b/resources/views/livewire/team/member.blade.php @@ -7,10 +7,15 @@ ])>
-
- {{ Str::upper(Str::substr($member->name ?: $member->email, 0, 1)) }} -
+ @if ($member->avatar_path) + {{ $member->name }} + @else +
+ {{ Str::upper(Str::substr($member->name ?: $member->email, 0, 1)) }} +
+ @endif {{ $member->name }} @if ($member->id === Auth::id()) @can('manageMembers', currentTeam()) @if ($member->id !== Auth::id()) -
- - @endif @endcan diff --git a/resources/views/livewire/terminal/index.blade.php b/resources/views/livewire/terminal/index.blade.php index 0a3f361596..cd65d085dc 100644 --- a/resources/views/livewire/terminal/index.blade.php +++ b/resources/views/livewire/terminal/index.blade.php @@ -65,6 +65,7 @@
+ + +

diff --git a/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php index e6b1e9be4f..df862b9ec1 100644 --- a/resources/views/livewire/traffic/_breakdown-section.blade.php +++ b/resources/views/livewire/traffic/_breakdown-section.blade.php @@ -1,5 +1,5 @@ {{-- - One breakdown dimension rendered as a paginated settings-section. Handles the + One breakdown dimension rendered as a sortable, paginated settings-section. Handles the per-dimension presentation: referrers get a DuckDuckGo favicon + an openable external link (rel="noopener noreferrer nofollow", new tab), devices are mapped to friendly capitalized labels, and Sentinel's `__other__` overflow bucket reads @@ -20,7 +20,14 @@ @else -
+
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Name', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent']) +
+
@foreach ($rows as $row) @php $value = (string) ($row['value'] ?? ''); @@ -35,11 +42,13 @@ default => $value !== '' ? $value : 'Unknown', }; $requests = (int) ($row['requests'] ?? 0); + $bytesOut = (int) ($row['bytesOut'] ?? 0); $width = min(100, round(($requests / $maxRequests) * 100, 1)); @endphp
+ data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif + x-show="shown($el)" + class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]"> @if ($dimension === 'referer' && $host) @@ -51,11 +60,12 @@ - {{ compactNumber($requests) }} - +
@endforeach +
@include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_geo.blade.php b/resources/views/livewire/traffic/_geo.blade.php index 06f24bfee6..754edd9aa6 100644 --- a/resources/views/livewire/traffic/_geo.blade.php +++ b/resources/views/livewire/traffic/_geo.blade.php @@ -1,13 +1,14 @@ {{-- - Shared geo visualization for traffic analytics: a lightweight interactive 3D - dotted globe (WebGL, via cobe — see resources/js/traffic-globe.js) with a - request-volume marker per country, beside a scrollable ranked country list. - Hovering a country row rotates the globe to face it. Driven by the `country` - breakdown; each row is ['value' => ISO-A2, 'requests', 'bytesOut']. The globe - live-updates from the host's `refreshChartData-{chartId}-status` payload - (`geo` key), so `$chartId` must be in scope. + "Locations" settings-section for traffic analytics: a lightweight interactive + 3D dotted globe (WebGL, via cobe — see resources/js/traffic-globe.js) beside a + scrollable ranked list. A Countries / Cities switch in the section header picks + the list and the globe markers; the choice is saved per-browser in localStorage. + Hovering a row rotates the globe to face it. The globe live-updates from the + host's `refreshChartData-{chartId}-status` payload (`geo` key), so `$chartId` + must be in scope. - @param iterable $countries country-breakdown rows + @param iterable $countries country-breakdown rows: ['value' => ISO-A2, 'requests', 'bytesOut'] + @param array $cities output of trafficCityRows(); empty when there is no city data @param ?string $attribution optional Sentinel attribution note --}} @php @@ -25,9 +26,6 @@ fn ($r) => preg_match('/^[A-Z]{2}$/', $r['value']) && countryName($r['value']) !== 'Unknown' ); - // Initial marker payload for the globe, keyed by ISO-A2 request volume. - $geoInit = $known->map(fn ($r) => ['code' => $r['value'], 'requests' => $r['requests']])->values()->all(); - $countryRows = $known->values(); if ($unknown->isNotEmpty()) { $countryRows->push([ @@ -39,99 +37,179 @@ $countryRows = $countryRows->sortByDesc('requests')->values(); $maxRequests = max(1, (int) $countryRows->max('requests')); + $cityRows = collect($cities ?? []); + $maxCityRequests = max(1, (int) $cityRows->max('requests')); + + // Initial marker payload for the globe: one set for each view. + $geoInit = trafficGeoMarkers($known->all(), $cityRows->all()); + $hasData = $countryRows->isNotEmpty(); $globeId = ($chartId ?? 'traffic').'-globe'; + $viewButton = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $viewButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'; + $viewButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'; + $listClass = 'max-h-[320px] overflow-y-auto pr-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-neutral-300 dark:scrollbar-thumb-white/10'; + $rowClass = 'group flex min-h-10 items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]'; @endphp -
- @if (! $hasData) - - @else -
- {{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear - down the WebGL canvas. Mounted via Alpine, not Livewire's script directive: - it ran before the lazy-loaded canvas was in the DOM, and only once per - component, so a globe inserted by a later render (no data -> data after a - range change or live poll) never mounted either. Alpine's - init()/destroy() run each time this element enters or leaves the DOM. --}} -
-
- -
-
+ -
- @foreach ($countryRows as $row) - @php - $isUnknown = $row['value'] === ''; - $width = min(100, round(($row['requests'] / $maxRequests) * 100, 1)); - @endphp -
- @php $flagUrl = $isUnknown ? null : countryFlagUrl($row['value']); @endphp - @if ($flagUrl) - - @else - - @endif - - {{ $isUnknown ? 'Unknown' : countryName($row['value']) }} - - - {{ compactNumber($row['requests']) }} - -
- @endforeach -
-
+ return canvas ? canvas._trafficGlobe : null; + }, + }"> + + @if ($hasData) +
+ @foreach (['countries' => 'Countries', 'cities' => 'Cities'] as $viewKey => $viewLabel) + + @endforeach
- - @if (! empty($attribution)) -

- {{ $attribution }} -

@endif - @endif -
+ + +
+ @if (! $hasData) + + @else + {{-- Keyed so the lazy-load morph inserts fresh nodes instead of reusing the + placeholder skeleton, which has the same shape. --}} +
+ {{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear + down the WebGL canvas. Mounted via Alpine init()/destroy(), which run each + time this element enters or leaves the DOM (a Livewire script directive + runs one time only, before the lazy-loaded canvas exists). --}} +
+
+ +
+
+ + {{-- Ranked, scrollable lists (60%). Hover a row to face it on the globe. --}} +
+
+ @foreach ($countryRows as $row) + @php + $isUnknown = $row['value'] === ''; + $width = min(100, round(($row['requests'] / $maxRequests) * 100, 1)); + $flagUrl = $isUnknown ? null : countryFlagUrl($row['value']); + @endphp +
+ @if ($flagUrl) + + @else + + @endif + + {{ $isUnknown ? 'Unknown' : countryName($row['value']) }} + + + {{ compactNumber($row['requests']) }} + +
+ @endforeach +
+ +
+ @forelse ($cityRows as $row) + @php + $hasCoordinates = $row['lat'] !== null && $row['lon'] !== null; + $width = min(100, round(($row['requests'] / $maxCityRequests) * 100, 1)); + $flagUrl = countryFlagUrl($row['country']); + @endphp +
+ @if ($flagUrl) + + @else + + @endif + + {{ $row['name'] }} + @if ($row['detail'] !== '') + + @endif + + + {{ compactNumber($row['requests']) }} + +
+ @empty + + @endforelse +
+
+
+ + @if (! empty($attribution)) +

+ {{ $attribution }} +

+ @endif + @endif +
+ diff --git a/resources/views/livewire/traffic/_hosts-list.blade.php b/resources/views/livewire/traffic/_hosts-list.blade.php index 710e8fa637..9785bd38eb 100644 --- a/resources/views/livewire/traffic/_hosts-list.blade.php +++ b/resources/views/livewire/traffic/_hosts-list.blade.php @@ -1,7 +1,8 @@ {{-- - Paginated "top hosts" list — request volume grouped by served hostname (an app's - primary domain), with a proportional bar and right-aligned compact metrics. - Links each host to its live origin. Expects `$hosts` in scope. + Sortable, paginated "top hosts" list — request volume grouped by served hostname + (an app's primary domain), with a proportional bar and right-aligned compact + metrics. Select a column header to sort by it. Links each host to its live + origin. Expects `$hosts` in scope. @param iterable $hosts host rows: ['host', 'requests', 'bandwidth'] @param ?string $keyPrefix wire:key prefix (default "analytics-host") @@ -15,37 +16,42 @@ @else -
-
- Host - - Requests - +
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Host', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent'])
- @foreach ($hosts as $row) - @php - $host = (string) ($row['host'] ?? ''); - $known = $host !== ''; - $requests = (int) ($row['requests'] ?? 0); - $width = min(100, round(($requests / $maxRequests) * 100, 1)); - @endphp -
- @if ($known) - {{ $host }} - @else - Unknown host - @endif - @include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_pager.blade.php b/resources/views/livewire/traffic/_pager.blade.php index 73aa17cc9b..d0dcf08dd0 100644 --- a/resources/views/livewire/traffic/_pager.blade.php +++ b/resources/views/livewire/traffic/_pager.blade.php @@ -1,7 +1,7 @@ {{-- Client-side "top 10" pager footer for traffic lists. Expects an enclosing Alpine scope that defines reactive `page` (0-based), `per` (page size) and - `total` (row count). Rows themselves are toggled with x-show on their index; + `total` (row count). Rows themselves are toggled with x-show by their scope; this partial only renders the summary + prev/next controls, and hides itself when everything fits on one page. --}} diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php index 2882b0e69b..0287c84996 100644 --- a/resources/views/livewire/traffic/_paths-list.blade.php +++ b/resources/views/livewire/traffic/_paths-list.blade.php @@ -1,82 +1,90 @@ {{-- - Paginated "top paths" list. Each row shows the request path, a proportional - request-volume bar, and right-aligned compact metrics (requests / bytes / p95). - The path links to its live URL when the owning domain is known (new tab, - rel="noopener noreferrer nofollow"); the domain shows next to the path so the - same path of two apps stays apart. Sentinel's `__other__` overflow row reads - as "Other paths". Expects `$paths` in scope; optional - `$keyPrefix` to namespace wire:keys. + Sortable, paginated "top paths" list. Each row shows the request path, a + proportional request-volume bar, and right-aligned compact metrics. Select a + column header to sort by it. The path links to its live URL when the owning + domain is known (new tab, rel="noopener noreferrer nofollow"); the domain shows + next to the path so the same path of two apps stays apart. Sentinel's + `__other__` overflow row reads as "Other paths" and stays last. Expects + `$paths` in scope; optional `$keyPrefix` to namespace wire:keys. - @param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 'p95'] + @param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 's4xx', 's5xx', 'p95'] @param ?string $keyPrefix wire:key prefix (default "analytics-path") --}} @php $paths = $paths ?? []; $keyPrefix = $keyPrefix ?? 'analytics-path'; $maxRequests = max(1, (int) collect($paths)->max('requests')); + $muted = 'text-neutral-300 dark:text-fg-faint'; @endphp @if (collect($paths)->isEmpty()) @else -
-
- Path - - Requests - - 5xx errors - - - +
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Path', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 's4xx', 'label' => '4xx', 'class' => 'hidden w-12 shrink-0 sm:inline-flex', 'title' => 'HTTP 4xx client-error responses']) + @include('livewire.traffic._sort-header', ['column' => 's5xx', 'label' => '5xx', 'class' => 'w-12 shrink-0', 'title' => 'HTTP 5xx server-error responses']) + @include('livewire.traffic._sort-header', ['column' => 'errorRate', 'label' => 'Error %', 'class' => 'hidden w-14 shrink-0 lg:inline-flex', 'title' => 'Percentage of requests with a 4xx or 5xx response']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent']) + @include('livewire.traffic._sort-header', ['column' => 'p95', 'label' => 'p95', 'class' => 'hidden w-16 shrink-0 md:inline-flex', 'title' => '95% of requests completed within this response time'])
- @foreach ($paths as $path) - @php - $domain = $path['domain'] ?? null; - $pathStr = (string) ($path['path'] ?? ''); - $isOther = $pathStr === '__other__'; - $href = $domain && ! $isOther ? 'https://'.$domain.$pathStr : null; - $requests = (int) ($path['requests'] ?? 0); - $s4xx = (int) ($path['s4xx'] ?? 0); - $s5xx = (int) ($path['s5xx'] ?? 0); - $errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0; - $width = min(100, round(($requests / $maxRequests) * 100, 1)); - @endphp -
- @if ($isOther) - Other paths - @else - - @if ($href) - {{ $pathStr }} - @else - {{ $pathStr }} - @endif - @if ($domain) - {{ $domain }} - @endif - - @endif - @include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_sort-header.blade.php b/resources/views/livewire/traffic/_sort-header.blade.php new file mode 100644 index 0000000000..82602ee4cf --- /dev/null +++ b/resources/views/livewire/traffic/_sort-header.blade.php @@ -0,0 +1,31 @@ +{{-- + One sortable column header for a traffic list. Expects an enclosing Alpine + scope from `trafficTable()` (resources/js/traffic-table.js). + + @param string $column key in the rows' `data-row` JSON + @param string $label header text + @param ?string $class width and responsive classes of the column; a column that + hides on small screens passes `hidden :inline-flex` + @param ?string $title tooltip + @param ?bool $text true for a text column: left-aligned, first sort ascending + @param ?string $dir first sort direction, when it differs from the default of the alignment +--}} +@php + $text = $text ?? false; + $firstDir = $dir ?? ($text ? 'asc' : 'desc'); + $class = $class ?? ''; + // `hidden` and `inline-flex` on one element conflict, so add the display only when the caller sets none. + $display = str_contains($class, 'hidden') ? '' : 'inline-flex'; +@endphp + diff --git a/resources/views/livewire/traffic/_sparkline.blade.php b/resources/views/livewire/traffic/_sparkline.blade.php index 4fb3ce8cc2..5999cc1b82 100644 --- a/resources/views/livewire/traffic/_sparkline.blade.php +++ b/resources/views/livewire/traffic/_sparkline.blade.php @@ -16,14 +16,16 @@ @param string $colorVar CSS custom property for the line color (e.g. --chart-status-3xx) @param ?string $event Livewire event to listen on for updates (optional) @param ?string $key payload key holding the numeric array (required with $event) + @param ?array $categories initial x-axis timestamps (defaults to $series buckets) --}} @php $initial = $initial ?? []; $colorVar = $colorVar ?? '--chart-status-3xx'; + $color = $color ?? ''; $event = $event ?? ''; $key = $key ?? ''; - $initialCategories = array_column($series ?? [], 'bucket'); - $label = match ($key) { + $initialCategories = $categories ?? array_column($series ?? [], 'bucket'); + $label = $label ?? match ($key) { 'requestsSpark' => 'Requests', 'uniquesSpark' => 'Visitors', 'bandwidthSpark' => 'Bandwidth', @@ -31,12 +33,17 @@ 'latencySpark' => 'p95 latency', default => 'Value', }; + $format = $format ?? match ($key) { + 'bandwidthSpark' => 'bytes', + 'latencySpark' => 'ms', + default => 'number', + }; @endphp
503 Service Unavailable

+

This site is not available at the moment

+

This site cannot answer your request right now. Please try again later.

+
+

Are you the site owner?

+
    +
  • The domain is not configured on a resource in Coolify, or its DNS points to another server.
  • +
  • The application is stopped, still deploying, or has crashed.
  • +
  • The application health check is failing.
  • +
+ Read the troubleshooting guide +
+@endsection diff --git a/resources/views/proxy/layout.blade.php b/resources/views/proxy/layout.blade.php new file mode 100644 index 0000000000..077b3ce2f6 --- /dev/null +++ b/resources/views/proxy/layout.blade.php @@ -0,0 +1,116 @@ + + + + + + + @yield('title') + + + +
+ @yield('content') +
+ + + diff --git a/resources/views/proxy/maintenance-page.blade.php b/resources/views/proxy/maintenance-page.blade.php new file mode 100644 index 0000000000..236e6c8706 --- /dev/null +++ b/resources/views/proxy/maintenance-page.blade.php @@ -0,0 +1,9 @@ +@extends('proxy.layout') + +@section('title', 'Under maintenance') + +@section('content') +

503 Service Unavailable

+

We are doing some maintenance

+

This site is temporarily unavailable for maintenance. Please check back soon.

+@endsection diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index fc93d4a72c..528f92d98c 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -44,6 +44,16 @@ + + + + + +
@endcan @@ -56,11 +66,15 @@ @else @php $items = $sources->map(function ($source) { - $isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp'; + $isGitlab = $source->getMorphClass() === 'App\\Models\\GitlabApp'; return [ 'name' => $source->name, - 'provider' => $isGithub ? 'GitHub' : 'GitLab', - 'organization' => $isGithub ? $source->organization : $source->group_name, + 'provider' => match ($source->getMorphClass()) { + 'App\\Models\\GithubApp' => 'GitHub', + 'App\\Models\\GiteaApp' => 'Gitea', + default => 'GitLab', + }, + 'organization' => $isGitlab ? $source->group_name : $source->organization, 'status' => $source->isConnected() ? 'Connected' : 'Setup incomplete', ]; })->values(); @@ -71,7 +85,7 @@ matches(values) { const query = this.search.trim().toLowerCase(); return !query || values.some(value => String(value || '').toLowerCase().includes(query)); }, setViewMode(mode) { this.viewMode = mode; localStorage.setItem('coolify-sources-view', mode); } }"> - @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources', 'singular' => 'source', 'plural' => 'sources']) + @include('livewire.shared.list-search-controls', ['placeholder' => 'Search sources'])
@foreach ($sources as $source) @if ($source->getMorphClass() === 'App\Models\GithubApp') @@ -120,6 +134,33 @@
+
+ @if ($source->isConnected()) + + @else + + @endif +
+ + @elseif ($source->getMorphClass() === 'App\Models\GiteaApp') + +
+
+ +
+
+

+ {{ $source->name }} +

+

+ {{ $source->organization ? "Gitea · {$source->organization}" : 'Gitea' }} +

+
+
+
@if ($source->isConnected()) @@ -135,10 +176,11 @@
Source
Provider
Status
@foreach ($sources as $source) @php - $isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp'; - $provider = $isGithub ? 'GitHub' : 'GitLab'; - $organization = $isGithub ? $source->organization : $source->group_name; - $href = $isGithub ? route('source.github.show', ['github_app_uuid' => $source->uuid]) : route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]); + [$provider, $organization, $href] = match ($source->getMorphClass()) { + 'App\\Models\\GithubApp' => ['GitHub', $source->organization, route('source.github.show', ['github_app_uuid' => $source->uuid])], + 'App\\Models\\GiteaApp' => ['Gitea', $source->organization, route('source.gitea.show', ['gitea_app_uuid' => $source->uuid])], + default => ['GitLab', $source->group_name, route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid])], + }; @endphp
{{ $source->name }}
diff --git a/routes/ai.php b/routes/ai.php index d3f9bf83d2..154866edcc 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -1,8 +1,7 @@ middleware(['mcp.enabled', 'auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'mcp.team.enabled']); + ->middleware(['mcp.enabled', 'api.ip.allowed', 'auth:sanctum', 'api.token.team', 'mcp.team.enabled']); diff --git a/routes/api.php b/routes/api.php index bfbb2d29fa..f2efce3b00 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\HostingerController; use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\IntegrationTokensController; use App\Http\Controllers\Api\NotificationsController; @@ -40,7 +41,6 @@ use App\Http\Controllers\Api\TeamController; use App\Http\Controllers\Api\VolumeBackupsController; use App\Http\Controllers\Api\VultrController; use App\Http\Middleware\ApiAllowed; -use App\Http\Middleware\ApiIpAllowed; use Illuminate\Support\Facades\Route; Route::get('/health', [OtherController::class, 'healthcheck']); @@ -54,12 +54,10 @@ Route::post('/feedback', [OtherController::class, 'feedback']) ->middleware('throttle:feedback'); Route::group([ - 'middleware' => ['auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write'], + 'middleware' => ['api.ip.allowed', 'auth:sanctum', 'api.token.team', 'api.ability:write'], 'prefix' => 'v1', ], function () { - Route::get('/enable', [OtherController::class, 'post_required']); Route::get('/disable', [OtherController::class, 'post_required']); - Route::post('/enable', [OtherController::class, 'enable_api']); Route::post('/disable', [OtherController::class, 'disable_api']); Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']); Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']); @@ -205,9 +203,9 @@ Route::group([ Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:deploy']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); + Route::get('/servers/import/check', [ServerTransferController::class, 'check'])->middleware(['api.ability:write', 'api.ability:deploy']); Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write', 'api.ability:deploy']); - Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']); - Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/transfer', [ServerTransferController::class, 'status'])->middleware(['api.ability:read,write']); Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); @@ -233,6 +231,13 @@ Route::group([ Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/hostinger/data-centers', [HostingerController::class, 'dataCenters'])->middleware(['api.ability:read']); + Route::get('/hostinger/catalog', [HostingerController::class, 'catalog'])->middleware(['api.ability:read']); + Route::get('/hostinger/templates', [HostingerController::class, 'templates'])->middleware(['api.ability:read']); + Route::get('/hostinger/ssh-keys', [HostingerController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::get('/hostinger/post-install-scripts', [HostingerController::class, 'postInstallScripts'])->middleware(['api.ability:read']); + Route::post('/servers/hostinger', [HostingerController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); diff --git a/routes/web.php b/routes/web.php index 65a9cb4d48..27e1bfa892 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,18 +1,38 @@ group(function () { }); Route::get('/', Dashboard::class)->name('dashboard'); + Route::get('/dashboard/canvas', DashboardCanvas::class)->name('dashboard.canvas'); Route::get('/analytics', Analytics::class)->name('analytics'); + Route::get('/metrics', FleetMetrics::class)->name('metrics'); + Route::get('/canvas', DeployCanvas::class)->name('deploy-canvas'); + Route::get('/canvas/tags', TagsCanvas::class)->name('tags.canvas'); + // The canvas calls these API controllers with the browser session (no API token). + Route::prefix('canvas/api')->middleware([UseSessionTeamApiToken::class, 'api.token.team', 'api.sensitive'])->group(function () { + Route::get('/projects', [ProjectController::class, 'projects'])->middleware(['api.ability:read']); + Route::post('/projects', [ProjectController::class, 'create_project'])->middleware(['api.ability:write']); + Route::get('/projects/canvas', [ProjectCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/projects/{uuid}/canvas', [ProjectCanvasController::class, 'show'])->middleware(['api.ability:read']); + Route::get('/projects/{uuid}/environments', [ProjectController::class, 'get_environments'])->middleware(['api.ability:read']); + Route::post('/projects/{uuid}/environments', [ProjectController::class, 'create_environment'])->middleware(['api.ability:write']); + Route::get('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas', [EnvironmentCanvasController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/positions', [EnvironmentCanvasController::class, 'updatePositions'])->middleware(['api.ability:write']); + Route::post('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links', [EnvironmentCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); + Route::patch('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'updateLink'])->middleware(['api.ability:write']); + Route::delete('/projects/{uuid}/environments/{environment_name_or_uuid}/canvas/links/{link_uuid}', [EnvironmentCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/tags/canvas', [TagCanvasController::class, 'show'])->middleware(['api.ability:read']); + Route::post('/tags/canvas/links', [TagCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); + Route::delete('/tags/canvas/links/{link_id}', [TagCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/shared-variables/team/canvas', [SharedVariableCanvasController::class, 'show'])->middleware(['api.ability:read']); + Route::post('/shared-variables/team/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->middleware(['api.ability:write']); + Route::delete('/shared-variables/team/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->middleware(['api.ability:write']); + Route::get('/shared-variables/{scope}/{uuid}/canvas', [SharedVariableCanvasController::class, 'show'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:read']); + Route::post('/shared-variables/{scope}/{uuid}/canvas/links', [SharedVariableCanvasController::class, 'storeLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); + Route::delete('/shared-variables/{scope}/{uuid}/canvas/links/{link_id}', [SharedVariableCanvasController::class, 'destroyLink'])->whereIn('scope', ['project', 'environment', 'server'])->middleware(['api.ability:write']); + Route::get('/servers', [ServersController::class, 'servers'])->middleware(['api.ability:read']); + Route::get('/servers/canvas', [ServerCanvasController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/dashboard/canvas', [ServerCanvasController::class, 'dashboard'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas', [ServerCanvasController::class, 'show'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas/containers', [ServerCanvasController::class, 'containers'])->middleware(['api.ability:read']); + Route::get('/servers/{uuid}/canvas/usage', [ServerCanvasController::class, 'usage'])->middleware(['api.ability:read']); + Route::get('/servers/{server_uuid}/destinations', [DestinationsController::class, 'index_by_server'])->middleware(['api.ability:read']); + Route::get('/github-apps', [GithubController::class, 'list_github_apps'])->middleware(['api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']); + Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']); + Route::get('/security/keys', [SecurityController::class, 'keys'])->middleware(['api.ability:read']); + Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); + Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); + Route::post('/applications/private-deploy-key', [ApplicationsController::class, 'create_private_deploy_key_application'])->middleware(['api.ability:write']); + Route::post('/applications/dockerfile', [ApplicationsController::class, 'create_dockerfile_application'])->middleware(['api.ability:write']); + Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']); + Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']); + Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']); + Route::patch('/applications/{uuid}/envs/bulk', [ApplicationsController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); + Route::post('/services', [ServicesController::class, 'create_service'])->middleware(['api.ability:write']); + Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); + Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); + Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']); + Route::post('/databases/mariadb', [DatabasesController::class, 'create_database_mariadb'])->middleware(['api.ability:write']); + Route::post('/databases/mongodb', [DatabasesController::class, 'create_database_mongodb'])->middleware(['api.ability:write']); + Route::post('/databases/redis', [DatabasesController::class, 'create_database_redis'])->middleware(['api.ability:write']); + Route::post('/databases/clickhouse', [DatabasesController::class, 'create_database_clickhouse'])->middleware(['api.ability:write']); + Route::post('/databases/dragonfly', [DatabasesController::class, 'create_database_dragonfly'])->middleware(['api.ability:write']); + Route::post('/databases/keydb', [DatabasesController::class, 'create_database_keydb'])->middleware(['api.ability:write']); + Route::post('/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); + Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']); + }); Route::get('/admin', AdminIndex::class)->name('admin.index'); Route::get('/onboarding', BoardingIndex::class)->name('onboarding'); @@ -186,6 +271,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/profile', ProfileIndex::class)->name('profile'); Route::get('/profile/avatar', ProfileAvatarController::class)->name('profile.avatar'); Route::get('/profile/appearance', ProfileAppearance::class)->name('profile.appearance'); + Route::get('/profile/passkeys/confirm', fn () => redirect()->route('profile')->with('status', 'passkey-add')) + ->middleware('password.confirm') + ->name('profile.passkeys.confirm'); Route::prefix('tags')->group(function () { Route::get('/{tagName?}', TagsShow::class)->name('tags.show'); @@ -209,17 +297,23 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::prefix('shared-variables')->group(function () { Route::get('/', SharedVariablesIndex::class)->name('shared-variables.index'); Route::get('/team', TeamSharedVariablesIndex::class)->name('shared-variables.team.index'); + Route::get('/team/canvas', SharedVariablesCanvas::class)->name('shared-variables.team.canvas'); Route::get('/projects', ProjectSharedVariablesIndex::class)->name('shared-variables.project.index'); Route::get('/project/{project_uuid}', ProjectSharedVariablesShow::class)->name('shared-variables.project.show'); + Route::get('/project/{project_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.project.canvas'); Route::get('/environments', EnvironmentSharedVariablesIndex::class)->name('shared-variables.environment.index'); Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}', EnvironmentSharedVariablesShow::class)->name('shared-variables.environment.show'); + Route::get('/environments/project/{project_uuid}/environment/{environment_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.environment.canvas'); Route::get('/servers', ServerSharedVariablesIndex::class)->name('shared-variables.server.index'); Route::get('/server/{server_uuid}', ServerSharedVariablesShow::class)->name('shared-variables.server.show'); + Route::get('/server/{server_uuid}/canvas', SharedVariablesCanvas::class)->name('shared-variables.server.canvas'); }); Route::prefix('team')->group(function () { Route::get('/', TeamIndex::class)->name('team.index'); + Route::get('/{team_id}/avatar', TeamAvatarController::class)->whereNumber('team_id')->name('team.avatar'); Route::get('/members', TeamMemberIndex::class)->name('team.member.index'); + Route::get('/members/{user_id}/avatar', ProfileAvatarController::class)->whereNumber('user_id')->name('team.member.avatar'); Route::get('/audit-log', TeamAuditLog::class)->name('team.audit-log'); Route::get('/admin', TeamAdminView::class)->name('team.admin-view'); Route::get('/danger', TeamDangerZone::class)->name('team.danger-zone'); @@ -273,9 +367,12 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/projects', ProjectIndex::class)->name('project.index'); + Route::get('/projects/canvas', ProjectsCanvas::class)->name('project.canvas.index'); Route::get('/project/{project_uuid}/icon', ProjectIconController::class)->name('project.icon'); + Route::get('/files/download/{token}', FileBrowserDownloadController::class)->name('project.files.download')->middleware('can.access.terminal'); Route::prefix('project/{project_uuid}')->group(function () { Route::get('/', ProjectShow::class)->name('project.show'); + Route::get('/canvas', ProjectCanvas::class)->name('project.canvas'); Route::get('/edit', ProjectEdit::class)->name('project.edit')->middleware('can.update.resource'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}')->group(function () { @@ -283,6 +380,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/clone', ProjectCloneMe::class)->name('project.clone-me')->middleware('can.create.resources'); Route::get('/new', ResourceCreate::class)->name('project.resource.create')->middleware('can.create.resources'); Route::get('/edit', EnvironmentEdit::class)->name('project.environment.edit')->middleware('can.update.resource'); + Route::get('/canvas', EnvironmentCanvas::class)->name('project.environment.canvas'); }); Route::prefix('project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}')->group(function () { Route::get('/', ApplicationConfiguration::class)->name('project.application.configuration'); @@ -293,8 +391,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/persistent-storage', ApplicationConfiguration::class)->name('project.application.persistent-storage'); Route::get('/backups', ApplicationBackupIndex::class)->name('project.application.backup.index'); Route::get('/backups/{backup_uuid}', ApplicationBackupShow::class)->name('project.application.backup.show'); - Route::get('/backups/{backup_uuid}/s3', ApplicationBackupShow::class)->name('project.application.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', ApplicationBackupShow::class)->name('project.application.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/application/{application_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', ApplicationBackupShow::class)->name('project.application.backup.executions'); Route::get('/backups/{backup_uuid}/danger', ApplicationBackupShow::class)->name('project.application.backup.danger'); Route::get('/source', ApplicationConfiguration::class)->name('project.application.source'); @@ -315,7 +414,9 @@ Route::middleware(['auth', 'verified'])->group(function () { 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}', ApplicationConfiguration::class)->name('project.application.scheduled-tasks'); + Route::get('/files', FileBrowser::class)->name('project.application.files')->middleware('can.access.terminal'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.application.scheduled-tasks.show', get_route_parameters()))->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'); @@ -333,10 +434,12 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/logs', Logs::class)->name('project.database.logs'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.database.command')->middleware('can.access.terminal'); + Route::get('/files', FileBrowser::class)->name('project.database.files')->middleware('can.access.terminal'); Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index'); Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution'); - Route::get('/backups/{backup_uuid}/s3', DatabaseBackupExecution::class)->name('project.database.backup.s3'); - Route::get('/backups/{backup_uuid}/retention', DatabaseBackupExecution::class)->name('project.database.backup.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); + Route::redirect('/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}/backups/{backup_uuid}'); Route::get('/backups/{backup_uuid}/executions', DatabaseBackupExecution::class)->name('project.database.backup.executions'); Route::get('/backups/{backup_uuid}/danger', DatabaseBackupExecution::class)->name('project.database.backup.danger'); }); @@ -351,8 +454,9 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/import-backup/{stack_service_uuid}', ServiceImportBackup::class)->name('project.service.import-backup.database')->middleware('can.update.resource'); Route::get('/storage-backups', Index::class)->name('project.service.volume-backups.index'); Route::get('/storage-backups/{backup_uuid}', Show::class)->name('project.service.volume-backups.show'); - Route::get('/storage-backups/{backup_uuid}/s3', Show::class)->name('project.service.volume-backups.s3'); - Route::get('/storage-backups/{backup_uuid}/retention', Show::class)->name('project.service.volume-backups.retention'); + // S3 and retention settings moved into the general page; keep old links working. + Route::redirect('/storage-backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); + Route::redirect('/storage-backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/storage-backups/{backup_uuid}'); Route::get('/storage-backups/{backup_uuid}/executions', Show::class)->name('project.service.volume-backups.executions'); Route::get('/storage-backups/{backup_uuid}/danger', Show::class)->name('project.service.volume-backups.danger'); Route::get('/scheduled-tasks', ServiceConfiguration::class)->name('project.service.scheduled-tasks.show'); @@ -361,27 +465,30 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/tags', ServiceConfiguration::class)->name('project.service.tags'); Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger'); Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal'); + Route::get('/files', FileBrowser::class)->name('project.service.files')->middleware('can.access.terminal'); Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}', ServiceDatabaseBackups::class)->name('project.service.database.backup.show'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/s3', ServiceDatabaseBackups::class)->name('project.service.database.backup.s3'); - Route::get('/{stack_service_uuid}/backups/{backup_uuid}/retention', ServiceDatabaseBackups::class)->name('project.service.database.backup.retention'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/s3', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); + Route::redirect('/{stack_service_uuid}/backups/{backup_uuid}/retention', '/project/{project_uuid}/environment/{environment_uuid}/service/{service_uuid}/{stack_service_uuid}/backups/{backup_uuid}'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/executions', ServiceDatabaseBackups::class)->name('project.service.database.backup.executions'); Route::get('/{stack_service_uuid}/backups/{backup_uuid}/danger', ServiceDatabaseBackups::class)->name('project.service.database.backup.danger'); Route::get('/{stack_service_uuid}/import', ServiceImportBackup::class)->name('project.service.database.import')->middleware('can.update.resource'); Route::get('/{stack_service_uuid}/advanced', ServiceIndex::class)->name('project.service.index.advanced'); Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index'); - Route::get('/tasks/{task_uuid}', ServiceConfiguration::class)->name('project.service.scheduled-tasks'); + // Tasks open in a modal on the list page; keep old links (e.g. notifications) working. + Route::get('/tasks/{task_uuid}', fn () => redirect()->route('project.service.scheduled-tasks.show', get_route_parameters()))->name('project.service.scheduled-tasks'); }); Route::get('/servers', ServerIndex::class)->name('server.index'); + Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index'); Route::get('/registries', ServerDockerRegistries::class)->name('registries.index'); - Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class); Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); - Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']); - Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']); + Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']); + Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']); Route::prefix('server/{server_uuid}')->group(function () { Route::get('/', ServerShow::class)->name('server.show'); + Route::get('/canvas', ServerCanvas::class)->name('server.canvas'); Route::get('/advanced', ServerAdvanced::class)->name('server.advanced'); Route::get('/swarm', ServerSwarm::class)->name('server.swarm'); Route::get('/sentinel', SentinelShow::class)->name('server.sentinel'); @@ -442,6 +549,7 @@ Route::middleware(['auth'])->group(function () { Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions'); Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources'); Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show'); + Route::get('/source/gitea/{gitea_app_uuid}', GiteaChange::class)->name('source.gitea.show'); }); Route::middleware(['auth'])->group(function () { @@ -471,15 +579,29 @@ Route::middleware(['auth'])->group(function () { } } $filename = data_get($execution, 'filename'); - if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { - $server = $execution->scheduledDatabaseBackup->database->service->destination->server; - } else { - $server = $execution->scheduledDatabaseBackup->database->destination->server; + if (! $execution->local_storage_deleted && filled($filename)) { + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class) { + $server = $execution->scheduledDatabaseBackup->database->service->destination->server; + } else { + $server = $execution->scheduledDatabaseBackup->database->destination->server; + } + + try { + return streamBackupFromServer($server, $filename, 'application/octet-stream'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } } - return streamBackupFromServer($server, $filename, 'application/octet-stream'); - } catch (FileNotFoundException) { - if (isset($execution) && $execution->scheduledDatabaseBackup->disable_local_backup === true && $execution->scheduledDatabaseBackup->save_s3 === true) { + // Only storages of the team that owns the database can serve the copy. + $s3DownloadUrl = is_null($execution_team_id) + ? null + : $execution->s3DownloadUrl($execution_team_id, $execution->scheduledDatabaseBackup->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if ($execution->scheduledDatabaseBackup->disable_local_backup && $execution->scheduledDatabaseBackup->save_s3) { return response()->json(['message' => 'Backup not available locally, but available on S3.'], 404); } @@ -506,17 +628,29 @@ Route::middleware(['auth'])->group(function () { if ($team->id !== 0 && $team->id !== $execution->scheduledVolumeBackup->team_id) { return response()->json(['message' => 'Permission denied.'], 403); } - if ($execution->local_storage_deleted || blank($execution->filename)) { + if (blank($execution->filename)) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } - $server = $execution->scheduledVolumeBackup->server(); - if (! $server) { + $server = $execution->local_storage_deleted ? null : $execution->scheduledVolumeBackup->server(); + if ($server) { + try { + return streamBackupFromServer($server, $execution->filename, 'application/gzip'); + } catch (FileNotFoundException) { + // Fall back to an S3 copy. + } + } + + // Only storages of the team that owns the backup can serve the copy. + $s3DownloadUrl = $execution->s3DownloadUrl($execution->scheduledVolumeBackup->team_id, $execution->s3_storage_id); + if ($s3DownloadUrl) { + return redirect()->away($s3DownloadUrl); + } + + if (! $execution->local_storage_deleted && ! $server) { return response()->json(['message' => 'Server not found.'], 404); } - return streamBackupFromServer($server, $execution->filename, 'application/gzip'); - } catch (FileNotFoundException) { return response()->json(['message' => 'Backup not found locally on the server.'], 404); } catch (Throwable) { return response()->json(['message' => 'Failed to download backup.'], 500); diff --git a/routes/webhooks.php b/routes/webhooks.php index cc5524de4f..a0ffa528de 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -11,6 +11,7 @@ Route::middleware(['web', 'auth', 'throttle:60,1'])->group(function () { Route::get('/source/github/redirect', [Github::class, 'redirect']); Route::get('/source/github/install', [Github::class, 'install']); Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']); + Route::get('/source/gitea/redirect', [Gitea::class, 'redirect']); }); Route::post('/source/github/events', [Github::class, 'normal']); @@ -21,6 +22,7 @@ Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']); Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']); +Route::post('/source/gitea/events', [Gitea::class, 'normal']); Route::post('/source/gitea/events/manual', [Gitea::class, 'manual']); Route::post('/payments/stripe/events', [Stripe::class, 'events']); diff --git a/scripts/dev b/scripts/dev index 5a02758a58..3102f0b9d7 100755 --- a/scripts/dev +++ b/scripts/dev @@ -24,12 +24,17 @@ # # Env: COOLIFY_DEV_SERVER_BACKEND=auto|lima|testing-host, COOLIFY_DEV_KVM_PROFILE= # +# Side by side: COOLIFY_DEV_INSTANCE= COOLIFY_DEV_SIDE_BY_SIDE=1 starts a second instance of +# this checkout without stopping the other ones (for example a transfer target, see +# scripts/transfer-test). It gets its own SSH key folder; the instance remembers the mode. +# set -euo pipefail cd "$(dirname "$0")/.." ROOT="$(pwd -P)" COMPOSE_FILE="docker-compose.dev-multi.yml" +SIDE_BY_SIDE_COMPOSE_FILE="docker-compose.dev-side-by-side.yml" GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)" # Shared by all worktrees, so slots, APP_KEYs, and data survive worktree removal. ENV_DIR="$(dirname "$GIT_COMMON_DIR")/.dev-instances" @@ -38,7 +43,7 @@ QEMU_STORAGE="${DEVELOPMENT_QEMU_STORAGE_PATH:-/var/lib/libvirt/images/coolify-d LEGACY_CONTAINERS=(coolify coolify-db coolify-redis coolify-vite coolify-mail coolify-minio coolify-minio-init coolify-testing-host) usage() { - sed -n '2,26p' "$0" | sed 's/^# \?//' + sed -n '2,30p' "$0" | sed 's/^# \?//' } normalize_name() { @@ -66,6 +71,7 @@ current_instance() { project_name() { echo "coolify-dev-$1"; } env_file() { echo "$ENV_DIR/$1.env"; } env_value() { grep -E "^$2=" "$(env_file "$1")" | cut -d= -f2-; } +is_side_by_side() { [[ -f "$(env_file "$1")" ]] && grep -qxF 'DEV_SIDE_BY_SIDE=1' "$(env_file "$1")"; } # Lowest free slot (1..254), stable per instance name. It selects the port block # and the libvirt network (10.221..0/24) of the instance. @@ -135,6 +141,11 @@ ensure_env() { fi printf '%s\n' "$existing_key" >"$ENV_DIR/$name.appkey" + local side_by_side="${COOLIFY_DEV_SIDE_BY_SIDE:-}" + if [[ -z "$side_by_side" ]] && is_side_by_side "$name"; then + side_by_side=1 + fi + cat >"$env_file" <>"$env_file" + fi } # compose : .env of the checkout first, generated instance env wins. @@ -168,7 +182,11 @@ compose() { if [[ -f .env ]]; then env_files+=(--env-file .env) fi - docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" \ + local -a compose_files=(-f "$COMPOSE_FILE") + if is_side_by_side "$name"; then + compose_files+=(-f "$SIDE_BY_SIDE_COMPOSE_FILE") + fi + docker compose -p "$(project_name "$name")" "${compose_files[@]}" \ "${env_files[@]}" --env-file "$(env_file "$name")" "${profiles[@]}" "$@" } @@ -221,12 +239,14 @@ wait_for_coolify() { # Only one instance of a checkout runs at a time: they share its ports (main checkout) # and its public/hot Vite file. The legacy fixed-name stack also holds the main ports. +# Side-by-side instances have their own ports and run next to the others on purpose. stop_other_checkout_instances() { local file other + is_side_by_side "$instance" && return 0 for file in "$ENV_DIR"/*.env; do [[ -f "$file" ]] || continue other="$(basename "$file" .env)" - if [[ "$other" != "$instance" ]] && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" && + if [[ "$other" != "$instance" ]] && ! is_side_by_side "$other" && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" && [[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$(project_name "$other")")" ]]; then echo "Stopping instance ${other} (same checkout)" docker compose -p "$(project_name "$other")" stop @@ -382,9 +402,10 @@ start_backend() { local host="${DEV_BIND_ADDRESS:-127.0.0.1}" [[ "$host" == 0.0.0.0 ]] && host=127.0.0.1 echo "Using KVM profile: $profile" - # The host command must reach this instance's database and encrypt with this instance's - # APP_KEY, not the ones in .env, and must not create root-owned files in storage/logs. - LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ + # Use this instance database and encryption key, without a stale environment or config cache. + # Send logs to stderr to avoid root-owned files in storage/logs. + APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" \ + LOG_CHANNEL=stderr APP_KEY="$(env_value "$instance" APP_KEY)" \ DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \ REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \ DEVELOPMENT_QEMU_INSTANCE="$instance" \ diff --git a/scripts/patch.sh b/scripts/patch.sh new file mode 100755 index 0000000000..5cf29c4a0b --- /dev/null +++ b/scripts/patch.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Patches Coolify 4.0 to 4.4 to the security fix release of its own line. +# Only the coolify container is recreated, the compose files and the other containers stay as they are. +# Usage: curl -fsSL https://cdn.coollabs.io/coolify/patch.sh | sudo bash +set -euo pipefail + +SOURCE_DIR=/data/coolify/source +ENV_FILE="$SOURCE_DIR/.env" +TIMESTAMP="$(date +%Y-%m-%d-%H-%M-%S)" +LOG_FILE="$SOURCE_DIR/patch-$TIMESTAMP.log" +BACKUP_FILE="$ENV_FILE-$TIMESTAMP" +PATCH_VERSIONS=(4.0.1 4.1.3 4.2.1 4.3.24 4.4.7) + +BOLD=$'\033[1m' GREEN=$'\033[32m' BLUE=$'\033[34m' RED=$'\033[31m' RESET=$'\033[0m' + +heading() { printf '\n %s%s%s\n' "$BOLD" "$*" "$RESET"; } +info() { printf ' %sℹ%s %s\n' "$BLUE" "$RESET" "$*"; } +ok() { printf ' %s✔%s %s\n' "$GREEN" "$RESET" "$*"; } +die() { + printf ' %s✖%s %s\n' "$RED" "$RESET" "$*" >&2 + exit 1 +} + +main() { + check_installation + read_version + select_patch_version + check_compose_files + heading "Patching Coolify $current_version to $patch_version" + pull_image + update_env_file + restart_coolify + wait_for_coolify +} + +check_installation() { + heading "Checking the installation" + [ "$EUID" = 0 ] || die "run this script as root, for example with sudo" + [ -f "$ENV_FILE" ] && [ -f "$SOURCE_DIR/docker-compose.prod.yml" ] || die "Coolify is not installed in $SOURCE_DIR" + docker compose version > /dev/null 2>&1 || die "the Docker Compose plugin is not installed" + compose=(docker compose --env-file "$ENV_FILE" -f "$SOURCE_DIR/docker-compose.yml" -f "$SOURCE_DIR/docker-compose.prod.yml") + if [ -f "$SOURCE_DIR/docker-compose.custom.yml" ]; then + compose+=(-f "$SOURCE_DIR/docker-compose.custom.yml") + fi + if [ -f "$SOURCE_DIR/docker-compose.postgres-upgrade.yml" ]; then + compose+=(-f "$SOURCE_DIR/docker-compose.postgres-upgrade.yml") + fi + ok "Coolify is installed in $SOURCE_DIR" +} + +read_version() { + # Copy the file out of the container, because this also works when the mitigation advice was followed and the container is stopped + current_version="$(docker cp coolify:/var/www/html/config/constants.php - 2> /dev/null | tar -xO 2> /dev/null | awk -F "'" "/'version' =>/ && !found { print \$(NF - 1); found = 1 }")" && [ -n "$current_version" ] || die "could not read the Coolify version from the coolify container" + ok "Coolify $BOLD$current_version$RESET is installed" +} + +select_patch_version() { + [[ "$current_version" =~ ^4\.([0-4])\.([0-9]+)$ ]] || die "Coolify $current_version has no security fix release, upgrade to the latest version from the dashboard instead" + patch_version="${PATCH_VERSIONS[${BASH_REMATCH[1]}]}" + if [ "${BASH_REMATCH[2]}" -ge "${patch_version##*.}" ]; then + ok "Coolify $current_version already includes the security fixes" + exit 0 + fi +} + +check_compose_files() { + local has_realtime=false needs_realtime=true + if grep -q 'coollabsio/coolify-realtime:' "$SOURCE_DIR/docker-compose.prod.yml"; then + has_realtime=true + fi + if [[ "$patch_version" == 4.4.* ]]; then + needs_realtime=false + fi + [ "$has_realtime" = "$needs_realtime" ] || die "the compose files in $SOURCE_DIR do not belong to Coolify $current_version, update to ${PATCH_VERSIONS[4]} instead: curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh | sudo bash -s ${PATCH_VERSIONS[4]}" + ok "the compose files belong to Coolify $current_version" +} + +pull_image() { + LATEST_IMAGE="$patch_version" "${compose[@]}" pull coolify < /dev/null >> "$LOG_FILE" 2>&1 || die "could not pull Coolify $patch_version, nothing was changed (log: $LOG_FILE)" + ok "pulled Coolify $patch_version" +} + +update_env_file() { + local key updated + cp "$ENV_FILE" "$BACKUP_FILE" + for key in LATEST_IMAGE COOLIFY_VERSION; do + if grep -q "^$key=" "$ENV_FILE"; then + updated="$(sed "s|^$key=.*|$key=$patch_version|" "$ENV_FILE")" + printf '%s\n' "$updated" > "$ENV_FILE" + fi + done + ok "backed up .env to $BACKUP_FILE" +} + +restart_coolify() { + # Detached, because the Coolify 4.4 terminal runs inside the coolify container and closes when it stops + LATEST_IMAGE="$patch_version" nohup "${compose[@]}" up -d --no-deps coolify < /dev/null >> "$LOG_FILE" 2>&1 & + wait $! || die "could not restart the coolify container (log: $LOG_FILE)" + ok "restarted the coolify container" +} + +wait_for_coolify() { + local status + for _ in $(seq 60); do + status="$(docker inspect --format '{{.State.Health.Status}}' coolify 2> /dev/null || true)" + if [ "$status" = healthy ]; then + ok "Coolify $BOLD$patch_version$RESET is healthy and runs PHP $(docker exec coolify php -r 'echo PHP_VERSION;')" + return + fi + sleep 5 + done + info "roll back with: cp $BACKUP_FILE $ENV_FILE && LATEST_IMAGE=$current_version ${compose[*]} up -d --no-deps coolify" + die "Coolify $patch_version is not healthy after 5 minutes, check it with: docker logs coolify" +} + +main diff --git a/scripts/transfer-test/README.md b/scripts/transfer-test/README.md new file mode 100644 index 0000000000..0a90e0f1c4 --- /dev/null +++ b/scripts/transfer-test/README.md @@ -0,0 +1,70 @@ +# Server transfer test kit + +Live end-to-end tests for the server transfer (moving a server and its resources to another Coolify instance). +The unit tests in `tests/Unit/ServerTransfer/` fake SSH and HTTP. These scripts use two real dev instances and +real VMs, so they also find problems with shells, sudo, networks, queues, and running containers. + +- **Source (A):** the dev instance of this checkout (`./scripts/dev start`). Its localhost is the `ubuntu-root` VM. + The tests transfer its `ubuntu-non-root` and `debian-root` VMs. +- **Target (B):** a second instance of the same checkout that runs side by side (`transfer-target`). Its localhost is + the testing-host container. + +Requirements: Linux with KVM (the dev VMs), root or passwordless sudo (VMs and iptables), `jq`, and PHP on the host. +macOS with Lima is not supported yet. + +## Steps + +```bash +./scripts/dev start # A, if it does not run yet +scripts/transfer-test/setup.sh # start B and connect A, B, and A's VMs (safe to run again) +scripts/transfer-test/reset.sh # WIPES BOTH DEV DATABASES, then seeds A with deployed resources (~4 min) + +scripts/transfer-test/e2e.sh # transfer both VMs A -> B through the API (41 checks) +scripts/transfer-test/reset.sh +scripts/transfer-test/reverse-e2e.sh # undo paths: transfer back, transfer over an archive, take back, archive delete (18 checks) +scripts/transfer-test/reset.sh +scripts/transfer-test/extras-e2e.sh # fake tokens, DNS, secret links, runner config, links, icon ... (31 checks) +scripts/transfer-test/reset.sh +``` + +For a manual test, run `scripts/transfer-test/manual-setup.sh`: it resets, adds the fake extra records to A, and +prints the URLs, logins, and tokens. + +Each test ends with `=== N failure(s)` and exits with N. Run `reset.sh` between two tests: a test leaves servers +transferred. After `reset.sh`, you can also test by hand: log in to A and B with `test@example.com` / `password`, open a +VM server on A → Transfer, and use the target URL `http://localhost:` with the token in +`
/.dev-instances/transfer-test//target.token`. + +## Files + +| File | What it does | +|---|---| +| `lib.sh` | Shared values (from `.dev-instances/*.env` and `config/development-qemu.php`) and helpers | +| `setup.sh` | Starts B side by side, adds the iptables rule for B → A's VMs, and adds socat forwards when the ports listen on 127.0.0.1 | +| `reset.sh` | Removes test workloads from the VMs, wipes both databases, seeds A's VMs, makes the helper image available, creates API tokens, runs `seed.sh` | +| `seed.sh` | Creates and deploys resources on A through the API | +| `e2e.sh`, `reverse-e2e.sh`, `extras-e2e.sh` | The tests | +| `seed-extras.php`, `verify-extras-*.php` | Fake records and checks of `extras-e2e.sh` | +| `manual-setup.sh` | Reset plus the fake extra records, then prints what to open and enter for a manual test | +| `seed-inventory.php` | Seeds a large database-only demo server (no deploys) on the current instance | + +Tokens and the VM SSH key are written to `
/.dev-instances/transfer-test//` (not in git). +For a full manual test of every transferred setting, see `docs/server-transfer-full-coverage.md`. + +## Options + +- `TRANSFER_TEST_SOURCE=`: another source instance than the one of this checkout. +- `TRANSFER_TEST_TARGET=`: another target name than `transfer-target`. + +## Notes + +- A and B share `storage/` of the checkout. The side-by-side mode of `scripts/dev` gives B its own SSH folder, because + both instances have a server with the uuid `localhost`. +- A and B have the same `app.id`, so the DNS owner comment does not change in a transfer here. + `extras-e2e.sh` expects that. With different ids, the target must change the comment at the DNS provider. +- `reset.sh` tags the newest local helper image with the configured helper version when that version is not + published yet. +- Remove the kit: run `./scripts/dev destroy transfer-target` and + `docker rm -f $(docker ps -aq --filter name=coolify-transfer-test-forward)`. Then remove the iptables rule that + `setup.sh` added: `sudo iptables -D LIBVIRT_FWI -s -d 10.221..0/24 -o cdevbr -j ACCEPT` + (`setup.sh` prints the subnet; `sudo iptables -S LIBVIRT_FWI` lists the rules). diff --git a/scripts/transfer-test/e2e.sh b/scripts/transfer-test/e2e.sh new file mode 100755 index 0000000000..7f13cc253a --- /dev/null +++ b/scripts/transfer-test/e2e.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Transfer both VM servers A -> B through the API and check source, target, server, and a redeploy from B. +# Run reset.sh before and after. +source "$(dirname "$0")/lib.sh" +TA="$(source_token)" +TB="$(target_token)" + +for profile in "${TRANSFER_PROFILES[@]}"; do + S="$(vm_field "$profile" uuid)" + ip="$(vm_field "$profile" ip)" + echo "=== $S" + before="$(stable_workloads "$profile")" + apps="$(tinker "$A" "echo App\\Models\\Server::where('uuid','$S')->first()->applications()->pluck('uuid')->implode(' ');" | tail -1)" + + echo '-- start the transfer on A' + r="$(api POST "$A_PORT" "/servers/$S/migrate" "$TA" "$(jq -nc --arg u "http://localhost:$B_PORT" --arg t "$TB" '{target_url:$u,target_token:$t}')")" + if [[ "$(jq -r .migration.status <<<"$r" 2>/dev/null)" != running ]]; then + bad "transfer request accepted: $r" + continue + fi + ok 'transfer request accepted' + for _ in $(seq 1 40); do sleep 3; st="$(api GET "$A_PORT" "/servers/$S/transfer" "$TA" | jq -r .migration.status)"; [[ "$st" != running ]] && break; done + check "transfer finished ($st)" test "$st" = succeeded + + echo '-- source A' + a="$(api GET "$A_PORT" "/servers/$S/transfer" "$TA")" + export_id="$(jq -r .transfer.export_id <<<"$a")" + check 'A transfer.status = transferred' test "$(jq -r .transfer.status <<<"$a")" = transferred + check 'A force_disabled + Sentinel off' test "$(tinker "$A" "\$s=App\\Models\\Server::where('uuid','$S')->first()->settings; echo (int)\$s->force_disabled.(int)\$s->is_sentinel_enabled;" | tail -1)" = 10 + check 'A shows the take-back modal' test "$(tinker "$A" "$login_php echo (int) str_contains(Livewire\\Livewire::test(App\\Livewire\\Server\\Show::class, ['server_uuid'=>'$S'])->html(), 'Take back a transferred server?');" | tail -1)" = 1 + r="$(api POST "$A_PORT" "/servers/$S/migrate" "$TA" "$(jq -nc --arg u "http://localhost:$B_PORT" --arg t "$TB" '{target_url:$u,target_token:$t}')")" + check 'second transfer refused by the prechecks' grep -q 'already transferred' <<<"$r" + + echo '-- target B' + b="$(api GET "$B_PORT" "/servers/$S/transfer" "$TB")" + check 'B transfer.status = claimed' test "$(jq -r .transfer.status <<<"$b")" = claimed + check 'same export id on A and B' test "$(jq -r .transfer.export_id <<<"$b")" = "$export_id" + for _ in $(seq 1 20); do + sleep 3 + meta="$(tinker "$B" "\$s=App\\Models\\Server::where('uuid','$S')->first(); echo json_encode(['os'=>data_get(\$s->server_metadata,'os'),'reach'=>\$s->settings->is_reachable,'usable'=>\$s->settings->is_usable,'logs'=>\$s->validation_logs,'transfer'=>data_get(\$s->server_metadata,'transfer.status')]);" | tail -1)" + [[ "$(jq -r .os <<<"$meta")" != null ]] && break + done + check "automatic validation filled the metadata ($(jq -r .os <<<"$meta"))" test "$(jq -r .os <<<"$meta")" != null + check 'B reachable + usable, no validation error' test "$(jq -c '[.reach,.usable,.logs]' <<<"$meta")" = '[true,true,null]' + check 'transfer state kept after validation' test "$(jq -r .transfer <<<"$meta")" = claimed + check 'B started the finish list' test "$(tinker "$B" "echo count((array) data_get(App\\Models\\Server::where('uuid','$S')->first()->server_metadata,'transfer.finish.items')) > 0 ? 1 : 0;" | tail -1)" = 1 + check 'A links to the server on B' test "$(jq -r .migration.target_server_url <<<"$a")" = "$B_URL/server/$S" + check 'B has all applications' test "$(tinker "$B" "echo App\\Models\\Server::where('uuid','$S')->first()->applications()->pluck('uuid')->implode(' ');" | tail -1)" = "$apps" + check 'B validates the server over SSH' test "$(tinker "$B" "\$r=App\\Models\\Server::where('uuid','$S')->first()->validateConnection(); echo (int)(bool)\$r['uptime'];" | tail -1)" = 1 + + echo '-- server' + sleep 5 + after="$(workloads "$profile")" + check 'proxy + workloads not recreated' test "$before" = "$after" + sentinel_env="$(ssh_vm "$profile" "docker inspect coolify-sentinel --format '{{range .Config.Env}}{{println .}}{{end}}'")" + check 'Sentinel sends to B' grep -qx "PUSH_ENDPOINT=$B_URL" <<<"$sentinel_env" + for app in $apps; do check "app $app answers over HTTP" bash -c "curl -sf -m5 -H 'Host: $app.$ip.sslip.io' http://$ip/ >/dev/null"; done + + echo '-- redeploy one app from B' + app="${apps%% *}" + api POST "$B_PORT" "/deploy?uuid=$app&force=false" "$TB" >/dev/null + for _ in $(seq 1 40); do sleep 5; ds="$(tinker "$B" "echo App\\Models\\ApplicationDeploymentQueue::where('application_id', App\\Models\\Application::where('uuid','$app')->value('id'))->latest('id')->value('status');" | tail -1)"; [[ "$ds" == finished || "$ds" == failed ]] && break; done + check "redeploy from B ($ds)" test "$ds" = finished + check 'app answers after the redeploy' bash -c "for i in {1..10}; do curl -sf -m5 -H 'Host: $app.$ip.sslip.io' http://$ip/ >/dev/null && exit 0; sleep 3; done; exit 1" +done + +echo "=== $FAILS failure(s)" +exit "$FAILS" diff --git a/scripts/transfer-test/extras-e2e.sh b/scripts/transfer-test/extras-e2e.sh new file mode 100755 index 0000000000..652ff577aa --- /dev/null +++ b/scripts/transfer-test/extras-e2e.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Add fake records around debian-root on A (tokens, DNS, secret manager link, tunnel token, GitHub App + runner config, +# resource links, canvas positions, team variable, maintenance mode, proxy error page, project icon, and an app of the +# same project on the other VM), transfer debian-root to B through the Transfer page, and check them on B and A. +# Run reset.sh before and after. +source "$(dirname "$0")/lib.sh" +S="$(vm_field debian-root uuid)" + +echo '=== seed fake records on A' +php_file "$A" "$TT_DIR/seed-extras.php" +echo "=== transfer $S A -> B" +echo "status: $(transfer_from_page "$S")" + +# The target compares the DNS owner comment, which contains the instance id, with the one of the source. +tinker "$A" 'echo config("app.id");' | tail -1 | docker exec -i "$B" sh -c 'cat > /tmp/source-app-id' + +echo '=== target B' +b="$(php_file "$B" "$TT_DIR/verify-extras-target.php")" +echo "$b" +echo '=== source A' +a="$(php_file "$A" "$TT_DIR/verify-extras-source.php")" +echo "$a" +FAILS="$(printf '%s\n%s\n' "$a" "$b" | grep -c '^FAIL')" +echo "=== $FAILS failure(s)" +exit "$FAILS" diff --git a/scripts/transfer-test/lib.sh b/scripts/transfer-test/lib.sh new file mode 100644 index 0000000000..bb0b9d975f --- /dev/null +++ b/scripts/transfer-test/lib.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Shared values and helpers of the server transfer test kit. The other scripts source this file. +# +# Source (A): the dev instance of this checkout, as `./scripts/dev` names it. Override: TRANSFER_TEST_SOURCE. +# Target (B): a side-by-side instance of the same checkout. Default "transfer-target". Override: TRANSFER_TEST_TARGET. +# Tokens and the VM SSH key are stored in
/.dev-instances/transfer-test// (not in git). + +set -uo pipefail + +TT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +ROOT="$(cd "$TT_DIR/../.." && pwd -P)" +cd "$ROOT" +ENV_DIR="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/.dev-instances" + +SOURCE="${TRANSFER_TEST_SOURCE:-$(./scripts/dev container | sed 's/^coolify-dev-//')}" +TARGET="${TRANSFER_TEST_TARGET:-transfer-target}" +A="coolify-dev-$SOURCE" +B="coolify-dev-$TARGET" +STATE_DIR="$ENV_DIR/transfer-test/$SOURCE" +VM_KEY="$STATE_DIR/vm_key" + +# The VM servers that the tests transfer, and the profile that is localhost of the source. +TRANSFER_PROFILES=(ubuntu-non-root debian-root) +LOCALHOST_PROFILE=ubuntu-root + +PRIVILEGED=() +if [[ $EUID -ne 0 ]]; then + PRIVILEGED=(sudo -n -E) +fi + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +env_value() { + local file="$ENV_DIR/$1.env" + [[ -f "$file" ]] || die "No dev instance '$1' ($file). See ./scripts/dev urls" + grep -E "^$2=" "$file" | cut -d= -f2- +} + +A_PORT="$(env_value "$SOURCE" APP_PORT)" +A_SLOT="$(env_value "$SOURCE" DEVELOPMENT_QEMU_SLOT)" +A_URL="$(env_value "$SOURCE" APP_URL)" + +target_values() { + B_PORT="$(env_value "$TARGET" APP_PORT)" + B_URL="$(env_value "$TARGET" APP_URL)" +} +if [[ -f "$ENV_DIR/$TARGET.env" ]]; then + target_values +fi + +# vm_field : a value of config/development-qemu.php for the source instance (ip, user, uuid). +vm_field() { + DEVELOPMENT_QEMU_INSTANCE="$SOURCE" DEVELOPMENT_QEMU_SLOT="$A_SLOT" php -r ' + function env($key, $default = null) { $value = getenv($key); return $value === false ? $default : $value; } + $config = require "config/development-qemu.php"; + echo $config["profiles"][$argv[1]][$argv[2]]; + ' "$1" "$2" +} + +running() { [[ "$(docker inspect --format '{{.State.Running}}' "$1" 2>/dev/null)" == true ]]; } + +# tinker : run PHP in a Coolify container. +tinker() { docker exec -u www-data "$1" php artisan tinker --execute "$2" 2>/dev/null; } + +# php_file : run a PHP file of this kit in a Coolify container. +php_file() { + docker cp "$2" "$1:/tmp/$(basename "$2")" >/dev/null && + docker exec -u www-data "$1" php artisan tinker --execute "require '/tmp/$(basename "$2")';" 2>&1 | grep -v '^$' +} + +# api [json] +api() { + curl -sS -m 30 -X "$1" "http://127.0.0.1:$2/api/v1$3" -H "Authorization: Bearer $4" \ + -H 'Content-Type: application/json' -H 'Accept: application/json' ${5:+-d "$5"} +} + +source_token() { cat "$STATE_DIR/source.token"; } +target_token() { cat "$STATE_DIR/target.token"; } + +# The VMs accept the dev key that the seeders create as private key 1. +fetch_vm_key() { + mkdir -p "$STATE_DIR" + tinker "$A" 'echo optional(App\Models\PrivateKey::find(1))->private_key;' >"$VM_KEY.tmp" + if grep -q 'PRIVATE KEY' "$VM_KEY.tmp"; then + mv "$VM_KEY.tmp" "$VM_KEY" + chmod 600 "$VM_KEY" + return 0 + fi + rm -f "$VM_KEY.tmp" + return 1 +} + +# ssh_vm : run a shell command as root on a VM of the source. +ssh_vm() { + local user ip sudo='' + user="$(vm_field "$1" user)" + ip="$(vm_field "$1" ip)" + [[ "$user" != root ]] && sudo='sudo ' + ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 \ + "$user@$ip" "${sudo}sh -c $(printf '%q' "$2")" +} + +# workloads : the containers on a VM, without Sentinel, which a claim restarts on purpose. +workloads() { ssh_vm "$1" 'docker ps --format "{{.Names}} {{.ID}}" | grep -v coolify-sentinel | sort'; } + +# stable_workloads : the workloads once they stop changing. reset.sh returns while databases still start. +stable_workloads() { + local previous='' current + for _ in $(seq 1 30); do + current="$(workloads "$1")" + [[ -n "$current" && "$current" == "$previous" ]] && break + previous="$current" + sleep 10 + done + echo "$current" +} + +# require_dev_instance : refuse to wipe anything that is not a running local dev instance. +require_dev_instance() { + running "$1" || die "Container $1 is not running." + [[ "$1" == coolify-dev-* ]] || die "$1 is not a scripts/dev instance." + [[ "$(tinker "$1" 'echo app()->environment();' | tail -1)" == local ]] || die "$1 does not run with APP_ENV=local." +} + +# Pass/fail counter of the e2e scripts. +FAILS=0 +ok() { echo " PASS $*"; } +bad() { + echo " FAIL $*" + FAILS=$((FAILS + 1)) +} +check() { + local name="$1" + shift + if "$@" >/dev/null 2>&1; then ok "$name"; else bad "$name"; fi +} + +login_php="auth()->login(App\\Models\\User::find(0)); session(['currentTeam' => App\\Models\\Team::find(0)]);" + +# transfer_from_page : start a transfer A -> B through the Transfer page, wait, print the final status. +transfer_from_page() { + local status + tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Transfer::class, ['server_uuid' => '$1'])->set('targetUrl', 'http://localhost:$B_PORT')->set('targetToken', '$(target_token)')->call('migrateServer');" >/dev/null + for _ in $(seq 1 60); do + sleep 3 + status="$(tinker "$A" "echo data_get(App\\Models\\Server::where('uuid', '$1')->first()?->server_metadata, 'migration.status');" | tail -1)" + [[ "$status" != running ]] && break + done + echo "$status" +} diff --git a/scripts/transfer-test/manual-setup.sh b/scripts/transfer-test/manual-setup.sh new file mode 100755 index 0000000000..bb90def1b1 --- /dev/null +++ b/scripts/transfer-test/manual-setup.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Prepare A and B for a manual transfer test: reset, then add the fake extra records of extras-e2e.sh to A +# (tokens, DNS, secret manager link, tunnel token, GitHub App + runner config, resource links, canvas positions, +# team variable, maintenance mode, proxy error page, project icon, a git app with a system-wide GitHub App, and an +# app of the same project on the other VM). Prints what to open and what to enter. +# +# Usage: scripts/transfer-test/manual-setup.sh +source "$(dirname "$0")/lib.sh" + +"$TT_DIR/reset.sh" || die 'reset.sh failed' +echo '== add the fake extra records to A' +php_file "$A" "$TT_DIR/seed-extras.php" + +cat < Transfer + Target instance URL: http://localhost:$B_PORT + Target API token: $(target_token) + Transfer back on A (archive -> Transfer): + Instance that manages the server now: http://localhost:$B_PORT (same token) + URL of this instance: http://localhost:$A_PORT + Token of A (for a transfer from B to A): $(source_token) + +Reset again: scripts/transfer-test/manual-setup.sh +EOF diff --git a/scripts/transfer-test/reset.sh b/scripts/transfer-test/reset.sh new file mode 100755 index 0000000000..0abbea8c42 --- /dev/null +++ b/scripts/transfer-test/reset.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Put the kit back to its start: A manages its localhost VM and the transfer VMs with deployed resources, B is empty. +# WIPES BOTH DEV DATABASES (migrate:fresh --seed) and the test workloads on the VMs. VM disks are kept. About 4 minutes. +# +# Usage: scripts/transfer-test/reset.sh +source "$(dirname "$0")/lib.sh" + +require_dev_instance "$A" +require_dev_instance "$B" +mkdir -p "$STATE_DIR" + +echo '== 1. remove test workloads from the VMs (the proxy stays)' +if fetch_vm_key; then + for profile in "${TRANSFER_PROFILES[@]}"; do + ssh_vm "$profile" ' + for c in $(docker ps -aq); do n=$(docker inspect -f "{{.Name}}" $c); case "$n" in /coolify-proxy*) ;; *) docker rm -f $c >/dev/null;; esac; done + docker volume ls -q | grep -v "^coolify" | xargs -r docker volume rm >/dev/null 2>&1 || true + rm -rf /data/coolify/applications/* /data/coolify/databases/* /data/coolify/services/* /data/coolify/sentinel/* /data/coolify/transfer + echo " $(hostname): $(docker ps --format "{{.Names}}" | tr "\n" " ")"' || echo " $profile: not reachable, skipped" + done +else + echo ' no VM key yet (first run), skipped' +fi + +echo '== 2. fresh databases, Redis, SSH folders' +for C in "$A" "$B"; do + docker exec "$C-redis-1" redis-cli FLUSHALL >/dev/null + docker exec "$C" sh -c 'rm -rf /var/www/html/storage/app/ssh/keys/* /var/www/html/storage/app/ssh/mux/*; mkdir -p /var/www/html/storage/app/ssh/keys /var/www/html/storage/app/ssh/mux; chown -R www-data:www-data /var/www/html/storage/app/ssh' + docker exec -u www-data "$C" php artisan migrate:fresh --seed --force >/dev/null || die "migrate:fresh failed in $C" + echo " $C: database seeded" +done + +echo '== 3. seed the VM servers of A' +host="${DEV_BIND_ADDRESS:-$(grep -E '^DEV_BIND_ADDRESS=' .env 2>/dev/null | tail -1 | cut -d= -f2-)}" +[[ -z "$host" || "$host" == 0.0.0.0 ]] && host=127.0.0.1 +APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" LOG_CHANNEL=stderr \ + APP_KEY="$(env_value "$SOURCE" APP_KEY)" DB_HOST="$host" DB_PORT="$(env_value "$SOURCE" FORWARD_DB_PORT)" \ + REDIS_HOST="$host" REDIS_PORT="$(env_value "$SOURCE" FORWARD_REDIS_PORT)" \ + DEVELOPMENT_QEMU_INSTANCE="$SOURCE" DEVELOPMENT_QEMU_SLOT="$A_SLOT" \ + DEVELOPMENT_QEMU_DOCKER_NETWORK="$A" DEVELOPMENT_QEMU_COOLIFY_CONTAINER="$A" \ + "${PRIVILEGED[@]}" sh -c "php artisan dev:qemu $LOCALHOST_PROFILE --as-localhost && php artisan dev:qemu ${TRANSFER_PROFILES[*]}" 2>&1 | grep -E 'Started|rror' +docker exec "$A" chown -R www-data:www-data /var/www/html/storage/app/ssh +fetch_vm_key || die 'The VM key (private key 1) is missing on A after seeding.' + +echo '== 4. helper image on the VMs' +helper="$(tinker "$A" 'echo config("constants.coolify.helper_image").":".config("constants.coolify.helper_version");' | tail -1)" +for profile in "${TRANSFER_PROFILES[@]}"; do + # An unreleased helper version cannot be pulled; tag the newest local helper image with that version instead. + ssh_vm "$profile" "docker image inspect $helper >/dev/null 2>&1 || docker pull -q $helper >/dev/null 2>&1 || { + latest=\$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '(^|/)coollabsio/coolify-helper:' | sort -V | tail -1) + [ -n \"\$latest\" ] && docker tag \"\$latest\" $helper && echo \" \$(hostname): tagged \$latest as $helper (not published yet)\" + }" +done + +echo '== 5. proxies, API tokens, new code in Horizon' +tinker "$A" 'foreach (App\Models\Server::where("id", "!=", 0)->get() as $s) { $s->proxy->set("type", "TRAEFIK"); $s->proxy->set("status", "exited"); $s->save(); App\Actions\Proxy\StartProxy::run($s->fresh(), async: false, force: true); echo " proxy ok: ".$s->name.PHP_EOL; }' +for side in source target; do + C="$A" + [[ "$side" == target ]] && C="$B" + tinker "$C" '$s = App\Models\InstanceSettings::get(); $s->is_api_enabled = true; $s->save(); session(["currentTeam" => App\Models\Team::find(0)]); echo App\Models\User::find(0)->createToken("transfer-test", ["root"])->plainTextToken;' | tail -1 >"$STATE_DIR/$side.token" + chmod 600 "$STATE_DIR/$side.token" + docker exec -u www-data "$C" php artisan horizon:terminate >/dev/null +done + +echo '== 6. create and deploy resources on A' +sleep 10 +"$TT_DIR/seed.sh" >/dev/null || die 'seed.sh failed' +echo ' waiting for deployments...' +for _ in $(seq 1 30); do + sleep 10 + [[ "$(tinker "$A" 'echo App\Models\ApplicationDeploymentQueue::whereIn("status", ["queued", "in_progress"])->count();' | tail -1)" == 0 ]] && break +done +tinker "$A" 'foreach (App\Models\ApplicationDeploymentQueue::get() as $d) echo " deploy ".$d->application_name.": ".$d->status.PHP_EOL;' +for profile in "${TRANSFER_PROFILES[@]}"; do + ssh_vm "$profile" 'echo " $(hostname): $(docker ps --format "{{.Names}}" | grep -v "^coolify-" | wc -l) workload containers"' +done +echo "Done. A: $A_URL B: $B_URL (login test@example.com / password)" diff --git a/scripts/transfer-test/reverse-e2e.sh b/scripts/transfer-test/reverse-e2e.sh new file mode 100755 index 0000000000..52aea2b887 --- /dev/null +++ b/scripts/transfer-test/reverse-e2e.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Undo paths: the owner marker, transfer back from the archive, a transfer over an archive, take back with automatic +# release, and deleting an archive. +# Uses the debian-root VM. Run reset.sh before and after. +source "$(dirname "$0")/lib.sh" +TA="$(source_token)" +TB="$(target_token)" +PROFILE=debian-root +S="$(vm_field "$PROFILE" uuid)" + +marker() { ssh_vm "$PROFILE" 'cat /data/coolify/transfer/owner.json 2>/dev/null' | jq -r .claim_id; } +claim_id() { tinker "$1" "echo data_get(App\\Models\\Server::where('uuid','$S')->first()?->server_metadata,'transfer.claim.claim_id');" | tail -1; } +status() { tinker "$1" "echo data_get(App\\Models\\Server::withTrashed()->where('uuid','$S')->first()?->server_metadata,'transfer.status','none');" | tail -1; } +# migrate : transfer through the API, wait, print the transfer status JSON. +migrate() { + local r + api POST "$1" "/servers/$S/migrate" "$2" "$(jq -nc --arg u "http://localhost:$3" --arg t "$4" '{target_url:$u,target_token:$t}')" >/dev/null + for _ in $(seq 1 40); do sleep 3; r="$(api GET "$1" "/servers/$S/transfer" "$2")"; [[ "$(jq -r .migration.status <<<"$r")" != running ]] && break; done + echo "$r" +} + +before="$(stable_workloads "$PROFILE")" + +echo '=== 1. A -> B writes the owner marker of B' +r="$(migrate "$A_PORT" "$TA" "$B_PORT" "$TB")" +check "transfer A -> B ($(jq -r .migration.status <<<"$r"))" test "$(jq -r .migration.status <<<"$r")" = succeeded +cb="$(claim_id "$B")" +check "marker = claim of B ($cb)" test "$(marker)" = "$cb" + +echo '=== 2. Transfer back from the Transfer page of the archive on A' +tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Transfer::class, ['server_uuid'=>'$S'])->set('managerUrl','http://localhost:$B_PORT')->set('managerToken','$TB')->set('thisInstanceUrl','http://localhost:$A_PORT')->call('transferBack');" >/dev/null +for _ in $(seq 1 40); do sleep 3; [[ "$(status "$A")" == claimed ]] && break; done +ca="$(claim_id "$A")" +check 'A claimed (archive replaced), B transferred' test "$(status "$A")/$(status "$B")" = claimed/transferred +check 'A has one server with this uuid' test "$(tinker "$A" "echo App\\Models\\Server::withTrashed()->where('uuid','$S')->count();" | tail -1)" = 1 +check "marker = claim of A ($ca)" test "$(marker)" = "$ca" +check 'workloads unchanged after the transfer back' test "$(workloads "$PROFILE")" = "$before" + +echo '=== 3. A -> B again replaces the archive on B' +r="$(migrate "$A_PORT" "$TA" "$B_PORT" "$TB")" +check "transfer A -> B again ($(jq -r .migration.status <<<"$r"))" test "$(jq -r .migration.status <<<"$r")" = succeeded +check 'B claimed, A transferred' test "$(status "$B")/$(status "$A")" = claimed/transferred +check 'workloads unchanged after the second transfer' test "$(workloads "$PROFILE")" = "$before" + +echo '=== 4. Take back on A, then B releases the server on its next connection check' +tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Show::class, ['server_uuid'=>'$S'])->call('enableManagement');" >/dev/null +ca="$(claim_id "$A")" +check 'A claimed again' test "$(status "$A")" = claimed +check "marker = new claim of A ($ca)" test "$(marker)" = "$ca" +check 'A started the finish list' test "$(tinker "$A" "echo count((array) data_get(App\\Models\\Server::where('uuid','$S')->first()->server_metadata,'transfer.finish.items')) > 0 ? 1 : 0;" | tail -1)" = 1 +check 'B still claimed before its check' test "$(status "$B")" = claimed +tinker "$B" "(new App\\Jobs\\ServerConnectionCheckJob(App\\Models\\Server::where('uuid','$S')->first()))->handle();" >/dev/null +check 'B released the server after its check' test "$(status "$B")" = transferred +check 'B is force disabled' test "$(tinker "$B" "echo (int) App\\Models\\Server::where('uuid','$S')->first()->settings->force_disabled;" | tail -1)" = 1 +tinker "$A" "(new App\\Jobs\\ServerConnectionCheckJob(App\\Models\\Server::where('uuid','$S')->first()))->handle();" >/dev/null +check 'A keeps the server after its own check' test "$(status "$A")" = claimed + +echo '=== 5. Delete the archive on B (records only)' +tinker "$B" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Delete::class, ['server_uuid'=>'$S'])->call('delete','password');" >/dev/null +for _ in $(seq 1 20); do sleep 3; [[ "$(status "$B")" == none ]] && break; done +check 'archive is gone on B' test "$(status "$B")" = none +check 'workloads unchanged at the end' test "$(workloads "$PROFILE")" = "$before" + +echo "=== $FAILS failure(s)" +exit "$FAILS" diff --git a/scripts/transfer-test/seed-extras.php b/scripts/transfer-test/seed-extras.php new file mode 100644 index 0000000000..11b6a85fde --- /dev/null +++ b/scripts/transfer-test/seed-extras.php @@ -0,0 +1,92 @@ +forceFill(['uuid' => new_public_id(), ...$attributes])->saveQuietly(); + + return $model; +}; + +$server = Server::where('uuid', 'development-qemu-debian-root')->firstOrFail(); +$teamId = $server->team_id; +$app = Application::where('name', 'whoami-2')->firstOrFail(); +$db = StandaloneMariadb::where('name', 'mariadb')->firstOrFail(); +$environment = $app->environment; +$project = $environment->project; + +// Team credentials +$cloudflare = $quiet(IntegrationToken::class, ['team_id' => $teamId, 'provider' => 'cloudflare', 'name' => 'fake-cloudflare', 'token' => 'fake-cf-token', 'capabilities' => ['dns', 'tunnel']]); +$infisical = $quiet(IntegrationToken::class, ['team_id' => $teamId, 'provider' => 'infisical', 'name' => 'fake-infisical', 'token' => 'fake-inf-token', 'capabilities' => ['secrets'], 'metadata' => ['client_id' => 'fake-client']]); + +// DNS zone + owned record used only by whoami-2 +$zone = $quiet(DnsProviderZone::class, ['integration_token_id' => $cloudflare->id, 'provider_zone_id' => 'fake-zone-id', 'name' => 'example.test', 'account_id' => 'fake-account', 'account_name' => 'Fake']); +$record = $quiet(ManagedDnsRecord::class, ['team_id' => $teamId, 'integration_token_id' => $cloudflare->id, 'dns_provider_zone_id' => $zone->id, 'provider_record_id' => 'fake-record-id', 'type' => 'A', 'name' => 'whoami2.example.test', 'content' => $server->ip, 'owned' => true]); +$record->addReference($app); + +// Secret manager link +$quiet(SecretManagerLink::class, ['resourceable_type' => $app->getMorphClass(), 'resourceable_id' => $app->id, 'integration_token_id' => $infisical->id, 'settings' => ['project_id' => 'fake-project', 'environment' => 'prod', 'secret_path' => '/']]); + +// Server: Cloudflare tunnel token + custom proxy error page +$server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $cloudflare->id, 'cloudflare_tunnel_id' => 'fake-tunnel', 'cloudflare_tunnel_account_id' => 'fake-account'])->saveQuietly(); +$server->forceFill(['proxy_error_page' => '

fake error page

'])->saveQuietly(); + +// GitHub App + runner config (enabled) + one idle runner +$githubApp = $quiet(GithubApp::class, ['name' => 'fake-gh-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'organization' => 'fake-org', 'app_id' => 123, 'installation_id' => 456, 'client_id' => 'fake-client', 'client_secret' => 'fake-secret', 'webhook_secret' => 'fake-webhook', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $teamId, 'private_key_id' => $server->private_key_id]); +$runnerConfig = $quiet(GithubRunnerConfig::class, ['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['fake-label'], 'is_enabled' => true, 'max_runners' => 2]); +$quiet(GithubRunnerExecution::class, ['github_app_id' => $githubApp->id, 'github_runner_config_id' => $runnerConfig->id, 'server_id' => $server->id, 'status' => 'idle', 'trigger_workflow_job_id' => 1, 'runner_name' => 'coolify-runner-fake', 'runner_id' => 99, 'labels' => ['fake-label']]); + +// A resource of the same project on the OTHER server: same environment (link that stays) and a staging environment +$ubuntu = Server::where('uuid', 'development-qemu-ubuntu-non-root')->firstOrFail(); +$ubuntuDestination = StandaloneDocker::where('server_id', $ubuntu->id)->firstOrFail(); +$onUbuntu = fn (Environment $env, string $name) => Application::create([ + 'name' => $name, 'environment_id' => $env->id, 'destination_id' => $ubuntuDestination->id, 'destination_type' => $ubuntuDestination->getMorphClass(), + 'build_pack' => 'dockerimage', 'docker_registry_image_name' => 'traefik/whoami', 'docker_registry_image_tag' => 'latest', 'ports_exposes' => '80', + 'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main', +]); +$otherServerApp = $onUbuntu($environment, 'fake-on-ubuntu'); +$staging = Environment::firstOrCreate(['project_id' => $project->id, 'name' => 'fake-staging']); +$onUbuntu($staging, 'fake-staging-on-ubuntu'); + +// Resource links: one between two resources of this server, one to the other server +$quiet(ResourceLink::class, ['environment_id' => $environment->id, 'source_type' => $app->getMorphClass(), 'source_id' => $app->id, 'target_type' => $db->getMorphClass(), 'target_id' => $db->id, 'label' => 'uses db']); +$quiet(ResourceLink::class, ['environment_id' => $environment->id, 'source_type' => $app->getMorphClass(), 'source_id' => $app->id, 'target_type' => $otherServerApp->getMorphClass(), 'target_id' => $otherServerApp->id, 'label' => 'calls other']); + +// Canvas positions, team shared variable used by an app variable, maintenance mode +$environment->forceFill(['canvas_positions' => [$app->uuid => ['x' => 111, 'y' => 222], $db->uuid => ['x' => 333, 'y' => 444]]])->saveQuietly(); +SharedEnvironmentVariable::create(['type' => 'team', 'team_id' => $teamId, 'key' => 'FAKE_TEAM_VAR', 'value' => 'team-value']); +EnvironmentVariable::create(['key' => 'USES_TEAM', 'value' => '{{team.FAKE_TEAM_VAR}}', 'resourceable_type' => $app->getMorphClass(), 'resourceable_id' => $app->id]); +$app->forceFill(['is_maintenance_enabled' => true, 'maintenance_page' => '

fake maintenance

'])->saveQuietly(); + +// A git application that uses a system-wide GitHub App, which the transfer does not move +$systemApp = $quiet(GithubApp::class, ['name' => 'fake-system-wide-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true, 'is_public' => false]); +$gitApp = new Application; +$gitApp->forceFill([ + 'uuid' => new_public_id(), 'name' => 'git-shop', 'build_pack' => 'nixpacks', 'git_repository' => 'acme/shop', 'git_branch' => 'main', 'ports_exposes' => '3000', + 'environment_id' => $environment->id, 'destination_id' => $app->destination_id, 'destination_type' => $app->destination_type, + 'source_type' => GithubApp::class, 'source_id' => $systemApp->id, +])->saveQuietly(); + +// Project icon: a 1x1 JPEG +app(ProjectIconStorageService::class)->storeProjectContents($project, base64_decode('/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA=')); + +echo "seeded\n"; diff --git a/scripts/seed-transfer-demo.php b/scripts/transfer-test/seed-inventory.php similarity index 98% rename from scripts/seed-transfer-demo.php rename to scripts/transfer-test/seed-inventory.php index 03c6ae17d2..e8a29020b6 100644 --- a/scripts/seed-transfer-demo.php +++ b/scripts/transfer-test/seed-inventory.php @@ -1,9 +1,10 @@ /resources.json. +source "$(dirname "$0")/lib.sh" +set -e + +T="$(source_token)" +S1="$(vm_field ubuntu-non-root uuid)" +S2="$(vm_field debian-root uuid)" + +call() { # call METHOD PATH [JSON]: fails on an HTTP error + local out code + out="$(curl -sS -X "$1" "http://127.0.0.1:$A_PORT/api/v1$2" -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -H 'Accept: application/json' ${3:+-d "$3"} -w $'\n%{http_code}')" + code="${out##*$'\n'}" + out="${out%$'\n'*}" + if [[ "$code" -ge 300 ]]; then + echo "FAIL $1 $2 ($code): $out" >&2 + return 1 + fi + echo "$out" +} +uuid() { jq -r '.uuid'; } + +echo '== projects' +P1=$(call POST /projects '{"name":"Transfer Demo","description":"Resources on the ubuntu non-root VM"}' | uuid) +P2=$(call POST /projects '{"name":"Second Server","description":"Resources on the debian root VM"}' | uuid) +call POST "/projects/$P1/environments" '{"name":"staging"}' >/dev/null + +echo '== server 1 (ubuntu non-root): Docker image app with variables, volume, tag, scheduled task' +APP1=$(call POST /applications/dockerimage "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"whoami",docker_registry_image_name:"traefik/whoami",docker_registry_image_tag:"latest",ports_exposes:"80",instant_deploy:false}')" | uuid) +call POST "/applications/$APP1/envs" '{"key":"WHOAMI_NAME","value":"transfer-demo"}' >/dev/null +call POST "/applications/$APP1/envs" '{"key":"APP_SECRET","value":"very-secret-value"}' >/dev/null +call POST "/applications/$APP1/storages" '{"type":"persistent","name":"whoami-data","mount_path":"/data"}' >/dev/null || true +call POST "/applications/$APP1/tags" '{"tag_name":"transfer-demo"}' >/dev/null || true +call POST "/applications/$APP1/scheduled-tasks" '{"name":"hello","command":"echo hello","frequency":"*/30 * * * *"}' >/dev/null || true + +echo '== server 1: Dockerfile app (staging)' +DF=$(printf 'FROM nginx:alpine\nRUN echo "built by Coolify A" > /usr/share/nginx/html/index.html\nEXPOSE 80\n' | base64 -w0) +APP2=$(call POST /applications/dockerfile "$(jq -nc --arg p "$P1" --arg s "$S1" --arg d "$DF" '{project_uuid:$p,server_uuid:$s,environment_name:"staging",name:"nginx-dockerfile",dockerfile:$d,ports_exposes:"80",instant_deploy:false}')" | uuid) + +echo '== server 1: PostgreSQL with a backup, Redis' +PG=$(call POST /databases/postgresql "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"postgres",postgres_user:"demo",postgres_password:"pg-secret-password",postgres_db:"demo",instant_deploy:false}')" | uuid) +call POST "/databases/$PG/backups" '{"frequency":"0 3 * * *","enabled":true,"save_s3":false,"databases_to_backup":"demo"}' >/dev/null || true +RD=$(call POST /databases/redis "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"redis",instant_deploy:false}')" | uuid) + +echo '== server 1: one-click service (uptime-kuma)' +SV=$(call POST /services "$(jq -nc --arg p "$P1" --arg s "$S1" '{type:"uptime-kuma",project_uuid:$p,server_uuid:$s,environment_name:"production",name:"uptime-kuma",instant_deploy:false}')" | uuid) + +echo '== server 2 (debian root): whoami-2, MariaDB' +APP3=$(call POST /applications/dockerimage "$(jq -nc --arg p "$P2" --arg s "$S2" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"whoami-2",docker_registry_image_name:"traefik/whoami",docker_registry_image_tag:"latest",ports_exposes:"80",instant_deploy:false}')" | uuid) +MD=$(call POST /databases/mariadb "$(jq -nc --arg p "$P2" --arg s "$S2" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"mariadb",instant_deploy:false}')" | uuid) + +echo '== deploy and start everything' +for u in $APP1 $APP2 $APP3; do call POST "/deploy?uuid=$u" >/dev/null && echo "deploy queued $u"; done +for u in $PG $RD $MD; do call POST "/databases/$u/start" >/dev/null && echo "start queued $u"; done +call POST "/services/$SV/start" >/dev/null && echo "start queued $SV" + +jq -n --arg p1 "$P1" --arg p2 "$P2" --arg app1 "$APP1" --arg app2 "$APP2" --arg app3 "$APP3" --arg pg "$PG" --arg rd "$RD" --arg md "$MD" --arg sv "$SV" \ + '{projects:[$p1,$p2],apps:[$app1,$app2,$app3],databases:[$pg,$rd,$md],service:$sv}' | tee "$STATE_DIR/resources.json" diff --git a/scripts/transfer-test/setup.sh b/scripts/transfer-test/setup.sh new file mode 100755 index 0000000000..55c02fbb96 --- /dev/null +++ b/scripts/transfer-test/setup.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Prepare the transfer test kit: start the target instance (B) next to the source (A), and let them reach +# each other and A's VMs. Safe to run again, for example after a host reboot. Linux with KVM only. +# +# Usage: scripts/transfer-test/setup.sh (then scripts/transfer-test/reset.sh) +source "$(dirname "$0")/lib.sh" + +[[ -c /dev/kvm ]] || die 'The kit needs KVM VMs (Linux). See scripts/transfer-test/README.md.' +running "$A" || die "Start the source instance first: ./scripts/dev start" + +echo "== 1. target instance $TARGET (side by side, testing-host as its localhost)" +# On a new instance, its server seeding can run before the migrations. That does not matter: reset.sh seeds B again. +COOLIFY_DEV_INSTANCE="$TARGET" COOLIFY_DEV_SIDE_BY_SIDE=1 COOLIFY_DEV_SERVER_BACKEND=testing-host ./scripts/dev start || + echo ' (the seeding of the new instance failed; reset.sh seeds both databases again)' +running "$B" || die "The target instance $B does not run." +target_values + +echo "== 2. firewall: B may reach the VM network of A (10.221.$A_SLOT.0/24)" +subnet="$(docker network inspect "$B" --format '{{(index .IPAM.Config 0).Subnet}}')" +rule=(LIBVIRT_FWI -s "$subnet" -d "10.221.$A_SLOT.0/24" -o "cdevbr$A_SLOT" -j ACCEPT) +if "${PRIVILEGED[@]}" iptables -C "${rule[@]}" 2>/dev/null; then + echo " rule exists" +else + "${PRIVILEGED[@]}" iptables -I "${rule[0]}" 1 "${rule[@]:1}" && echo " rule added for $subnet" +fi + +echo "== 3. A and B reach each other's app port" +bind="$(grep -E '^DEV_BIND_ADDRESS=' .env 2>/dev/null | tail -1 | cut -d= -f2-)" +if [[ "${bind:-0.0.0.0}" == 127.0.0.1 ]]; then + # The ports listen on 127.0.0.1 only; containers reach the host through the docker0 gateway. + gateway="$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')" + for port in "$A_PORT" "$B_PORT"; do + name="coolify-transfer-test-forward-$port" + if running "$name"; then + echo " $name runs" + else + docker rm -f "$name" >/dev/null 2>&1 || true + docker run -d --name "$name" --network host --restart unless-stopped alpine/socat \ + "tcp-listen:$port,bind=$gateway,fork,reuseaddr" "tcp:127.0.0.1:$port" >/dev/null && echo " $name started ($gateway:$port -> 127.0.0.1:$port)" + fi + done +else + echo " not needed: the ports listen on ${bind:-0.0.0.0}" +fi + +echo "Done. A: $A_URL B: $B_URL" +echo "Next: scripts/transfer-test/reset.sh" diff --git a/scripts/transfer-test/verify-extras-source.php b/scripts/transfer-test/verify-extras-source.php new file mode 100644 index 0000000000..2caeb569c1 --- /dev/null +++ b/scripts/transfer-test/verify-extras-source.php @@ -0,0 +1,28 @@ +first(); +$migration = (array) data_get($server?->server_metadata, 'migration'); +$messages = collect($migration['log'] ?? [])->pluck('message'); +$check('source is transferred away', $server?->isTransferredAway() === true); +$check('transfer succeeded', ($migration['status'] ?? null) === 'succeeded', (string) ($migration['error'] ?? '')); +$check('precheck NOTE about resources on the other server', $messages->contains(fn ($m) => str_starts_with($m, "NOTE: project 'Second Server' also has 2 resources on other servers"))); +$check('runner config disabled on the source', GithubRunnerConfig::where('server_id', $server?->id)->value('is_enabled') === false); +$check('runner execution cancelled on the source', GithubRunnerExecution::where('runner_name', 'coolify-runner-fake')->value('status')?->value === 'cancelled'); +$record = ManagedDnsRecord::where('name', 'whoami2.example.test')->first(); +$check('source keeps the DNS record for a take back, not owned', $record !== null && $record->owned === false); +$check('precheck NOTE about the system-wide GitHub App', $messages->contains(fn ($m) => str_starts_with($m, "NOTE: the system-wide GitHub App 'fake-system-wide-app'"))); +$checklist = implode("\n", (array) ($migration['checklist'] ?? [])); +$check('checklist: GitHub App URLs', str_contains($checklist, 'GitHub App fake-gh-app')); +$check('checklist: enable GitHub runners', str_contains($checklist, 'enable GitHub runners')); +echo "--- warnings\n".implode("\n", (array) ($migration['warnings'] ?? []))."\n--- checklist\n{$checklist}\n"; diff --git a/scripts/transfer-test/verify-extras-target.php b/scripts/transfer-test/verify-extras-target.php new file mode 100644 index 0000000000..b34de610e4 --- /dev/null +++ b/scripts/transfer-test/verify-extras-target.php @@ -0,0 +1,69 @@ +first(); +$check('server is on B and claimed', data_get($server?->server_metadata, 'transfer.status') === 'claimed'); +$check('custom proxy error page', $server?->proxy_error_page === '

fake error page

'); + +$cloudflare = IntegrationToken::where('name', 'fake-cloudflare')->first(); +$infisical = IntegrationToken::where('name', 'fake-infisical')->first(); +$check('Cloudflare token moved with its secret', $cloudflare?->token === 'fake-cf-token'); +$check('Infisical token moved with its secret', $infisical?->token === 'fake-inf-token'); +$check('Cloudflare tunnel uses the moved token', $server?->settings?->cloudflare_tunnel_integration_token_id === $cloudflare?->id && $server?->settings?->cloudflare_tunnel_id === 'fake-tunnel'); + +$app = Application::where('name', 'whoami-2')->first(); +$link = SecretManagerLink::where('resourceable_type', $app?->getMorphClass())->where('resourceable_id', $app?->id)->first(); +$check('secret manager link moved to the moved token', $link?->integration_token_id === $infisical?->id && data_get($link?->settings, 'project_id') === 'fake-project'); + +$record = ManagedDnsRecord::where('name', 'whoami2.example.test')->first(); +$check('DNS record + zone moved', $record !== null && $record->zone?->provider_zone_id === 'fake-zone-id' && $record->integration_token_id === $cloudflare?->id); +$check('DNS record references the moved app', $record?->references()->where('resource_type', $app?->getMorphClass())->where('resource_id', $app?->id)->exists() === true); +// Same app.id on both instances: the owner comment does not change, so the record stays owned. +// Different app.id: the target must change the comment at Cloudflare, which fails with a fake token. +$sourceAppId = trim((string) @file_get_contents('/tmp/source-app-id')); +$sameInstanceId = $sourceAppId === (string) config('app.id'); +$check('DNS ownership '.($sameInstanceId ? 'kept (same instance ID, no adoption needed)' : 'dropped (fake token cannot adopt)'), $record?->owned === $sameInstanceId); + +$githubApp = GithubApp::where('name', 'fake-gh-app')->first(); +$runner = GithubRunnerConfig::where('server_id', $server?->id)->first(); +$check('GitHub App moved (only the runner config uses it)', $githubApp !== null && $githubApp->organization === 'fake-org'); +$check('runner config moved, DISABLED, same App and labels', $runner !== null && $runner->is_enabled === false && $runner->github_app_id === $githubApp?->id && in_array('fake-label', (array) $runner->labels, true)); + +$db = StandaloneMariadb::where('name', 'mariadb')->first(); +$check('resource link between the two moved resources', ResourceLink::where('source_id', $app?->id)->where('target_type', $db?->getMorphClass())->where('target_id', $db?->id)->where('label', 'uses db')->exists()); +$check('resource link to the other server is not moved', ! ResourceLink::where('label', 'calls other')->exists()); + +$environment = $app?->environment; +$check('canvas positions moved', data_get($environment?->canvas_positions, ($app?->uuid).'.x') === 111); +$check('environment without resources on this server is not moved', ! Environment::where('name', 'fake-staging')->exists()); +$check('apps on the other server are not moved', ! Application::whereIn('name', ['fake-on-ubuntu', 'fake-staging-on-ubuntu'])->exists()); + +$check('team shared variable created', SharedEnvironmentVariable::where('type', 'team')->where('key', 'FAKE_TEAM_VAR')->value('value') === 'team-value'); +$check('app variable that uses it moved', $app?->environment_variables()->where('key', 'USES_TEAM')->value('value') === '{{team.FAKE_TEAM_VAR}}'); +$check('maintenance mode moved', $app?->is_maintenance_enabled === true && $app?->maintenance_page === '

fake maintenance

'); + +$gitShop = Application::where('name', 'git-shop')->first(); +$check('git-shop moved without its system-wide source', $gitShop !== null && $gitShop->source_id === null); +$check('finish list has a git source step for git-shop', collect(data_get($server?->server_metadata, 'transfer.finish.items'))->contains('key', 'git_source:'.$gitShop?->uuid)); + +$project = $environment?->project; +$icon = $project ? app(ProjectIconStorageService::class)->projectContents($project) : null; +$check('project icon moved', $icon !== null && str_starts_with($icon, "\xFF\xD8\xFF")); diff --git a/templates/compose/appwrite.yaml b/templates/compose/appwrite.yaml index f20cc86649..7ba5c69bb0 100644 --- a/templates/compose/appwrite.yaml +++ b/templates/compose/appwrite.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://appwrite.io/docs # slogan: A backend-as-a-service platform that simplifies the web & mobile app development. # category: backend diff --git a/templates/compose/babybuddy.yaml b/templates/compose/babybuddy.yaml index af07b2282e..89313047c8 100644 --- a/templates/compose/babybuddy.yaml +++ b/templates/compose/babybuddy.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.baby-buddy.net # slogan: It helps parents track their baby's daily activities, growth, and health with ease. # category: productivity diff --git a/templates/compose/bookstack.yaml b/templates/compose/bookstack.yaml index c2ac53675f..4de93bc045 100644 --- a/templates/compose/bookstack.yaml +++ b/templates/compose/bookstack.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.bookstackapp.com/docs/ # slogan: BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information # category: cms diff --git a/templates/compose/budge.yaml b/templates/compose/budge.yaml index d4b7828540..b613dab375 100644 --- a/templates/compose/budge.yaml +++ b/templates/compose/budge.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/linuxserver/budge # slogan: A budgeting personal finance app. # category: finance diff --git a/templates/compose/castopod.yaml b/templates/compose/castopod.yaml index 8eaed59e5c..0a2516a5d3 100644 --- a/templates/compose/castopod.yaml +++ b/templates/compose/castopod.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation:https://docs.castopod.org/main/en/ # slogan: Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience. # category: media diff --git a/templates/compose/classicpress-with-mariadb.yaml b/templates/compose/classicpress-with-mariadb.yaml index 19ee86e424..46b142847c 100644 --- a/templates/compose/classicpress-with-mariadb.yaml +++ b/templates/compose/classicpress-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.classicpress.net/ # slogan: A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg). # category: cms diff --git a/templates/compose/classicpress-with-mysql.yaml b/templates/compose/classicpress-with-mysql.yaml index 2d90e046c9..e1561763fc 100644 --- a/templates/compose/classicpress-with-mysql.yaml +++ b/templates/compose/classicpress-with-mysql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.classicpress.net/ # slogan: A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg). # category: cms diff --git a/templates/compose/classicpress-without-database.yaml b/templates/compose/classicpress-without-database.yaml index c52fc38605..c1f5bbee0d 100644 --- a/templates/compose/classicpress-without-database.yaml +++ b/templates/compose/classicpress-without-database.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.classicpress.net/ # slogan: A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg). # category: cms diff --git a/templates/compose/cockpit.yaml b/templates/compose/cockpit.yaml index 11588bac81..43f97b89db 100644 --- a/templates/compose/cockpit.yaml +++ b/templates/compose/cockpit.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://getcockpit.com/documentation/ # slogan: Cockpit is a headless content platform that is lightweight, fast and ready for takeoff. # category: cms diff --git a/templates/compose/dokuwiki.yaml b/templates/compose/dokuwiki.yaml index 32ce13b293..5bc1b4771c 100644 --- a/templates/compose/dokuwiki.yaml +++ b/templates/compose/dokuwiki.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.dokuwiki.org/ # slogan: A lightweight and easy-to-use wiki platform for creating and managing documentation and knowledge bases. # category: cms diff --git a/templates/compose/dolibarr.yaml b/templates/compose/dolibarr.yaml index 4cdfc05fe5..9a453f54c7 100644 --- a/templates/compose/dolibarr.yaml +++ b/templates/compose/dolibarr.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.dolibarr.org/documentation-home.php # slogan: Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...). # category: productivity diff --git a/templates/compose/drupal-with-postgresql.yaml b/templates/compose/drupal-with-postgresql.yaml index 419f0e3002..d3ba2aee66 100644 --- a/templates/compose/drupal-with-postgresql.yaml +++ b/templates/compose/drupal-with-postgresql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.drupal.org/about # slogan: Drupal is a free and open-source web content management system written in PHP and distributed under the GNU General Public License. # category: cms diff --git a/templates/compose/easyappointments.yaml b/templates/compose/easyappointments.yaml index 55b9d0c85f..95138d8877 100644 --- a/templates/compose/easyappointments.yaml +++ b/templates/compose/easyappointments.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://easyappointments.org/ # slogan: Schedule Anything. Let's start with easy! Get the best free online appointment scheduler on your server, today. # category: productivity diff --git a/templates/compose/espocrm.yaml b/templates/compose/espocrm.yaml index 8fec5d2ed9..1e95c99cbd 100644 --- a/templates/compose/espocrm.yaml +++ b/templates/compose/espocrm.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.espocrm.com # slogan: EspoCRM is a free and open-source CRM platform. # category: productivity diff --git a/templates/compose/firefly.yaml b/templates/compose/firefly.yaml index 2fbbdd2989..964e724187 100644 --- a/templates/compose/firefly.yaml +++ b/templates/compose/firefly.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://firefly-iii.org # slogan: A personal finances manager that can help you save money. # category: finance diff --git a/templates/compose/freescout.yaml b/templates/compose/freescout.yaml index 247220f962..9350e306bf 100644 --- a/templates/compose/freescout.yaml +++ b/templates/compose/freescout.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/freescout-help-desk/freescout/wiki/ # slogan: FreeScout is the super lightweight and powerful free open source help desk and shared inbox written in PHP (Laravel framework). # category: helpdesk diff --git a/templates/compose/freshrss-with-mariadb.yaml b/templates/compose/freshrss-with-mariadb.yaml index dc0e189169..433a034302 100644 --- a/templates/compose/freshrss-with-mariadb.yaml +++ b/templates/compose/freshrss-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://freshrss.org/index.html # slogan: A free, self-hostable feed aggregator. # category: RSS diff --git a/templates/compose/freshrss-with-mysql.yaml b/templates/compose/freshrss-with-mysql.yaml index f475de5695..b796c8661e 100644 --- a/templates/compose/freshrss-with-mysql.yaml +++ b/templates/compose/freshrss-with-mysql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://freshrss.org/index.html # slogan: A free, self-hostable feed aggregator. # category: RSS diff --git a/templates/compose/freshrss-with-postgresql.yaml b/templates/compose/freshrss-with-postgresql.yaml index 381aabc8be..c3467f1d31 100644 --- a/templates/compose/freshrss-with-postgresql.yaml +++ b/templates/compose/freshrss-with-postgresql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://freshrss.org/index.html # slogan: A free, self-hostable feed aggregator. # category: RSS diff --git a/templates/compose/freshrss.yaml b/templates/compose/freshrss.yaml index b5c2e92c68..e19c407af2 100644 --- a/templates/compose/freshrss.yaml +++ b/templates/compose/freshrss.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://freshrss.org/index.html # slogan: A free, self-hostable feed aggregator. # category: RSS diff --git a/templates/compose/glpi.yaml b/templates/compose/glpi.yaml index c0b3201c90..7ac784a2c4 100644 --- a/templates/compose/glpi.yaml +++ b/templates/compose/glpi.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://help.glpi-project.org/documentation # slogan: GLPI (Gestionnaire Libre de Parc Informatique) is a free, open-source IT Service Management (ITSM) platform used for IT asset management, helpdesk, and service desk operations. # category: helpdesk diff --git a/templates/compose/grocy.yaml b/templates/compose/grocy.yaml index fc96d272df..783ce27767 100644 --- a/templates/compose/grocy.yaml +++ b/templates/compose/grocy.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/grocy/grocy # slogan: Grocy is a web-based household management and grocery list application. # category: productivity diff --git a/templates/compose/heimdall.yaml b/templates/compose/heimdall.yaml index 56e193f1cf..5c9f4729b3 100644 --- a/templates/compose/heimdall.yaml +++ b/templates/compose/heimdall.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/linuxserver/Heimdall # slogan: Heimdall is a dashboard for managing and organizing your server applications. # category: productivity diff --git a/templates/compose/invoice-ninja.yaml b/templates/compose/invoice-ninja.yaml index c4581cc9f4..f9908a6deb 100644 --- a/templates/compose/invoice-ninja.yaml +++ b/templates/compose/invoice-ninja.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://invoiceninja.github.io/selfhost.html # slogan: The leading open-source invoicing platform # category: finance diff --git a/templates/compose/joomla-with-mariadb.yaml b/templates/compose/joomla-with-mariadb.yaml index 06ce5c20e3..76c5ee560d 100644 --- a/templates/compose/joomla-with-mariadb.yaml +++ b/templates/compose/joomla-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://joomla.org # slogan: Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source. # category: cms diff --git a/templates/compose/kimai.yaml b/templates/compose/kimai.yaml index c9795f0d21..f846598829 100644 --- a/templates/compose/kimai.yaml +++ b/templates/compose/kimai.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.kimai.org/ # slogan: Open source time-tracking app. # category: productivity diff --git a/templates/compose/leantime.yaml b/templates/compose/leantime.yaml index 44235d8af3..7b2f693282 100644 --- a/templates/compose/leantime.yaml +++ b/templates/compose/leantime.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://leantime.io # slogan: Leantime is a goals focused project management system for non-project managers. # category: productivity diff --git a/templates/compose/librespeed.yaml b/templates/compose/librespeed.yaml index 6e53a3aff5..e2a06a9360 100644 --- a/templates/compose/librespeed.yaml +++ b/templates/compose/librespeed.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/librespeed/speedtest # slogan: Self-hosted lightweight Speed Test. # category: devtools diff --git a/templates/compose/limesurvey.yaml b/templates/compose/limesurvey.yaml index 47b8c5e0ea..b3add1382a 100644 --- a/templates/compose/limesurvey.yaml +++ b/templates/compose/limesurvey.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.limesurvey.org/manual/ # slogan: Simple, quick and anonymous online survey tool that's bursting with juicy insights. # category: productivity diff --git a/templates/compose/mediawiki.yaml b/templates/compose/mediawiki.yaml index ba6a2871f6..fe76b28946 100644 --- a/templates/compose/mediawiki.yaml +++ b/templates/compose/mediawiki.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.mediawiki.org # slogan: MediaWiki is a collaboration and documentation platform brought to you by a vibrant community. # category: cms diff --git a/templates/compose/mixpost.yaml b/templates/compose/mixpost.yaml index ca0937f4de..80542038e0 100644 --- a/templates/compose/mixpost.yaml +++ b/templates/compose/mixpost.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.mixpost.app/lite # slogan: Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies. # category: analytics diff --git a/templates/compose/moodle.yaml b/templates/compose/moodle.yaml index 41b7916b9b..2fa8f784c4 100644 --- a/templates/compose/moodle.yaml +++ b/templates/compose/moodle.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://moodle.org # slogan: Moodle is the world’s most customisable and trusted eLearning solution that empowers educators to improve our world. # category: cms diff --git a/templates/compose/nextcloud-with-mariadb.yaml b/templates/compose/nextcloud-with-mariadb.yaml index 033423cafd..734599d0dc 100644 --- a/templates/compose/nextcloud-with-mariadb.yaml +++ b/templates/compose/nextcloud-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.nextcloud.com # slogan: NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management. # category: storage diff --git a/templates/compose/nextcloud-with-mysql.yaml b/templates/compose/nextcloud-with-mysql.yaml index 17d803c5da..abbe868eaf 100644 --- a/templates/compose/nextcloud-with-mysql.yaml +++ b/templates/compose/nextcloud-with-mysql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.nextcloud.com # slogan: NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management. # category: storage diff --git a/templates/compose/nextcloud-with-postgres.yaml b/templates/compose/nextcloud-with-postgres.yaml index 0f717cd856..bea81f8673 100644 --- a/templates/compose/nextcloud-with-postgres.yaml +++ b/templates/compose/nextcloud-with-postgres.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.nextcloud.com # slogan: NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management. # category: storage diff --git a/templates/compose/nextcloud.yaml b/templates/compose/nextcloud.yaml index a5b7836415..986d92d02f 100644 --- a/templates/compose/nextcloud.yaml +++ b/templates/compose/nextcloud.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.nextcloud.com # slogan: NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management. # category: storage diff --git a/templates/compose/observium.yaml b/templates/compose/observium.yaml index 5227704e59..f0ac2ed7d3 100644 --- a/templates/compose/observium.yaml +++ b/templates/compose/observium.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.observium.org/ # slogan: Observium is a comprehensive network monitoring platform designed to deliver powerful monitoring capabilities, combined with an elegant and intuitive user interface. # category: monitoring diff --git a/templates/compose/opnform.yaml b/templates/compose/opnform.yaml index 048327b017..281d9ad703 100644 --- a/templates/compose/opnform.yaml +++ b/templates/compose/opnform.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.opnform.com/introduction # slogan: OpnForm is an open-source form builder that lets you create beautiful forms and share them anywhere. It's super fast, you don't need to know how to code # category: productivity diff --git a/templates/compose/orangehrm.yaml b/templates/compose/orangehrm.yaml index c93495a1f9..9413350659 100644 --- a/templates/compose/orangehrm.yaml +++ b/templates/compose/orangehrm.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://starterhelp.orangehrm.com/hc/en-us # slogan: OrangeHRM open source HR management software. # category: productivity diff --git a/templates/compose/organizr.yaml b/templates/compose/organizr.yaml index aaa92e30dd..c135dfa6a9 100644 --- a/templates/compose/organizr.yaml +++ b/templates/compose/organizr.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.organizr.app/ # slogan: Homelab Services Organizer # category: productivity diff --git a/templates/compose/owncloud.yaml b/templates/compose/owncloud.yaml index c341a53902..69a628096e 100644 --- a/templates/compose/owncloud.yaml +++ b/templates/compose/owncloud.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://owncloud.com/docs-guides/ # slogan: OwnCloud with Open Web UI integrates file management with a powerful, user-friendly interface. # category: storage diff --git a/templates/compose/passbolt.yaml b/templates/compose/passbolt.yaml index 4705e04096..468b0bcc60 100644 --- a/templates/compose/passbolt.yaml +++ b/templates/compose/passbolt.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.passbolt.com/ce/docker # slogan: Passbolt Community Edition (CE) API. The JSON API for the open source password manager for teams! # category: auth diff --git a/templates/compose/paymenter.yaml b/templates/compose/paymenter.yaml index d86c101a4c..9201bdd867 100644 --- a/templates/compose/paymenter.yaml +++ b/templates/compose/paymenter.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://paymenter.org/docs/guides/docker # slogan: Open-Source Billing, Built for Hosting # category: finance diff --git a/templates/compose/phpmyadmin.yaml b/templates/compose/phpmyadmin.yaml index 6c95948e07..b0aa26b3f1 100644 --- a/templates/compose/phpmyadmin.yaml +++ b/templates/compose/phpmyadmin.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://phpmyadmin.net # slogan: phpMyAdmin is a web-based database management tool for administering your MySQL and MariaDB databases through a user-friendly interface. # category: database diff --git a/templates/compose/privatebin.yaml b/templates/compose/privatebin.yaml index caca2c42b4..98353d3fdf 100644 --- a/templates/compose/privatebin.yaml +++ b/templates/compose/privatebin.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/PrivateBin/PrivateBin/blob/master/doc/README.md # slogan: PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data. # category: security diff --git a/templates/compose/pterodactyl-panel.yaml b/templates/compose/pterodactyl-panel.yaml index 2ee3b74815..3c6349e1f9 100644 --- a/templates/compose/pterodactyl-panel.yaml +++ b/templates/compose/pterodactyl-panel.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media diff --git a/templates/compose/pterodactyl-with-wings.yaml b/templates/compose/pterodactyl-with-wings.yaml index f88b671b7f..f9f881d97c 100644 --- a/templates/compose/pterodactyl-with-wings.yaml +++ b/templates/compose/pterodactyl-with-wings.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://pterodactyl.io/ # slogan: Pterodactyl is a free, open-source game server management panel # category: media diff --git a/templates/compose/shlink.yaml b/templates/compose/shlink.yaml index 6d12269701..fc004ec9be 100644 --- a/templates/compose/shlink.yaml +++ b/templates/compose/shlink.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://shlink.io/ # slogan: The definitive self-hosted URL shortener # category: productivity diff --git a/templates/compose/snapdrop.yaml b/templates/compose/snapdrop.yaml index 6fba2adbb6..7731fb458e 100644 --- a/templates/compose/snapdrop.yaml +++ b/templates/compose/snapdrop.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/RobinLinus/snapdrop # slogan: A self-hosted file-sharing service for secure and convenient file transfers, whether on a local network or the internet. # category: productivity diff --git a/templates/compose/snipe-it.yaml b/templates/compose/snipe-it.yaml index fad5674105..8c2ad863f6 100644 --- a/templates/compose/snipe-it.yaml +++ b/templates/compose/snipe-it.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://snipe-it.readme.io/docs/introduction # slogan: Snipe-IT is an open-source asset management platform built for IT departments. # category: productivity diff --git a/templates/compose/soketi-app-manager.yaml b/templates/compose/soketi-app-manager.yaml index b431817586..66ddd23247 100644 --- a/templates/compose/soketi-app-manager.yaml +++ b/templates/compose/soketi-app-manager.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://github.com/rahulhaque/soketi-app-manager-filament # slogan: Manage soketi websocket server and apps with ease. # category: devtools diff --git a/templates/compose/vvveb-with-mariadb.yaml b/templates/compose/vvveb-with-mariadb.yaml index b1b3db3053..75d3ec2876 100644 --- a/templates/compose/vvveb-with-mariadb.yaml +++ b/templates/compose/vvveb-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.vvveb.com # slogan: Powerful and easy to use cms to build websites, blogs or ecommerce stores. # category: cms diff --git a/templates/compose/vvveb-with-mysql.yaml b/templates/compose/vvveb-with-mysql.yaml index 1feb3f627e..2a8af5b472 100644 --- a/templates/compose/vvveb-with-mysql.yaml +++ b/templates/compose/vvveb-with-mysql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.vvveb.com # slogan: Powerful and easy to use cms to build websites, blogs or ecommerce stores. # category: cms diff --git a/templates/compose/vvveb.yaml b/templates/compose/vvveb.yaml index 483e6dbb5a..6b77339745 100644 --- a/templates/compose/vvveb.yaml +++ b/templates/compose/vvveb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://docs.vvveb.com # slogan: Powerful and easy to use cms to build websites, blogs or ecommerce stores. # category: cms diff --git a/templates/compose/windmill.yaml b/templates/compose/windmill.yaml index f22b471d8c..ec8d6bbfed 100644 --- a/templates/compose/windmill.yaml +++ b/templates/compose/windmill.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://www.windmill.dev/docs/ # slogan: Windmill is a developer platform to build production-grade multi-steps automations and internal apps. # category: automation diff --git a/templates/compose/wordpress-with-mariadb.yaml b/templates/compose/wordpress-with-mariadb.yaml index 7822a05539..ad23ee8e65 100644 --- a/templates/compose/wordpress-with-mariadb.yaml +++ b/templates/compose/wordpress-with-mariadb.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://wordpress.org # slogan: WordPress is open source software you can use to create a beautiful website, blog, or app. # category: cms diff --git a/templates/compose/wordpress-with-mysql.yaml b/templates/compose/wordpress-with-mysql.yaml index 269208e514..5adb619779 100644 --- a/templates/compose/wordpress-with-mysql.yaml +++ b/templates/compose/wordpress-with-mysql.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://wordpress.org # slogan: WordPress is open source software you can use to create a beautiful website, blog, or app. # category: cms diff --git a/templates/compose/wordpress-without-database.yaml b/templates/compose/wordpress-without-database.yaml index cb9a459b58..c3ea291b0e 100644 --- a/templates/compose/wordpress-without-database.yaml +++ b/templates/compose/wordpress-without-database.yaml @@ -1,3 +1,4 @@ +# ignore: true # documentation: https://wordpress.org # slogan: WordPress is open source software you can use to create a beautiful website, blog, or app. # category: cms diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index b68fbfc5ac..135d486d55 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -14,7 +14,7 @@ "category": "ai", "logo": "svgs/9router.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-04T18:02:06+02:00", + "template_last_updated_at": "2026-10-05T11:08:52+02:00", "port": "20128" }, "activepieces": { @@ -125,7 +125,7 @@ "category": "productivity", "logo": "svgs/appflowy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-16T11:26:29-05:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "apprise-api": { @@ -159,21 +159,6 @@ "template_last_updated_at": "2026-09-02T19:39:19+02:00", "port": "80" }, - "appwrite": { - "documentation": "https://appwrite.io/docs?utm_source=coolify.io", - "slogan": "A backend-as-a-service platform that simplifies the web & mobile app development.", - "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjIuNS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQVBQV1JJVEU9L3YxCiAgICAgIC0gJ19BUFBfRE5TX0VYVEVSTkFMPSR7X0FQUF9ETlNfRVhURVJOQUx9JwogICAgICAtICdfQVBQX0FMTE9XRURfSU5URVJOQUxfQUREUkVTU0VTPSR7X0FQUF9BTExPV0VEX0lOVEVSTkFMX0FERFJFU1NFU30nCiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gX0FQUF9VU0FHRV9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0UKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9VU0FHRT1odHRwOi8vYXBwd3JpdGU6JFNFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRUBhcHB3cml0ZS1jbGlja2hvdXNlOjgxMjMvYXBwd3JpdGUnCiAgICAgIC0gJ19BUFBfUE9PTF9TSVpFX1VTQUdFPSR7X0FQUF9QT09MX1NJWkVfVVNBR0U6LTJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RCX0VYRUNVVElPTlM9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9FWEVDVVRJT05TPSR7X0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUzotMn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTDotMTgwfScKICAgICAgLSAnX0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FOi12MS1zdGF0cy11c2FnZX0nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FOi12MS1zdGF0cy1yZXNvdXJjZXN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMOi0zNjAwfScKICAgICAgLSAnX0FQUF9FRElUSU9OPSR7X0FQUF9FRElUSU9OOi1zZWxmLWhvc3RlZH0nCiAgICAgIC0gJ19BUFBfV09SS0VSX1BFUl9DT1JFPSR7X0FQUF9XT1JLRVJfUEVSX0NPUkU6LTZ9JwogICAgICAtICdfQVBQX0xPQ0FMRT0ke19BUFBfTE9DQUxFOi1lbn0nCiAgICAgIC0gJ19BUFBfQ09NUFJFU1NJT05fRU5BQkxFRD0ke19BUFBfQ09NUFJFU1NJT05fRU5BQkxFRDotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfQ09NUFJFU1NJT05fTUlOX1NJWkVfQllURVM9JHtfQVBQX0NPTVBSRVNTSU9OX01JTl9TSVpFX0JZVEVTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9ST09UPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9ST09UOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9FTUFJTFM9JHtfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0VNQUlMU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9TRVNTSU9OX0FMRVJUUz0ke19BUFBfQ09OU09MRV9TRVNTSU9OX0FMRVJUUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0lQUz0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfSVBTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0hPU1ROQU1FUz0ke19BUFBfQ09OU09MRV9IT1NUTkFNRVN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0NIRU1BPSR7X0FQUF9DT05TT0xFX1NDSEVNQTotYXBwd3JpdGVpb30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9HSVRIVUJfQVBQX0lEPSR7X0FQUF9DT05TT0xFX0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfR0lUSFVCX1NFQ1JFVD0ke19BUFBfQ09OU09MRV9HSVRIVUJfU0VDUkVUfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0dJVExBQl9BUFBfSUQ9JHtfQVBQX0NPTlNPTEVfR0lUTEFCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9HSVRMQUJfU0VDUkVUPSR7X0FQUF9DT05TT0xFX0dJVExBQl9TRUNSRVR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfR0lUTEFCX0VORFBPSU5UPSR7X0FQUF9DT05TT0xFX0dJVExBQl9FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9CSVRCVUNLRVRfQVBQX0lEPSR7X0FQUF9DT05TT0xFX0JJVEJVQ0tFVF9BUFBfSUR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfQklUQlVDS0VUX1NFQ1JFVD0ke19BUFBfQ09OU09MRV9CSVRCVUNLRVRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0dPT0dMRV9BUFBfSUQ9JHtfQVBQX0NPTlNPTEVfR09PR0xFX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9HT09HTEVfU0VDUkVUPSR7X0FQUF9DT05TT0xFX0dPT0dMRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9OQU1FPSR7X0FQUF9TWVNURU1fRU1BSUxfTkFNRTotQXBwd3JpdGV9JwogICAgICAtICdfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTPSR7X0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUzotbm9yZXBseUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1RFQU1fRU1BSUw9JHtfQVBQX1NZU1RFTV9URUFNX0VNQUlMOi10ZWFtQGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9FTUFJTF9TRUNVUklUWT0ke19BUFBfRU1BSUxfU0VDVVJJVFk6LXNlY3VyaXR5QGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUPSR7X0FQUF9TWVNURU1fUkVTUE9OU0VfRk9STUFUfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0FCVVNFPSR7X0FQUF9PUFRJT05TX0FCVVNFOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0FCVVNFX0lOQ1JFQVNFRF9MSU1JVF9QUk9KRUNUUz0ke19BUFBfT1BUSU9OU19BQlVTRV9JTkNSRUFTRURfTElNSVRfUFJPSkVDVFN9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT049JHtfQVBQX09QVElPTlNfUk9VVEVSX1BST1RFQ1RJT046LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfUk9VVEVSX0FVVE9fQ0VSVElGSUNBVEVTPSR7X0FQUF9ST1VURVJfQVVUT19DRVJUSUZJQ0FURVM6LWRpc2FibGVkfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gX0FQUF9OT1RJRklDQVRJT05TX1RSQUNLSU5HX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9OT1RJRklDQVRJT05TCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0NPTlNPTEVfRE9NQUlOPSR7X0FQUF9DT05TT0xFX0RPTUFJTn0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9VUkw9JHtfQVBQX0NPTlNPTEVfVVJMfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1RSVVNURURfUFJPSkVDVFM9JHtfQVBQX0NPTlNPTEVfVFJVU1RFRF9QUk9KRUNUU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9ET01BSU5fRlVOQ1RJT05TPSR7X0FQUF9ET01BSU5fRlVOQ1RJT05TOi1mdW5jdGlvbnMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0RCX0FEQVBURVI9JHtfQVBQX0RCX0FEQVBURVI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX1ZFQ1RPUlNEQj0ke19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1RfVkVDVE9SU0RCPSR7X0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQjotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVF9WRUNUT1JTREI9JHtfQVBQX0RCX1BPUlRfVkVDVE9SU0RCOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCPSR7X0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSX1ZFQ1RPUlNEQj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTX1ZFQ1RPUlNEQj0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI6LW1vbmdvZGJ9JwogICAgICAtICdfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI6LTI3MDE3fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1NDSEVNQV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQj0ke19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfUEFTU19ET0NVTUVOVFNEQj0ke19BUFBfREJfUEFTU19ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfRE9DVU1FTlRTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX1ZFQ1RPUlNEQn0nCiAgICAgIC0gJ19BUFBfU01UUF9IT1NUPSR7X0FQUF9TTVRQX0hPU1R9JwogICAgICAtICdfQVBQX1NNVFBfUE9SVD0ke19BUFBfU01UUF9QT1JUfScKICAgICAgLSAnX0FQUF9TTVRQX1NFQ1VSRT0ke19BUFBfU01UUF9TRUNVUkV9JwogICAgICAtICdfQVBQX1NNVFBfVVNFUk5BTUU9JHtfQVBQX1NNVFBfVVNFUk5BTUV9JwogICAgICAtICdfQVBQX1NNVFBfUEFTU1dPUkQ9JHtfQVBQX1NNVFBfUEFTU1dPUkR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElNSVQ9JHtfQVBQX1NUT1JBR0VfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1BSRVZJRVdfTElNSVQ9JHtfQVBQX1NUT1JBR0VfUFJFVklFV19MSU1JVDotMjAwMDAwMDB9JwogICAgICAtICdfQVBQX0FVVE9HUkFWSVRZX0hPU1Q9JHtfQVBQX0FVVE9HUkFWSVRZX0hPU1R9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1Q9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTX0hPU1R9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQU5USVZJUlVTX1BPUlQ9JHtfQVBQX1NUT1JBR0VfQU5USVZJUlVTX1BPUlQ6LTMzMTB9JwogICAgICAtICdfQVBQX1NUT1JBR0VfREVWSUNFPSR7X0FQUF9TVE9SQUdFX0RFVklDRTotbG9jYWx9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9TM19TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9TM19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlQ9JHtfQVBQX1NUT1JBR0VfUzNfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9ET19TUEFDRVNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT049JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfUkVHSU9OOi11cy13ZXN0LTAwNH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9MSU5PREVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT049JHtfQVBQX1NUT1JBR0VfTElOT0RFX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWT0ke19BUFBfU1RPUkFHRV9XQVNBQklfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUPSR7X0FQUF9TVE9SQUdFX1dBU0FCSV9TRUNSRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9XQVNBQklfUkVHSU9OOi1ldS1jZW50cmFsLTF9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfQlVDS0VUfScKICAgICAgLSAnX0FQUF9DT01QVVRFX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfU0laRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQlVJTERfU0laRV9MSU1JVD0ke19BUFBfQ09NUFVURV9CVUlMRF9TSVpFX0xJTUlUOi0yMDAwMDAwMDAwfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfVElNRU9VVD0ke19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfU0lURVNfVElNRU9VVD0ke19BUFBfU0lURVNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVDotMjcwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTPSR7X0FQUF9GVU5DVElPTlNfUlVOVElNRVM6LW5vZGUtMjJ9JwogICAgICAtICdfQVBQX1NJVEVTX1JVTlRJTUVTPSR7X0FQUF9TSVRFU19SVU5USU1FUzotc3RhdGljLTEsbm9kZS0yMn0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1NJVEVTPSR7X0FQUF9ET01BSU5fU0lURVM6LXNpdGVzLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtIF9BUFBfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0VYRUNVVE9SCiAgICAgIC0gJ19BUFBfRVhFQ1VUT1JfSE9TVD0ke19BUFBfRVhFQ1VUT1JfSE9TVDotaHR0cDovL29wZW5ydW50aW1lcy1leGVjdXRvci92MX0nCiAgICAgIC0gJ19BUFBfQlVJTERTX1ZPTFVNRT0ke0NPTVBPU0VfUFJPSkVDVF9OQU1FfV9hcHB3cml0ZS1idWlsZHMnCiAgICAgIC0gJ19BUFBfSk9CU19IT1NUPSR7X0FQUF9KT0JTX0hPU1Q6LWh0dHA6Ly9vcmNoZXN0cmF0b3I6ODA4MH0nCiAgICAgIC0gX0FQUF9KT0JTX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9KT0JTCiAgICAgIC0gJ19BUFBfSk9CU19FTkRQT0lOVD0ke19BUFBfSk9CU19FTkRQT0lOVDotaHR0cDovL2FwcHdyaXRlfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0NPTkZJRz0ke19BUFBfTE9HR0lOR19DT05GSUd9JwogICAgICAtICdfQVBQX0xPR0dJTkdfRk9STUFUPSR7X0FQUF9MT0dHSU5HX0ZPUk1BVDotcHJldHR5fScKICAgICAgLSAnX0FQUF9MT0NLSU5HX0VOQUJMRUQ9JHtfQVBQX0xPQ0tJTkdfRU5BQkxFRDotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTj0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTjotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU6LTI1OTIwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUz0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUzotODY0MDB9JwogICAgICAtICdfQVBQX1NNU19QUk9WSURFUj0ke19BUFBfU01TX1BST1ZJREVSfScKICAgICAgLSAnX0FQUF9TTVNfRlJPTT0ke19BUFBfU01TX0ZST019JwogICAgICAtICdfQVBQX0dSQVBIUUxfSU5UUk9TUEVDVElPTj0ke19BUFBfR1JBUEhRTF9JTlRST1NQRUNUSU9OOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFPSR7X0FQUF9HUkFQSFFMX01BWF9CQVRDSF9TSVpFOi0xMH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWT0ke19BUFBfR1JBUEhRTF9NQVhfQ09NUExFWElUWTotMjUwfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX01BWF9ERVBUSD0ke19BUFBfR1JBUEhRTF9NQVhfREVQVEg6LTN9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUU9JHtfQVBQX1ZDU19HSVRIVUJfQVBQX05BTUV9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVk9JHtfQVBQX1ZDU19HSVRIVUJfUFJJVkFURV9LRVl9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQVBQX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVD0ke19BUFBfVkNTX0dJVEhVQl9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX0VORFBPSU5UPSR7X0FQUF9WQ1NfR0lURUFfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9CUk9XU0VSX0VORFBPSU5UPSR7X0FQUF9WQ1NfR0lURUFfQlJPV1NFUl9FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX0NMSUVOVF9JRD0ke19BUFBfVkNTX0dJVEVBX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX1ZDU19HSVRFQV9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9WQ1NfR0lURUFfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRFQV9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVExBQl9FTkRQT0lOVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9DTElFTlRfSUQ9JHtfQVBQX1ZDU19HSVRMQUJfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX1ZDU19HSVRMQUJfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9XRUJIT09LX1NFQ1JFVD0ke19BUFBfVkNTX0dJVExBQl9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0JJVEJVQ0tFVF9DTElFTlRfSUQ9JHtfQVBQX1ZDU19CSVRCVUNLRVRfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX1ZDU19CSVRCVUNLRVRfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0JJVEJVQ0tFVF9XRUJIT09LX1NFQ1JFVD0ke19BUFBfVkNTX0JJVEJVQ0tFVF9XRUJIT09LX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX09SSUdJTl9DTElFTlRfSUQ9JHtfQVBQX1ZDU19PUklHSU5fQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9WQ1NfT1JJR0lOX1BSSVZBVEVfS0VZPSR7X0FQUF9WQ1NfT1JJR0lOX1BSSVZBVEVfS0VZfScKICAgICAgLSAnX0FQUF9WQ1NfV0VCSE9PS19VUkw9JHtfQVBQX1ZDU19XRUJIT09LX1VSTH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9DT1VOVFJJRVNfREVOWUxJU1Q9JHtfQVBQX0NPTlNPTEVfQ09VTlRSSUVTX0RFTllMSVNUfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfVEFCTEVTfScKICAgICAgLSAnX0FQUF9EQVRBQkFTRV9TSEFSRURfTkFNRVNQQUNFPSR7X0FQUF9EQVRBQkFTRV9TSEFSRURfTkFNRVNQQUNFfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfQ1JFQVRJT05fQUJVU0VfTElNSVQ9JHtfQVBQX0ZVTkNUSU9OU19DUkVBVElPTl9BQlVTRV9MSU1JVH0nCiAgICAgIC0gJ19BUFBfQ1VTVE9NX0RPTUFJTl9ERU5ZX0xJU1Q9JHtfQVBQX0NVU1RPTV9ET01BSU5fREVOWV9MSVNUOi1leGFtcGxlLmNvbSx0ZXN0LmNvbSxhcHAuZXhhbXBsZS5jb219JwogICAgICAtICdfQVBQX1RSVVNURURfSEVBREVSUz0ke19BUFBfVFJVU1RFRF9IRUFERVJTOi14LWZvcndhcmRlZC1mb3J9JwogICAgICAtICdfQVBQX1RSVVNURURfUFJPWElFUz0ke19BUFBfVFJVU1RFRF9QUk9YSUVTOi0xMjcuMC4wLjEsOjoxLDEwLjAuMC4wLzgsMTAwLjY0LjAuMC8xMCwxNzIuMTYuMC4wLzEyLDE5Mi4xNjguMC4wLzE2fScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05fSE9TVD0ke19BUFBfTUlHUkFUSU9OX0hPU1Q6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0dFT19TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfR0VPCiAgICAgIC0gJ19BUFBfR0VPX0VORFBPSU5UPSR7X0FQUF9HRU9fRU5EUE9JTlQ6LWh0dHA6Ly9hcHB3cml0ZS1nZW8vdjF9JwogICAgICAtICdfQVBQX1BXTkVEX1BBU1NXT1JEU19EU049JHtfQVBQX1BXTkVEX1BBU1NXT1JEU19EU046LW5vbmU6Ly9sb2NhbGhvc3R9JwogICAgICAtICdfQVBQX0xJTUlUX0RBVEFCQVNFX0JBVENIPSR7X0FQUF9MSU1JVF9EQVRBQkFTRV9CQVRDSDotMTAwfScKICAgICAgLSAnX0FQUF9ET0NVTUVOVFNEQj0ke19BUFBfRE9DVU1FTlRTREI6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9WRUNUT1JTREI9JHtfQVBQX1ZFQ1RPUlNEQjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0VNQkVERElORz0ke19BUFBfRU1CRURESU5HOi1kaXNhYmxlZH0nCiAgICB2b2x1bWVzOgogICAgICAtICdhcHB3cml0ZS11cGxvYWRzOi9zdG9yYWdlL3VwbG9hZHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWltcG9ydHM6L3N0b3JhZ2UvaW1wb3J0czpydycKICAgICAgLSAnYXBwd3JpdGUtY2FjaGU6L3N0b3JhZ2UvY2FjaGU6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNvbmZpZzovc3RvcmFnZS9jb25maWc6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNlcnRpZmljYXRlczovc3RvcmFnZS9jZXJ0aWZpY2F0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWZ1bmN0aW9uczovc3RvcmFnZS9mdW5jdGlvbnM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLXNpdGVzOi9zdG9yYWdlL3NpdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1idWlsZHM6L3N0b3JhZ2UvYnVpbGRzOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGUtcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1yZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1jbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLWdlbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mc1MgLW8gL2Rldi9udWxsIGh0dHA6Ly9sb2NhbGhvc3QvdjEvaGVhbHRoL3ZlcnNpb24gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDYKICAgICAgc3RhcnRfcGVyaW9kOiAxMjBzCiAgYXBwd3JpdGUtY29uc29sZToKICAgIGltYWdlOiAnYXBwd3JpdGUvbmV3OjEuMi43NC1zZWxmLWhvc3RlZCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFXzMwMDA9LwogICAgICAtIFZJVEVfQ09OU09MRV9QUk9GSUxFPXNlbGYtaG9zdGVkCiAgICAgIC0gVklURV9BUFBXUklURV9WRVJTSU9OPTIuNS4wCiAgICAgIC0gQVBQV1JJVEVfRU5EUE9JTlRfU0FNRV9PUklHSU49dHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGU6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnYnVuIC1lICJmZXRjaCgnJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMCcnKS50aGVuKHIgPT4gcHJvY2Vzcy5leGl0KHIuc3RhdHVzIDwgNTAwID8gMCA6IDEpKS5jYXRjaCgoKSA9PiBwcm9jZXNzLmV4aXQoMSkpIicKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgYXBwd3JpdGUtcmVhbHRpbWU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjIuNS4wJwogICAgZW50cnlwb2ludDogcmVhbHRpbWUKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0FQUFdSSVRFPS92MS9yZWFsdGltZQogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1NUQVRTX1VTQUdFX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1VTQUdFX1FVRVVFX05BTUU6LXYxLXN0YXRzLXVzYWdlfScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRT0ke19BUFBfT1BUSU9OU19BQlVTRTotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTj0ke19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfSE9TVE5BTUVTPSR7X0FQUF9DT05TT0xFX0hPU1ROQU1FU30nCiAgICAgIC0gJ19BUFBfUkVBTFRJTUVfVEFJTF9SQVRFPSR7X0FQUF9SRUFMVElNRV9UQUlMX1JBVEU6LTUwfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfVFJVU1RFRF9IRUFERVJTPSR7X0FQUF9UUlVTVEVEX0hFQURFUlM6LXgtZm9yd2FyZGVkLWZvcn0nCiAgICAgIC0gJ19BUFBfVFJVU1RFRF9QUk9YSUVTPSR7X0FQUF9UUlVTVEVEX1BST1hJRVM6LTEyNy4wLjAuMSw6OjEsMTAuMC4wLjAvOCwxMDAuNjQuMC4wLzEwLDE3Mi4xNi4wLjAvMTIsMTkyLjE2OC4wLjAvMTZ9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSPSR7X0FQUF9EQl9BREFQVEVSOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI9JHtfQVBQX0RCX0FEQVBURVJfVkVDVE9SU0RCOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQj0ke19BUFBfREJfSE9TVF9WRUNUT1JTREI6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlRfVkVDVE9SU0RCPSR7X0FQUF9EQl9QT1JUX1ZFQ1RPUlNEQjotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQj0ke19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQjotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUl9WRUNUT1JTREI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTU19WRUNUT1JTREI9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCOi1tb25nb2RifScKICAgICAgLSAnX0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCOi0yNzAxN30nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfVkVDVE9SU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREJ9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0xJTUlUX0RBVEFCQVNFX0JBVENIPSR7X0FQUF9MSU1JVF9EQVRBQkFTRV9CQVRDSDotMTAwfScKICAgICAgLSAnX0FQUF9ET0NVTUVOVFNEQj0ke19BUFBfRE9DVU1FTlRTREI6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9WRUNUT1JTREI9JHtfQVBQX1ZFQ1RPUlNEQjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0VNQkVERElORz0ke19BUFBfRU1CRURESU5HOi1kaXNhYmxlZH0nCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgbG9jYWxob3N0ID4gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgYXBwd3JpdGUtd29ya2VyOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX0ROU19FWFRFUk5BTD0ke19BUFBfRE5TX0VYVEVSTkFMfScKICAgICAgLSAnX0FQUF9BTExPV0VEX0lOVEVSTkFMX0FERFJFU1NFUz0ke19BUFBfQUxMT1dFRF9JTlRFUk5BTF9BRERSRVNTRVN9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gX0FQUF9VU0FHRV9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0UKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9VU0FHRT1odHRwOi8vYXBwd3JpdGU6JFNFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRUBhcHB3cml0ZS1jbGlja2hvdXNlOjgxMjMvYXBwd3JpdGUnCiAgICAgIC0gJ19BUFBfUE9PTF9TSVpFX1VTQUdFPSR7X0FQUF9QT09MX1NJWkVfVVNBR0U6LTJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RCX0VYRUNVVElPTlM9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9FWEVDVVRJT05TPSR7X0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUzotMn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTDotMTgwfScKICAgICAgLSAnX0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FOi12MS1zdGF0cy11c2FnZX0nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FOi12MS1zdGF0cy1yZXNvdXJjZXN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMOi0zNjAwfScKICAgICAgLSBfQVBQX1dPUktFUlNfTlVNPTEKICAgICAgLSBfQVBQX1dPUktFUl9NQVhfQ09ST1VUSU5FUz03OAogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1zZWN1cml0eUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVN9JwogICAgICAtICdfQVBQX0RCX0FEQVBURVI9JHtfQVBQX0RCX0FEQVBURVI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX1ZFQ1RPUlNEQj0ke19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1RfVkVDVE9SU0RCPSR7X0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQjotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVF9WRUNUT1JTREI9JHtfQVBQX0RCX1BPUlRfVkVDVE9SU0RCOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCPSR7X0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSX1ZFQ1RPUlNEQj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTX1ZFQ1RPUlNEQj0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI6LW1vbmdvZGJ9JwogICAgICAtICdfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI6LTI3MDE3fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1NDSEVNQV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQj0ke19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfUEFTU19ET0NVTUVOVFNEQj0ke19BUFBfREJfUEFTU19ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfRE9DVU1FTlRTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX1ZFQ1RPUlNEQn0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1FVRVVFX05BTUU9JHtfQVBQX1FVRVVFX05BTUV9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfVVJMPSR7X0FQUF9DT05TT0xFX1VSTH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9ET01BSU49JHtfQVBQX0NPTlNPTEVfRE9NQUlOfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1RSVVNURURfUFJPSkVDVFM9JHtfQVBQX0NPTlNPTEVfVFJVU1RFRF9QUk9KRUNUU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU6LWxvY2FsaG9zdH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUE6LTo6MX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0E6LTEyNy4wLjAuMX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1zaXRlcy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfV0VCSE9PS19NQVhfRkFJTEVEX0FUVEVNUFRTPSR7X0FQUF9XRUJIT09LX01BWF9GQUlMRURfQVRURU1QVFM6LTEwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfRVhFQ1VUT1IKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9ET0NLRVJfSFVCX1BBU1NXT1JEPSR7X0FQUF9ET0NLRVJfSFVCX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfVElNRU9VVD0ke19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfU0lURVNfVElNRU9VVD0ke19BUFBfU0lURVNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVDotMjcwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfT1BFTl9SVU5USU1FU19ORlQ9JHtfQVBQX09QRU5fUlVOVElNRVNfTkZUOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9DT01QVVRFX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfU0laRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQlVJTERfU0laRV9MSU1JVD0ke19BUFBfQ09NUFVURV9CVUlMRF9TSVpFX0xJTUlUOi0yMDAwMDAwMDAwfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZPSR7X0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9JRD0ke19BUFBfVkNTX0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9WQ1NfR0lURUFfQlJPV1NFUl9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0JST1dTRVJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUQ9JHtfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX1dFQkhPT0tfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlQ9JHtfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0JJVEJVQ0tFVF9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19PUklHSU5fQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfT1JJR0lOX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWT0ke19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ19BUFBfVkNTX1dFQkhPT0tfVVJMPSR7X0FQUF9WQ1NfV0VCSE9PS19VUkx9JwogICAgICAtICdfQVBQX0JVSUxEU19WT0xVTUU9JHtDT01QT1NFX1BST0pFQ1RfTkFNRX1fYXBwd3JpdGUtYnVpbGRzJwogICAgICAtICdfQVBQX0pPQlNfSE9TVD0ke19BUFBfSk9CU19IT1NUOi1odHRwOi8vb3JjaGVzdHJhdG9yOjgwODB9JwogICAgICAtIF9BUFBfSk9CU19TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSk9CUwogICAgICAtICdfQVBQX0pPQlNfRU5EUE9JTlQ9JHtfQVBQX0pPQlNfRU5EUE9JTlQ6LWh0dHA6Ly9hcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfQlJPV1NFUl9IT1NUPSR7X0FQUF9CUk9XU0VSX0hPU1Q6LWh0dHA6Ly9hcHB3cml0ZS1icm93c2VyOjMwMDAvdjF9JwogICAgICAtICdfQVBQX1dPUktFUl9TQ1JFRU5TSE9UU19ST1VURVI9JHtfQVBQX1dPUktFUl9TQ1JFRU5TSE9UU19ST1VURVI6LWh0dHA6Ly9hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9OT1RJRklDQVRJT05TX1RSQUNLSU5HX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9OT1RJRklDQVRJT05TCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX05BTUU9JHtfQVBQX1NZU1RFTV9FTUFJTF9OQU1FOi1BcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTOi1ub3JlcGx5QGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TTVRQX0hPU1Q9JHtfQVBQX1NNVFBfSE9TVH0nCiAgICAgIC0gJ19BUFBfU01UUF9QT1JUPSR7X0FQUF9TTVRQX1BPUlR9JwogICAgICAtICdfQVBQX1NNVFBfU0VDVVJFPSR7X0FQUF9TTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ19BUFBfU01UUF9VU0VSTkFNRT0ke19BUFBfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfU01UUF9QQVNTV09SRD0ke19BUFBfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVD0ke19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OX0hPU1Q9JHtfQVBQX01JR1JBVElPTl9IT1NUOi1hcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTElNSVRfREFUQUJBU0VfQkFUQ0g9JHtfQVBQX0xJTUlUX0RBVEFCQVNFX0JBVENIOi0xMDB9JwogICAgICAtICdfQVBQX0RPQ1VNRU5UU0RCPSR7X0FQUF9ET0NVTUVOVFNEQjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1ZFQ1RPUlNEQj0ke19BUFBfVkVDVE9SU0RCOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfRU1CRURESU5HPSR7X0FQUF9FTUJFRERJTkc6LWRpc2FibGVkfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgICAgLSAnYXBwd3JpdGUtaW1wb3J0czovc3RvcmFnZS9pbXBvcnRzOnJ3JwogICAgICAtICdhcHB3cml0ZS1jYWNoZTovc3RvcmFnZS9jYWNoZTpydycKICAgICAgLSAnYXBwd3JpdGUtY29uZmlnOi9zdG9yYWdlL2NvbmZpZzpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9yY2hlc3RyYXRvcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlcjoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6Mi41LjAnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gX0FQUF9VU0FHRV9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0UKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9VU0FHRT1odHRwOi8vYXBwd3JpdGU6JFNFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRUBhcHB3cml0ZS1jbGlja2hvdXNlOjgxMjMvYXBwd3JpdGUnCiAgICAgIC0gJ19BUFBfUE9PTF9TSVpFX1VTQUdFPSR7X0FQUF9QT09MX1NJWkVfVVNBR0U6LTJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RCX0VYRUNVVElPTlM9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9FWEVDVVRJT05TPSR7X0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUzotMn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTDotMTgwfScKICAgICAgLSAnX0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FOi12MS1zdGF0cy11c2FnZX0nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FOi12MS1zdGF0cy1yZXNvdXJjZXN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMOi0zNjAwfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0ZPUk1BVD0ke19BUFBfTE9HR0lOR19GT1JNQVQ6LXByZXR0eX0nCiAgICAgIC0gX0FQUF9XT1JLRVJfTUFYX0NPUk9VVElORVM9OAogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0RPTUFJTj0ke19BUFBfQ09OU09MRV9ET01BSU59JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0NIRU1BPSR7X0FQUF9DT05TT0xFX1NDSEVNQTotYXBwd3JpdGVpb30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1zaXRlcy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05fSE9TVD0ke19BUFBfTUlHUkFUSU9OX0hPU1Q6LWFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUj0ke19BUFBfREJfQURBUFRFUjotcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0ZVTkNUSU9OU19TQ0hFRFVMRV9TUFJFQUQ9JHtfQVBQX0ZVTkNUSU9OU19TQ0hFRFVMRV9TUFJFQUQ6LTB9JwogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGUtcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1yZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IG1haW50ZW5hbmNlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9ET01BSU5fRlVOQ1RJT05TPSR7X0FQUF9ET01BSU5fRlVOQ1RJT05TOi1mdW5jdGlvbnMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRURJVElPTj0ke19BUFBfRURJVElPTjotc2VsZi1ob3N0ZWR9JwogICAgICAtICdfQVBQX1JPVVRFUl9BVVRPX0NFUlRJRklDQVRFUz0ke19BUFBfUk9VVEVSX0FVVE9fQ0VSVElGSUNBVEVTOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSPSR7X0FQUF9EQl9BREFQVEVSOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTj0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTjotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU6LTI1OTIwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUz0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUzotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUU9JHtfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUV9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGUtcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1yZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLWludGVydmFsOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IGludGVydmFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9ET01BSU5fRlVOQ1RJT05TPSR7X0FQUF9ET01BSU5fRlVOQ1RJT05TOi1mdW5jdGlvbnMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1NJVEVTPSR7X0FQUF9ET01BSU5fU0lURVM6LXNpdGVzLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUj0ke19BUFBfREJfQURBUFRFUjotcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0lOVEVSVkFMX0RPTUFJTl9WRVJJRklDQVRJT049JHtfQVBQX0lOVEVSVkFMX0RPTUFJTl9WRVJJRklDQVRJT046LTEyMH0nCiAgICAgIC0gJ19BUFBfSU5URVJWQUxfQ0xFQU5VUF9TVEFMRV9FWEVDVVRJT05TPSR7X0FQUF9JTlRFUlZBTF9DTEVBTlVQX1NUQUxFX0VYRUNVVElPTlM6LTMwMH0nCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgLXEgJ1twXWhwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIGFwcHdyaXRlLWdlbzoKICAgIGltYWdlOiAnYXBwd3JpdGUvZ2VvOjAuMy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gR0VPX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9HRU8KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAicGhwIC1yIFwiZXhpdChqc29uX2RlY29kZShmaWxlX2dldF9jb250ZW50cygnaHR0cDovL2xvY2FsaG9zdC92MS9oZWFsdGgnKSktPnN0YXR1cyA9PT0gJ29rJyA/IDAgOiAxKTtcIiIKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgYXBwd3JpdGUtYnJvd3NlcjoKICAgIGltYWdlOiAnYXBwd3JpdGUvYnJvd3NlcjowLjMuNicKICAgIGVudmlyb25tZW50OgogICAgICAtIEFMTE9XRURfSE9TVFM9YXBwd3JpdGUKICAgIGRuczoKICAgICAgLSAxLjEuMS4xCiAgICAgIC0gOC44LjguOAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgaW1hZ2U6ICdvcGVucnVudGltZXMvZXhlY3V0b3I6MS4xLjInCiAgICBob3N0bmFtZTogb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yCiAgICBzdG9wX3NpZ25hbDogU0lHSU5UCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnT1BSX0VYRUNVVE9SX0lNQUdFUz0ke19BUFBfRVhFQ1VUT1JfSU1BR0VTOi1vcGVucnVudGltZXMvbm9kZTp2NS0yMixvcGVucnVudGltZXMvc3RhdGljOnY1LTF9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfSU5BQ1RJVkVfVEhSRVNIT0xEPSR7X0FQUF9DT01QVVRFX0lOQUNUSVZFX1RIUkVTSE9MRDotNjAwfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9DT01QVVRFX01BSU5URU5BTkNFX0lOVEVSVkFMOi02MDB9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTkVUV09SSz0ke19BUFBfQ09NUFVURV9SVU5USU1FU19ORVRXT1JLOi1ydW50aW1lc30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfUEFTU1dPUkQ9JHtfQVBQX0RPQ0tFUl9IVUJfUEFTU1dPUkR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdPUFJfRVhFQ1VUT1JfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FUzotbm9kZS0yMn0sJHtfQVBQX1NJVEVTX1JVTlRJTUVTOi1zdGF0aWMtMSxub2RlLTIyfScKICAgICAgLSBPUFJfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0VYRUNVVE9SCiAgICAgIC0gT1BSX0VYRUNVVE9SX1JVTlRJTUVfVkVSU0lPTlM9djUKICAgICAgLSAnT1BFTl9SVU5USU1FU19ORlQ9JHtfQVBQX09QRU5fUlVOVElNRVNfTkZUOi1lbmFibGVkfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9DT05ORUNUSU9OX1NUT1JBR0U9JHtfQVBQX0VYRUNVVE9SX0NPTk5FQ1RJT05fU1RPUkFHRTotbG9jYWw6Ly9sb2NhbGhvc3R9JwogICAgICAtIE9QUl9FWEVDVVRPUl9DT05ORUNUSU9OX0JVSUxEX0NBQ0hFX1NUT1JBR0U9CiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJy90bXA6L3RtcDpydycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIC1IICJBdXRob3JpemF0aW9uOiBCZWFyZXIgJCRPUFJfRVhFQ1VUT1JfU0VDUkVUIiBodHRwOi8vbG9jYWxob3N0L3YxL2hlYWx0aCA+IC9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG9yY2hlc3RyYXRvcjoKICAgIGltYWdlOiAnZ2hjci5pby9vcGVuLXJ1bnRpbWVzL29yY2hlc3RyYXRvci9vcmNoZXN0cmF0b3I6Mi4yLjInCiAgICBob3N0bmFtZTogb3JjaGVzdHJhdG9yCiAgICB1c2VyOiByb290CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBPUkNIRVNUUkFUT1JfQkFDS0VORD1kb2NrZXIKICAgICAgLSAnT1JDSEVTVFJBVE9SX05FVFdPUks9JHtDT01QT1NFX1BST0pFQ1RfTkFNRX0nCiAgICAgIC0gJ0RPQ0tFUl9ORVRXT1JLPSR7Q09NUE9TRV9QUk9KRUNUX05BTUV9JwogICAgICAtICdBUlRJRkFDVF9FTkRQT0lOVD1odHRwOi8vb3JjaGVzdHJhdG9yOjgwODAnCiAgICAgIC0gJ1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnUzNfQUNDRVNTX0tFWV9JRD0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9rby1hcHAvb3JjaGVzdHJhdG9yCiAgICAgICAgLSAnLWNoZWNrLXJlYWR5JwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9wb3N0Z3JlczowLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXBvc3RncmVzcWw6L3Zhci9saWIvcG9zdGdyZXNxbDpydycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBhcHB3cml0ZS1yZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny40LjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLW1heG1lbW9yeSA1MTJtYiAtLW1heG1lbW9yeS1wb2xpY3kgYWxsa2V5cy1scnUgLS1tYXhtZW1vcnktc2FtcGxlcyA1JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtcmVkaXM6L2RhdGE6cncnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICBhcHB3cml0ZS1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjQuMy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBDTElDS0hPVVNFX0RCPWFwcHdyaXRlCiAgICAgIC0gQ0xJQ0tIT1VTRV9VU0VSPWFwcHdyaXRlCiAgICAgIC0gQ0xJQ0tIT1VTRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFCiAgICAgIC0gQ0xJQ0tIT1VTRV9ERUZBVUxUX0FDQ0VTU19NQU5BR0VNRU5UPTEKICAgICAgLSBDTElDS0hPVVNFX0FMV0FZU19SVU5fSU5JVERCX1NDUklQVFM9MQogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtY2xpY2tob3VzZTovdmFyL2xpYi9jbGlja2hvdXNlOnJ3JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLWNvbmZpZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvYXBwd3JpdGUueG1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogIjw/eG1sIHZlcnNpb249XCIxLjBcIj8+XG48IS0tXG4gICAgUmV0ZW50aW9uIGZvciBDbGlja0hvdXNlJ3Mgb3duIHN5c3RlbSBsb2cgdGFibGVzLlxuXG4gICAgRXZlcnkgPHR0bD4gaW4gdGhlIGltYWdlJ3MgY29uZmlnLnhtbCBzaXRzIGluc2lkZSBhbiBYTUwgY29tbWVudCwgc28gdGhlc2VcbiAgICB0YWJsZXMga2VlcCBldmVyeSByb3cgZm9yZXZlciwgYW5kIHRoZSBtZXRyaWMgb25lcyBhZGQgYSByb3cgcGVyIHNlY29uZFxuICAgIHdoZXRoZXIgb3Igbm90IHRoZSBpbnN0YW5jZSBzZXJ2ZXMgdHJhZmZpYy4gTGVmdCBhbG9uZSB0aGV5IG91dGdyb3cgdGhlXG4gICAgdXNhZ2UgZGF0YSB0aGV5IHNpdCBuZXh0IHRvIGFuZCBmaWxsIHRoZSB2b2x1bWUuXG5cbiAgICBXaGVuIGEgd2luZG93IGhlcmUgY2hhbmdlcywgQ2xpY2tIb3VzZSByZXBsYWNlcyB0aGUgdGFibGUgb24gdGhlIG5leHRcbiAgICByZXN0YXJ0IGFuZCBrZWVwcyB0aGUgb2xkIG9uZSwgcm93cyBhbmQgYWxsLCBhcyA8bG9nPl88Tj4uXG4gICAgY2xpY2tob3VzZS1pbml0LnNoIGdpdmVzIHRob3NlIG9sZCB0YWJsZXMgdGhlIHNhbWUgd2luZG93LlxuLS0+XG48Y2xpY2tob3VzZT5cbiAgICA8IS0tIFRoZSBtZXRyaWMsIHRyYWNlIGFuZCBwcm9maWxlIGxvZ3Mgc2FtcGxlIHRoZSBzZXJ2ZXIgZXZlcnkgc2Vjb25kIHdoZXRoZXJcbiAgICAgICAgIG9yIG5vdCBBcHB3cml0ZSBzZXJ2ZXMgdHJhZmZpYywgYW5kIG5vdGhpbmcgcmVhZHMgdGhlbSBiYWNrLCBzbyB0aGVpclxuICAgICAgICAgd3JpdGVzIGFuZCB0aGUgbWVyZ2VzIGJlaGluZCB0aGVtIGFyZSBwdXJlIG92ZXJoZWFkLiAtLT5cbiAgICA8YXN5bmNocm9ub3VzX21ldHJpY19sb2cgcmVtb3ZlPVwiMVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCIxXCIvPlxuICAgIDxoaXN0b2dyYW1fbWV0cmljX2xvZyByZW1vdmU9XCIxXCIvPlxuICAgIDxxdWVyeV9tZXRyaWNfbG9nIHJlbW92ZT1cIjFcIi8+XG4gICAgPHByb2Nlc3NvcnNfcHJvZmlsZV9sb2cgcmVtb3ZlPVwiMVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cIjFcIi8+XG4gICAgPCEtLSB0ZXh0X2xvZyByZXBlYXRzIHRoZSBzZXJ2ZXIgbG9nIHRoZSBjb250YWluZXIgYWxyZWFkeSB3cml0ZXMuIC0tPlxuICAgIDx0ZXh0X2xvZyByZW1vdmU9XCIxXCIvPlxuXG4gICAgPCEtLSBLZXB0IGZvciBkZWJ1Z2dpbmcsIGNhcHBlZCBzbyB0aGV5IGNhbm5vdCBmaWxsIHRoZSB2b2x1bWUuIC0tPlxuICAgIDxhc3luY2hyb25vdXNfaW5zZXJ0X2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAzIERBWSBERUxFVEU8L3R0bD48L2FzeW5jaHJvbm91c19pbnNlcnRfbG9nPlxuICAgIDxxdWVyeV9sb2c+PHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgNyBEQVkgREVMRVRFPC90dGw+PC9xdWVyeV9sb2c+XG4gICAgPHF1ZXJ5X3ZpZXdzX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEU8L3R0bD48L3F1ZXJ5X3ZpZXdzX2xvZz5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEU8L3R0bD48L3F1ZXJ5X3RocmVhZF9sb2c+XG4gICAgPHBhcnRfbG9nPjx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDcgREFZIERFTEVURTwvdHRsPjwvcGFydF9sb2c+XG4gICAgPGJhY2tncm91bmRfc2NoZWR1bGVfcG9vbF9sb2c+PHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgNyBEQVkgREVMRVRFPC90dGw+PC9iYWNrZ3JvdW5kX3NjaGVkdWxlX3Bvb2xfbG9nPlxuXG4gICAgPCEtLSBMb25nZXIsIHNvIGl0IHN0aWxsIGNvdmVycyBhIHJlcG9ydCB0aGF0IHJlYWNoZXMgdXMgbGF0ZS4gY3Jhc2hfbG9nIGlzXG4gICAgICAgICBkZWxpYmVyYXRlbHkgbGVmdCBhbG9uZTogaXQgaXMgdGlueSBhbmQgd29ydGgga2VlcGluZy4gLS0+XG4gICAgPGVycm9yX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxNCBEQVkgREVMRVRFPC90dGw+PC9lcnJvcl9sb2c+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2UtaW5pdC5zaAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2NsaWNraG91c2UtaW5pdC5zaAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vYmFzaFxuIyBUaGUgQ2xpY2tIb3VzZSBlbnRyeXBvaW50IHNvdXJjZXMgdGhpcyBmaWxlLCBzbyBhIHN1YnNoZWxsIGtlZXBzIHRoZXNlIG9wdGlvbnMgb3V0IG9mIGl0LlxuKFxuICBzZXQgLWV1byBwaXBlZmFpbFxuXG4gIGNsaWVudCgpIHtcbiAgICBjbGlja2hvdXNlLWNsaWVudCAtLWhvc3QgMTI3LjAuMC4xIC0tdXNlciBcIiRDTElDS0hPVVNFX1VTRVJcIiAtLXBhc3N3b3JkIFwiJENMSUNLSE9VU0VfUEFTU1dPUkRcIiBcIiRAXCJcbiAgfVxuXG4gICMgRmx1c2hpbmcgcmVwbGFjZXMgbG9nIHRhYmxlcyB3aG9zZSBkZWZpbml0aW9uIGNoYW5nZWQsIGtlZXBpbmcgZWFjaCBvbGQgdGFibGUgYXMgPGxvZz5fPE4+LlxuICBjbGllbnQgLS1xdWVyeSAnU1lTVEVNIEZMVVNIIExPR1MnXG5cbiAgIyBUYWJsZXMgbGVmdCBiZWhpbmQgbmV2ZXIgZXhwaXJlIG9uIHRoZWlyIG93bjogdGFrZSB0aGUgVFRMIG9mIHRoZSBsb2cgdGhhdFxuICAjIHJlcGxhY2VkIHRoaXMgb25lLCBvciBhIHdlZWsgd2hlbiB0aGUgbG9nIGlzIG5vIGxvbmdlciB3cml0dGVuIGF0IGFsbC5cbiAgIyBUaGUgbmFtZWQgbG9ncyBtdXN0IG1hdGNoIHRoZSBvbmVzIGNsaWNraG91c2UtY29uZmlnLnhtbCByZW1vdmVzOiBhIGxvZyB0aGF0XG4gICMgaXMgc3RpbGwgd3JpdHRlbiBnZXRzIHJlcGxhY2VkIG9uIHRoZSBuZXh0IHJlc3RhcnQgaWYgaXRzIFRUTCBpcyBhbHRlcmVkIGhlcmUuXG4gIGNsaWVudCAtLWZvcm1hdCBUU1ZSYXcgPDwnU1FMJyB8IGNsaWVudCAtLW11bHRpcXVlcnlcblNFTEVDVCBjb25jYXQoJ0FMVEVSIFRBQkxFIHN5c3RlbS4nLCBzdGFsZS5uYW1lLCAnIE1PRElGWSBUVEwgJywgaWYoYWN0aXZlLm5hbWUgPSAnJywgJ2V2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEUnLCBzcGxpdEJ5U3RyaW5nKCcgU0VUVElOR1MgJywgc3BsaXRCeVN0cmluZygnIFRUTCAnLCBhY3RpdmUuZW5naW5lX2Z1bGwpWzJdKVsxXSksICc7JylcbkZST00gKFxuICBTRUxFQ1QgbmFtZSwgc3BsaXRCeUNoYXIoJ18nLCBuYW1lKVstMV0gQVMgc3VmZml4LCBhcnJheVN0cmluZ0NvbmNhdChhcnJheVBvcEJhY2soc3BsaXRCeUNoYXIoJ18nLCBuYW1lKSksICdfJykgQVMgbG9nXG4gIEZST00gc3lzdGVtLnRhYmxlc1xuICBXSEVSRSBkYXRhYmFzZSA9ICdzeXN0ZW0nXG4gICAgQU5EIHBvc2l0aW9uKGVuZ2luZV9mdWxsLCAnIFRUTCAnKSA9IDBcbiAgICBBTkQgZW5kc1dpdGgoZW5naW5lLCAnTWVyZ2VUcmVlJylcbiAgICBBTkQgbmFtZSBJTiAoU0VMRUNUIHRhYmxlIEZST00gc3lzdGVtLmNvbHVtbnMgV0hFUkUgZGF0YWJhc2UgPSAnc3lzdGVtJyBBTkQgbmFtZSA9ICdldmVudF9kYXRlJyBBTkQgdHlwZSA9ICdEYXRlJylcbikgQVMgc3RhbGVcbkxFRlQgSk9JTiBzeXN0ZW0udGFibGVzIEFTIGFjdGl2ZVxuICBPTiBhY3RpdmUuZGF0YWJhc2UgPSAnc3lzdGVtJ1xuICBBTkQgYWN0aXZlLm5hbWUgPSBzdGFsZS5sb2dcbiAgQU5EIHBvc2l0aW9uKGFjdGl2ZS5lbmdpbmVfZnVsbCwgJyBUVEwgJykgPiAwXG5XSEVSRSAoZW5kc1dpdGgoc3RhbGUubG9nLCAnX2xvZycpIEFORCB0b1VJbnQ2NE9yTnVsbChzdGFsZS5zdWZmaXgpIElTIE5PVCBOVUxMKVxuICBPUiBzdGFsZS5uYW1lIElOICgnYXN5bmNocm9ub3VzX21ldHJpY19sb2cnLCAnbWV0cmljX2xvZycsICdoaXN0b2dyYW1fbWV0cmljX2xvZycsICdxdWVyeV9tZXRyaWNfbG9nJywgJ3Byb2Nlc3NvcnNfcHJvZmlsZV9sb2cnLCAndHJhY2VfbG9nJywgJ3RleHRfbG9nJylcblNRTFxuKSIKICAgIHVsaW1pdHM6CiAgICAgIG5vZmlsZToKICAgICAgICBzb2Z0OiAyNjIxNDQKICAgICAgICBoYXJkOiAyNjIxNDQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY2xpY2tob3VzZS1jbGllbnQgLS11c2VyICIkJENMSUNLSE9VU0VfVVNFUiIgLS1wYXNzd29yZCAiJCRDTElDS0hPVVNFX1BBU1NXT1JEIiAtLXF1ZXJ5ICJTRUxFQ1QgMSIgPiAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDIwCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", - "tags": [ - "backend", - "backend-as-a-service", - "platform" - ], - "category": "backend", - "logo": "svgs/appwrite.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-08T19:41:04+02:00", - "port": "80" - }, "argilla": { "documentation": "https://docs.argilla.io/latest/?utm_source=coolify.io", "slogan": "Argilla is a collaboration tool for AI engineers and domain experts who need to build high-quality datasets for their projects.", @@ -254,23 +239,6 @@ "template_last_updated_at": "2026-09-27T23:49:51+02:00", "port": "80" }, - "babybuddy": { - "documentation": "https://docs.baby-buddy.net?utm_source=coolify.io", - "slogan": "It helps parents track their baby's daily activities, growth, and health with ease.", - "compose": "c2VydmljZXM6CiAgYmFieWJ1ZGR5OgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL2JhYnlidWRkeTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CQUJZQlVERFkKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gQ1NSRl9UUlVTVEVEX09SSUdJTlM9JFNFUlZJQ0VfVVJMX0JBQllCVUREWQogICAgdm9sdW1lczoKICAgICAgLSAnYmFieWJ1ZGR5LWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", - "tags": [ - "baby", - "parents", - "health", - "growth", - "activities" - ], - "category": "productivity", - "logo": "svgs/babybuddy.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "8000" - }, "bento-pdf": { "documentation": "https://bentopdf.com/docs?utm_source=coolify.io", "slogan": "Process PDFs entirely in your browser. No uploads. No servers. Complete privacy.", @@ -350,26 +318,6 @@ "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, - "bookstack": { - "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", - "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", - "compose": "c2VydmljZXM6CiAgYm9va3N0YWNrOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL2Jvb2tzdGFjazpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9CT09LU1RBQ0tfODAKICAgICAgLSAnQVBQX1VSTD0ke1NFUlZJQ0VfVVJMX0JPT0tTVEFDS30nCiAgICAgIC0gJ0FQUF9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0FQUEtFWX0nCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtIERCX0hPU1Q9bWFyaWFkYgogICAgICAtIERCX1BPUlQ9MzMwNgogICAgICAtICdEQl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RCX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWJvb2tzdGFja2FwcH0nCiAgICAgIC0gJ1FVRVVFX0NPTk5FQ1RJT049JHtRVUVVRV9DT05ORUNUSU9OfScKICAgICAgLSAnR0lUSFVCX0FQUF9JRD0ke0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdHSVRIVUJfQVBQX1NFQ1JFVD0ke0dJVEhVQl9BUFBfU0VDUkVUfScKICAgICAgLSAnTUFJTF9EUklWRVI9JHtNQUlMX0RSSVZFUjotc210cH0nCiAgICAgIC0gJ01BSUxfSE9TVD0ke01BSUxfSE9TVH0nCiAgICAgIC0gJ01BSUxfUE9SVD0ke01BSUxfUE9SVDotNTg3fScKICAgICAgLSAnTUFJTF9FTkNSWVBUSU9OPSR7TUFJTF9FTkNSWVBUSU9OOi10bHN9JwogICAgICAtICdNQUlMX1VTRVJOQU1FPSR7TUFJTF9VU0VSTkFNRX0nCiAgICAgIC0gJ01BSUxfUEFTU1dPUkQ9JHtNQUlMX1BBU1NXT1JEfScKICAgICAgLSAnTUFJTF9GUk9NPSR7TUFJTF9GUk9NfScKICAgICAgLSAnTUFJTF9GUk9NX05BTUU9JHtNQUlMX0ZST01fTkFNRTotQm9va1N0YWNrfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tzdGFjay1kYXRhOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWYgaHR0cDovLzEyNy4wLjAuMTo4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL21hcmlhZGI6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9CZXJsaW59JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1ib29rc3RhY2t9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Jvb2tzdGFjay1tYXJpYWRiLWRhdGE6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "free-and-open-source", - "mfa", - "dark-light-themes", - "searchable", - "connected", - "simple-interface", - "diagramms", - "notes" - ], - "category": "cms", - "logo": "svgs/bookstack.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "browserless": { "documentation": "https://docs.browserless.io/?utm_source=coolify.io", "slogan": "A headless Chrome browser as a service.", @@ -386,21 +334,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "budge": { - "documentation": "https://github.com/linuxserver/budge?utm_source=coolify.io", - "slogan": "A budgeting personal finance app.", - "compose": "c2VydmljZXM6CiAgYnVkZ2U6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvYnVkZ2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQlVER0UKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdidWRnZS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "personal finance", - "budgeting", - "expense tracking" - ], - "category": "finance", - "logo": "svgs/budge.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", "slogan": "Low code platform for building business apps and workflows in minutes. Supports PostgreSQL, MySQL, MSSQL, MongoDB, Rest API, Docker, K8s, and more.", @@ -421,7 +354,7 @@ "category": "automation", "logo": "svgs/budibase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-17T10:54:40+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "10000" }, "bugsink": { @@ -454,7 +387,7 @@ "category": "Mail", "logo": "svgs/bulwark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T17:21:51+02:00", + "template_last_updated_at": "2026-10-01T20:31:00+02:00", "port": "3000" }, "buzz": { @@ -474,7 +407,7 @@ "category": "messaging", "logo": "svgs/buzz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T19:27:01+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "calibre-web-automated-book-downloader": { @@ -555,26 +488,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5679" }, - "castopod": { - "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", - "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0NBU1RPUE9EXzgwODAKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jYXN0b3BvZAogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NWVNRTAogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gJ0NQX0RJU0FCTEVfSFRUUFM9JHtDUF9ESVNBQkxFX0hUVFBTOi0xfScKICAgICAgLSBDUF9CQVNFVVJMPSRTRVJWSUNFX1VSTF9DQVNUT1BPRAogICAgICAtIENQX0FOQUxZVElDU19TQUxUPSRTRVJWSUNFX1JFQUxCQVNFNjRfNjRfU0FMVAogICAgICAtIENQX0NBQ0hFX0hBTkRMRVI9cmVkaXMKICAgICAgLSBDUF9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gQ1BfUkVESVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWRiOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNhc3RvcG9kCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjItYWxwaW5lJwogICAgY29tbWFuZDogJy0tcmVxdWlyZXBhc3MgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMnCiAgICB2b2x1bWVzOgogICAgICAtICdjYXN0b3BvZC1jYWNoZTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIC1hICRTRVJWSUNFX1BBU1NXT1JEX1JFRElTIHBpbmcgfCBncmVwIFBPTkcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "podcast", - "media", - "audio", - "video", - "streaming", - "hosting", - "platform", - "castopod" - ], - "category": "media", - "logo": "svgs/castopod.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-03-13T12:05:06+05:30", - "port": "8080" - }, "celld": { "documentation": "https://celld.dev/docs?utm_source=coolify.io", "slogan": "Self-hosted, distributed Durable Objects powered by Deno.", @@ -705,54 +618,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8000" }, - "classicpress-with-mariadb": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0xBU1NJQ1BSRVNTCiAgICAgIC0gQ0xBU1NJQ1BSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIENMQVNTSUNQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfQ0xBU1NJQ1BSRVNTCiAgICAgIC0gQ0xBU1NJQ1BSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX0NMQVNTSUNQUkVTUwogICAgICAtIENMQVNTSUNQUkVTU19EQl9OQU1FPWNsYXNzaWNwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2xhc3NpY3ByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX0NMQVNTSUNQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX0NMQVNTSUNQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "classicpress-with-mysql": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0xBU1NJQ1BSRVNTCiAgICAgIC0gQ0xBU1NJQ1BSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBDTEFTU0lDUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX0NMQVNTSUNQUkVTUwogICAgICAtIENMQVNTSUNQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTEFTU0lDUFJFU1MKICAgICAgLSBDTEFTU0lDUFJFU1NfREJfTkFNRT1jbGFzc2ljcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNsYXNzaWNwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9DTEFTU0lDUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTEFTU0lDUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "classicpress-without-database": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfQ0xBU1NJQ1BSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "cloudbeaver": { "documentation": "https://dbeaver.com/docs/cloudbeaver/?utm_source=coolify.io", "slogan": "CloudBeaver is a lightweight web application designed for comprehensive data management.", @@ -813,23 +678,6 @@ "template_last_updated_at": "2026-03-03T11:20:40+05:30", "port": "5212" }, - "cockpit": { - "documentation": "https://getcockpit.com/documentation/?utm_source=coolify.io", - "slogan": "Cockpit is a headless content platform that is lightweight, fast and ready for takeoff.", - "compose": "c2VydmljZXM6CiAgY29ja3BpdDoKICAgIGltYWdlOiAnY29ja3BpdGhxL2NvY2twaXQ6Y29yZS1sYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdjb2NrcGl0LWNvbmZpZzovdmFyL3d3dy9odG1sL2NvbmZpZycKICAgICAgLSAnY29ja3BpdC1zcGFjZXM6L3Zhci93d3cvaHRtbC8uc3BhY2VzJwogICAgICAtICdjb2NrcGl0LXN0b3JhZ2U6L3Zhci93d3cvaHRtbC9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xL2FwaS9zeXN0ZW0vaGVhbHRoY2hlY2snCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cockpit", - "headless", - "cms", - "database", - "nosql" - ], - "category": "cms", - "logo": "svgs/cockpit.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "code-server": { "documentation": "https://coder.com/docs/code-server/latest?utm_source=coolify.io", "slogan": "Code-Server is a web-based code editor that enables remote coding and collaboration from any device, anywhere.", @@ -1120,36 +968,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "dokuwiki": { - "documentation": "https://www.dokuwiki.org/?utm_source=coolify.io", - "slogan": "A lightweight and easy-to-use wiki platform for creating and managing documentation and knowledge bases.", - "compose": "c2VydmljZXM6CiAgZG9rdXdpa2k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZG9rdXdpa2k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfRE9LVVdJS0kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdkb2t1d2lraS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "wiki", - "documentation", - "knowledge", - "base" - ], - "category": "cms", - "logo": "svgs/dokuwiki.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "dolibarr": { - "documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io", - "slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).", - "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0RPTElCQVJSXzgwCiAgICAgIC0gJ1dXV19VU0VSX0lEPSR7V1dXX1VTRVJfSUQ6LTEwMDB9JwogICAgICAtICdXV1dfR1JPVVBfSUQ9JHtXV1dfR1JPVVBfSUQ6LTEwMDB9JwogICAgICAtIERPTElfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RPTElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ0RPTElfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0RPTElfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnRE9MSV9VUkxfUk9PVD0ke1NFUlZJQ0VfVVJMX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9MT0dJTj0ke1NFUlZJQ0VfVVNFUl9ET0xJQkFSUn0nCiAgICAgIC0gJ0RPTElfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9DUk9OPSR7RE9MSV9DUk9OOi0wfScKICAgICAgLSAnRE9MSV9JTklUX0RFTU89JHtET0xJX0lOSVRfREVNTzotMH0nCiAgICAgIC0gJ0RPTElfQ09NUEFOWV9OQU1FPSR7RE9MSV9DT01QQU5ZX05BTUU6LU15QmlnQ29tcGFueX0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9kb2NzOi92YXIvd3d3L2RvY3VtZW50cycKICAgICAgLSAnZG9saWJhcnJfY3VzdG9tOi92YXIvd3d3L2h0bWwvY3VzdG9tJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdkb2xpYmFycl9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "crm", - "erp" - ], - "category": "productivity", - "logo": "svgs/dolibarr.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "dozzle-with-auth": { "documentation": "https://dozzle.dev/?utm_source=coolify.io", "slogan": "Dozzle is a simple and lightweight web UI for Docker logs.", @@ -1199,23 +1017,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4983" }, - "drupal-with-postgresql": { - "documentation": "https://www.drupal.org/about?utm_source=coolify.io", - "slogan": "Drupal is a free and open-source web content management system written in PHP and distributed under the GNU General Public License.", - "compose": "c2VydmljZXM6CiAgZHJ1cGFsOgogICAgaW1hZ2U6ICdkcnVwYWw6MTAtYXBhY2hlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfRFJVUEFMCiAgICAgIC0gREJfSE9TVD1wb3N0Z3JlcwogICAgICAtIERCX05BTUU9cG9zdGdyZXMKICAgICAgLSBEQl9VU0VSPXBvc3RncmVzCiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IGRydXBhbF9tb2R1bGVzCiAgICAgICAgdGFyZ2V0OiAvdmFyL3d3dy9odG1sL21vZHVsZXMKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogZHJ1cGFsX3Byb2ZpbGVzCiAgICAgICAgdGFyZ2V0OiAvdmFyL3d3dy9odG1sL3Byb2ZpbGVzCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IGRydXBhbF90aGVtZXMKICAgICAgICB0YXJnZXQ6IC92YXIvd3d3L2h0bWwvdGhlbWVzCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IGRydXBhbF9zaXRlcwogICAgICAgIHRhcmdldDogL3Zhci93d3cvaHRtbC9zaXRlcwogICAgICAgIGlzX2RpcmVjdG9yeTogdHJ1ZQogICAgZGVwZW5kc19vbjoKICAgICAgLSBwb3N0Z3JlcwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mIGh0dHA6Ly9sb2NhbGhvc3Q6ODAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1CiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfREI9cG9zdGdyZXMKICAgICAgLSBQT1NUR1JFU19VU0VSPXBvc3RncmVzCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "postgresql" - ], - "category": "cms", - "logo": "svgs/drupal.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "duplicati": { "documentation": "https://duplicati.readthedocs.io?utm_source=coolify.io", "slogan": "Duplicati is a backup solution, allowing you to make scheduled backups with encryption.", @@ -1230,21 +1031,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8200" }, - "easyappointments": { - "documentation": "https://easyappointments.org/?utm_source=coolify.io", - "slogan": "Schedule Anything. Let's start with easy! Get the best free online appointment scheduler on your server, today.", - "compose": "c2VydmljZXM6CiAgZWFzeWFwcG9pbnRtZW50czoKICAgIGltYWdlOiAnYWxleHRzZWxlZ2lkaXMvZWFzeWFwcG9pbnRtZW50czpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FQVNZQVBQT0lOVE1FTlRTXzgwCiAgICAgIC0gJ0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfRUFTWUFQUE9JTlRNRU5UU30nCiAgICAgIC0gREJfSE9TVD1teXNxbAogICAgICAtIERCX05BTUU9ZWFzeWFwcG9pbnRtZW50cwogICAgICAtIERCX1VTRVJOQU1FPXJvb3QKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0VBU1lBUFBPSU5UTUVOVFN9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdlYXN5YXBwb2ludG1lbnRzLW15c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUFTWUFQUE9JTlRNRU5UU30nCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9ZWFzeWFwcG9pbnRtZW50cwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "calendar", - "scheduling", - "database" - ], - "category": "productivity", - "logo": "svgs/easyappointments.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "elasticsearch-with-kibana": { "documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io", "slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack", @@ -1390,7 +1176,7 @@ "category": "media", "logo": "svgs/ente-photos.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-22T16:05:17+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "ente-photos": { @@ -1429,24 +1215,6 @@ "template_last_updated_at": "2026-01-05T14:20:17+01:00", "port": "6052" }, - "espocrm": { - "documentation": "https://docs.espocrm.com?utm_source=coolify.io", - "slogan": "EspoCRM is a free and open-source CRM platform.", - "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0VTUE9DUk0KICAgICAgLSAnRVNQT0NSTV9BRE1JTl9VU0VSTkFNRT0ke0VTUE9DUk1fQURNSU5fVVNFUk5BTUU6LWFkbWlufScKICAgICAgLSAnRVNQT0NSTV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtIEVTUE9DUk1fREFUQUJBU0VfUExBVEZPUk09TXlzcWwKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX0hPU1Q9ZXNwb2NybS1kYgogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX05BTUU9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdFU1BPQ1JNX0RBVEFCQVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9TSVRFX1VSTD0ke1NFUlZJQ0VfVVJMX0VTUE9DUk19JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYXRhOi92YXIvd3d3L2h0bWwvZGF0YScKICAgICAgLSAnZXNwb2NybS1jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICAgIC0gJ2VzcG9jcm0tY3VzdG9tLWNsaWVudDovdmFyL3d3dy9odG1sL2NsaWVudC9jdXN0b20nCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDYwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGFlbW9uOgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYWVtb24KICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLWRhZW1vbi5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLXdlYnNvY2tldDoKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0td2Vic29ja2V0CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX1VSTF9FU1BPQ1JNX1dFQlNPQ0tFVAogICAgICAtICdFU1BPQ1JNX0NPTkZJR19XRUJfU09DS0VUX1pFUk9fTV9RX1NVQlNDUklCRVJfRFNOPXRjcDovLyo6Nzc3NycKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJNSVNTSU9OX0RTTj10Y3A6Ly9lc3BvY3JtLXdlYnNvY2tldDo3Nzc3JwogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItd2Vic29ja2V0LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0tZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTIuMycKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LWVzcG9jcm19JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZXNwb2NybS1kYjovdmFyL2xpYi9teXNxbCcKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "crm", - "self-hosted", - "open-source", - "workflow", - "automation", - "project management" - ], - "category": "productivity", - "logo": "svgs/espocrm.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-07-03T15:15:43+03:00", - "port": "80" - }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", @@ -1563,22 +1331,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5000" }, - "firefly": { - "documentation": "https://firefly-iii.org?utm_source=coolify.io", - "slogan": "A personal finances manager that can help you save money.", - "compose": "c2VydmljZXM6CiAgZmlyZWZseToKICAgIGltYWdlOiAnZmlyZWZseWlpaS9jb3JlOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZJUkVGTFlfODA4MAogICAgICAtIEFQUF9LRVk9JFNFUlZJQ0VfQkFTRTY0X0FQUEtFWQogICAgICAtIERCX0hPU1Q9bXlzcWwKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9DT05ORUNUSU9OPW15c3FsCiAgICAgIC0gJ0RCX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWZpcmVmbHl9JwogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIFNUQVRJQ19DUk9OX1RPS0VOPSRTRVJWSUNFX0JBU0U2NF9DUk9OVE9LRU4KICAgICAgLSAnVFJVU1RFRF9QUk9YSUVTPSonCiAgICB2b2x1bWVzOgogICAgICAtICdmaXJlZmx5LXVwbG9hZDovdmFyL3d3dy9odG1sL3N0b3JhZ2UvdXBsb2FkJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBteXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG15c3FsOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1maXJlZmx5fScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYXJpYWRiLWFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgICAtICctdXJvb3QnCiAgICAgICAgLSAnLXAke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnZmlyZWZseS1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogIGNyb246CiAgICBpbWFnZTogYWxwaW5lCiAgICBlbnRyeXBvaW50OgogICAgICAtIC9lbnRyeXBvaW50LnNoCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9lbnRyeXBvaW50LnNoCiAgICAgICAgdGFyZ2V0OiAvZW50cnlwb2ludC5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbiMgU3Vic3RpdHV0ZSB0aGUgZW52aXJvbm1lbnQgdmFyaWFibGUgaW50byB0aGUgY3JvbiBjb21tYW5kXG5DUk9OX0NPTU1BTkQ9XCIwIDMgKiAqICogd2dldCAtcU8tIGh0dHA6Ly9maXJlZmx5OjgwODAvYXBpL3YxL2Nyb24vJHtTVEFUSUNfQ1JPTl9UT0tFTn1cIlxuIyBBZGQgdGhlIGNyb24gY29tbWFuZCB0byB0aGUgY3JvbnRhYlxuZWNobyBcIiRDUk9OX0NPTU1BTkRcIiB8IGNyb250YWIgLVxuIyBTdGFydCB0aGUgY3JvbiBkYWVtb24gaW4gdGhlIGZvcmVncm91bmQgd2l0aCBsb2dnaW5nIHRvIHN0ZG91dFxuY3JvbmQgLWYgLUwgL2Rldi9zdGRvdXQiCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTVEFUSUNfQ1JPTl9UT0tFTj0kU0VSVklDRV9CQVNFNjRfQ1JPTlRPS0VOCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2xzIC9lbnRyeXBvaW50LnNoIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDQwcwo=", - "tags": [ - "finance", - "money", - "personal", - "manager" - ], - "category": "finance", - "logo": "svgs/firefly.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T10:43:04+02:00", - "port": "8080" - }, "firefox": { "documentation": "https://github.com/jlesage/docker-firefox?utm_source=coolify.io", "slogan": "Fast, private, and self-hosted secure browser for browsing without limits.", @@ -1696,7 +1448,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-mysql": { @@ -1714,7 +1466,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-postgresql": { @@ -1732,7 +1484,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-mariadb": { @@ -1754,7 +1506,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-mysql": { @@ -1776,7 +1528,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-postgresql": { @@ -1798,7 +1550,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner": { @@ -1819,7 +1571,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo": { @@ -1836,7 +1588,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "formbricks": { @@ -1852,7 +1604,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "foundryvtt": { @@ -1872,78 +1624,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "30000" }, - "freescout": { - "documentation": "https://github.com/freescout-help-desk/freescout/wiki/?utm_source=coolify.io", - "slogan": "FreeScout is the super lightweight and powerful free open source help desk and shared inbox written in PHP (Laravel framework).", - "compose": "c2VydmljZXM6CiAgZnJlZXNjb3V0OgogICAgaW1hZ2U6ICd0aXJlZG9maXQvZnJlZXNjb3V0OmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZWVzY291dC1kYXRhOi9kYXRhJwogICAgICAtICdmcmVlc2NvdXQtbG9nczovd3d3L2xvZ3MnCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9GUkVFU0NPVVRfODAKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSAnREJfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LWZyZWVzY291dH0nCiAgICAgIC0gJ0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQjotZnJlZXNjb3V0fScKICAgICAgLSAnREJfUEFTUz0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ1NJVEVfVVJMPSR7U0VSVklDRV9VUkxfRlJFRVNDT1VUfScKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9QQVNTPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ0RJU1BMQVlfRVJST1JTPSR7RElTUExBWV9FUlJPUlM6LUZBTFNFfScKICAgICAgLSAnVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQogIG1hcmlhZGI6CiAgICBpbWFnZTogbWFyaWFkYgogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "helpdesk", - "support", - "ticketing", - "customer-support" - ], - "category": "helpdesk", - "logo": "svgs/freescout.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-mariadb": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZSRVNIUlNTXzgwCiAgICAgIC0gJ0NST05fTUlOPSR7Q1JPTl9NSU46LTEsMzF9JwogICAgICAtICdNQVJJQURCX0RCPSR7TUFSSUFEQl9EQVRBQkFTRTotZnJlc2hyc3N9JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnZnJlc2hyc3MtZGF0YTovdmFyL3d3dy9GcmVzaFJTUy9kYXRhJwogICAgICAtICdmcmVzaHJzcy1leHRlbnNpb25zOi92YXIvd3d3L0ZyZXNoUlNTL2V4dGVuc2lvbnMnCiAgICBkZXBlbmRzX29uOgogICAgICBmcmVzaHJzcy1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvODAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCiAgZnJlc2hyc3MtZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1mcmVzaHJzc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-mysql": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZSRVNIUlNTXzgwCiAgICAgIC0gJ0NST05fTUlOPSR7Q1JPTl9NSU46LTEsMzF9JwogICAgICAtICdNWVNRTF9EQj0ke01ZU1FMX0RBVEFCQVNFOi1mcmVzaHJzc30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZnJlc2hyc3MtZGF0YTovdmFyL3d3dy9GcmVzaFJTUy9kYXRhJwogICAgICAtICdmcmVzaHJzcy1leHRlbnNpb25zOi92YXIvd3d3L0ZyZXNoUlNTL2V4dGVuc2lvbnMnCiAgICBkZXBlbmRzX29uOgogICAgICBmcmVzaHJzcy1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvODAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCiAgZnJlc2hyc3MtZGI6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWZyZXNocnNzfScKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-postgresql": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZSRVNIUlNTXzgwCiAgICAgIC0gJ0NST05fTUlOPSR7Q1JPTl9NSU46LTEsMzF9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWZyZXNocnNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtIFBPU1RHUkVTX0hPU1Q9ZnJlc2hyc3MtZGIKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZXNocnNzLWRhdGE6L3Zhci93d3cvRnJlc2hSU1MvZGF0YScKICAgICAgLSAnZnJlc2hyc3MtZXh0ZW5zaW9uczovdmFyL3d3dy9GcmVzaFJTUy9leHRlbnNpb25zJwogICAgZGVwZW5kc19vbjoKICAgICAgZnJlc2hyc3MtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzgwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGZyZXNocnNzLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZXNocnNzLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTUUxfREFUQUJBU0U6LWZyZXNocnNzfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0ZSRVNIUlNTXzgwCiAgICAgIC0gJ0NST05fTUlOPSR7Q1JPTl9NSU46LTEsMzF9JwogICAgdm9sdW1lczoKICAgICAgLSAnZnJlc2hyc3MtZGF0YTovdmFyL3d3dy9GcmVzaFJTUy9kYXRhJwogICAgICAtICdmcmVzaHJzcy1leHRlbnNpb25zOi92YXIvd3d3L0ZyZXNoUlNTL2V4dGVuc2lvbnMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS84MCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "garage": { "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", @@ -2168,22 +1848,6 @@ "template_last_updated_at": "2026-02-10T11:22:57+05:30", "port": "8000" }, - "glpi": { - "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", - "slogan": "GLPI (Gestionnaire Libre de Parc Informatique) is a free, open-source IT Service Management (ITSM) platform used for IT asset management, helpdesk, and service desk operations.", - "compose": "c2VydmljZXM6CiAgZ2xwaToKICAgIGltYWdlOiAnZ2xwaS9nbHBpOjExJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfR0xQSV84MAogICAgICAtIEdMUElfREJfSE9TVD1nbHBpLWRiCiAgICAgIC0gR0xQSV9EQl9QT1JUPTMzMDYKICAgICAgLSAnR0xQSV9EQl9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LWdscGktZGJ9JwogICAgICAtICdHTFBJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdHTFBJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICB2b2x1bWVzOgogICAgICAtICdnbHBpLWRhdGE6L3Zhci9nbHBpOnJ3JwogICAgZGVwZW5kc19vbjoKICAgICAgZ2xwaS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0LycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIGdscGktZGI6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1nbHBpLWRifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "helpdesk", - "ticketing", - "support", - "open-source" - ], - "category": "helpdesk", - "logo": "svgs/glpi.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-01-13T21:07:54+01:00", - "port": "80" - }, "goatcounter": { "documentation": "https://www.goatcounter.com/help?utm_source=coolify.io", "slogan": "Lightweight web analytics platform.", @@ -2327,23 +1991,6 @@ "template_last_updated_at": "2026-02-16T15:13:50+02:00", "port": "8484" }, - "grocy": { - "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", - "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9HUk9DWQogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2dyb2N5LWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", - "tags": [ - "groceries", - "household", - "management", - "grocery", - "shopping" - ], - "category": "productivity", - "logo": "svgs/grocy.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", "slogan": "Hatchet allows you to run background tasks at scale with a high-throughput, low-latency computing service built on an open-source, fault-tolerant queue.", @@ -2357,7 +2004,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-15T22:51:15+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "healthchecks": { @@ -2379,26 +2026,10 @@ "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, - "heimdall": { - "documentation": "https://github.com/linuxserver/Heimdall?utm_source=coolify.io", - "slogan": "Heimdall is a dashboard for managing and organizing your server applications.", - "compose": "c2VydmljZXM6CiAgaGVpbWRhbGw6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvaGVpbWRhbGw6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSEVJTURBTEwKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdoZWltZGFsbC1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "dashboard", - "server", - "applications", - "interface" - ], - "category": "productivity", - "logo": "svgs/heimdall.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "hermes-agent-with-webui": { "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", "slogan": "Hermes Agent - autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", - "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnYyMDI2LjYuNScKICAgIGNvbW1hbmQ6ICdnYXRld2F5IHJ1bicKICAgIGVudmlyb25tZW50OgogICAgICAtIEhFUk1FU19IT01FPS9ob21lL2hlcm1lcy8uaGVybWVzCiAgICAgIC0gV0lLSV9QQVRIPS9ob21lL2hlcm1lcy8uaGVybWVzL3dpa2kKICAgICAgLSBHQVRFV0FZX0FMTE9XX0FMTF9VU0VSUz10cnVlCiAgICAgIC0gSEVSTUVTX1JFREFDVF9TRUNSRVRTPXRydWUKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX0VOQUJMRV9QUk9KRUNUX1BMVUdJTlM9JHtIRVJNRVNfRU5BQkxFX1BST0pFQ1RfUExVR0lOUzotdHJ1ZX0nCiAgICAgIC0gJ0hFUk1FU19PUFRJT05BTF9TS0lMTFM9JHtIRVJNRVNfT1BUSU9OQUxfU0tJTExTOi19JwogICAgICAtICdIRVJNRVNfQUxMT1dfUFJJVkFURV9VUkxTPSR7SEVSTUVTX0FMTE9XX1BSSVZBVEVfVVJMUzotZmFsc2V9JwogICAgICAtICdGSVJFQ1JBV0xfQVBJX0tFWT0ke0ZJUkVDUkFXTF9BUElfS0VZOi19JwogICAgICAtICdGSVJFQ1JBV0xfQVBJX1VSTD0ke0ZJUkVDUkFXTF9BUElfVVJMOi1odHRwczovL2FwaS5maXJlY3Jhd2wuZGV2fScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZOi19JwogICAgICAtICdBTlRIUk9QSUNfQVBJX0tFWT0ke0FOVEhST1BJQ19BUElfS0VZOi19JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZOi19JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZOi19JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1za2lsbHM6L2hvbWUvaGVybWVzLy5oZXJtZXMvc2tpbGxzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjQ4OScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gaGVybWVzLWFnZW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gU0VSVklDRV9VU0VSX0hFUk1FU1dFQlVJCiAgICAgIC0gU0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSQogICAgICAtIEhFUk1FU19XRUJVSV9IT1NUPTAuMC4wLjAKICAgICAgLSBIRVJNRVNfV0VCVUlfUE9SVD04Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX1NUQVRFX0RJUj0vaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzL3dlYnVpCiAgICAgIC0gV0FOVEVEX1VJRD0xMDAwCiAgICAgIC0gV0FOVEVEX0dJRD0xMDAwCiAgICAgIC0gJ0hFUk1FU19XRUJVSV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9IRVJNRVNXRUJVSX0nCiAgICAgIC0gJ0hFUk1FU19XRUJVSV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSEVSTUVTV0VCVUl9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcycKICAgICAgLSAnaGVybWVzLWFnZW50LXNyYzovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzL2hlcm1lcy1hZ2VudDpybycKICAgICAgLSAnaGVybWVzLXdvcmtzcGFjZTovd29ya3NwYWNlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjg3ODcvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnYyMDI2LjkuMjQnCiAgICBjb21tYW5kOiAnZ2F0ZXdheSBydW4nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIRVJNRVNfSE9NRT0vaG9tZS9oZXJtZXMvLmhlcm1lcwogICAgICAtIFdJS0lfUEFUSD0vaG9tZS9oZXJtZXMvLmhlcm1lcy93aWtpCiAgICAgIC0gR0FURVdBWV9BTExPV19BTExfVVNFUlM9dHJ1ZQogICAgICAtIEhFUk1FU19SRURBQ1RfU0VDUkVUUz10cnVlCiAgICAgIC0gSEVSTUVTX1VJRD0xMDAwCiAgICAgIC0gSEVSTUVTX0dJRD0xMDAwCiAgICAgIC0gJ0hFUk1FU19FTkFCTEVfUFJPSkVDVF9QTFVHSU5TPSR7SEVSTUVTX0VOQUJMRV9QUk9KRUNUX1BMVUdJTlM6LXRydWV9JwogICAgICAtICdIRVJNRVNfT1BUSU9OQUxfU0tJTExTPSR7SEVSTUVTX09QVElPTkFMX1NLSUxMUzotfScKICAgICAgLSAnSEVSTUVTX0FMTE9XX1BSSVZBVEVfVVJMUz0ke0hFUk1FU19BTExPV19QUklWQVRFX1VSTFM6LWZhbHNlfScKICAgICAgLSAnRklSRUNSQVdMX0FQSV9LRVk9JHtGSVJFQ1JBV0xfQVBJX0tFWTotfScKICAgICAgLSAnRklSRUNSQVdMX0FQSV9VUkw9JHtGSVJFQ1JBV0xfQVBJX1VSTDotaHR0cHM6Ly9hcGkuZmlyZWNyYXdsLmRldn0nCiAgICAgIC0gJ09QRU5ST1VURVJfQVBJX0tFWT0ke09QRU5ST1VURVJfQVBJX0tFWTotfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWTotfScKICAgICAgLSAnT1BFTkFJX0FQSV9LRVk9JHtPUEVOQUlfQVBJX0tFWTotfScKICAgICAgLSAnR09PR0xFX0FQSV9LRVk9JHtHT09HTEVfQVBJX0tFWTotfScKICAgICAgLSBBUElfU0VSVkVSX0VOQUJMRUQ9dHJ1ZQogICAgICAtIEFQSV9TRVJWRVJfSE9TVD0wLjAuMC4wCiAgICAgIC0gJ0FQSV9TRVJWRVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9IRVJNRVNBUEl9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1za2lsbHM6L2hvbWUvaGVybWVzLy5oZXJtZXMvc2tpbGxzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUyLjQ1OCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gaGVybWVzLWFnZW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9IRVJNRVNXRUJVSV84Nzg3CiAgICAgIC0gU0VSVklDRV9VU0VSX0hFUk1FU1dFQlVJCiAgICAgIC0gU0VSVklDRV9QQVNTV09SRF9IRVJNRVNXRUJVSQogICAgICAtIEhFUk1FU19XRUJVSV9IT1NUPTAuMC4wLjAKICAgICAgLSBIRVJNRVNfV0VCVUlfUE9SVD04Nzg3CiAgICAgIC0gSEVSTUVTX1dFQlVJX1NUQVRFX0RJUj0vaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzL3dlYnVpCiAgICAgIC0gV0FOVEVEX1VJRD0xMDAwCiAgICAgIC0gV0FOVEVEX0dJRD0xMDAwCiAgICAgIC0gJ0hFUk1FU19XRUJVSV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9IRVJNRVNXRUJVSX0nCiAgICAgIC0gJ0hFUk1FU19XRUJVSV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSEVSTUVTV0VCVUl9JwogICAgICAtICdIRVJNRVNfQVBJX1VSTD1odHRwOi8vaGVybWVzLWFnZW50Ojg2NDInCiAgICAgIC0gJ0hFUk1FU19XRUJVSV9HQVRFV0FZX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0hFUk1FU0FQSX0nCiAgICB2b2x1bWVzOgogICAgICAtICdoZXJtZXMtaG9tZTovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMvaGVybWVzLWFnZW50OnJvJwogICAgICAtICdoZXJtZXMtd29ya3NwYWNlOi93b3Jrc3BhY2UnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODc4Ny9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", "tags": [ "ai", "agent", @@ -2412,7 +2043,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T16:39:52+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8787" }, "hermes-agent": { @@ -2467,13 +2098,13 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T19:03:43+02:00", + "template_last_updated_at": "2026-10-01T20:31:00+02:00", "port": "7575" }, "home-assistant": { "documentation": "https://www.home-assistant.io/installation/linux#docker-compose?utm_source=coolify.io", "slogan": "Open source home automation that puts local control and privacy first.", - "compose": "c2VydmljZXM6CiAgaG9tZWFzc2lzdGFudDoKICAgIGltYWdlOiAnZ2hjci5pby9ob21lLWFzc2lzdGFudC9ob21lLWFzc2lzdGFudDoyMDI1LjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9IT01FQVNTSVNUQU5UXzgxMjMKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnRElTQUJMRV9KRU1BTExPQz0ke0RJU0FCTEVfSkVNQUxMT0M6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hvbWVhc3Npc3RhbnQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJy9ydW4vZGJ1czovcnVuL2RidXM6cm8nCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NvbmZpZ3VyYXRpb24ueWFtbAogICAgICAgIHRhcmdldDogL2NvbmZpZy9jb25maWd1cmF0aW9uLnlhbWwKICAgICAgICBjb250ZW50OiAiIyBMb2FkcyBkZWZhdWx0IHNldCBvZiBpbnRlZ3JhdGlvbnMuIERvIG5vdCByZW1vdmUuXG5kZWZhdWx0X2NvbmZpZzpcblxuIyBDb25maWd1cmF0aW9uIGZvciByZXZlcnNlIHByb3h5IHN1cHBvcnQgKHJlcXVpcmVkIGZvciBDb29saWZ5KVxuaHR0cDpcbiAgdXNlX3hfZm9yd2FyZGVkX2ZvcjogdHJ1ZVxuICB0cnVzdGVkX3Byb3hpZXM6XG4gICAgLSAxMC4wLjAuMC84XG4gICAgLSAxNzIuMTYuMC4wLzEyXG4gICAgLSAxOTIuMTY4LjAuMC8xNlxuICBpcF9iYW5fZW5hYmxlZDogdHJ1ZVxuICBsb2dpbl9hdHRlbXB0c190aHJlc2hvbGQ6IDUiCiAgICBwcml2aWxlZ2VkOiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODEyMycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDYwcwo=", + "compose": "c2VydmljZXM6CiAgaG9tZWFzc2lzdGFudDoKICAgIGltYWdlOiAnZ2hjci5pby9ob21lLWFzc2lzdGFudC9ob21lLWFzc2lzdGFudDoyMDI1LjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9IT01FQVNTSVNUQU5UXzgxMjMKICAgICAgLSAnVFo9JHtUWjotVVRDfScKICAgICAgLSAnRElTQUJMRV9KRU1BTExPQz0ke0RJU0FCTEVfSkVNQUxMT0M6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hvbWVhc3Npc3RhbnQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJy9ydW4vZGJ1czovcnVuL2RidXM6cm8nCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NvbmZpZ3VyYXRpb24ueWFtbAogICAgICAgIHRhcmdldDogL2NvbmZpZy9jb25maWd1cmF0aW9uLnlhbWwKICAgICAgICBjb250ZW50OiAiIyBMb2FkcyBkZWZhdWx0IHNldCBvZiBpbnRlZ3JhdGlvbnMuIERvIG5vdCByZW1vdmUuXG5kZWZhdWx0X2NvbmZpZzpcblxuIyBDb25maWd1cmF0aW9uIGZvciByZXZlcnNlIHByb3h5IHN1cHBvcnQgKHJlcXVpcmVkIGZvciBDb29saWZ5KVxuaHR0cDpcbiAgdXNlX3hfZm9yd2FyZGVkX2ZvcjogdHJ1ZVxuICB0cnVzdGVkX3Byb3hpZXM6XG4gICAgLSAxMC4wLjAuMC84XG4gICAgLSAxNzIuMTYuMC4wLzEyXG4gICAgLSAxOTIuMTY4LjAuMC8xNlxuICBpcF9iYW5fZW5hYmxlZDogdHJ1ZVxuICBsb2dpbl9hdHRlbXB0c190aHJlc2hvbGQ6IDVcbiIKICAgIHByaXZpbGVnZWQ6IHRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MTIzJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCg==", "tags": [ "home-automation", "iot", @@ -2516,7 +2147,7 @@ "category": "productivity", "logo": "svgs/homepage.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-10-19T21:18:41+02:00", "port": "3000" }, "honcho": { @@ -2651,23 +2282,6 @@ "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, - "invoice-ninja": { - "documentation": "https://invoiceninja.github.io/selfhost.html?utm_source=coolify.io", - "slogan": "The leading open-source invoicing platform", - "compose": "c2VydmljZXM6CiAgaW52b2ljZS1uaW5qYToKICAgIGltYWdlOiAnaW52b2ljZW5pbmphL2ludm9pY2VuaW5qYTo1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfSU5WT0lDRU5JTkpBCiAgICAgIC0gJ0FQUF9OQU1FPSR7QVBQX05BTUU6LSJJbnZvaWNlIE5pbmphIn0nCiAgICAgIC0gJ0FQUF9FTlY9JHtBUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnQVBQX1VSTD0ke1NFUlZJQ0VfVVJMX0lOVk9JQ0VOSU5KQX0nCiAgICAgIC0gJ0FQUF9LRVk9YmFzZTY0OiR7U0VSVklDRV9SRUFMQkFTRTY0X0lOVk9JQ0VOSU5KQX0nCiAgICAgIC0gJ0FQUF9ERUJVRz0ke0FQUF9ERUJVRzotZmFsc2V9JwogICAgICAtICdSRVFVSVJFX0hUVFBTPSR7UkVRVUlSRV9IVFRQUzotZmFsc2V9JwogICAgICAtICdQSEFOVE9NSlNfUERGX0dFTkVSQVRJT049JHtQSEFOVE9NSlNfUERGX0dFTkVSQVRJT046LWZhbHNlfScKICAgICAgLSAnUERGX0dFTkVSQVRPUj0ke1BERl9HRU5FUkFUT1I6LWhvc3RlZF9uaW5qYX0nCiAgICAgIC0gJ1RSVVNURURfUFJPWElFUz0ke1RSVVNURURfUFJPWElFUzotKn0nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gJ1FVRVVFX0NPTk5FQ1RJT049JHtRVUVVRV9DT05ORUNUSU9OOi1yZWRpc30nCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSAnUkVESVNfSE9TVD0ke1JFRElTX0hPU1Q6LXJlZGlzfScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgLSAnUkVESVNfUE9SVD0ke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdEQl9IT1NUPSR7REJfSE9TVDotbWFyaWFkYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtEQl9QT1JUOi0zMzA2fScKICAgICAgLSAnREJfREFUQUJBU0U9JHtEQl9EQVRBQkFTRTotaW52b2ljZW5pbmphfScKICAgICAgLSAnREJfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnSU5fVVNFUl9FTUFJTD0ke0lOX1VTRVJfRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0lOVk9JQ0VOSU5KQVVTRVJ9JwogICAgICAtICdNQUlMX01BSUxFUj0ke01BSUxfTUFJTEVSOi1sb2d9JwogICAgICAtICdNQUlMX0hPU1Q9JHtNQUlMX0hPU1R9JwogICAgICAtICdNQUlMX1BPUlQ9JHtNQUlMX1BPUlR9JwogICAgICAtICdNQUlMX1VTRVJOQU1FPSR7TUFJTF9VU0VSTkFNRX0nCiAgICAgIC0gJ01BSUxfUEFTU1dPUkQ9JHtNQUlMX1BBU1NXT1JEfScKICAgICAgLSAnTUFJTF9FTkNSWVBUSU9OPSR7TUFJTF9FTkNSWVBUSU9OfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFJFU1M9JHtNQUlMX0ZST01fQUREUkVTU30nCiAgICAgIC0gJ01BSUxfRlJPTV9OQU1FPSR7TUFJTF9GUk9NX05BTUV9JwogICAgICAtICdBV1NfQUNDRVNTX0tFWV9JRD0ke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQVdTX0RFRkFVTFRfUkVHSU9OPSR7QVdTX0RFRkFVTFRfUkVHSU9OfScKICAgICAgLSAnQVdTX0JVQ0tFVD0ke0FXU19CVUNLRVR9JwogICAgICAtICdBV1NfVVJMPSR7QVdTX1VSTH0nCiAgICAgIC0gJ0FXU19FTkRQT0lOVD0ke0FXU19FTkRQT0lOVH0nCiAgICAgIC0gJ05PUkRJR0VOX1NFQ1JFVF9JRD0ke05PUkRJR0VOX1NFQ1JFVF9JRH0nCiAgICAgIC0gJ05PUkRJR0VOX1NFQ1JFVF9LRVk9JHtOT1JESUdFTl9TRUNSRVRfS0VZfScKICAgICAgLSBJU19ET0NLRVI9dHJ1ZQogICAgICAtICdTQ09VVF9EUklWRVI9JHtTQ09VVF9EUklWRVJ9JwogICAgICAtICdMSUNFTlNFX0tFWT0ke0xJQ0VOU0VfS0VZfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSBvawogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICdpbnZvaWNlLW5pbmphLXB1YmxpYzovdmFyL3d3dy9hcHAvcHVibGljJwogICAgICAtICdpbnZvaWNlLW5pbmphLXN0b3JhZ2U6L3Zhci93d3cvYXBwL3N0b3JhZ2UnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVydmlzb3JkLmNvbmYKICAgICAgICB0YXJnZXQ6IC9ldGMvc3VwZXJ2aXNvcmQuY29uZgogICAgICAgIGNvbnRlbnQ6ICJbc3VwZXJ2aXNvcmRdXG5ub2RhZW1vbj10cnVlXG5waWRmaWxlPS90bXAvc3VwZXJ2aXNvcmQucGlkXG5sb2dmaWxlPS9kZXYvbnVsbCA7IG5vZGFlbW9uIHdpbGwgY2F1c2UgbG9ncyB0byBnbyB0byBzdGRvdXRcbmxvZ2ZpbGVfbWF4Ynl0ZXM9MFxubG9nbGV2ZWw9aW5mb1xuXG5bcHJvZ3JhbTpwaHAtZnBtXVxucmVkaXJlY3Rfc3RkZXJyPXRydWVcbnN0ZG91dF9sb2dmaWxlPS9kZXYvc3Rkb3V0XG5zdGRvdXRfbG9nZmlsZV9tYXhieXRlcz0wXG5zdGRlcnJfbG9nZmlsZT0vZGV2L3N0ZGVyclxuc3RkZXJyX2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxuY29tbWFuZD1waHAgYXJ0aXNhbiBzZXJ2ZSAtLWhvc3QgMC4wLjAuMCAtLXBvcnQgOTAwMFxuXG5bcHJvZ3JhbTpzY2hlZHVsZXJdXG5hdXRvcmVzdGFydD10cnVlXG5yZWRpcmVjdF9zdGRlcnI9dHJ1ZVxuc3Rkb3V0X2xvZ2ZpbGU9L2Rldi9zdGRvdXRcbnN0ZG91dF9sb2dmaWxlX21heGJ5dGVzPTBcbnN0ZGVycl9sb2dmaWxlPS9kZXYvc3RkZXJyXG5zdGRlcnJfbG9nZmlsZV9tYXhieXRlcz0wXG5jb21tYW5kPXBocCBhcnRpc2FuIHNjaGVkdWxlOndvcmtcblxuW3Byb2dyYW06cXVldWUtd29ya2VyXVxucHJvY2Vzc19uYW1lPSUocHJvZ3JhbV9uYW1lKXNfJShwcm9jZXNzX251bSkwMmRcbmF1dG9yZXN0YXJ0PXRydWVcbnJlZGlyZWN0X3N0ZGVycj10cnVlXG5zdGRvdXRfbG9nZmlsZT0vZGV2L3N0ZG91dFxuc3Rkb3V0X2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxuc3RkZXJyX2xvZ2ZpbGU9L2Rldi9zdGRlcnJcbnN0ZGVycl9sb2dmaWxlX21heGJ5dGVzPTBcbm51bXByb2NzPTJcbmNvbW1hbmQ9cGhwIGFydGlzYW4gcXVldWU6d29yayAtLXNsZWVwPTMgLS10cmllcz0xIC0tbWVtb3J5PTI1NiAtLXRpbWVvdXQ9MzYwMFxuXG5bZXZlbnRsaXN0ZW5lcjpzaHV0ZG93bl1cbmNvbW1hbmQ9c2h1dGRvd24uc2hcbmV2ZW50cz1QUk9DRVNTX1NUQVRFX1NUT1BQRUQsIFBST0NFU1NfU1RBVEVfRVhJVEVELCBQUk9DRVNTX1NUQVRFX0ZBVEFMXG5zdGRvdXRfbG9nZmlsZT0vZGV2L3N0ZG91dFxuc3Rkb3V0X2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxuc3RkZXJyX2xvZ2ZpbGU9L2Rldi9zdGRlcnJcbnN0ZGVycl9sb2dmaWxlX21heGJ5dGVzPTBcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vcGhwLmluaQogICAgICAgIHRhcmdldDogL3Vzci9sb2NhbC9ldGMvcGhwL3BocC5pbmkKICAgICAgICBjb250ZW50OiAic2Vzc2lvbi5hdXRvX3N0YXJ0ID0gT2ZmXG5zaG9ydF9vcGVuX3RhZyA9IE9mZlxuXG5lcnJvcl9yZXBvcnRpbmcgPSBFX0FMTCAmIH5FX05PVElDRSAmIH5FX1dBUk5JTkcgJiB+RV9TVFJJQ1QgJiB+RV9ERVBSRUNBVEVEXG5cbjsgb3BjYWNoZS5lbmFibGU9MVxuOyBvcGNhY2hlLnByZWxvYWQ9L3Nydi93d3cvaW52b2ljZW5pbmphL2N1cnJlbnQvcHJlbG9hZC5waHBcbjsgb3BjYWNoZS5wcmVsb2FkX3VzZXI9d3d3LWRhdGFcblxuOyA7IFRoZSBPUGNhY2hlIHNoYXJlZCBtZW1vcnkgc3RvcmFnZSBzaXplLlxuOyBvcGNhY2hlLm1heF9hY2NlbGVyYXRlZF9maWxlcz0zMDAwMDBcbjsgb3BjYWNoZS52YWxpZGF0ZV90aW1lc3RhbXBzPTFcbjsgb3BjYWNoZS5yZXZhbGlkYXRlX2ZyZXE9MzBcbjsgb3BjYWNoZS5qaXRfYnVmZmVyX3NpemU9MjU2TVxuOyBvcGNhY2hlLmppdD0xMjA1XG47IG9wY2FjaGUubWVtb3J5X2NvbnN1bXB0aW9uPTEwMjRNXG5cbnBvc3RfbWF4X3NpemUgPSA2ME1cbnVwbG9hZF9tYXhfZmlsZXNpemUgPSA1ME1cbm1lbW9yeV9saW1pdD01MTJNXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3BocC1jbGkuaW5pCiAgICAgICAgdGFyZ2V0OiAvdXNyL2xvY2FsL2V0Yy9waHAvcGhwLWNsaS5pbmkKICAgICAgICBjb250ZW50OiAic2Vzc2lvbi5hdXRvX3N0YXJ0ID0gT2ZmXG5zaG9ydF9vcGVuX3RhZyA9IE9mZlxuXG5lcnJvcl9yZXBvcnRpbmcgPSBFX0FMTCAmIH5FX05PVElDRSAmIH5FX1dBUk5JTkcgJiB+RV9TVFJJQ1QgJiB+RV9ERVBSRUNBVEVEXG5cbjsgb3BjYWNoZS5lbmFibGVfY2xpPTFcbjsgb3BjYWNoZS5mYXN0X3NodXRkb3duPTFcbjsgb3BjYWNoZS5tZW1vcnlfY29uc3VtcHRpb249MjU2XG47IG9wY2FjaGUuaW50ZXJuZWRfc3RyaW5nc19idWZmZXI9OFxuOyBvcGNhY2hlLm1heF9hY2NlbGVyYXRlZF9maWxlcz00MDAwXG47IG9wY2FjaGUucmV2YWxpZGF0ZV9mcmVxPTYwXG47ICMgaHR0cDovL3N5bWZvbnkuY29tL2RvYy9jdXJyZW50L3BlcmZvcm1hbmNlLmh0bWxcbjsgcmVhbHBhdGhfY2FjaGVfc2l6ZSA9IDQwOTZLXG47IHJlYWxwYXRoX2NhY2hlX3R0bCA9IDYwMFxuXG5tZW1vcnlfbGltaXQgPSAyR1xucG9zdF9tYXhfc2l6ZSA9IDYwTVxudXBsb2FkX21heF9maWxlc2l6ZSA9IDUwTVxuIgogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQlJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke0RCX0RBVEFCQVNFOi1pbnZvaWNlbmluamF9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny40LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1JFRElTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICB2b2x1bWVzOgogICAgICAtICdpbnZvaWNlLW5pbmphLXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", - "tags": [ - "invoicing", - "billing", - "accounting", - "finance", - "self-hosted" - ], - "category": "finance", - "logo": "svgs/invoiceninja.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "9000" - }, "it-tools": { "documentation": "https://github.com/corentinth/it-tools?utm_source=coolify.io", "slogan": "IT Tools is a self-hosted solution for managing various IT tasks.", @@ -2752,23 +2366,6 @@ "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, - "joomla-with-mariadb": { - "documentation": "https://joomla.org?utm_source=coolify.io", - "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", - "compose": "c2VydmljZXM6CiAgam9vbWxhOgogICAgaW1hZ2U6ICdqb29tbGE6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnam9vbWxhX2RhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0pPT01MQQogICAgICAtIEpPT01MQV9EQl9IT1NUPW1hcmlhZGIKICAgICAgLSAnSk9PTUxBX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfSk9PTUxBfScKICAgICAgLSAnSk9PTUxBX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KT09NTEF9JwogICAgICAtICdKT09NTEFfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1qb29tbGEtZGJ9JwogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2pvb21sYV9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWpvb21sYS1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfSk9PTUxBfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pPT01MQX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "mariadb" - ], - "category": "cms", - "logo": "svgs/joomla.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "joplin": { "documentation": "https://github.com/laurent22/joplin/blob/dev/packages/server/README.md?utm_source=coolify.io", "slogan": "Self-hosted sync server for Joplin", @@ -2873,20 +2470,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, - "kimai": { - "documentation": "https://www.kimai.org/?utm_source=coolify.io", - "slogan": "Open source time-tracking app.", - "compose": "c2VydmljZXM6CiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdraW1haS1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWtpbWFpfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgY29tbWFuZDogJy0tZGVmYXVsdC1zdG9yYWdlLWVuZ2luZSBpbm5vZGInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBraW1haToKICAgIGltYWdlOiAna2ltYWkva2ltYWkyOmFwYWNoZScKICAgIGNvbnRhaW5lcl9uYW1lOiBraW1haQogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2tpbWFpLWRhdGE6L29wdC9raW1haS92YXIvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0tJTUFJXzgwMDEKICAgICAgLSAnQVBQX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfQVBQU0VDUkVUfScKICAgICAgLSAnTUFJTEVSX0ZST009JHtNQUlMRVJfRlJPTTota2ltYWlAZXhhbXBsZS5jb219JwogICAgICAtICdNQUlMRVJfVVJMPSR7TUFJTEVSX1VSTDotbnVsbDovL251bGx9JwogICAgICAtICdBRE1JTk1BSUw9JHtBRE1JTk1BSUw6LWFkbWluQGtpbWFpLmxvY2FsfScKICAgICAgLSAnQURNSU5QQVNTPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTlBBU1N9JwogICAgICAtICdEQVRBQkFTRV9VUkw9bXlzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9NWVNRTH06JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfUBteXNxbC8ke01ZU1FMX0RBVEFCQVNFfT9jaGFyc2V0PXV0ZjhtYjQmc2VydmVyVmVyc2lvbj04LjMuMCcKICAgICAgLSAnVFJVU1RFRF9IT1NUUz0ke1RSVVNURURfSE9TVFN9fGxvY2FsaG9zdHwxMjcuMC4wLjEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAwMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "time-tracking", - "open-source" - ], - "category": "productivity", - "logo": "svgs/kimai.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:11:35+01:00", - "port": "8001" - }, "kuzzle": { "documentation": "https://kuzzle.io?utm_source=coolify.io", "slogan": "Kuzzle is a generic backend offering the basic building blocks common to every application.", @@ -2978,27 +2561,9 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T18:08:40+02:00", + "template_last_updated_at": "2026-10-19T17:18:16+02:00", "port": "3000" }, - "leantime": { - "documentation": "https://leantime.io?utm_source=coolify.io", - "slogan": "Leantime is a goals focused project management system for non-project managers.", - "compose": "c2VydmljZXM6CiAgbGVhbnRpbWU6CiAgICBpbWFnZTogJ2xlYW50aW1lL2xlYW50aW1lOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xFQU5USU1FXzgwODAKICAgICAgLSAnTEVBTl9BUFBfVVJMPSR7U0VSVklDRV9VUkxfTEVBTlRJTUV9JwogICAgICAtIExFQU5fREJfSE9TVD1teXNxbAogICAgICAtICdMRUFOX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUxEQn0nCiAgICAgIC0gJ0xFQU5fREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMREJ9JwogICAgICAtICdMRUFOX0RCX0RBVEFCQVNFPSR7REJfTkFNRTotbGVhbnRpbWUtZGJ9JwogICAgICAtICdMRUFOX1NFU1NJT05fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1NBTFR9JwogICAgICAtICdMRUFOX1NFU1NJT05fRVhQSVJBVElPTj0ke1NFU1NJT05fRVhQSVJBVElPTjotMjg4MDB9JwogICAgICAtICdMRUFOX1VTRV9SRURJUz0ke1VTRV9SRURJUzotdHJ1ZX0nCiAgICAgIC0gTEVBTl9SRURJU19IT1NUPXJlZGlzCiAgICAgIC0gJ0xFQU5fUkVESVNfUE9SVD0ke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdMRUFOX1JFRElTX1NDSEVNRT0ke1JFRElTX1NDSEVNRTotdGNwfScKICAgIGRlcGVuZHNfb246CiAgICAgIG15c3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICB2b2x1bWVzOgogICAgICAtICdwdWJsaWMtdXNlcmZpbGVzOi92YXIvd3d3L2h0bWwvcHVibGljL3VzZXJmaWxlcycKICAgICAgLSAndXNlcmZpbGVzOi92YXIvd3d3L2h0bWwvdXNlcmZpbGVzJwogICAgICAtICdwbHVnaW5zOi92YXIvd3d3L2h0bWwvYXBwL1BsdWdpbnMnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4LjQnCiAgICB2b2x1bWVzOgogICAgICAtICdsZWFudGltZV9teXNxbF9kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUxEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTERCfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtEQl9OQU1FOi1sZWFudGltZS1kYn0nCiAgICBjb21tYW5kOiAnLS1jaGFyYWN0ZXItc2V0LXNlcnZlcj1VVEY4TUI0IC0tY29sbGF0aW9uLXNlcnZlcj1VVEY4TUI0X3VuaWNvZGVfY2knCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gbG9jYWxob3N0CiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdsZWFudGltZV9yZWRpc19kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "project management", - "productivity", - "php", - "mysql", - "laravel", - "open source" - ], - "category": "productivity", - "logo": "svgs/leantime.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8080" - }, "librechat": { "documentation": "https://docs.librechat.ai/install/configuration/dotenv.html?utm_source=coolify.io", "slogan": "Self-hosted, powerful, and privacy-focused chat UI for multiple AI models", @@ -3039,20 +2604,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "librespeed": { - "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", - "slogan": "Self-hosted lightweight Speed Test.", - "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElCUkVTUEVFRF84MgogICAgICAtIE1PREU9c3RhbmRhbG9uZQogICAgICAtIFRFTEVNRVRSWT1mYWxzZQogICAgICAtIERJU1RBTkNFPWttCiAgICAgIC0gV0VCUE9SVD04MgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdjdXJsIDEyNy4wLjAuMTo4MiB8fCBleGl0IDEnCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIGludGVydmFsOiAxbTBzCiAgICAgIHJldHJpZXM6IDEK", - "tags": [ - "speedtest", - "internet-speed" - ], - "category": "devtools", - "logo": "svgs/librespeed.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15Z", - "port": "82" - }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -3067,22 +2618,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5000" }, - "limesurvey": { - "documentation": "https://www.limesurvey.org/manual/?utm_source=coolify.io", - "slogan": "Simple, quick and anonymous online survey tool that's bursting with juicy insights.", - "compose": "c2VydmljZXM6CiAgbGltZXN1cnZleToKICAgIGltYWdlOiAnYWRhbXphbW1pdC9saW1lc3VydmV5OmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0xJTUVTVVJWRVlfODAKICAgICAgLSBMSU1FU1VSVkVZX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdMSU1FU1VSVkVZX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1saW1lc3VydmV5LWRifScKICAgICAgLSAnTElNRVNVUlZFWV9BRE1JTl9VU0VSPSR7TElNRVNVUlZFWV9BRE1JTl9VU0VSOi1hZG1pbn0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnTElNRVNVUlZFWV9BRE1JTl9OQU1FPSR7TElNRVNVUlZFWV9BRE1JTl9OQU1FOi1BZG1pbn0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfQURNSU5fRU1BSUw9JHtMSU1FU1VSVkVZX0FETUlOX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfRlJPTV9FTUFJTD0ke0xJTUVTVVJWRVlfRlJPTV9FTUFJTH0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfU01UUF9IT1NUPSR7TElNRVNVUlZFWV9TTVRQX0hPU1R9JwogICAgICAtICdMSU1FU1VSVkVZX1NNVFBfVVNFUj0ke0xJTUVTVVJWRVlfU01UUF9VU0VSfScKICAgICAgLSAnTElNRVNVUlZFWV9TTVRQX1BBU1NXT1JEPSR7TElNRVNVUlZFWV9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnTElNRVNVUlZFWV9TTVRQX1NTTD0ke0xJTUVTVVJWRVlfU01UUF9TU0x9JwogICAgICAtIExJTUVTVVJWRVlfUEhQX1NFU1NJT05fU0FWRV9IQU5ETEVSPXJlZGlzCiAgICAgIC0gJ0xJTUVTVVJWRVlfUEhQX1NFU1NJT05fU0FWRV9QQVRIPXRjcDovL3JlZGlzOjYzNzknCiAgICAgIC0gJ1RaPSR7VFo6LUFtZXJpY2EvSmFtYWljYX0nCiAgICB2b2x1bWVzOgogICAgICAtICdsaW1lc3VydmV5X3BsdWdpbnNfZGF0YTovdmFyL3d3dy9odG1sL3BsdWdpbnMnCiAgICAgIC0gJ2xpbWVzdXJ2ZXlfdXBsb2FkX2RhdGE6L3Zhci93d3cvaHRtbC91cGxvYWQnCiAgICAgIC0gJ2xpbWVzdXJ2ZXlfY29uZmlnX2RhdGE6L3Zhci93d3cvaHRtbC9hcHBsaWNhdGlvbi9jb25maWcnCiAgICAgIC0gJ2xpbWVzdXJ2ZXlfc2Vzc2lvbnNfZGF0YTovdmFyL2xpbWUvc2Vzc2lvbnMnCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1saW1lc3VydmV5LWRifScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xpbWVzdXJ2ZXlfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogM3MKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "online survey", - "forms", - "no code", - "open source" - ], - "category": "productivity", - "logo": "svgs/limesurvey.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "linkding-plus": { "documentation": "https://linkding.link/?utm_source=coolify.io", "slogan": "A self-hosted bookmark manager designed to be minimal, fast, and easy to set up. (Includes feature for archiving websites as HTML snapshots)", @@ -3147,7 +2682,7 @@ "litellm": { "documentation": "https://docs.litellm.ai?utm_source=coolify.io", "slogan": "Call all LLM APIs using the OpenAI format. Use Bedrock, Azure, OpenAI, Cohere, Anthropic, Ollama, Sagemaker, HuggingFace, Replicate, Groq (100+ LLMs)", - "compose": "c2VydmljZXM6CiAgbGl0ZWxsbToKICAgIGltYWdlOiAnZ2hjci5pby9iZXJyaWFpL2xpdGVsbG0tZGF0YWJhc2U6bWFpbi1zdGFibGUnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElURUxMTV80MDAwCiAgICAgIC0gJ0xJVEVMTE1fTE9HPSR7TElURUxMTV9MT0c6LUVSUk9SfScKICAgICAgLSAnTElURUxMTV9NT0RFPSR7TElURUxMTV9NT0RFOi1QUk9EVUNUSU9OfScKICAgICAgLSAnTElURUxMTV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NQVNURVJLRVl9JwogICAgICAtICdVSV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9VSX0nCiAgICAgIC0gJ1VJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9VSX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1saXRlbGxtfScKICAgICAgLSAnUkVESVNfSE9TVD0ke1JFRElTX0hPU1Q6LXJlZGlzfScKICAgICAgLSAnUkVESVNfUE9SVD0ke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0JBU0U9JHtPUEVOQUlfQVBJX0JBU0V9JwogICAgICAtICdBTlRIUk9QSUNfQVBJX0tFWT0ke0FOVEhST1BJQ19BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9CQVNFPSR7QU5USFJPUElDX0FQSV9CQVNFfScKICAgICAgLSAnVk9ZQUdFX0FQSV9LRVk9JHtWT1lBR0VfQVBJX0tFWX0nCiAgICAgIC0gJ1ZPWUFHRV9BUElfQkFTRT0ke1ZPWUFHRV9BUElfQkFTRX0nCiAgICAgIC0gJ1NUT1JFX01PREVMX0lOX0RCPSR7U1RPUkVfTU9ERUxfSU5fREJ9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vbGl0ZWxsbS1jb25maWcueWFtbAogICAgICAgIHRhcmdldDogL2FwcC9jb25maWcueWFtbAogICAgICAgIGNvbnRlbnQ6ICJnZW5lcmFsX3NldHRpbmdzOlxuICBwcm94eV9iYXRjaF93cml0ZV9hdDogNjBcbiAgc3RvcmVfcHJvbXB0c19pbl9zcGVuZF9sb2dzOiBmYWxzZVxuXG5yb3V0ZXJfc2V0dGluZ3M6XG4gIHJlZGlzX2hvc3Q6IG9zLmVudmlyb24vUkVESVNfSE9TVFxuICByZWRpc19wb3J0OiBvcy5lbnZpcm9uL1JFRElTX1BPUlRcbiAgcmVkaXNfcGFzc3dvcmQ6IG9zLmVudmlyb24vUkVESVNfUEFTU1dPUkRcbiAgZW5hYmxlX3ByZV9jYWxsX2NoZWNrOiB0cnVlXG5cbmxpdGVsbG1fc2V0dGluZ3M6XG4gIHNldF92ZXJib3NlOiBmYWxzZVxuICBqc29uX2xvZ3M6IHRydWVcbiAgbG9nX3Jhd19yZXF1ZXN0X3Jlc3BvbnNlOiBmYWxzZVxuICAjIHR1cm5fb2ZmX21lc3NhZ2VfbG9nZ2luZzogZmFsc2VcbiAgIyByZWRhY3RfdXNlcl9hcGlfa2V5X2luZm86IGZhbHNlXG4gIHNlcnZpY2VfY2FsbGJhY2s6IFtcInByb21ldGhldXNfc3lzdGVtXCJdXG4gIGRyb3BfcGFyYW1zOiB0cnVlXG4gICMgbWF4X2J1ZGdldDogMTAwXG4gICMgYnVkZ2V0X2R1cmF0aW9uOiAzMGRcbiAgbnVtX3JldHJpZXM6IDFcbiAgcmVxdWVzdF90aW1lb3V0OiAxMjBcbiAgdGVsZW1ldHJ5OiBmYWxzZVxuICBjYWNoZTogdHJ1ZVxuICBjYWNoZV9wYXJhbXM6XG4gICAgdHlwZTogcmVkaXNcbiAgICBob3N0OiBvcy5lbnZpcm9uL1JFRElTX0hPU1RcbiAgICBwb3J0OiBvcy5lbnZpcm9uL1JFRElTX1BPUlRcbiAgICBwYXNzd29yZDogb3MuZW52aXJvbi9SRURJU19QQVNTV09SRFxuICAgIG5hbWVzcGFjZTogXCJsaXRlbGxtX2NhY2hlXCJcbiAgICB0dGw6IDYwMFxuICBzdWNjZXNzX2NhbGxiYWNrOlxuICAgICMgLSBcImxhbmdmdXNlXCJcbiAgICAtIFwicHJvbWV0aGV1c1wiXG4gIGZhaWx1cmVfY2FsbGJhY2s6XG4gICAgIyAtIFwibGFuZ2Z1c2VcIlxuICAgIC0gXCJwcm9tZXRoZXVzXCJcbm1vZGVsX2xpc3Q6XG4gICMgT3BlbkFJXG4gIC0gbW9kZWxfbmFtZTogZ3B0LTRcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBvcGVuYWkvZ3B0LTRcbiAgICAgIGFwaV9rZXk6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9LRVlcbiAgICAgIGFwaV9iYXNlOiBvcy5lbnZpcm9uL09QRU5BSV9BUElfQkFTRVxuICAtIG1vZGVsX25hbWU6IGdwdC00b1xuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IG9wZW5haS9ncHQtNG9cbiAgICAgIGFwaV9rZXk6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9LRVlcbiAgICAgIGFwaV9iYXNlOiBvcy5lbnZpcm9uL09QRU5BSV9BUElfQkFTRVxuICAtIG1vZGVsX25hbWU6IGdwdC00by1taW5pXG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogb3BlbmFpL2dwdC00by1taW5pXG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgIyBBbnRocm9waWNcbiAgLSBtb2RlbF9uYW1lOiBjbGF1ZGUtMy1oYWlrdVxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IGNsYXVkZS0zLWhhaWt1LTIwMjQwMzA3XG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0JBU0VcIlxuICAtIG1vZGVsX25hbWU6IGNsYXVkZS0zLjUtc29ubmV0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogY2xhdWRlLTMtNS1zb25uZXQtMjAyNDA2MjBcbiAgICAgIGFwaV9rZXk6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0tFWVwiXG4gICAgICBhcGlfYmFzZTogXCJvcy5lbnZpcm9uL0FOVEhST1BJQ19BUElfQkFTRVwiXG4gICMgVm95YWdlQUlcbiAgLSBtb2RlbF9uYW1lOiB2b3lhZ2UtbGF3LTJcbiAgICBtb2RlbF9pbmZvOlxuICAgICAgb3V0cHV0X3ZlY3Rvcl9zaXplOiAxMDI0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogdm95YWdlL3ZveWFnZS1sYXctMlxuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9CQVNFXCJcbiAgICAgICMgcnBtOiAzMDBcbiAgICAgICMgdHBtOiAxMDAwMDAwXG4gIC0gbW9kZWxfbmFtZTogdm95YWdlLW11bHRpbGluZ3VhbC0yXG4gICAgbW9kZWxfaW5mbzpcbiAgICAgIG1vZGU6IGVtYmVkZGluZ1xuICAgICAgbWF4X3Rva2VuczogMzIwMDBcbiAgICAgIG1heF9pbnB1dF90b2tlbnM6IDMyMDAwXG4gICAgICBvdXRwdXRfdmVjdG9yX3NpemU6IDEwMjRcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiB2b3lhZ2Uvdm95YWdlLW11bHRpbGluZ3VhbC0yXG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9WT1lBR0VfQVBJX0JBU0VcIlxuICAgICAgaW5wdXRfY29zdF9wZXJfdG9rZW46IDAuMDAwMDAwMTJcbiAgICAgIG91dHB1dF9jb3N0X3Blcl90b2tlbjogMFxuICAgICAgIyBycG06IDMwMFxuICAgICAgIyB0cG06IDEwMDAwMDAiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcHl0aG9uCiAgICAgICAgLSAnLWMnCiAgICAgICAgLSAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0O3VybGxpYi5yZXF1ZXN0LnVybG9wZW4oJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgvbGl2ZWxpbmVzcycsdGltZW91dD01KSIKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSAvYXBwL2NvbmZpZy55YW1sCiAgICAgIC0gJy0tcG9ydCcKICAgICAgLSAnNDAwMCcKICAgICAgLSAnLS1udW1fd29ya2VycycKICAgICAgLSAnMicKICAgICAgLSAnLS1ydW5fZ3VuaWNvcm4nCiAgICAgIC0gJy0tbWF4X3JlcXVlc3RzX2JlZm9yZV9yZXN0YXJ0JwogICAgICAtICcxMDAwJwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3BnLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK", + "compose": "c2VydmljZXM6CiAgbGl0ZWxsbToKICAgIGltYWdlOiAnZ2hjci5pby9iZXJyaWFpL2xpdGVsbG0tZGF0YWJhc2U6bWFpbi1zdGFibGUnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTElURUxMTV80MDAwCiAgICAgIC0gJ0xJVEVMTE1fTE9HPSR7TElURUxMTV9MT0c6LUVSUk9SfScKICAgICAgLSAnTElURUxMTV9NT0RFPSR7TElURUxMTV9NT0RFOi1QUk9EVUNUSU9OfScKICAgICAgLSAnTElURUxMTV9NQVNURVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NQVNURVJLRVl9JwogICAgICAtICdVSV9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9VSX0nCiAgICAgIC0gJ1VJX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9VSX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke1BPU1RHUkVTX0RCOi1saXRlbGxtfScKICAgICAgLSAnUkVESVNfSE9TVD0ke1JFRElTX0hPU1Q6LXJlZGlzfScKICAgICAgLSAnUkVESVNfUE9SVD0ke1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gJ09QRU5BSV9BUElfS0VZPSR7T1BFTkFJX0FQSV9LRVl9JwogICAgICAtICdPUEVOQUlfQVBJX0JBU0U9JHtPUEVOQUlfQVBJX0JBU0V9JwogICAgICAtICdBTlRIUk9QSUNfQVBJX0tFWT0ke0FOVEhST1BJQ19BUElfS0VZfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9CQVNFPSR7QU5USFJPUElDX0FQSV9CQVNFfScKICAgICAgLSAnVk9ZQUdFX0FQSV9LRVk9JHtWT1lBR0VfQVBJX0tFWX0nCiAgICAgIC0gJ1ZPWUFHRV9BUElfQkFTRT0ke1ZPWUFHRV9BUElfQkFTRX0nCiAgICAgIC0gJ1NUT1JFX01PREVMX0lOX0RCPSR7U1RPUkVfTU9ERUxfSU5fREJ9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vbGl0ZWxsbS1jb25maWcueWFtbAogICAgICAgIHRhcmdldDogL2FwcC9jb25maWcueWFtbAogICAgICAgIGNvbnRlbnQ6ICJnZW5lcmFsX3NldHRpbmdzOlxuICBwcm94eV9iYXRjaF93cml0ZV9hdDogNjBcbiAgc3RvcmVfcHJvbXB0c19pbl9zcGVuZF9sb2dzOiBmYWxzZVxuXG5yb3V0ZXJfc2V0dGluZ3M6XG4gIHJlZGlzX2hvc3Q6IG9zLmVudmlyb24vUkVESVNfSE9TVFxuICByZWRpc19wb3J0OiBvcy5lbnZpcm9uL1JFRElTX1BPUlRcbiAgcmVkaXNfcGFzc3dvcmQ6IG9zLmVudmlyb24vUkVESVNfUEFTU1dPUkRcbiAgZW5hYmxlX3ByZV9jYWxsX2NoZWNrOiB0cnVlXG5cbmxpdGVsbG1fc2V0dGluZ3M6XG4gIHNldF92ZXJib3NlOiBmYWxzZVxuICBqc29uX2xvZ3M6IHRydWVcbiAgbG9nX3Jhd19yZXF1ZXN0X3Jlc3BvbnNlOiBmYWxzZVxuICAjIHR1cm5fb2ZmX21lc3NhZ2VfbG9nZ2luZzogZmFsc2VcbiAgIyByZWRhY3RfdXNlcl9hcGlfa2V5X2luZm86IGZhbHNlXG4gIHNlcnZpY2VfY2FsbGJhY2s6IFtcInByb21ldGhldXNfc3lzdGVtXCJdXG4gIGRyb3BfcGFyYW1zOiB0cnVlXG4gICMgbWF4X2J1ZGdldDogMTAwXG4gICMgYnVkZ2V0X2R1cmF0aW9uOiAzMGRcbiAgbnVtX3JldHJpZXM6IDFcbiAgcmVxdWVzdF90aW1lb3V0OiAxMjBcbiAgdGVsZW1ldHJ5OiBmYWxzZVxuICBjYWNoZTogdHJ1ZVxuICBjYWNoZV9wYXJhbXM6XG4gICAgdHlwZTogcmVkaXNcbiAgICBob3N0OiBvcy5lbnZpcm9uL1JFRElTX0hPU1RcbiAgICBwb3J0OiBvcy5lbnZpcm9uL1JFRElTX1BPUlRcbiAgICBwYXNzd29yZDogb3MuZW52aXJvbi9SRURJU19QQVNTV09SRFxuICAgIG5hbWVzcGFjZTogXCJsaXRlbGxtX2NhY2hlXCJcbiAgICB0dGw6IDYwMFxuICBzdWNjZXNzX2NhbGxiYWNrOlxuICAgICMgLSBcImxhbmdmdXNlXCJcbiAgICAtIFwicHJvbWV0aGV1c1wiXG4gIGZhaWx1cmVfY2FsbGJhY2s6XG4gICAgIyAtIFwibGFuZ2Z1c2VcIlxuICAgIC0gXCJwcm9tZXRoZXVzXCJcbm1vZGVsX2xpc3Q6XG4gICMgT3BlbkFJXG4gIC0gbW9kZWxfbmFtZTogZ3B0LTRcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBvcGVuYWkvZ3B0LTRcbiAgICAgIGFwaV9rZXk6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9LRVlcbiAgICAgIGFwaV9iYXNlOiBvcy5lbnZpcm9uL09QRU5BSV9BUElfQkFTRVxuICAtIG1vZGVsX25hbWU6IGdwdC00b1xuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IG9wZW5haS9ncHQtNG9cbiAgICAgIGFwaV9rZXk6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9LRVlcbiAgICAgIGFwaV9iYXNlOiBvcy5lbnZpcm9uL09QRU5BSV9BUElfQkFTRVxuICAtIG1vZGVsX25hbWU6IGdwdC00by1taW5pXG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogb3BlbmFpL2dwdC00by1taW5pXG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgIyBBbnRocm9waWNcbiAgLSBtb2RlbF9uYW1lOiBjbGF1ZGUtMy1oYWlrdVxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IGNsYXVkZS0zLWhhaWt1LTIwMjQwMzA3XG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0JBU0VcIlxuICAtIG1vZGVsX25hbWU6IGNsYXVkZS0zLjUtc29ubmV0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogY2xhdWRlLTMtNS1zb25uZXQtMjAyNDA2MjBcbiAgICAgIGFwaV9rZXk6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0tFWVwiXG4gICAgICBhcGlfYmFzZTogXCJvcy5lbnZpcm9uL0FOVEhST1BJQ19BUElfQkFTRVwiXG4gICMgVm95YWdlQUlcbiAgLSBtb2RlbF9uYW1lOiB2b3lhZ2UtbGF3LTJcbiAgICBtb2RlbF9pbmZvOlxuICAgICAgb3V0cHV0X3ZlY3Rvcl9zaXplOiAxMDI0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogdm95YWdlL3ZveWFnZS1sYXctMlxuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9CQVNFXCJcbiAgICAgICMgcnBtOiAzMDBcbiAgICAgICMgdHBtOiAxMDAwMDAwXG4gIC0gbW9kZWxfbmFtZTogdm95YWdlLW11bHRpbGluZ3VhbC0yXG4gICAgbW9kZWxfaW5mbzpcbiAgICAgIG1vZGU6IGVtYmVkZGluZ1xuICAgICAgbWF4X3Rva2VuczogMzIwMDBcbiAgICAgIG1heF9pbnB1dF90b2tlbnM6IDMyMDAwXG4gICAgICBvdXRwdXRfdmVjdG9yX3NpemU6IDEwMjRcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiB2b3lhZ2Uvdm95YWdlLW11bHRpbGluZ3VhbC0yXG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9WT1lBR0VfQVBJX0JBU0VcIlxuICAgICAgaW5wdXRfY29zdF9wZXJfdG9rZW46IDAuMDAwMDAwMTJcbiAgICAgIG91dHB1dF9jb3N0X3Blcl90b2tlbjogMFxuICAgICAgIyBycG06IDMwMFxuICAgICAgIyB0cG06IDEwMDAwMDBcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBweXRob24KICAgICAgICAtICctYycKICAgICAgICAtICJpbXBvcnQgdXJsbGliLnJlcXVlc3Q7dXJsbGliLnJlcXVlc3QudXJsb3BlbignaHR0cDovLzEyNy4wLjAuMTo0MDAwL2hlYWx0aC9saXZlbGluZXNzJyx0aW1lb3V0PTUpIgogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy0tY29uZmlnJwogICAgICAtIC9hcHAvY29uZmlnLnlhbWwKICAgICAgLSAnLS1wb3J0JwogICAgICAtICc0MDAwJwogICAgICAtICctLW51bV93b3JrZXJzJwogICAgICAtICcyJwogICAgICAtICctLXJ1bl9ndW5pY29ybicKICAgICAgLSAnLS1tYXhfcmVxdWVzdHNfYmVmb3JlX3Jlc3RhcnQnCiAgICAgIC0gJzEwMDAnCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1saXRlbGxtfScKICAgICAgLSBQT1NUR1JFU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtIFBPU1RHUkVTX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgdm9sdW1lczoKICAgICAgLSAncGctZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLWggbG9jYWxob3N0IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tYXBwZW5kb25seSB5ZXMnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", "tags": [ "ai", "qdrant", @@ -3299,7 +2834,7 @@ "matrix-synapse-with-sqlite": { "documentation": "https://element-hq.github.io/synapse/latest/welcome_and_overview.html?utm_source=coolify.io", "slogan": "Chat securely with your family, friends, community, or build great apps with Matrix!", - "compose": "c2VydmljZXM6CiAgc3luYXBzZToKICAgIGltYWdlOiAnbWF0cml4ZG90b3JnL3N5bmFwc2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1lOQVBTRV84MDA4CiAgICAgIC0gJ1NZTkFQU0VfU0VSVkVSX05BTUU9JHtTWU5BUFNFX1NFUlZFUl9OQU1FOj99JwogICAgICAtICdTWU5BUFNFX1JFUE9SVF9TVEFUUz0ke1NZTkFQU0VfUkVQT1JUX1NUQVRTOi1ub30nCiAgICAgIC0gJ0VOQUJMRV9SRUdJU1RSQVRJT049JHtFTkFCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ1NFUlZJQ0VfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFUlZJQ0VfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N5bmFwc2UtZGF0YTovZGF0YScKICAgIGVudHJ5cG9pbnQ6IC9iaW4vYmFzaAogICAgY29tbWFuZDoKICAgICAgLSAnLWMnCiAgICAgIC0gIiMgR2VuZXJhdGUgY29uZmlnIG9uIGZpcnN0IHJ1blxuaWYgWyAhIC1mIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbCBdOyB0aGVuXG4gICMgR2VuZXJhdGUgZGVmYXVsdCBjb25maWcgdXNpbmcgU1lOQVBTRV9TRVJWRVJfTkFNRSAocGVybWFuZW50LCB1c2VkIGluIHVzZXIgSURzKVxuICAvc3RhcnQucHkgZ2VuZXJhdGVcblxuICAjIFNldCBwdWJsaWNfYmFzZXVybCB0byBhY3R1YWwgZGVwbG95bWVudCBVUkwgKG1heSBkaWZmZXIgZnJvbSBzZXJ2ZXJfbmFtZSlcbiAgIyBUaGlzIGFsbG93cyBob3N0aW5nIGF0IG1hdHJpeC5leGFtcGxlLm9yZyB3aGlsZSBzZXJ2ZXJfbmFtZSBpcyBleGFtcGxlLm9yZ1xuICBpZiAhIGdyZXAgLXEgXCJwdWJsaWNfYmFzZXVybFwiIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbDsgdGhlblxuICAgIHNlZCAtaSBcIi9ec2VydmVyX25hbWU6L2EgcHVibGljX2Jhc2V1cmw6ICR7U0VSVklDRV9VUkxfU1lOQVBTRX0vXCIgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIGZpXG5cbiAgIyBDb25maWd1cmUgbGlzdGVuZXIgZm9yIHJldmVyc2UgcHJveHlcbiAgc2VkIC1pICdzL2JpbmRfYWRkcmVzc2VzOiBcXFsuKlxcXS9iaW5kX2FkZHJlc3NlczogW1wiMC4wLjAuMFwiXS8nIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbFxuICBzZWQgLWkgJy94X2ZvcndhcmRlZDovZCcgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIHNlZCAtaSAnL3R5cGU6IGh0dHAvYSBcXCAgICB4X2ZvcndhcmRlZDogdHJ1ZScgL2RhdGEvaG9tZXNlcnZlci55YW1sXG5cbiAgIyBFbmFibGUgcmVnaXN0cmF0aW9uIGlmIHJlcXVlc3RlZFxuICBpZiBbIFwiJHtFTkFCTEVfUkVHSVNUUkFUSU9OfVwiID0gXCJ0cnVlXCIgXSAmJiAhIGdyZXAgLXEgXCJlbmFibGVfcmVnaXN0cmF0aW9uXCIgL2RhdGEvaG9tZXNlcnZlci55YW1sOyB0aGVuXG4gICAgZWNobyBcImVuYWJsZV9yZWdpc3RyYXRpb246IHRydWVcIiA+PiAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgZmlcbmZpXG5cbiMgUmVnaXN0ZXIgYWRtaW4gdXNlciBpZiBjcmVkZW50aWFscyBwcm92aWRlZFxuaWYgWyAtbiBcIiR7U0VSVklDRV9VU0VSX0FETUlOfVwiIF0gJiYgWyAtbiBcIiR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn1cIiBdOyB0aGVuXG4gIChcbiAgICB3aGlsZSAhIGN1cmwgLXNmIGh0dHA6Ly9sb2NhbGhvc3Q6ODAwOC9oZWFsdGggPiAvZGV2L251bGwgMj4mMTsgZG9cbiAgICAgIHNsZWVwIDJcbiAgICBkb25lXG4gICAgcmVnaXN0ZXJfbmV3X21hdHJpeF91c2VyIC1hIFxcXG4gICAgICAtdSBcIiR7U0VSVklDRV9VU0VSX0FETUlOfVwiIFxcXG4gICAgICAtcCBcIiR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn1cIiBcXFxuICAgICAgLWMgL2RhdGEvaG9tZXNlcnZlci55YW1sIFxcXG4gICAgICBodHRwOi8vbG9jYWxob3N0OjgwMDggMj4vZGV2L251bGwgfHwgdHJ1ZVxuICApICZcbmZpXG5cbmV4ZWMgL3N0YXJ0LnB5XG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODAwOC9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMK", + "compose": "c2VydmljZXM6CiAgc3luYXBzZToKICAgIGltYWdlOiAnbWF0cml4ZG90b3JnL3N5bmFwc2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1lOQVBTRV84MDA4CiAgICAgIC0gJ1NZTkFQU0VfU0VSVkVSX05BTUU9JHtTWU5BUFNFX1NFUlZFUl9OQU1FOj99JwogICAgICAtICdTWU5BUFNFX1JFUE9SVF9TVEFUUz0ke1NZTkFQU0VfUkVQT1JUX1NUQVRTOi1ub30nCiAgICAgIC0gJ0VOQUJMRV9SRUdJU1RSQVRJT049JHtFTkFCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ1NFUlZJQ0VfVVNFUl9BRE1JTj0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ1NFUlZJQ0VfUEFTU1dPUkRfQURNSU49JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N5bmFwc2UtZGF0YTovZGF0YScKICAgIGVudHJ5cG9pbnQ6IC9iaW4vYmFzaAogICAgY29tbWFuZDoKICAgICAgLSAnLWMnCiAgICAgIC0gIiMgR2VuZXJhdGUgY29uZmlnIG9uIGZpcnN0IHJ1blxuaWYgWyAhIC1mIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbCBdOyB0aGVuXG4gICMgR2VuZXJhdGUgZGVmYXVsdCBjb25maWcgdXNpbmcgU1lOQVBTRV9TRVJWRVJfTkFNRSAocGVybWFuZW50LCB1c2VkIGluIHVzZXIgSURzKVxuICAvc3RhcnQucHkgZ2VuZXJhdGVcblxuICAjIFNldCBwdWJsaWNfYmFzZXVybCB0byBhY3R1YWwgZGVwbG95bWVudCBVUkwgKG1heSBkaWZmZXIgZnJvbSBzZXJ2ZXJfbmFtZSlcbiAgIyBUaGlzIGFsbG93cyBob3N0aW5nIGF0IG1hdHJpeC5leGFtcGxlLm9yZyB3aGlsZSBzZXJ2ZXJfbmFtZSBpcyBleGFtcGxlLm9yZ1xuICBpZiAhIGdyZXAgLXEgXCJwdWJsaWNfYmFzZXVybFwiIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbDsgdGhlblxuICAgIHNlZCAtaSBcIi9ec2VydmVyX25hbWU6L2EgcHVibGljX2Jhc2V1cmw6ICR7U0VSVklDRV9VUkxfU1lOQVBTRX0vXCIgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIGZpXG5cbiAgIyBDb25maWd1cmUgbGlzdGVuZXIgZm9yIHJldmVyc2UgcHJveHlcbiAgc2VkIC1pICdzL2JpbmRfYWRkcmVzc2VzOiBcXFsuKlxcXS9iaW5kX2FkZHJlc3NlczogW1wiMC4wLjAuMFwiXS8nIC9kYXRhL2hvbWVzZXJ2ZXIueWFtbFxuICBzZWQgLWkgJy94X2ZvcndhcmRlZDovZCcgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIHNlZCAtaSAnL3R5cGU6IGh0dHAvYSBcXCAgICB4X2ZvcndhcmRlZDogdHJ1ZScgL2RhdGEvaG9tZXNlcnZlci55YW1sXG5cbiAgIyBFbmFibGUgcmVnaXN0cmF0aW9uIGlmIHJlcXVlc3RlZFxuICBpZiBbIFwiJHtFTkFCTEVfUkVHSVNUUkFUSU9OfVwiID0gXCJ0cnVlXCIgXSAmJiAhIGdyZXAgLXEgXCJlbmFibGVfcmVnaXN0cmF0aW9uXCIgL2RhdGEvaG9tZXNlcnZlci55YW1sOyB0aGVuXG4gICAgZWNobyBcImVuYWJsZV9yZWdpc3RyYXRpb246IHRydWVcIiA+PiAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgZmlcbmZpXG5cbiMgUmVnaXN0ZXIgYWRtaW4gdXNlciBpZiBjcmVkZW50aWFscyBwcm92aWRlZFxuaWYgWyAtbiBcIiR7U0VSVklDRV9VU0VSX0FETUlOfVwiIF0gJiYgWyAtbiBcIiR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn1cIiBdOyB0aGVuXG4gIChcbiAgICB3aGlsZSAhIGN1cmwgLXNmIGh0dHA6Ly9sb2NhbGhvc3Q6ODAwOC9oZWFsdGggPiAvZGV2L251bGwgMj4mMTsgZG9cbiAgICAgIHNsZWVwIDJcbiAgICBkb25lXG4gICAgcmVnaXN0ZXJfbmV3X21hdHJpeF91c2VyIC1hIFxcXG4gICAgICAtdSBcIiR7U0VSVklDRV9VU0VSX0FETUlOfVwiIFxcXG4gICAgICAtcCBcIiR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn1cIiBcXFxuICAgICAgLWMgL2RhdGEvaG9tZXNlcnZlci55YW1sIFxcXG4gICAgICBodHRwOi8vbG9jYWxob3N0OjgwMDggMj4vZGV2L251bGwgfHwgdHJ1ZVxuICApICZcbmZpXG5cbmV4ZWMgL3N0YXJ0LnB5IgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDgvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMjBzCg==", "tags": [ "chat", "slack", @@ -3349,21 +2884,6 @@ "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, - "mediawiki": { - "documentation": "https://www.mediawiki.org?utm_source=coolify.io", - "slogan": "MediaWiki is a collaboration and documentation platform brought to you by a vibrant community.", - "compose": "c2VydmljZXM6CiAgbWVkaWF3aWtpOgogICAgaW1hZ2U6ICdtZWRpYXdpa2k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTUVESUFXSUtJXzgwCiAgICB2b2x1bWVzOgogICAgICAtICdtZWRpYXdpa2ktaW1hZ2VzOi92YXIvd3d3L2h0bWwvaW1hZ2VzJwogICAgICAtICdtZWRpYXdpa2ktc3FsaXRlOi92YXIvd3d3L2h0bWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "wiki", - "collaboration", - "documentation" - ], - "category": "cms", - "logo": "svgs/mediawiki.ico", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "meilisearch": { "documentation": "https://www.meilisearch.com?utm_source=coolify.io", "slogan": "MeiliSearch is a powerful, fast, easy to use and deploy search engine.", @@ -3494,45 +3014,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, - "mixpost": { - "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", - "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", - "compose": "c2VydmljZXM6CiAgbWl4cG9zdDoKICAgIGltYWdlOiAnaW5vdmVjdG9yL21peHBvc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTUlYUE9TVAogICAgICAtIEFQUF9OQU1FPU1peHBvc3QKICAgICAgLSAnQVBQX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X01JWFBPU1R9JwogICAgICAtIEFQUF9ERUJVRz1mYWxzZQogICAgICAtICdBUFBfRE9NQUlOPSR7U0VSVklDRV9VUkxfTUlYUE9TVH0nCiAgICAgIC0gJ0FQUF9VUkw9JHtTRVJWSUNFX1VSTF9NSVhQT1NUfScKICAgICAgLSAnREJfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotbWl4cG9zdF9kYn0nCiAgICAgIC0gJ0RCX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnU1NMX0VNQUlMPSR7U1NMX0VNQUlMOi11c2VyQGV4YW1wbGUuY29tfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21peHBvc3Qtc3RvcmFnZTovdmFyL3d3dy9odG1sL3N0b3JhZ2UvYXBwJwogICAgICAtICdtaXhwb3N0LWxvZ3M6L3Zhci93d3cvaHRtbC9zdG9yYWdlL2xvZ3MnCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICAgIC0gcmVkaXMKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWwvbXlzcWwtc2VydmVyOjguMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1taXhwb3N0X2RifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtaXhwb3N0LW15c3FsOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICAgIC0gJy11cm9vdCcKICAgICAgICAtICctcCR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXJhdycKICAgICAgICAtIGluY3IKICAgICAgICAtIHBpbmcK", - "tags": [ - "social media", - "marketing", - "content", - "management", - "automation", - "self-hosted" - ], - "category": "analytics", - "logo": "svgs/mixpost.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "moodle": { - "documentation": "https://moodle.org?utm_source=coolify.io", - "slogan": "Moodle is the world\u2019s most customisable and trusted eLearning solution that empowers educators to improve our world.", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc8L2Rldi90Y3AvbG9jYWxob3N0LzMzMDYnIgogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBtb29kbGU6CiAgICBpbWFnZTogJ2RvY2tlci5pby9iaXRuYW1pbGVnYWN5L21vb2RsZTo0LjMnCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01PT0RMRV84MDgwCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX0hPU1Q9bWFyaWFkYgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9QT1JUX05VTUJFUj0zMzA2CiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX05BTUU9Yml0bmFtaV9tb29kbGUKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIEFMTE9XX0VNUFRZX1BBU1NXT1JEPW5vCiAgICAgIC0gJ01PT0RMRV9VU0VSTkFNRT0ke01PT0RMRV9VU0VSTkFNRTotdXNlcn0nCiAgICAgIC0gTU9PRExFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01PT0RMRQogICAgICAtIE1PT0RMRV9FTUFJTD11c2VyQGV4YW1wbGUuY29tCiAgICAgIC0gJ01PT0RMRV9TSVRFX05BTUU9JHtNT09ETEVfU0lURV9OQU1FOi1OZXcgU2l0ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdtb29kbGUtZGF0YTovYml0bmFtaS9tb29kbGUnCiAgICAgIC0gJ21vb2RsZWRhdGEtZGF0YTovYml0bmFtaS9tb29kbGVkYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHBocAogICAgICAgIC0gJy1yJwogICAgICAgIC0gImV4aXQoZmlsZV9leGlzdHMoJy9vcHQvYml0bmFtaS9tb29kbGUvY29uZmlnLnBocCcpID8gMCA6IDEpOyIKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQo=", - "tags": [ - "moodle", - "elearning", - "education", - "lms", - "cms", - "open", - "source", - "low", - "code" - ], - "category": "cms", - "logo": "svgs/moodle.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-10-09T09:49:33+05:30", - "port": "8080" - }, "mosquitto": { "documentation": "https://mosquitto.org/documentation/?utm_source=coolify.io", "slogan": "Mosquitto is lightweight and suitable for use on all devices, from low-power single-board computers to full servers.", @@ -3703,74 +3184,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "nextcloud-with-mariadb": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ORVhUQ0xPVURfODAKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL1BhcmlzfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1uZXh0Y2xvdWR9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gTVlTUUxfSE9TVD1uZXh0Y2xvdWQtZGIKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ25leHRjbG91ZC1kYXRhOi9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgbmV4dGNsb3VkLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAvc3RhdHVzLnBocCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBuZXh0Y2xvdWQtZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LW5leHRjbG91ZH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud-with-mysql": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ORVhUQ0xPVURfODAKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL1BhcmlzfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotbmV4dGNsb3VkfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gTVlTUUxfSE9TVD1uZXh0Y2xvdWQtZGIKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ25leHRjbG91ZC1kYXRhOi9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgbmV4dGNsb3VkLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAvc3RhdHVzLnBocCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBuZXh0Y2xvdWQtZGI6CiAgICBpbWFnZTogJ215c3FsOjguNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLW15c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LW5leHRjbG91ZH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuNC1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyMAo=", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud-with-postgres": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ORVhUQ0xPVURfODAKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL1BhcmlzfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbmV4dGNsb3VkfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1uZXh0Y2xvdWQtZGIKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gUkVESVNfUE9SVD02Mzc5CiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ25leHRjbG91ZC1kYXRhOi9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgbmV4dGNsb3VkLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAvc3RhdHVzLnBocCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBuZXh0Y2xvdWQtZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ25leHRjbG91ZC1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1uZXh0Y2xvdWR9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9ORVhUQ0xPVURfODAKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL01hZHJpZH0nCiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtY29uZmlnOi9jb25maWcnCiAgICAgIC0gJ25leHRjbG91ZC1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwL3N0YXR1cy5waHAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, "nexus-arm": { "documentation": "https://help.sonatype.com/en/sonatype-nexus-repository.html?utm_source=coolify.io", "slogan": "Open source Universal Repository Manager (ARM version, community edition), default credentials: admin/admin123", @@ -3914,24 +3327,10 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, - "observium": { - "documentation": "https://docs.observium.org/?utm_source=coolify.io", - "slogan": "Observium is a comprehensive network monitoring platform designed to deliver powerful monitoring capabilities, combined with an elegant and intuitive user interface.", - "compose": "c2VydmljZXM6CiAgb2JzZXJ2aXVtOgogICAgaW1hZ2U6ICdtYml4dGVjaC9vYnNlcnZpdW06bGF0ZXN0JwogICAgcGxhdGZvcm06IGxpbnV4L2FtZDY0CiAgICB2b2x1bWVzOgogICAgICAtICdvYnNlcnZpdW1fbG9nczovb3B0L29ic2Vydml1bS9sb2dzJwogICAgICAtICdvYnNlcnZpdW1fcnJkOi9vcHQvb2JzZXJ2aXVtL3JyZCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdTRVJWSUNFX1VSTF9PQlNFUlZJVU09JHtTRVJWSUNFX1VSTF9PQlNFUlZJVU19JwogICAgICAtICdPQlNFUlZJVU1fQURNSU5fUEFTUz0ke1NFUlZJQ0VfUEFTU1dPUkRfT0JTRVJWSVVNQURNSU59JwogICAgICAtICdPQlNFUlZJVU1fQURNSU5fVVNFUj0ke1NFUlZJQ0VfVVNFUl9PQlNFUlZJVU1BRE1JTn0nCiAgICAgIC0gT0JTRVJWSVVNX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdPQlNFUlZJVU1fREJfTkFNRT0ke09CU0VSVklVTV9EQl9OQU1FOi1vYnNlcnZpdW0tZGJ9JwogICAgICAtICdPQlNFUlZJVU1fREJfUEFTUz0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ09CU0VSVklVTV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0LycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDVzCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjpsdHMnCiAgICB2b2x1bWVzOgogICAgICAtICdvYnNlcnZpdW1fbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQlJPT1R9JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotb2JzZXJ2aXVtLWRifScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "networking", - "monitoring" - ], - "category": "monitoring", - "logo": "svgs/observium.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "obsidian-livesync": { "documentation": "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md?utm_source=coolify.io", "slogan": "Self-hosted sync backend for Obsidian LiveSync plugin using CouchDB", - "compose": "c2VydmljZXM6CiAgY291Y2hkYjoKICAgIGltYWdlOiAnY291Y2hkYjozLjQuMicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DT1VDSERCXzU5ODQKICAgICAgLSAnQ09VQ0hEQl9VU0VSPSR7U0VSVklDRV9VU0VSX0NPVUNIREJ9JwogICAgICAtICdDT1VDSERCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9DT1VDSERCfScKICAgICAgLSAnTUFYX0RPQ1VNRU5UX1NJWkU9JHtNQVhfRE9DVU1FTlRfU0laRTotNTI0Mjg4MDB9JwogICAgICAtICdNQVhfSFRUUF9SRVFVRVNUX1NJWkU9JHtNQVhfSFRUUF9SRVFVRVNUX1NJWkU6LTY3MTA4ODY0fScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xvY2FsLmluaQogICAgICAgIHRhcmdldDogL29wdC9jb3VjaGRiL2V0Yy9sb2NhbC5pbmkKICAgICAgICBjb250ZW50OiAiW2NvdWNoZGJdXG5zaW5nbGVfbm9kZT10cnVlXG5tYXhfZG9jdW1lbnRfc2l6ZSA9ICR7TUFYX0RPQ1VNRU5UX1NJWkV9XG5cbltjaHR0cGRdXG5yZXF1aXJlX3ZhbGlkX3VzZXIgPSB0cnVlXG5tYXhfaHR0cF9yZXF1ZXN0X3NpemUgPSAke01BWF9IVFRQX1JFUVVFU1RfU0laRX1cblxuW2NodHRwZF9hdXRoXVxucmVxdWlyZV92YWxpZF91c2VyID0gdHJ1ZVxuYXV0aGVudGljYXRpb25fcmVkaXJlY3QgPSAvX3V0aWxzL3Nlc3Npb24uaHRtbFxuXG5baHR0cGRdXG5XV1ctQXV0aGVudGljYXRlID0gQmFzaWMgcmVhbG09XCJjb3VjaGRiXCJcbmVuYWJsZV9jb3JzID0gdHJ1ZVxuXG5bY29yc11cbm9yaWdpbnMgPSBhcHA6Ly9vYnNpZGlhbi5tZCxjYXBhY2l0b3I6Ly9sb2NhbGhvc3QsaHR0cDovL2xvY2FsaG9zdFxuY3JlZGVudGlhbHMgPSB0cnVlXG5oZWFkZXJzID0gYWNjZXB0LCBhdXRob3JpemF0aW9uLCBjb250ZW50LXR5cGUsIG9yaWdpbiwgcmVmZXJlclxubWV0aG9kcyA9IEdFVCwgUFVULCBQT1NULCBIRUFELCBERUxFVEVcbm1heF9hZ2UgPSAzNjAwIgogICAgICAtICdjb3VjaGRiLWRhdGE6L29wdC9jb3VjaGRiL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWYgLXUgIiQke0NPVUNIREJfVVNFUn06JCR7Q09VQ0hEQl9QQVNTV09SRH0iIGh0dHA6Ly9sb2NhbGhvc3Q6NTk4NC9fdXAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHN0YXJ0X3BlcmlvZDogNDBzCiAgICAgIHJldHJpZXM6IDMKdm9sdW1lczoKICBjb3VjaGRiLWRhdGE6IG51bGwK", + "compose": "c2VydmljZXM6CiAgY291Y2hkYjoKICAgIGltYWdlOiAnY291Y2hkYjozLjQuMicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DT1VDSERCXzU5ODQKICAgICAgLSAnQ09VQ0hEQl9VU0VSPSR7U0VSVklDRV9VU0VSX0NPVUNIREJ9JwogICAgICAtICdDT1VDSERCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9DT1VDSERCfScKICAgICAgLSAnTUFYX0RPQ1VNRU5UX1NJWkU9JHtNQVhfRE9DVU1FTlRfU0laRTotNTI0Mjg4MDB9JwogICAgICAtICdNQVhfSFRUUF9SRVFVRVNUX1NJWkU9JHtNQVhfSFRUUF9SRVFVRVNUX1NJWkU6LTY3MTA4ODY0fScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xvY2FsLmluaQogICAgICAgIHRhcmdldDogL29wdC9jb3VjaGRiL2V0Yy9sb2NhbC5pbmkKICAgICAgICBjb250ZW50OiAiW2NvdWNoZGJdXG5zaW5nbGVfbm9kZT10cnVlXG5tYXhfZG9jdW1lbnRfc2l6ZSA9ICR7TUFYX0RPQ1VNRU5UX1NJWkV9XG5cbltjaHR0cGRdXG5yZXF1aXJlX3ZhbGlkX3VzZXIgPSB0cnVlXG5tYXhfaHR0cF9yZXF1ZXN0X3NpemUgPSAke01BWF9IVFRQX1JFUVVFU1RfU0laRX1cblxuW2NodHRwZF9hdXRoXVxucmVxdWlyZV92YWxpZF91c2VyID0gdHJ1ZVxuYXV0aGVudGljYXRpb25fcmVkaXJlY3QgPSAvX3V0aWxzL3Nlc3Npb24uaHRtbFxuXG5baHR0cGRdXG5XV1ctQXV0aGVudGljYXRlID0gQmFzaWMgcmVhbG09XCJjb3VjaGRiXCJcbmVuYWJsZV9jb3JzID0gdHJ1ZVxuXG5bY29yc11cbm9yaWdpbnMgPSBhcHA6Ly9vYnNpZGlhbi5tZCxjYXBhY2l0b3I6Ly9sb2NhbGhvc3QsaHR0cDovL2xvY2FsaG9zdFxuY3JlZGVudGlhbHMgPSB0cnVlXG5oZWFkZXJzID0gYWNjZXB0LCBhdXRob3JpemF0aW9uLCBjb250ZW50LXR5cGUsIG9yaWdpbiwgcmVmZXJlclxubWV0aG9kcyA9IEdFVCwgUFVULCBQT1NULCBIRUFELCBERUxFVEVcbm1heF9hZ2UgPSAzNjAwXG4iCiAgICAgIC0gJ2NvdWNoZGItZGF0YTovb3B0L2NvdWNoZGIvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiAtdSAiJCR7Q09VQ0hEQl9VU0VSfTokJHtDT1VDSERCX1BBU1NXT1JEfSIgaHR0cDovL2xvY2FsaG9zdDo1OTg0L191cCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMKICAgICAgcmV0cmllczogMwp2b2x1bWVzOgogIGNvdWNoZGItZGF0YTogbnVsbAo=", "tags": [ "obsidian", "couchdb", @@ -3981,7 +3380,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-10-19T21:46:16+02:00", "port": "8069" }, "ollama-with-open-webui": { @@ -4136,7 +3535,7 @@ "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", - "compose": "c2VydmljZXM6CiAgb3BlbnBhbmVsLWRhc2hib2FyZDoKICAgIGltYWdlOiAnbGluZGVzdmFyZC9vcGVucGFuZWwtZGFzaGJvYXJkOjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfVVJMX09QREFTSEJPQVJEXzMwMDAKICAgICAgLSAnQVBJX1VSTD0ke1NFUlZJQ0VfVVJMX09QQVBJfScKICAgICAgLSAnREFTSEJPQVJEX1VSTD0ke1NFUlZJQ0VfVVJMX09QREFTSEJPQVJEfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9wZW5wYW5lbC13b3JrZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mIGh0dHA6Ly9sb2NhbGhvc3Q6MzAwMC9hcGkvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMKICBvcGVucGFuZWwtYXBpOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC1hcGk6MicKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIlxuICBlY2hvICdSdW5uaW5nIG1pZ3JhdGlvbnMuLi4nXG4gIENJPXRydWUgcG5wbSAtciBydW4gbWlncmF0ZTpkZXBsb3lcblxuICBwbnBtIHN0YXJ0XG5cIlxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBTRVJWSUNFX1VSTF9PUEFQSQogICAgICAtICdBUElfVVJMPSR7U0VSVklDRV9VUkxfT1BBUEl9JwogICAgICAtICdEQVNIQk9BUkRfVVJMPSR7U0VSVklDRV9VUkxfT1BEQVNIQk9BUkR9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ0RBVEFCQVNFX1VSTF9ESVJFQ1Q9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzL29wZW5wYW5lbCcKICAgICAgLSAnQ09PS0lFX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NPT0tJRVNFQ1JFVH0nCiAgICAgIC0gJ0FMTE9XX1JFR0lTVFJBVElPTj0ke09QRU5QQU5FTF9BTExPV19SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnQUxMT1dfSU5WSVRBVElPTj0ke09QRU5QQU5FTF9BTExPV19JTlZJVEFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VNQUlMX1NFTkRFUj0ke09QRU5QQU5FTF9FTUFJTF9TRU5ERVJ9JwogICAgICAtICdSRVNFTkRfQVBJX0tFWT0ke1JFU0VORF9BUElfS0VZfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBvcGVucGFuZWwtd29ya2VyOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC13b3JrZXI6MicKICAgIGVudmlyb25tZW50OgogICAgICAtICdESVNBQkxFX0JVTExCT0FSRD0ke0RJU0FCTEVfQlVMTEJPQVJEOi0xfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfVVJMX09QQlVMTEJPQVJECiAgICAgIC0gJ0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9PUEFQSX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnREFUQUJBU0VfVVJMX0RJUkVDVD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdDoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMvb3BlbnBhbmVsJwogICAgZGVwZW5kc19vbjoKICAgICAgb3BlbnBhbmVsLWFwaToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWYgaHR0cDovL2xvY2FsaG9zdDozMDAwL2hlYWx0aGNoZWNrIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9IC0tbWF4bWVtb3J5LXBvbGljeSBub2V2aWN0aW9uJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuMTAuMi42NScKICAgIGVudmlyb25tZW50OgogICAgICAtIENMSUNLSE9VU0VfU0tJUF9VU0VSX1NFVFVQPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29wZW5wYW5lbF9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnb3BlbnBhbmVsX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS1jb25maWcueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL29wLWNvbmZpZy54bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPGxvZ2dlcj5cbiAgICAgICAgPGxldmVsPndhcm5pbmc8L2xldmVsPlxuICAgICAgICA8Y29uc29sZT50cnVlPC9jb25zb2xlPlxuICAgIDwvbG9nZ2VyPlxuICAgIDxrZWVwX2FsaXZlX3RpbWVvdXQ+MTA8L2tlZXBfYWxpdmVfdGltZW91dD5cbiAgICA8IS0tIFN0b3AgYWxsIHRoZSB1bm5lY2Vzc2FyeSBsb2dnaW5nIC0tPlxuICAgIDxxdWVyeV90aHJlYWRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cXVlcnlfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dGV4dF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDx0cmFjZV9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxtZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8YXN5bmNocm9ub3VzX21ldHJpY19sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxzZXNzaW9uX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHBhcnRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bGlzdGVuX2hvc3Q+MC4wLjAuMDwvbGlzdGVuX2hvc3Q+XG4gICAgPGludGVyc2VydmVyX2xpc3Rlbl9ob3N0PjAuMC4wLjA8L2ludGVyc2VydmVyX2xpc3Rlbl9ob3N0PlxuICAgIDxpbnRlcnNlcnZlcl9odHRwX2hvc3Q+b3BjaDwvaW50ZXJzZXJ2ZXJfaHR0cF9ob3N0PlxuICAgIDwhLS0gRGlzYWJsZSBjZ3JvdXAgbWVtb3J5IG9ic2VydmVyIC0tPlxuICAgIDxjZ3JvdXBzX21lbW9yeV91c2FnZV9vYnNlcnZlcl93YWl0X3RpbWU+MDwvY2dyb3Vwc19tZW1vcnlfdXNhZ2Vfb2JzZXJ2ZXJfd2FpdF90aW1lPlxuICAgIDwhLS0gTm90IHVzZWQgYW55bW9yZSwgYnV0IGtlcHQgZm9yIGJhY2t3YXJkcyBjb21wYXRpYmlsaXR5IC0tPlxuICAgIDxtYWNyb3M+XG4gICAgICAgIDxzaGFyZD4xPC9zaGFyZD5cbiAgICAgICAgPHJlcGxpY2E+cmVwbGljYTE8L3JlcGxpY2E+XG4gICAgICAgIDxjbHVzdGVyPm9wZW5wYW5lbF9jbHVzdGVyPC9jbHVzdGVyPlxuICAgIDwvbWFjcm9zPlxuPC9jbGlja2hvdXNlPiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS11c2VyLWNvbmZpZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvdXNlcnMuZC9vcC11c2VyLWNvbmZpZy54bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPHByb2ZpbGVzPlxuICAgICAgICA8ZGVmYXVsdD5cbiAgICAgICAgICAgIDxsb2dfcXVlcmllcz4wPC9sb2dfcXVlcmllcz5cbiAgICAgICAgICAgIDxsb2dfcXVlcnlfdGhyZWFkcz4wPC9sb2dfcXVlcnlfdGhyZWFkcz5cbiAgICAgICAgPC9kZWZhdWx0PlxuICAgIDwvcHJvZmlsZXM+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2luaXQtZGIuc2gKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9pbml0LWRiLnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbmNsaWNraG91c2UgY2xpZW50IC1uIDw8LUVPU1FMXG4gIENSRUFURSBEQVRBQkFTRSBJRiBOT1QgRVhJU1RTIG9wZW5wYW5lbDtcbkVPU1FMIgogICAgdWxpbWl0czoKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjbGlja2hvdXNlLWNsaWVudCAtLXF1ZXJ5ICJTRUxFQ1QgMSInCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgb3BlbnBhbmVsLWRhc2hib2FyZDoKICAgIGltYWdlOiAnbGluZGVzdmFyZC9vcGVucGFuZWwtZGFzaGJvYXJkOjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfVVJMX09QREFTSEJPQVJEXzMwMDAKICAgICAgLSAnQVBJX1VSTD0ke1NFUlZJQ0VfVVJMX09QQVBJfScKICAgICAgLSAnREFTSEJPQVJEX1VSTD0ke1NFUlZJQ0VfVVJMX09QREFTSEJPQVJEfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9wZW5wYW5lbC13b3JrZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mIGh0dHA6Ly9sb2NhbGhvc3Q6MzAwMC9hcGkvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMKICBvcGVucGFuZWwtYXBpOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC1hcGk6MicKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIlxuICBlY2hvICdSdW5uaW5nIG1pZ3JhdGlvbnMuLi4nXG4gIENJPXRydWUgcG5wbSAtciBydW4gbWlncmF0ZTpkZXBsb3lcblxuICBwbnBtIHN0YXJ0XG5cIlxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBTRVJWSUNFX1VSTF9PUEFQSQogICAgICAtICdBUElfVVJMPSR7U0VSVklDRV9VUkxfT1BBUEl9JwogICAgICAtICdEQVNIQk9BUkRfVVJMPSR7U0VSVklDRV9VUkxfT1BEQVNIQk9BUkR9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ0RBVEFCQVNFX1VSTF9ESVJFQ1Q9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzL29wZW5wYW5lbCcKICAgICAgLSAnQ09PS0lFX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NPT0tJRVNFQ1JFVH0nCiAgICAgIC0gJ0FMTE9XX1JFR0lTVFJBVElPTj0ke09QRU5QQU5FTF9BTExPV19SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnQUxMT1dfSU5WSVRBVElPTj0ke09QRU5QQU5FTF9BTExPV19JTlZJVEFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VNQUlMX1NFTkRFUj0ke09QRU5QQU5FTF9FTUFJTF9TRU5ERVJ9JwogICAgICAtICdSRVNFTkRfQVBJX0tFWT0ke1JFU0VORF9BUElfS0VZfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBvcGVucGFuZWwtd29ya2VyOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC13b3JrZXI6MicKICAgIGVudmlyb25tZW50OgogICAgICAtICdESVNBQkxFX0JVTExCT0FSRD0ke0RJU0FCTEVfQlVMTEJPQVJEOi0xfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfVVJMX09QQlVMTEJPQVJECiAgICAgIC0gJ0FQSV9VUkw9JHtTRVJWSUNFX1VSTF9PUEFQSX0nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnREFUQUJBU0VfVVJMX0RJUkVDVD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0/c2NoZW1hPXB1YmxpYycKICAgICAgLSAnUkVESVNfVVJMPXJlZGlzOi8vZGVmYXVsdDoke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9QHJlZGlzOjYzNzknCiAgICAgIC0gJ0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly9jbGlja2hvdXNlOjgxMjMvb3BlbnBhbmVsJwogICAgZGVwZW5kc19vbjoKICAgICAgb3BlbnBhbmVsLWFwaToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWYgaHR0cDovL2xvY2FsaG9zdDozMDAwL2hlYWx0aGNoZWNrIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9IC0tbWF4bWVtb3J5LXBvbGljeSBub2V2aWN0aW9uJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuMTAuMi42NScKICAgIGVudmlyb25tZW50OgogICAgICAtIENMSUNLSE9VU0VfU0tJUF9VU0VSX1NFVFVQPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29wZW5wYW5lbF9jbGlja2hvdXNlX2RhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnb3BlbnBhbmVsX2NsaWNraG91c2VfbG9nczovdmFyL2xvZy9jbGlja2hvdXNlLXNlcnZlcicKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS1jb25maWcueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL29wLWNvbmZpZy54bWwKICAgICAgICByZWFkX29ubHk6IHRydWUKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPGxvZ2dlcj5cbiAgICAgICAgPGxldmVsPndhcm5pbmc8L2xldmVsPlxuICAgICAgICA8Y29uc29sZT50cnVlPC9jb25zb2xlPlxuICAgIDwvbG9nZ2VyPlxuICAgIDxrZWVwX2FsaXZlX3RpbWVvdXQ+MTA8L2tlZXBfYWxpdmVfdGltZW91dD5cbiAgICA8IS0tIFN0b3AgYWxsIHRoZSB1bm5lY2Vzc2FyeSBsb2dnaW5nIC0tPlxuICAgIDxxdWVyeV90aHJlYWRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cXVlcnlfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dGV4dF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDx0cmFjZV9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxtZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8YXN5bmNocm9ub3VzX21ldHJpY19sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxzZXNzaW9uX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHBhcnRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bGlzdGVuX2hvc3Q+MC4wLjAuMDwvbGlzdGVuX2hvc3Q+XG4gICAgPGludGVyc2VydmVyX2xpc3Rlbl9ob3N0PjAuMC4wLjA8L2ludGVyc2VydmVyX2xpc3Rlbl9ob3N0PlxuICAgIDxpbnRlcnNlcnZlcl9odHRwX2hvc3Q+b3BjaDwvaW50ZXJzZXJ2ZXJfaHR0cF9ob3N0PlxuICAgIDwhLS0gRGlzYWJsZSBjZ3JvdXAgbWVtb3J5IG9ic2VydmVyIC0tPlxuICAgIDxjZ3JvdXBzX21lbW9yeV91c2FnZV9vYnNlcnZlcl93YWl0X3RpbWU+MDwvY2dyb3Vwc19tZW1vcnlfdXNhZ2Vfb2JzZXJ2ZXJfd2FpdF90aW1lPlxuICAgIDwhLS0gTm90IHVzZWQgYW55bW9yZSwgYnV0IGtlcHQgZm9yIGJhY2t3YXJkcyBjb21wYXRpYmlsaXR5IC0tPlxuICAgIDxtYWNyb3M+XG4gICAgICAgIDxzaGFyZD4xPC9zaGFyZD5cbiAgICAgICAgPHJlcGxpY2E+cmVwbGljYTE8L3JlcGxpY2E+XG4gICAgICAgIDxjbHVzdGVyPm9wZW5wYW5lbF9jbHVzdGVyPC9jbHVzdGVyPlxuICAgIDwvbWFjcm9zPlxuPC9jbGlja2hvdXNlPlxuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLXVzZXItY29uZmlnLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci91c2Vycy5kL29wLXVzZXItY29uZmlnLnhtbAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8cHJvZmlsZXM+XG4gICAgICAgIDxkZWZhdWx0PlxuICAgICAgICAgICAgPGxvZ19xdWVyaWVzPjA8L2xvZ19xdWVyaWVzPlxuICAgICAgICAgICAgPGxvZ19xdWVyeV90aHJlYWRzPjA8L2xvZ19xdWVyeV90aHJlYWRzPlxuICAgICAgICA8L2RlZmF1bHQ+XG4gICAgPC9wcm9maWxlcz5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vaW5pdC1kYi5zaAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtZGIuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL3NoXG5zZXQgLWVcblxuY2xpY2tob3VzZSBjbGllbnQgLW4gPDwtRU9TUUxcbiAgQ1JFQVRFIERBVEFCQVNFIElGIE5PVCBFWElTVFMgb3BlbnBhbmVsO1xuRU9TUUxcbiIKICAgIHVsaW1pdHM6CiAgICAgIG5vZmlsZToKICAgICAgICBzb2Z0OiAyNjIxNDQKICAgICAgICBoYXJkOiAyNjIxNDQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY2xpY2tob3VzZS1jbGllbnQgLS1xdWVyeSAiU0VMRUNUIDEiJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "analytics", "insights", @@ -4192,58 +3591,6 @@ "template_last_updated_at": "2026-09-28T00:20:58+02:00", "port": "2785" }, - "opnform": { - "documentation": "https://docs.opnform.com/introduction?utm_source=coolify.io", - "slogan": "OpnForm is an open-source form builder that lets you create beautiful forms and share them anywhere. It's super fast, you don't need to know how to code", - "compose": "eC1zaGFyZWQtZW52OgogIEFQUF9OQU1FOiBPcG5Gb3JtCiAgQVBQX0VOVjogcHJvZHVjdGlvbgogIEFQUF9LRVk6ICcke1NFUlZJQ0VfQkFTRTY0X0FQSUtFWX0nCiAgQVBQX0RFQlVHOiAnJHtBUFBfREVCVUc6LWZhbHNlfScKICBBUFBfVVJMOiAnJHtTRVJWSUNFX1VSTF9OR0lOWH0nCiAgTE9HX0NIQU5ORUw6IGVycm9ybG9nCiAgTE9HX0xFVkVMOiAnJHtMT0dfTEVWRUw6LWRlYnVnfScKICBGSUxFU1lTVEVNX0RSSVZFUjogJyR7RklMRVNZU1RFTV9EUklWRVI6LWxvY2FsfScKICBMT0NBTF9GSUxFU1lTVEVNX1ZJU0lCSUxJVFk6IHB1YmxpYwogIENBQ0hFX0RSSVZFUjogcmVkaXMKICBRVUVVRV9DT05ORUNUSU9OOiByZWRpcwogIFNFU1NJT05fRFJJVkVSOiByZWRpcwogIFNFU1NJT05fTElGRVRJTUU6IDEyMAogIE1BSUxfTUFJTEVSOiAnJHtNQUlMX01BSUxFUjotbG9nfScKICBNQUlMX0hPU1Q6ICcke01BSUxfSE9TVH0nCiAgTUFJTF9QT1JUOiAnJHtNQUlMX1BPUlR9JwogIE1BSUxfVVNFUk5BTUU6ICcke01BSUxfVVNFUk5BTUU6LXlvdXJAZW1haWwuY29tfScKICBNQUlMX1BBU1NXT1JEOiAnJHtNQUlMX1BBU1NXT1JEfScKICBNQUlMX0VOQ1JZUFRJT046ICcke01BSUxfRU5DUllQVElPTn0nCiAgTUFJTF9GUk9NX0FERFJFU1M6ICcke01BSUxfRlJPTV9BRERSRVNTOi15b3VyQGVtYWlsLmNvbX0nCiAgTUFJTF9GUk9NX05BTUU6ICcke01BSUxfRlJPTV9OQU1FOi1PcG5Gb3JtfScKICBBV1NfQUNDRVNTX0tFWV9JRDogJyR7QVdTX0FDQ0VTU19LRVlfSUR9JwogIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJyR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICBBV1NfREVGQVVMVF9SRUdJT046ICcke0FXU19ERUZBVUxUX1JFR0lPTjotdXMtZWFzdC0xfScKICBBV1NfQlVDS0VUOiAnJHtBV1NfQlVDS0VUfScKICBPUEVOX0FJX0FQSV9LRVk6ICcke09QRU5fQUlfQVBJX0tFWX0nCiAgVEVMRUdSQU1fQk9UX0lEOiAnJHtURUxFR1JBTV9CT1RfSUR9JwogIFRFTEVHUkFNX0JPVF9UT0tFTjogJyR7VEVMRUdSQU1fQk9UX1RPS0VOfScKICBSRURJU19IT1NUOiByZWRpcwogIFJFRElTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICBEQl9IT1NUOiBwb3N0Z3Jlc3FsCiAgREJfREFUQUJBU0U6ICcke1BPU1RHUkVTUUxfREFUQUJBU0U6LW9wbmZvcm19JwogIERCX1VTRVJOQU1FOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgREJfUEFTU1dPUkQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgREJfQ09OTkVDVElPTjogcGdzcWwKICBQSFBfTUVNT1JZX0xJTUlUOiAxRwogIFBIUF9NQVhfRVhFQ1VUSU9OX1RJTUU6ICc2MDAnCiAgUEhQX1VQTE9BRF9NQVhfRklMRVNJWkU6IDY0TQogIFBIUF9QT1NUX01BWF9TSVpFOiA2NE0KICBGUk9OVF9VUkw6ICcke1NFUlZJQ0VfVVJMX05HSU5YfScKICBGUk9OVF9BUElfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICBQVUJMSUNfVVBMT0FEU19SQVRFX0xJTUlUX1BFUl9NSU5VVEU6ICcke1BVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX01JTlVURTotMzB9JwogIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6ICcke1BVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6LTMwMH0nCnNlcnZpY2VzOgogIG9wbmZvcm0tYXBpOgogICAgaW1hZ2U6ICdqaHVtYW5qL29wbmZvcm0tYXBpOjIuMi40JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBpLXN0b3JhZ2U6L3Vzci9zaGFyZS9uZ2lueC9odG1sL3N0b3JhZ2UnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX05BTUU6IE9wbkZvcm0KICAgICAgQVBQX0VOVjogcHJvZHVjdGlvbgogICAgICBBUFBfS0VZOiAnJHtTRVJWSUNFX0JBU0U2NF9BUElLRVl9JwogICAgICBBUFBfREVCVUc6ICcke0FQUF9ERUJVRzotZmFsc2V9JwogICAgICBBUFBfVVJMOiAnJHtTRVJWSUNFX1VSTF9OR0lOWH0nCiAgICAgIExPR19DSEFOTkVMOiBlcnJvcmxvZwogICAgICBMT0dfTEVWRUw6ICcke0xPR19MRVZFTDotZGVidWd9JwogICAgICBGSUxFU1lTVEVNX0RSSVZFUjogJyR7RklMRVNZU1RFTV9EUklWRVI6LWxvY2FsfScKICAgICAgTE9DQUxfRklMRVNZU1RFTV9WSVNJQklMSVRZOiBwdWJsaWMKICAgICAgQ0FDSEVfRFJJVkVSOiByZWRpcwogICAgICBRVUVVRV9DT05ORUNUSU9OOiByZWRpcwogICAgICBTRVNTSU9OX0RSSVZFUjogcmVkaXMKICAgICAgU0VTU0lPTl9MSUZFVElNRTogMTIwCiAgICAgIE1BSUxfTUFJTEVSOiAnJHtNQUlMX01BSUxFUjotbG9nfScKICAgICAgTUFJTF9IT1NUOiAnJHtNQUlMX0hPU1R9JwogICAgICBNQUlMX1BPUlQ6ICcke01BSUxfUE9SVH0nCiAgICAgIE1BSUxfVVNFUk5BTUU6ICcke01BSUxfVVNFUk5BTUU6LXlvdXJAZW1haWwuY29tfScKICAgICAgTUFJTF9QQVNTV09SRDogJyR7TUFJTF9QQVNTV09SRH0nCiAgICAgIE1BSUxfRU5DUllQVElPTjogJyR7TUFJTF9FTkNSWVBUSU9OfScKICAgICAgTUFJTF9GUk9NX0FERFJFU1M6ICcke01BSUxfRlJPTV9BRERSRVNTOi15b3VyQGVtYWlsLmNvbX0nCiAgICAgIE1BSUxfRlJPTV9OQU1FOiAnJHtNQUlMX0ZST01fTkFNRTotT3BuRm9ybX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAnJHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJyR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgQVdTX0RFRkFVTFRfUkVHSU9OOiAnJHtBV1NfREVGQVVMVF9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIEFXU19CVUNLRVQ6ICcke0FXU19CVUNLRVR9JwogICAgICBPUEVOX0FJX0FQSV9LRVk6ICcke09QRU5fQUlfQVBJX0tFWX0nCiAgICAgIFRFTEVHUkFNX0JPVF9JRDogJyR7VEVMRUdSQU1fQk9UX0lEfScKICAgICAgVEVMRUdSQU1fQk9UX1RPS0VOOiAnJHtURUxFR1JBTV9CT1RfVE9LRU59JwogICAgICBSRURJU19IT1NUOiByZWRpcwogICAgICBSRURJU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgIERCX0hPU1Q6IHBvc3RncmVzcWwKICAgICAgREJfREFUQUJBU0U6ICcke1BPU1RHUkVTUUxfREFUQUJBU0U6LW9wbmZvcm19JwogICAgICBEQl9VU0VSTkFNRTogJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICBEQl9QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgREJfQ09OTkVDVElPTjogcGdzcWwKICAgICAgUEhQX01FTU9SWV9MSU1JVDogMUcKICAgICAgUEhQX01BWF9FWEVDVVRJT05fVElNRTogJzYwMCcKICAgICAgUEhQX1VQTE9BRF9NQVhfRklMRVNJWkU6IDY0TQogICAgICBQSFBfUE9TVF9NQVhfU0laRTogNjRNCiAgICAgIEZST05UX1VSTDogJyR7U0VSVklDRV9VUkxfTkdJTlh9JwogICAgICBGUk9OVF9BUElfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOiAnJHtQVUJMSUNfVVBMT0FEU19SQVRFX0xJTUlUX1BFUl9NSU5VVEU6LTMwfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjotMzAwfScKICAgICAgSldUX1RUTDogJyR7SldUX1RUTDotMTQ0MH0nCiAgICAgIEpXVF9TRUNSRVQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfSldUU0VDUkVUfScKICAgICAgSldUX1NLSVBfSVBfVUFfVkFMSURBVElPTjogJyR7SldUX1NLSVBfSVBfVUFfVkFMSURBVElPTjotZmFsc2V9JwogICAgICBIX0NBUFRDSEFfU0lURV9LRVk6ICcke0hfQ0FQVENIQV9TSVRFX0tFWX0nCiAgICAgIEhfQ0FQVENIQV9TRUNSRVRfS0VZOiAnJHtIX0NBUFRDSEFfU0VDUkVUX0tFWX0nCiAgICAgIFJFX0NBUFRDSEFfU0lURV9LRVk6ICcke1JFX0NBUFRDSEFfU0lURV9LRVl9JwogICAgICBSRV9DQVBUQ0hBX1NFQ1JFVF9LRVk6ICcke1JFX0NBUFRDSEFfU0VDUkVUX0tFWX0nCiAgICAgIFNIT1dfT0ZGSUNJQUxfVEVNUExBVEVTOiAnJHtTSE9XX09GRklDSUFMX1RFTVBMQVRFUzotdHJ1ZX0nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BocCAvdXNyL3NoYXJlL25naW54L2h0bWwvYXJ0aXNhbiBhYm91dCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMKICBhcGktd29ya2VyOgogICAgaW1hZ2U6ICdqaHVtYW5qL29wbmZvcm0tYXBpOjIuMi40JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBpLXN0b3JhZ2U6L3Vzci9zaGFyZS9uZ2lueC9odG1sL3N0b3JhZ2UnCiAgICBlbnZpcm9ubWVudDoKICAgICAgQVBQX05BTUU6IE9wbkZvcm0KICAgICAgQVBQX0VOVjogcHJvZHVjdGlvbgogICAgICBBUFBfS0VZOiAnJHtTRVJWSUNFX0JBU0U2NF9BUElLRVl9JwogICAgICBBUFBfREVCVUc6ICcke0FQUF9ERUJVRzotZmFsc2V9JwogICAgICBBUFBfVVJMOiAnJHtTRVJWSUNFX1VSTF9OR0lOWH0nCiAgICAgIExPR19DSEFOTkVMOiBlcnJvcmxvZwogICAgICBMT0dfTEVWRUw6ICcke0xPR19MRVZFTDotZGVidWd9JwogICAgICBGSUxFU1lTVEVNX0RSSVZFUjogJyR7RklMRVNZU1RFTV9EUklWRVI6LWxvY2FsfScKICAgICAgTE9DQUxfRklMRVNZU1RFTV9WSVNJQklMSVRZOiBwdWJsaWMKICAgICAgQ0FDSEVfRFJJVkVSOiByZWRpcwogICAgICBRVUVVRV9DT05ORUNUSU9OOiByZWRpcwogICAgICBTRVNTSU9OX0RSSVZFUjogcmVkaXMKICAgICAgU0VTU0lPTl9MSUZFVElNRTogMTIwCiAgICAgIE1BSUxfTUFJTEVSOiAnJHtNQUlMX01BSUxFUjotbG9nfScKICAgICAgTUFJTF9IT1NUOiAnJHtNQUlMX0hPU1R9JwogICAgICBNQUlMX1BPUlQ6ICcke01BSUxfUE9SVH0nCiAgICAgIE1BSUxfVVNFUk5BTUU6ICcke01BSUxfVVNFUk5BTUU6LXlvdXJAZW1haWwuY29tfScKICAgICAgTUFJTF9QQVNTV09SRDogJyR7TUFJTF9QQVNTV09SRH0nCiAgICAgIE1BSUxfRU5DUllQVElPTjogJyR7TUFJTF9FTkNSWVBUSU9OfScKICAgICAgTUFJTF9GUk9NX0FERFJFU1M6ICcke01BSUxfRlJPTV9BRERSRVNTOi15b3VyQGVtYWlsLmNvbX0nCiAgICAgIE1BSUxfRlJPTV9OQU1FOiAnJHtNQUlMX0ZST01fTkFNRTotT3BuRm9ybX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAnJHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJyR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgQVdTX0RFRkFVTFRfUkVHSU9OOiAnJHtBV1NfREVGQVVMVF9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIEFXU19CVUNLRVQ6ICcke0FXU19CVUNLRVR9JwogICAgICBPUEVOX0FJX0FQSV9LRVk6ICcke09QRU5fQUlfQVBJX0tFWX0nCiAgICAgIFRFTEVHUkFNX0JPVF9JRDogJyR7VEVMRUdSQU1fQk9UX0lEfScKICAgICAgVEVMRUdSQU1fQk9UX1RPS0VOOiAnJHtURUxFR1JBTV9CT1RfVE9LRU59JwogICAgICBSRURJU19IT1NUOiByZWRpcwogICAgICBSRURJU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgIERCX0hPU1Q6IHBvc3RncmVzcWwKICAgICAgREJfREFUQUJBU0U6ICcke1BPU1RHUkVTUUxfREFUQUJBU0U6LW9wbmZvcm19JwogICAgICBEQl9VU0VSTkFNRTogJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICBEQl9QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgREJfQ09OTkVDVElPTjogcGdzcWwKICAgICAgUEhQX01FTU9SWV9MSU1JVDogMUcKICAgICAgUEhQX01BWF9FWEVDVVRJT05fVElNRTogJzYwMCcKICAgICAgUEhQX1VQTE9BRF9NQVhfRklMRVNJWkU6IDY0TQogICAgICBQSFBfUE9TVF9NQVhfU0laRTogNjRNCiAgICAgIEZST05UX1VSTDogJyR7U0VSVklDRV9VUkxfTkdJTlh9JwogICAgICBGUk9OVF9BUElfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOiAnJHtQVUJMSUNfVVBMT0FEU19SQVRFX0xJTUlUX1BFUl9NSU5VVEU6LTMwfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjotMzAwfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcGhwCiAgICAgIC0gYXJ0aXNhbgogICAgICAtICdxdWV1ZTp3b3JrJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwZ3JlcCAtZiAncGhwIGFydGlzYW4gcXVldWU6d29yaycgPiAvZGV2L251bGwgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgYXBpLXNjaGVkdWxlcjoKICAgIGltYWdlOiAnamh1bWFuai9vcG5mb3JtLWFwaToyLjIuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwaS1zdG9yYWdlOi91c3Ivc2hhcmUvbmdpbngvaHRtbC9zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9OQU1FOiBPcG5Gb3JtCiAgICAgIEFQUF9FTlY6IHByb2R1Y3Rpb24KICAgICAgQVBQX0tFWTogJyR7U0VSVklDRV9CQVNFNjRfQVBJS0VZfScKICAgICAgQVBQX0RFQlVHOiAnJHtBUFBfREVCVUc6LWZhbHNlfScKICAgICAgQVBQX1VSTDogJyR7U0VSVklDRV9VUkxfTkdJTlh9JwogICAgICBMT0dfQ0hBTk5FTDogZXJyb3Jsb2cKICAgICAgTE9HX0xFVkVMOiAnJHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgRklMRVNZU1RFTV9EUklWRVI6ICcke0ZJTEVTWVNURU1fRFJJVkVSOi1sb2NhbH0nCiAgICAgIExPQ0FMX0ZJTEVTWVNURU1fVklTSUJJTElUWTogcHVibGljCiAgICAgIENBQ0hFX0RSSVZFUjogcmVkaXMKICAgICAgUVVFVUVfQ09OTkVDVElPTjogcmVkaXMKICAgICAgU0VTU0lPTl9EUklWRVI6IHJlZGlzCiAgICAgIFNFU1NJT05fTElGRVRJTUU6IDEyMAogICAgICBNQUlMX01BSUxFUjogJyR7TUFJTF9NQUlMRVI6LWxvZ30nCiAgICAgIE1BSUxfSE9TVDogJyR7TUFJTF9IT1NUfScKICAgICAgTUFJTF9QT1JUOiAnJHtNQUlMX1BPUlR9JwogICAgICBNQUlMX1VTRVJOQU1FOiAnJHtNQUlMX1VTRVJOQU1FOi15b3VyQGVtYWlsLmNvbX0nCiAgICAgIE1BSUxfUEFTU1dPUkQ6ICcke01BSUxfUEFTU1dPUkR9JwogICAgICBNQUlMX0VOQ1JZUFRJT046ICcke01BSUxfRU5DUllQVElPTn0nCiAgICAgIE1BSUxfRlJPTV9BRERSRVNTOiAnJHtNQUlMX0ZST01fQUREUkVTUzoteW91ckBlbWFpbC5jb219JwogICAgICBNQUlMX0ZST01fTkFNRTogJyR7TUFJTF9GUk9NX05BTUU6LU9wbkZvcm19JwogICAgICBBV1NfQUNDRVNTX0tFWV9JRDogJyR7QVdTX0FDQ0VTU19LRVlfSUR9JwogICAgICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICcke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgICAgIEFXU19ERUZBVUxUX1JFR0lPTjogJyR7QVdTX0RFRkFVTFRfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICBBV1NfQlVDS0VUOiAnJHtBV1NfQlVDS0VUfScKICAgICAgT1BFTl9BSV9BUElfS0VZOiAnJHtPUEVOX0FJX0FQSV9LRVl9JwogICAgICBURUxFR1JBTV9CT1RfSUQ6ICcke1RFTEVHUkFNX0JPVF9JRH0nCiAgICAgIFRFTEVHUkFNX0JPVF9UT0tFTjogJyR7VEVMRUdSQU1fQk9UX1RPS0VOfScKICAgICAgUkVESVNfSE9TVDogcmVkaXMKICAgICAgUkVESVNfUEFTU1dPUkQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgICBEQl9IT1NUOiBwb3N0Z3Jlc3FsCiAgICAgIERCX0RBVEFCQVNFOiAnJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1vcG5mb3JtfScKICAgICAgREJfVVNFUk5BTUU6ICcke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU1FMfScKICAgICAgREJfUEFTU1dPUkQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIERCX0NPTk5FQ1RJT046IHBnc3FsCiAgICAgIFBIUF9NRU1PUllfTElNSVQ6IDFHCiAgICAgIFBIUF9NQVhfRVhFQ1VUSU9OX1RJTUU6ICc2MDAnCiAgICAgIFBIUF9VUExPQURfTUFYX0ZJTEVTSVpFOiA2NE0KICAgICAgUEhQX1BPU1RfTUFYX1NJWkU6IDY0TQogICAgICBGUk9OVF9VUkw6ICcke1NFUlZJQ0VfVVJMX05HSU5YfScKICAgICAgRlJPTlRfQVBJX1NFQ1JFVDogJyR7U0VSVklDRV9QQVNTV09SRF9GUk9OVEFQSVNFQ1JFVH0nCiAgICAgIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX01JTlVURTogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOi0zMH0nCiAgICAgIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6ICcke1BVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6LTMwMH0nCiAgICBjb21tYW5kOgogICAgICAtIHBocAogICAgICAtIGFydGlzYW4KICAgICAgLSAnc2NoZWR1bGU6d29yaycKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGhwIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9hcnRpc2FuIGFwcDpzY2hlZHVsZXItc3RhdHVzIC0tbW9kZT1jaGVjayAtLW1heC1taW51dGVzPTMgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDMwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogNzBzCiAgb3BuZm9ybS11aToKICAgIGltYWdlOiAnamh1bWFuai9vcG5mb3JtLWNsaWVudDoyLjIuNCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE5VWFRfUFVCTElDX0FQUF9VUkw9LwogICAgICAtIE5VWFRfUFVCTElDX0FQSV9CQVNFPS9hcGkKICAgICAgLSAnTlVYVF9QUklWQVRFX0FQSV9CQVNFPWh0dHA6Ly9uZ2lueC9hcGknCiAgICAgIC0gTlVYVF9QVUJMSUNfRU5WPXByb2R1Y3Rpb24KICAgICAgLSAnTlVYVF9QVUJMSUNfSF9DQVBUQ0hBX1NJVEVfS0VZPSR7SF9DQVBUQ0hBX1NJVEVfS0VZfScKICAgICAgLSAnTlVYVF9QVUJMSUNfUkVfQ0FQVENIQV9TSVRFX0tFWT0ke1JFX0NBUFRDSEFfU0lURV9LRVl9JwogICAgICAtICdOVVhUX0FQSV9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICAgICAgLSAnTlVYVF9QVUJMSUNfTElDRU5TRV9BUElfRU5EUE9JTlQ9JHtOVVhUX1BVQkxJQ19MSUNFTlNFX0FQSV9FTkRQT0lOVDotaHR0cHM6Ly9hcGkub3BuZm9ybS5jb219JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tc3BpZGVyIC1xIGh0dHA6Ly9vcG5mb3JtLXVpOjMwMDAvbG9naW4gfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogNDVzCiAgICBkZXBlbmRzX29uOgogICAgICBvcG5mb3JtLWFwaToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4JwogICAgdm9sdW1lczoKICAgICAgLSAnb3BuZm9ybS1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNRTF9EQVRBQkFTRTotb3BuZm9ybX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBjb21tYW5kOgogICAgICAtIHJlZGlzLXNlcnZlcgogICAgICAtICctLXJlcXVpcmVwYXNzJwogICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gJy1hJwogICAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICBuZ2lueDoKICAgIGltYWdlOiAnbmdpbng6MS4zMS4zJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfTkdJTlgKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL25naW54L25naW54LmNvbmYKICAgICAgICB0YXJnZXQ6IC9ldGMvbmdpbngvY29uZi5kL2RlZmF1bHQuY29uZgogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICJtYXAgJG9yaWdpbmFsX3VyaSAkYXBpX3VyaSB7XG4gICAgfl4vYXBpKC8uKiQpICQxO1xuICAgIGRlZmF1bHQgJG9yaWdpbmFsX3VyaTtcbn1cblxuc2VydmVyIHtcbiAgICBsaXN0ZW4gODAgZGVmYXVsdF9zZXJ2ZXI7XG4gICAgcm9vdCAvdXNyL3NoYXJlL25naW54L2h0bWwvcHVibGljO1xuXG4gICAgYWNjZXNzX2xvZyAvZGV2L3N0ZG91dDtcbiAgICBlcnJvcl9sb2cgL2Rldi9zdGRlcnIgZXJyb3I7XG5cbiAgICBpbmRleCBpbmRleC5odG1sIGluZGV4Lmh0bSBpbmRleC5waHA7XG5cbiAgICBsb2NhdGlvbiAvIHtcbiAgICAgICAgcHJveHlfaHR0cF92ZXJzaW9uIDEuMTtcbiAgICAgICAgcHJveHlfcGFzcyBodHRwOi8vb3BuZm9ybS11aTozMDAwO1xuICAgICAgICBwcm94eV9zZXRfaGVhZGVyIFgtUmVhbC1JUCAkcmVtb3RlX2FkZHI7XG4gICAgICAgIHByb3h5X3NldF9oZWFkZXIgWC1Gb3J3YXJkZWQtSG9zdCAkaG9zdDtcbiAgICAgICAgcHJveHlfc2V0X2hlYWRlciBYLUZvcndhcmRlZC1Qb3J0ICRzZXJ2ZXJfcG9ydDtcbiAgICAgICAgcHJveHlfc2V0X2hlYWRlciBVcGdyYWRlICRodHRwX3VwZ3JhZGU7XG4gICAgICAgIHByb3h5X3NldF9oZWFkZXIgQ29ubmVjdGlvbiBcIlVwZ3JhZGVcIjtcbiAgICB9XG5cbiAgICBsb2NhdGlvbiB+LyhhcGl8b3Blbnxsb2NhbFxcL3RlbXB8Zm9ybXNcXC9hc3NldHMpLyB7XG4gICAgICAgIHNldCAkb3JpZ2luYWxfdXJpICR1cmk7XG4gICAgICAgIHRyeV9maWxlcyAkdXJpICR1cmkvIC9pbmRleC5waHAkaXNfYXJncyRhcmdzO1xuICAgIH1cblxuICAgIGxvY2F0aW9uIH4gXFwucGhwJCB7XG4gICAgICAgIGZhc3RjZ2lfc3BsaXRfcGF0aF9pbmZvIF4oLitcXC5waHApKC8uKykkO1xuICAgICAgICBmYXN0Y2dpX3Bhc3Mgb3BuZm9ybS1hcGk6OTAwMDtcbiAgICAgICAgZmFzdGNnaV9pbmRleCBpbmRleC5waHA7XG4gICAgICAgIGluY2x1ZGUgZmFzdGNnaV9wYXJhbXM7XG4gICAgICAgIGZhc3RjZ2lfcGFyYW0gU0NSSVBUX0ZJTEVOQU1FIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9wdWJsaWMvaW5kZXgucGhwO1xuICAgICAgICBmYXN0Y2dpX3BhcmFtIFJFUVVFU1RfVVJJICRhcGlfdXJpO1xuICAgIH1cbn0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG9wbmZvcm0tYXBpCiAgICAgIC0gb3BuZm9ybS11aQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5naW54CiAgICAgICAgLSAnLXQnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMK", - "tags": [ - "opnform", - "form", - "survey", - "cloud", - "open-source", - "self-hosted", - "docker", - "no-code", - "embeddable" - ], - "category": "productivity", - "logo": "svg/opnform.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T12:49:29+02:00", - "port": "80" - }, - "orangehrm": { - "documentation": "https://starterhelp.orangehrm.com/hc/en-us?utm_source=coolify.io", - "slogan": "OrangeHRM open source HR management software.", - "compose": "c2VydmljZXM6CiAgb3JhbmdlaHJtOgogICAgaW1hZ2U6ICdvcmFuZ2Vocm0vb3JhbmdlaHJtOmxhdGVzdCcKICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfT1JBTkdFSFJNXzgwCiAgICAgIC0gT1JBTkdFSFJNX0RBVEFCQVNFX0hPU1Q9bWFyaWFkYgogICAgICAtICdPUkFOR0VIUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ09SQU5HRUhSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdPUkFOR0VIUk1fREFUQUJBU0VfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1vcmFuZ2Vocm0tZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnb3JhbmdlaHJtLWRhdGE6L29yYW5nZWhybScKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdvcmFuZ2Vocm1fbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LW9yYW5nZWhybS1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "hr", - "hris", - "hrms", - "human resource management", - "orangehrm", - "hr management" - ], - "category": "productivity", - "logo": "svgs/orangehrm.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "organizr": { - "documentation": "https://docs.organizr.app/?utm_source=coolify.io", - "slogan": "Homelab Services Organizer", - "compose": "c2VydmljZXM6CiAgb3JnYW5penI6CiAgICBpbWFnZTogJ29yZ2FuaXpyL29yZ2FuaXpyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX09SR0FOSVpSXzgwCiAgICAgIC0gJ2JyYW5jaD0ke2JyYW5jaDotdjItbWFzdGVyfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29yZ2FuaXpyLWRhdGE6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "tool" - ], - "category": "productivity", - "logo": "svgs/organizr.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "overseerr": { "documentation": "https://docs.overseerr.dev/getting-started/installation#docker?utm_source=coolify.io", "slogan": "Overseerr is a request management and media discovery tool built to work with your existing Plex ecosystem.", @@ -4261,23 +3608,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5055" }, - "owncloud": { - "documentation": "https://owncloud.com/docs-guides/?utm_source=coolify.io", - "slogan": "OwnCloud with Open Web UI integrates file management with a powerful, user-friendly interface.", - "compose": "c2VydmljZXM6CiAgb3duY2xvdWQ6CiAgICBpbWFnZTogJ293bmNsb3VkL3NlcnZlcjpsYXRlc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PV05DTE9VRF84MDgwCiAgICAgIC0gJ09XTkNMT1VEX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9PV05DTE9VRH0nCiAgICAgIC0gJ09XTkNMT1VEX1RSVVNURURfRE9NQUlOUz0ke1NFUlZJQ0VfRlFETl9PV05DTE9VRH0nCiAgICAgIC0gT1dOQ0xPVURfREJfVFlQRT1teXNxbAogICAgICAtIE9XTkNMT1VEX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdPV05DTE9VRF9EQl9OQU1FPSR7REJfTkFNRTotb3duY2xvdWR9JwogICAgICAtICdPV05DTE9VRF9EQl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnT1dOQ0xPVURfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdPV05DTE9VRF9BRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9PV05DTE9VRH0nCiAgICAgIC0gJ09XTkNMT1VEX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9PV05DTE9VRH0nCiAgICAgIC0gJ09XTkNMT1VEX01ZU1FMX1VURjhNQjQ9JHtNWVNRTF9VVEY4TUI0Oi10cnVlfScKICAgICAgLSAnT1dOQ0xPVURfUkVESVNfRU5BQkxFRD0ke1JFRElTX0VOQUJMRUQ6LXRydWV9JwogICAgICAtIE9XTkNMT1VEX1JFRElTX0hPU1Q9cmVkaXMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSAvdXNyL2Jpbi9oZWFsdGhjaGVjawogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiA1CiAgICB2b2x1bWVzOgogICAgICAtICdvd25jbG91ZC1kYXRhOi9tbnQvZGF0YScKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtEQl9OQU1FOi1vd25jbG91ZH0nCiAgICAgIC0gVFo9YXV0bwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jaGFyYWN0ZXItc2V0LXNlcnZlcj11dGY4bWI0JwogICAgICAtICctLWNvbGxhdGlvbi1zZXJ2ZXI9dXRmOG1iNF9iaW4nCiAgICAgIC0gJy0tbWF4LWFsbG93ZWQtcGFja2V0PTEyOE0nCiAgICAgIC0gJy0taW5ub2RiLWxvZy1maWxlLXNpemU9NjRNJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICdvd25jbG91ZC1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo2JwogICAgY29tbWFuZDoKICAgICAgLSAnLS1kYXRhYmFzZXMnCiAgICAgIC0gJzEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", - "tags": [ - "owncloud", - "file-management", - "open-web-ui", - "integration", - "cloud" - ], - "category": "storage", - "logo": "svgs/owncloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-06-02T12:12:42+03:00", - "port": "8080" - }, "pairdrop": { "documentation": "https://pairdrop.net/?utm_source=coolify.io", "slogan": "Pairdrop is a self-hosted file sharing and collaboration platform, offering secure file sharing and collaboration capabilities for efficient teamwork.", @@ -4315,37 +3645,6 @@ "template_last_updated_at": "2025-12-02T12:07:14+01:00", "port": "8000" }, - "passbolt": { - "documentation": "https://www.passbolt.com/ce/docker?utm_source=coolify.io", - "slogan": "Passbolt Community Edition (CE) API. The JSON API for the open source password manager for teams!", - "compose": "c2VydmljZXM6CiAgcGFzc2JvbHQ6CiAgICBpbWFnZTogJ3Bhc3Nib2x0L3Bhc3Nib2x0OmxhdGVzdC1jZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BBU1NCT0xUCiAgICAgIC0gJ0FQUF9GVUxMX0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfUEFTU0JPTFR9JwogICAgICAtIERBVEFTT1VSQ0VTX0RFRkFVTFRfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RBVEFTT1VSQ0VTX0RFRkFVTFRfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfUEFTU0JPTFR9JwogICAgICAtICdEQVRBU09VUkNFU19ERUZBVUxUX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QQVNTQk9MVH0nCiAgICAgIC0gJ0RBVEFTT1VSQ0VTX0RFRkFVTFRfREFUQUJBU0U9JHtNWVNRTF9EQjotcGFzc2JvbHQtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ3BnX3ZvbHVtZTovZXRjL3Bhc3Nib2x0L2dwZycKICAgICAgLSAnand0X3ZvbHVtZTovZXRjL3Bhc3Nib2x0L2p3dCcKICAgIGNvbW1hbmQ6CiAgICAgIC0gL3Vzci9iaW4vd2FpdC1mb3Iuc2gKICAgICAgLSAnLXQnCiAgICAgIC0gJzAnCiAgICAgIC0gJ21hcmlhZGI6MzMwNicKICAgICAgLSAnLS0nCiAgICAgIC0gL2RvY2tlci1lbnRyeXBvaW50LnNoCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVE1BUklBREJ9JwogICAgICAtICdNQVJJQURCX0RBVEFCQVNFPSR7TVlTUUxfREI6LXBhc3Nib2x0LWRifScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BBU1NCT0xUfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUEFTU0JPTFR9JwogICAgdm9sdW1lczoKICAgICAgLSAncGFzc2JvbHRfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "security", - "credentials", - "password-manager", - "open source" - ], - "category": "auth", - "logo": "svgs/passbolt.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "paymenter": { - "documentation": "https://paymenter.org/docs/guides/docker?utm_source=coolify.io", - "slogan": "Open-Source Billing, Built for Hosting", - "compose": "c2VydmljZXM6CiAgcGF5bWVudGVyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3BheW1lbnRlci9wYXltZW50ZXI6djEuNC41JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwX2xvZ3M6L2FwcC9zdG9yYWdlL2xvZ3MnCiAgICAgIC0gJ2V4dGVuc3Rpb25zOi9hcHAvZXh0ZW5zaW9ucycKICAgICAgLSAndGhlbWVzOi9hcHAvdGhlbWVzJwogICAgICAtICdhcHBfc3RvcmFnZTovYXBwL3N0b3JhZ2UvYXBwJwogICAgICAtICdhcHBfcHVibGljX3N0b3JhZ2U6L2FwcC9zdG9yYWdlL2FwcC9wdWJsaWMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgU0VSVklDRV9VUkxfUEFZTUVOVEVSOiAnJHtTRVJWSUNFX1VSTF9QQVlNRU5URVJfODB9JwogICAgICBEQl9EQVRBQkFTRTogJyR7TVlTUUxfREFUQUJBU0U6LXBheW1lbnRlci1kYn0nCiAgICAgIERCX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgREJfVVNFUk5BTUU6ICcke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIEFQUF9FTlY6IHByb2R1Y3Rpb24KICAgICAgQ0FDSEVfU1RPUkU6IHJlZGlzCiAgICAgIFNFU1NJT05fRFJJVkVSOiByZWRpcwogICAgICBRVUVVRV9DT05ORUNUSU9OOiByZWRpcwogICAgICBSRURJU19IT1NUOiByZWRpcwogICAgICBSRURJU19VU0VSTkFNRTogZGVmYXVsdAogICAgICBSRURJU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgIERCX0NPTk5FQ1RJT046IG1hcmlhZGIKICAgICAgREJfSE9TVDogbWFyaWFkYgogICAgICBEQl9QT1JUOiAzMzA2CiAgICAgIEFQUF9LRVk6ICcke1NFUlZJQ0VfQkFTRTY0X0tFWX0nCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXNmIGh0dHA6Ly9sb2NhbGhvc3Q6ODAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExLjgnCiAgICB2b2x1bWVzOgogICAgICAtICdwYXltZW50ZXJfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LXBheW1lbnRlci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "automation", - "billing", - "open source" - ], - "category": "finance", - "logo": "svgs/paymenter.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "peertube": { "documentation": "https://docs.joinpeertube.org/install/docker?utm_source=coolify.io", "slogan": "Free, open-source and decentralized video platform.", @@ -4379,7 +3678,7 @@ "category": "productivity", "logo": "svgs/penpot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-29T15:56:03+05:30", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "penpot": { @@ -4428,19 +3727,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8085" }, - "phpmyadmin": { - "documentation": "https://phpmyadmin.net?utm_source=coolify.io", - "slogan": "phpMyAdmin is a web-based database management tool for administering your MySQL and MariaDB databases through a user-friendly interface.", - "compose": "c2VydmljZXM6CiAgcGhwbXlhZG1pbjoKICAgIGltYWdlOiAnbHNjci5pby9saW51eHNlcnZlci9waHBteWFkbWluOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BIUE1ZQURNSU4KICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICAgIC0gUE1BX0FSQklUUkFSWT0xCiAgICAgIC0gUE1BX0FCU09MVVRFX1VSST0kU0VSVklDRV9VUkxfUEhQTVlBRE1JTgogICAgdm9sdW1lczoKICAgICAgLSAncGhwbXlhZG1pbi1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "database management" - ], - "category": "database", - "logo": "svgs/phpmyadmin.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "pi-hole": { "documentation": "https://pi-hole.net/?utm_source=coolify.io", "slogan": "Network-wide Ad Blocking", @@ -4478,7 +3764,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-15T22:51:15+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "plex": { @@ -4638,20 +3924,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4200" }, - "privatebin": { - "documentation": "https://github.com/PrivateBin/PrivateBin/blob/master/doc/README.md?utm_source=coolify.io", - "slogan": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data.", - "compose": "c2VydmljZXM6CiAgcHJpdmF0ZWJpbjoKICAgIGltYWdlOiBwcml2YXRlYmluL25naW54LWZwbS1hbHBpbmUKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1BSSVZBVEVCSU5fODA4MAogICAgdm9sdW1lczoKICAgICAgLSAncHJpdmF0ZWJpbl9kYXRhOi9zcnYvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnd2dldCAtcU8tIGh0dHA6Ly8xMjcuMC4wLjE6ODA4MC8nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "text", - "sharing" - ], - "category": "security", - "logo": "svgs/privatebin.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8080" - }, "prowlarr": { "documentation": "https://hub.docker.com/r/linuxserver/prowlarr?utm_source=coolify.io", "slogan": "Prowlarr\u2060 is a indexer manager/proxy built on the popular arr .net/reactjs base stack to integrate with your various PVR apps.", @@ -4687,40 +3959,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4zJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCg==", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T18:06:35+02:00", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4zJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9VUkxfUFRFUk9EQUNUWUxfODAKICAgICAgLSAnQURNSU5fRU1BSUw9JHtBRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdBRE1JTl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0FETUlOX0ZJUlNUTkFNRT0ke0FETUlOX0ZJUlNUTkFNRTotQWRtaW59JwogICAgICAtICdBRE1JTl9MQVNUTkFNRT0ke0FETUlOX0xBU1ROQU1FOi1Vc2VyfScKICAgICAgLSAnQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnUFRFUk9EQUNUWUxfSFRUUFM9JHtQVEVST0RBQ1RZTF9IVFRQUzotZmFsc2V9JwogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9FTlZJUk9OTUVOVF9PTkxZPWZhbHNlCiAgICAgIC0gQVBQX1VSTD0kU0VSVklDRV9VUkxfUFRFUk9EQUNUWUwKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7VElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9TRVJWSUNFX0FVVEhPUj0ke0FQUF9TRVJWSUNFX0FVVEhPUjotYXV0aG9yQGV4YW1wbGUuY29tfScKICAgICAgLSAnTE9HX0xFVkVMPSR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIC0gQ0FDSEVfRFJJVkVSPXJlZGlzCiAgICAgIC0gU0VTU0lPTl9EUklWRVI9cmVkaXMKICAgICAgLSBRVUVVRV9EUklWRVI9cmVkaXMKICAgICAgLSBSRURJU19IT1NUPXJlZGlzCiAgICAgIC0gREJfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBNQUlMX0ZST009JE1BSUxfRlJPTQogICAgICAtIE1BSUxfRFJJVkVSPSRNQUlMX0RSSVZFUgogICAgICAtIE1BSUxfSE9TVD0kTUFJTF9IT1NUCiAgICAgIC0gTUFJTF9QT1JUPSRNQUlMX1BPUlQKICAgICAgLSBNQUlMX1VTRVJOQU1FPSRNQUlMX1VTRVJOQU1FCiAgICAgIC0gTUFJTF9QQVNTV09SRD0kTUFJTF9QQVNTV09SRAogICAgICAtIE1BSUxfRU5DUllQVElPTj0kTUFJTF9FTkNSWVBUSU9OCiAgd2luZ3M6CiAgICBpbWFnZTogJ2doY3IuaW8vcHRlcm9kYWN0eWwvd2luZ3M6djEuMTIuMScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBwb3J0czoKICAgICAgLSAnMjAyMjoyMDIyJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T18:06:35+02:00", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4772,7 +4010,7 @@ "category": "messaging", "logo": "svgs/rabbitmq.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "15672" }, "radarr": { @@ -4820,7 +4058,7 @@ "category": "productivity", "logo": "svgs/rxresume.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:30:50+01:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "readeck": { @@ -4924,7 +4162,7 @@ "rybbit": { "documentation": "https://rybbit.io/docs?utm_source=coolify.io", "slogan": "Open-source, privacy-first web analytics.", - "compose": "c2VydmljZXM6CiAgcnliYml0OgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtY2xpZW50OnYyLjIuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JZQkJJVF8zMDAyCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19CQUNLRU5EX1VSTD0ke1NFUlZJQ0VfVVJMX1JZQkJJVH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcnliYml0X2JhY2tlbmQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnbmMgLXogMTI3LjAuMC4xIDMwMDInCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9yeWJiaXQtaW8vcnliYml0LWJhY2tlbmQ6djIuMi4zJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFRSVVNUX1BST1hZPXRydWUKICAgICAgLSAnQkFTRV9VUkw9JHtTRVJWSUNFX1VSTF9SWUJCSVR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQkVUVEVSX0FVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfQkFDS0VORH0nCiAgICAgIC0gJ0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9URUxFTUVUUlk9JHtESVNBQkxFX1RFTEVNRVRSWTotdHJ1ZX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfSE9TVD1odHRwOi8vcnliYml0X2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7Q0xJQ0tIT1VTRV9VU0VSOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWFuYWx5dGljc30nCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1yeWJiaXRfcG9zdGdyZXMKICAgICAgLSBQT1NUR1JFU19QT1JUPTU0MzIKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgZGVwZW5kc19vbjoKICAgICAgcnliYml0X2NsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnliYml0X3Bvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAxL2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcnliYml0X2NsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1hbmFseXRpY3N9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtDTElDS0hPVVNFX1VTRVI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgdm9sdW1lczoKICAgICAgLSAnY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2VuYWJsZV9qc29uLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9lbmFibGVfanNvbi54bWwKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPHNldHRpbmdzPlxuICAgICAgICA8ZW5hYmxlX2pzb25fdHlwZT4xPC9lbmFibGVfanNvbl90eXBlPlxuICAgIDwvc2V0dGluZ3M+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsb2dnZXI+XG4gICAgICAgIDxsZXZlbD53YXJuaW5nPC9sZXZlbD5cbiAgICAgICAgPGNvbnNvbGU+dHJ1ZTwvY29uc29sZT5cbiAgICA8L2xvZ2dlcj5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHF1ZXJ5X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRleHRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGFzeW5jaHJvbm91c19tZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8c2Vzc2lvbl9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxwYXJ0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGxhdGVuY3lfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cHJvY2Vzc29yc19wcm9maWxlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG48L2NsaWNraG91c2U+IgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlX2NvbmZpZy9uZXR3b3JrLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9uZXR3b3JrLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8bGlzdGVuX2hvc3Q+MC4wLjAuMDwvbGlzdGVuX2hvc3Q+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL3VzZXJfbG9nZ2luZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvdXNlcl9sb2dnaW5nLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8cHJvZmlsZXM+XG4gICAgICAgIDxkZWZhdWx0PlxuICAgICAgICAgICAgPGxvZ19xdWVyaWVzPjA8L2xvZ19xdWVyaWVzPlxuICAgICAgICAgICAgPGxvZ19xdWVyeV90aHJlYWRzPjA8L2xvZ19xdWVyeV90aHJlYWRzPlxuICAgICAgICAgICAgPGxvZ19wcm9jZXNzb3JzX3Byb2ZpbGVzPjA8L2xvZ19wcm9jZXNzb3JzX3Byb2ZpbGVzPlxuICAgICAgICA8L2RlZmF1bHQ+XG4gICAgPC9wcm9maWxlcz5cbjwvY2xpY2tob3VzZT5cbiIK", + "compose": "c2VydmljZXM6CiAgcnliYml0OgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtY2xpZW50OnYyLjIuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1JZQkJJVF8zMDAyCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtICdORVhUX1BVQkxJQ19CQUNLRU5EX1VSTD0ke1NFUlZJQ0VfVVJMX1JZQkJJVH0nCiAgICAgIC0gJ05FWFRfUFVCTElDX0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcnliYml0X2JhY2tlbmQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnbmMgLXogMTI3LjAuMC4xIDMwMDInCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfYmFja2VuZDoKICAgIGltYWdlOiAnZ2hjci5pby9yeWJiaXQtaW8vcnliYml0LWJhY2tlbmQ6djIuMi4zJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFRSVVNUX1BST1hZPXRydWUKICAgICAgLSAnQkFTRV9VUkw9JHtTRVJWSUNFX1VSTF9SWUJCSVR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQkVUVEVSX0FVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfQkFDS0VORH0nCiAgICAgIC0gJ0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9URUxFTUVUUlk9JHtESVNBQkxFX1RFTEVNRVRSWTotdHJ1ZX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfSE9TVD1odHRwOi8vcnliYml0X2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7Q0xJQ0tIT1VTRV9VU0VSOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWFuYWx5dGljc30nCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1yeWJiaXRfcG9zdGdyZXMKICAgICAgLSBQT1NUR1JFU19QT1JUPTU0MzIKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgZGVwZW5kc19vbjoKICAgICAgcnliYml0X2NsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnliYml0X3Bvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAxL2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcnliYml0X2NsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1hbmFseXRpY3N9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtDTElDS0hPVVNFX1VTRVI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgdm9sdW1lczoKICAgICAgLSAnY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2VuYWJsZV9qc29uLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9lbmFibGVfanNvbi54bWwKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPHNldHRpbmdzPlxuICAgICAgICA8ZW5hYmxlX2pzb25fdHlwZT4xPC9lbmFibGVfanNvbl90eXBlPlxuICAgIDwvc2V0dGluZ3M+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsb2dnZXI+XG4gICAgICAgIDxsZXZlbD53YXJuaW5nPC9sZXZlbD5cbiAgICAgICAgPGNvbnNvbGU+dHJ1ZTwvY29uc29sZT5cbiAgICA8L2xvZ2dlcj5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHF1ZXJ5X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRleHRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGFzeW5jaHJvbm91c19tZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8c2Vzc2lvbl9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxwYXJ0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGxhdGVuY3lfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cHJvY2Vzc29yc19wcm9maWxlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL25ldHdvcmsueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL25ldHdvcmsueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsaXN0ZW5faG9zdD4wLjAuMC4wPC9saXN0ZW5faG9zdD5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZV9jb25maWcvdXNlcl9sb2dnaW5nLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC91c2VyX2xvZ2dpbmcueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxwcm9maWxlcz5cbiAgICAgICAgPGRlZmF1bHQ+XG4gICAgICAgICAgICA8bG9nX3F1ZXJpZXM+MDwvbG9nX3F1ZXJpZXM+XG4gICAgICAgICAgICA8bG9nX3F1ZXJ5X3RocmVhZHM+MDwvbG9nX3F1ZXJ5X3RocmVhZHM+XG4gICAgICAgICAgICA8bG9nX3Byb2Nlc3NvcnNfcHJvZmlsZXM+MDwvbG9nX3Byb2Nlc3NvcnNfcHJvZmlsZXM+XG4gICAgICAgIDwvZGVmYXVsdD5cbiAgICA8L3Byb2ZpbGVzPlxuPC9jbGlja2hvdXNlPlxuIgo=", "tags": [ "analytics", "web", @@ -4987,7 +4225,7 @@ "category": "storage", "logo": "svgs/seafile.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-23T08:56:51+05:30", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "searxng": { @@ -5077,29 +4315,10 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, - "shlink": { - "documentation": "https://shlink.io/?utm_source=coolify.io", - "slogan": "The definitive self-hosted URL shortener", - "compose": "c2VydmljZXM6CiAgc2hsaW5rOgogICAgaW1hZ2U6ICdzaGxpbmtpby9zaGxpbms6c3RhYmxlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU0hMSU5LXzgwODAKICAgICAgLSAnREVGQVVMVF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fU0hMSU5LfScKICAgICAgLSBJU19IVFRQU19FTkFCTEVEPWZhbHNlCiAgICAgIC0gJ0lOSVRJQUxfQVBJX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X1NITElOS0FQSUtFWX0nCiAgICB2b2x1bWVzOgogICAgICAtICdzaGxpbmstZGF0YTovZXRjL3NobGluay9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcmVzdC92My9oZWFsdGgnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBzaGxpbmstd2ViOgogICAgaW1hZ2U6IHNobGlua2lvL3NobGluay13ZWItY2xpZW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TSExJTktXRUJfODA4MAogICAgICAtICdTSExJTktfU0VSVkVSX0FQSV9LRVk9JHtTRVJWSUNFX0JBU0U2NF9TSExJTktBUElLRVl9JwogICAgICAtICdTSExJTktfU0VSVkVSX1VSTD0ke1NFUlZJQ0VfVVJMX1NITElOS30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "links", - "shortener", - "sharing", - "url", - "short", - "link", - "sharing" - ], - "category": "productivity", - "logo": "svgs/default.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2025-10-02T19:40:32+02:00", - "port": "8080" - }, "signoz": { "documentation": "https://signoz.io/docs/introduction/?utm_source=coolify.io", "slogan": "An observability platform native to OpenTelemetry with logs, traces and metrics.", - "compose": "c2VydmljZXM6CiAgaW5pdC1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSBiYXNoCiAgICAgIC0gJy1jJwogICAgICAtICJ2ZXJzaW9uPVwidjAuMC4xXCJcbm5vZGVfb3M9JCQodW5hbWUgLXMgfCB0ciAnWzp1cHBlcjpdJyAnWzpsb3dlcjpdJylcbm5vZGVfYXJjaD0kJCh1bmFtZSAtbSB8IHNlZCBzL2FhcmNoNjQvYXJtNjQvIHwgc2VkIHMveDg2XzY0L2FtZDY0LylcbmVjaG8gXCJGZXRjaGluZyBoaXN0b2dyYW0tYmluYXJ5IGZvciAkJHtub2RlX29zfS8kJHtub2RlX2FyY2h9XCJcbmNkIC90bXBcbndnZXQgLU8gaGlzdG9ncmFtLXF1YW50aWxlLnRhci5neiBcImh0dHBzOi8vZ2l0aHViLmNvbS9TaWdOb3ovc2lnbm96L3JlbGVhc2VzL2Rvd25sb2FkL2hpc3RvZ3JhbS1xdWFudGlsZSUyRiQke3ZlcnNpb259L2hpc3RvZ3JhbS1xdWFudGlsZV8kJHtub2RlX29zfV8kJHtub2RlX2FyY2h9LnRhci5nelwiXG50YXIgLXh2emYgaGlzdG9ncmFtLXF1YW50aWxlLnRhci5nelxubXYgaGlzdG9ncmFtLXF1YW50aWxlIC92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzL2hpc3RvZ3JhbVF1YW50aWxlXG4iCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IHVzZXItc2NyaXB0cwogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS91c2VyX3NjcmlwdHMKICB6b29rZWVwZXI6CiAgICBpbWFnZTogJ3NpZ25vei96b29rZWVwZXI6My45LjMnCiAgICB1c2VyOiByb290CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgLW0gMiBodHRwOi8vbG9jYWxob3N0OjgwODAvY29tbWFuZHMvcnVvayB8IGdyZXAgZXJyb3IgfCBncmVwIG51bGwnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgdm9sdW1lczoKICAgICAgLSAnem9va2VlcGVyOi9iaXRuYW1pL3pvb2tlZXBlcicKICAgIGVudmlyb25tZW50OgogICAgICAtICdBTExPV19BTk9OWU1PVVNfTE9HSU49JHtaT09fQUxMT1dfQU5PTllNT1VTX0xPR0lOOi15ZXN9JwogICAgICAtICdaT09fQVVUT1BVUkdFX0lOVEVSVkFMPSR7Wk9PX0FVVE9QVVJHRV9JTlRFUlZBTDotMX0nCiAgICAgIC0gJ1pPT19FTkFCTEVfUFJPTUVUSEVVU19NRVRSSUNTPSR7Wk9PX0VOQUJMRV9QUk9NRVRIRVVTX01FVFJJQ1M6LXllc30nCiAgICAgIC0gJ1pPT19QUk9NRVRIRVVTX01FVFJJQ1NfUE9SVF9OVU1CRVI9JHtaT09fUFJPTUVUSEVVU19NRVRSSUNTX1BPUlRfTlVNQkVSOi05MTQxfScKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgdHR5OiB0cnVlCiAgICBkZXBlbmRzX29uOgogICAgICBpbml0LWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgem9va2VlcGVyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy1PLScKICAgICAgICAtICcwLjAuMC4wOjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICB1bGltaXRzOgogICAgICBucHJvYzogNjU1MzUKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogY2xpY2tob3VzZQogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS8KICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogdXNlci1zY3JpcHRzCiAgICAgICAgdGFyZ2V0OiAvdmFyL2xpYi9jbGlja2hvdXNlL3VzZXJfc2NyaXB0cwogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2N1c3RvbS1mdW5jdGlvbi54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY3VzdG9tLWZ1bmN0aW9uLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8ZnVuY3Rpb25zPlxuICAgIDxmdW5jdGlvbj5cbiAgICAgICAgPHR5cGU+ZXhlY3V0YWJsZTwvdHlwZT5cbiAgICAgICAgPG5hbWU+aGlzdG9ncmFtUXVhbnRpbGU8L25hbWU+XG4gICAgICAgIDxyZXR1cm5fdHlwZT5GbG9hdDY0PC9yZXR1cm5fdHlwZT5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+QXJyYXkoRmxvYXQ2NCk8L3R5cGU+XG4gICAgICAgICAgICA8bmFtZT5idWNrZXRzPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8YXJndW1lbnQ+XG4gICAgICAgICAgICA8dHlwZT5BcnJheShGbG9hdDY0KTwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPmNvdW50czwvbmFtZT5cbiAgICAgICAgPC9hcmd1bWVudD5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+RmxvYXQ2NDwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPnF1YW50aWxlPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8Zm9ybWF0PkNTVjwvZm9ybWF0PlxuICAgICAgICA8Y29tbWFuZD4uL2hpc3RvZ3JhbVF1YW50aWxlPC9jb21tYW5kPlxuICAgIDwvZnVuY3Rpb24+XG48L2Z1bmN0aW9ucz5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9jbHVzdGVyLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9jbHVzdGVyLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8P3htbCB2ZXJzaW9uPVwiMS4wXCI/PlxuPGNsaWNraG91c2U+XG4gICAgPHpvb2tlZXBlcj5cbiAgICAgICAgPG5vZGUgaW5kZXg9XCIxXCI+XG4gICAgICAgICAgICA8aG9zdD56b29rZWVwZXI8L2hvc3Q+XG4gICAgICAgICAgICA8cG9ydD4yMTgxPC9wb3J0PlxuICAgICAgICA8L25vZGU+XG4gICAgPC96b29rZWVwZXI+XG4gICAgPHJlbW90ZV9zZXJ2ZXJzPlxuICAgICAgICA8Y2x1c3Rlcj5cbiAgICAgICAgICAgIDxzaGFyZD5cbiAgICAgICAgICAgICAgICA8cmVwbGljYT5cbiAgICAgICAgICAgICAgICAgICAgPGhvc3Q+Y2xpY2tob3VzZTwvaG9zdD5cbiAgICAgICAgICAgICAgICAgICAgPHBvcnQ+OTAwMDwvcG9ydD5cbiAgICAgICAgICAgICAgICA8L3JlcGxpY2E+XG4gICAgICAgICAgICA8L3NoYXJkPlxuICAgICAgICA8L2NsdXN0ZXI+XG4gICAgPC9yZW1vdGVfc2VydmVycz5cbiAgICA8bWFjcm9zPlxuICAgICAgICA8c2hhcmQ+MDE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMDE8L3JlcGxpY2E+XG4gICAgPC9tYWNyb3M+XG4gICAgPHVzZXJfc2NyaXB0c19wYXRoPi92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzLzwvdXNlcl9zY3JpcHRzX3BhdGg+XG4gICAgPHVzZXJfZGVmaW5lZF9leGVjdXRhYmxlX2Z1bmN0aW9uc19jb25maWc+KmZ1bmN0aW9uLnhtbDwvdXNlcl9kZWZpbmVkX2V4ZWN1dGFibGVfZnVuY3Rpb25zX2NvbmZpZz5cbjwvY2xpY2tob3VzZT5cbiIKICBzaWdub3o6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3o6djAuMTM3LjEnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBzcWxpdGUKICAgICAgICB0YXJnZXQ6IC92YXIvbGliL3NpZ25vei8KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NJR05PWl84MDgwCiAgICAgIC0gJ1NJR05PWl9UT0tFTklaRVJfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1RTRUNSRVR9JwogICAgICAtIFNJR05PWl9TUUxTVE9SRV9QUk9WSURFUj1zcWxpdGUKICAgICAgLSBTSUdOT1pfU1FMU1RPUkVfU1FMSVRFX1BBVEg9L3Zhci9saWIvc2lnbm96L3NpZ25vei5kYgogICAgICAtIFNJR05PWl9URUxFTUVUUllTVE9SRV9QUk9WSURFUj1jbGlja2hvdXNlCiAgICAgIC0gJ1NJR05PWl9URUxFTUVUUllTVE9SRV9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gR09ERUJVRz1uZXRkbnM9Z28KICAgICAgLSBERVBMT1lNRU5UX1RZUEU9ZG9ja2VyLXN0YW5kYWxvbmUtYW1kCiAgICAgIC0gJ1NJR05PWl9TVEFUU1JFUE9SVEVSX0VOQUJMRUQ9JHtTSUdOT1pfU1RBVFNSRVBPUlRFUl9FTkFCTEVEOi10cnVlfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX0VOQUJMRUQ9JHtTSUdOT1pfRU1BSUxJTkdfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9BRERSRVNTPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19TTVRQX0ZST009JHtTSUdOT1pfRU1BSUxJTkdfU01UUF9GUk9NfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9VU0VSTkFNRT0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfVVNFUk5BTUV9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9BVVRIX1BBU1NXT1JEPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9QQVNTV09SRH0nCiAgICAgIC0gU0lHTk9aX0FMRVJUTUFOQUdFUl9QUk9WSURFUj1zaWdub3oKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1BBU1NXT1JEPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1BBU1NXT1JEfScKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1VTRVJOQU1FPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1VTRVJOQU1FfScKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0ZST009JHtTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fRlJPTX0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19TTUFSVEhPU1Q9JHtTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fU01BUlRIT1NUfScKICAgICAgLSBET1RfTUVUUklDU19FTkFCTEVEPXRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnbG9jYWxob3N0OjgwODAvYXBpL3YxL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgdGVsZW1ldHJ5c3RvcmUtbWlncmF0b3I6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3otb3RlbC1jb2xsZWN0b3I6djAuMTQ0LjgnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWMnCiAgICBjb21tYW5kOgogICAgICAtICIvc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgYm9vdHN0cmFwIC0tY2xpY2tob3VzZS1kc24gdGNwOi8vY2xpY2tob3VzZTo5MDAwICYmXG4vc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgc3luYyB1cCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMCAmJlxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIGFzeW5jIHVwIC0tY2xpY2tob3VzZS1kc24gdGNwOi8vY2xpY2tob3VzZTo5MDAwXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfRFNOPXRjcDovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9DTFVTVEVSPWNsdXN0ZXIKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9SRVBMSUNBVElPTj10cnVlCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX1RJTUVPVVQ9MTBtCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICBvdGVsLWNvbGxlY3RvcjoKICAgIGltYWdlOiAnc2lnbm96L3NpZ25vei1vdGVsLWNvbGxlY3Rvcjp2MC4xNDQuOCcKICAgIGRlcGVuZHNfb246CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdGVsZW1ldHJ5c3RvcmUtbWlncmF0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgc2lnbm96OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtICJzZXQgLWVcbmNhdCA+IC90bXAvb3RlbC1jb25maWcueWFtbCA8PCdTSUdOT1pfQ0ZHJ1xuY29ubmVjdG9yczpcbiAgc2lnbm96bWV0ZXI6XG4gICAgbWV0cmljc19mbHVzaF9pbnRlcnZhbDogMWhcbiAgICBkaW1lbnNpb25zOlxuICAgICAgLSBuYW1lOiBzZXJ2aWNlLm5hbWVcbiAgICAgIC0gbmFtZTogZGVwbG95bWVudC5lbnZpcm9ubWVudFxuICAgICAgLSBuYW1lOiBob3N0Lm5hbWVcbnJlY2VpdmVyczpcbiAgb3RscDpcbiAgICBwcm90b2NvbHM6XG4gICAgICBncnBjOlxuICAgICAgICBlbmRwb2ludDogMC4wLjAuMDo0MzE3XG4gICAgICBodHRwOlxuICAgICAgICBlbmRwb2ludDogMC4wLjAuMDo0MzE4XG4gICAgICAgIGF1dGg6XG4gICAgICAgICAgYXV0aGVudGljYXRvcjogYmFzaWNhdXRoL3NlcnZlclxucHJvY2Vzc29yczpcbiAgYmF0Y2g6XG4gICAgc2VuZF9iYXRjaF9zaXplOiA1MDAwMFxuICAgIHNlbmRfYmF0Y2hfbWF4X3NpemU6IDU1MDAwXG4gICAgdGltZW91dDogNXNcbiAgYmF0Y2gvbWV0ZXI6XG4gICAgc2VuZF9iYXRjaF9zaXplOiAyMDAwMFxuICAgIHNlbmRfYmF0Y2hfbWF4X3NpemU6IDI1MDAwXG4gICAgdGltZW91dDogNXNcbiAgc2lnbm96c3Bhbm1ldHJpY3MvZGVsdGE6XG4gICAgbWV0cmljc19leHBvcnRlcjogc2lnbm96Y2xpY2tob3VzZW1ldHJpY3NcbiAgICBtZXRyaWNzX2ZsdXNoX2ludGVydmFsOiA2MHNcbiAgICBsYXRlbmN5X2hpc3RvZ3JhbV9idWNrZXRzOiBbMTAwdXMsIDFtcywgMm1zLCA2bXMsIDEwbXMsIDUwbXMsIDEwMG1zLCAyNTBtcywgNTAwbXMsIDEwMDBtcywgMTQwMG1zLCAyMDAwbXMsIDVzLCAxMHMsIDIwcywgNDBzLCA2MHNdXG4gICAgZGltZW5zaW9uc19jYWNoZV9zaXplOiAxMDAwMDBcbiAgICBhZ2dyZWdhdGlvbl90ZW1wb3JhbGl0eTogQUdHUkVHQVRJT05fVEVNUE9SQUxJVFlfREVMVEFcbiAgICBlbmFibGVfZXhwX2hpc3RvZ3JhbTogdHJ1ZVxuICAgIGRpbWVuc2lvbnM6XG4gICAgICAtIG5hbWU6IHNlcnZpY2UubmFtZXNwYWNlXG4gICAgICAgIGRlZmF1bHQ6IGRlZmF1bHRcbiAgICAgIC0gbmFtZTogZGVwbG95bWVudC5lbnZpcm9ubWVudFxuICAgICAgICBkZWZhdWx0OiBkZWZhdWx0XG4gICAgICAjIFRoaXMgaXMgYWRkZWQgdG8gZW5zdXJlIHRoZSB1bmlxdWVuZXNzIG9mIHRoZSB0aW1lc2VyaWVzXG4gICAgICAjIE90aGVyd2lzZSwgaWRlbnRpY2FsIHRpbWVzZXJpZXMgcHJvZHVjZWQgYnkgbXVsdGlwbGUgcmVwbGljYXMgb2ZcbiAgICAgICMgY29sbGVjdG9ycyByZXN1bHQgaW4gaW5jb3JyZWN0IEFQTSBtZXRyaWNzXG4gICAgICAtIG5hbWU6IHNpZ25vei5jb2xsZWN0b3IuaWRcbiAgICAgIC0gbmFtZTogc2VydmljZS52ZXJzaW9uXG5leHBvcnRlcnM6XG4gIGNsaWNraG91c2V0cmFjZXM6XG4gICAgZGF0YXNvdXJjZTogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel90cmFjZXNcbiAgICBsb3dfY2FyZGluYWxfZXhjZXB0aW9uX2dyb3VwaW5nOiBmYWxzZVxuICAgIHVzZV9uZXdfc2NoZW1hOiB0cnVlXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIHNpZ25vemNsaWNraG91c2VtZXRyaWNzOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRyaWNzXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIGNsaWNraG91c2Vsb2dzZXhwb3J0ZXI6XG4gICAgZHNuOiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAvc2lnbm96X2xvZ3NcbiAgICB1c2VfbmV3X3NjaGVtYTogdHJ1ZVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBzaWdub3pjbGlja2hvdXNlbWV0ZXI6XG4gICAgZHNuOiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAvc2lnbm96X21ldGVyXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIG1ldGFkYXRhZXhwb3J0ZXI6XG4gICAgZW5hYmxlZDogdHJ1ZVxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRhZGF0YVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIGNhY2hlOlxuICAgICAgcHJvdmlkZXI6IGluX21lbW9yeVxuZXh0ZW5zaW9uczpcbiAgc2lnbm96X2hlYWx0aF9jaGVjazpcbiAgICBlbmRwb2ludDogMC4wLjAuMDoxMzEzM1xuICBwcHJvZjpcbiAgICBlbmRwb2ludDogMC4wLjAuMDoxNzc3XG4gIGJhc2ljYXV0aC9zZXJ2ZXI6XG4gICAgaHRwYXNzd2Q6XG4gICAgICBpbmxpbmU6IFwiJCR7ZW52Ok9URUxfQkFTSUNfQVVUSH1cXG5cIlxuc2VydmljZTpcbiAgdGVsZW1ldHJ5OlxuICAgIGxvZ3M6XG4gICAgICBlbmNvZGluZzoganNvblxuICBleHRlbnNpb25zOlxuICAgIC0gc2lnbm96X2hlYWx0aF9jaGVja1xuICAgIC0gcHByb2ZcbiAgICAtIGJhc2ljYXV0aC9zZXJ2ZXJcbiAgcGlwZWxpbmVzOlxuICAgIHRyYWNlczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbc2lnbm96c3Bhbm1ldHJpY3MvZGVsdGEsIGJhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbY2xpY2tob3VzZXRyYWNlcywgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbWV0cmljczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2hdXG4gICAgICBleHBvcnRlcnM6IFtzaWdub3pjbGlja2hvdXNlbWV0cmljcywgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbG9nczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2hdXG4gICAgICBleHBvcnRlcnM6IFtjbGlja2hvdXNlbG9nc2V4cG9ydGVyLCBzaWdub3ptZXRlciwgbWV0YWRhdGFleHBvcnRlcl1cbiAgICBtZXRyaWNzL21ldGVyOlxuICAgICAgcmVjZWl2ZXJzOiBbc2lnbm96bWV0ZXJdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2gvbWV0ZXJdXG4gICAgICBleHBvcnRlcnM6IFtzaWdub3pjbGlja2hvdXNlbWV0ZXJdXG5TSUdOT1pfQ0ZHXG5jYXQgPiAvdG1wL29wYW1wLWNvbmZpZy55YW1sIDw8J1NJR05PWl9PUEFNUCdcbnNlcnZlcl9lbmRwb2ludDogd3M6Ly9zaWdub3o6NDMyMC92MS9vcGFtcFxuU0lHTk9aX09QQU1QXG4vc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgc3luYyBjaGVja1xuZXhlYyAvc2lnbm96LW90ZWwtY29sbGVjdG9yIC0tY29uZmlnPS90bXAvb3RlbC1jb25maWcueWFtbCAtLW1hbmFnZXItY29uZmlnPS90bXAvb3BhbXAtY29uZmlnLnlhbWwgLS1jb3B5LXBhdGg9L3Zhci90bXAvY29sbGVjdG9yLWNvbmZpZy55YW1sXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVEVMQ09MTEVDVE9SSFRUUF80MzE4CiAgICAgIC0gJ09URUxfUkVTT1VSQ0VfQVRUUklCVVRFUz1ob3N0Lm5hbWU9c2lnbm96LWhvc3Qsb3MudHlwZT1saW51eCcKICAgICAgLSBMT1dfQ0FSRElOQUxfRVhDRVBUSU9OX0dST1VQSU5HPWZhbHNlCiAgICAgIC0gJ1NJR05PWl9PVEVMX0NPTExFQ1RPUl9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfQ0xVU1RFUj1jbHVzdGVyCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfUkVQTElDQVRJT049dHJ1ZQogICAgICAtIFNJR05PWl9PVEVMX0NPTExFQ1RPUl9USU1FT1VUPTEwbQogICAgICAtICdPVEVMX0JBU0lDX0FVVEg9c2lnbm96OiR7U0VSVklDRV9QQVNTV09SRF82NF9PVEVMQVVUSH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ2Jhc2ggLWMgImV4ZWMgNjw+IC9kZXYvdGNwL2xvY2FsaG9zdC8xMzEzMyInCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", + "compose": "c2VydmljZXM6CiAgaW5pdC1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSBiYXNoCiAgICAgIC0gJy1jJwogICAgICAtICJ2ZXJzaW9uPVwidjAuMC4xXCJcbm5vZGVfb3M9JCQodW5hbWUgLXMgfCB0ciAnWzp1cHBlcjpdJyAnWzpsb3dlcjpdJylcbm5vZGVfYXJjaD0kJCh1bmFtZSAtbSB8IHNlZCBzL2FhcmNoNjQvYXJtNjQvIHwgc2VkIHMveDg2XzY0L2FtZDY0LylcbmVjaG8gXCJGZXRjaGluZyBoaXN0b2dyYW0tYmluYXJ5IGZvciAkJHtub2RlX29zfS8kJHtub2RlX2FyY2h9XCJcbmNkIC90bXBcbndnZXQgLU8gaGlzdG9ncmFtLXF1YW50aWxlLnRhci5neiBcImh0dHBzOi8vZ2l0aHViLmNvbS9TaWdOb3ovc2lnbm96L3JlbGVhc2VzL2Rvd25sb2FkL2hpc3RvZ3JhbS1xdWFudGlsZSUyRiQke3ZlcnNpb259L2hpc3RvZ3JhbS1xdWFudGlsZV8kJHtub2RlX29zfV8kJHtub2RlX2FyY2h9LnRhci5nelwiXG50YXIgLXh2emYgaGlzdG9ncmFtLXF1YW50aWxlLnRhci5nelxubXYgaGlzdG9ncmFtLXF1YW50aWxlIC92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzL2hpc3RvZ3JhbVF1YW50aWxlXG4iCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IHVzZXItc2NyaXB0cwogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS91c2VyX3NjcmlwdHMKICB6b29rZWVwZXI6CiAgICBpbWFnZTogJ3NpZ25vei96b29rZWVwZXI6My45LjMnCiAgICB1c2VyOiByb290CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgLW0gMiBodHRwOi8vbG9jYWxob3N0OjgwODAvY29tbWFuZHMvcnVvayB8IGdyZXAgZXJyb3IgfCBncmVwIG51bGwnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgdm9sdW1lczoKICAgICAgLSAnem9va2VlcGVyOi9iaXRuYW1pL3pvb2tlZXBlcicKICAgIGVudmlyb25tZW50OgogICAgICAtICdBTExPV19BTk9OWU1PVVNfTE9HSU49JHtaT09fQUxMT1dfQU5PTllNT1VTX0xPR0lOOi15ZXN9JwogICAgICAtICdaT09fQVVUT1BVUkdFX0lOVEVSVkFMPSR7Wk9PX0FVVE9QVVJHRV9JTlRFUlZBTDotMX0nCiAgICAgIC0gJ1pPT19FTkFCTEVfUFJPTUVUSEVVU19NRVRSSUNTPSR7Wk9PX0VOQUJMRV9QUk9NRVRIRVVTX01FVFJJQ1M6LXllc30nCiAgICAgIC0gJ1pPT19QUk9NRVRIRVVTX01FVFJJQ1NfUE9SVF9OVU1CRVI9JHtaT09fUFJPTUVUSEVVU19NRVRSSUNTX1BPUlRfTlVNQkVSOi05MTQxfScKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgdHR5OiB0cnVlCiAgICBkZXBlbmRzX29uOgogICAgICBpbml0LWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgem9va2VlcGVyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy1PLScKICAgICAgICAtICcwLjAuMC4wOjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICB1bGltaXRzOgogICAgICBucHJvYzogNjU1MzUKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogY2xpY2tob3VzZQogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS8KICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogdXNlci1zY3JpcHRzCiAgICAgICAgdGFyZ2V0OiAvdmFyL2xpYi9jbGlja2hvdXNlL3VzZXJfc2NyaXB0cwogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2N1c3RvbS1mdW5jdGlvbi54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY3VzdG9tLWZ1bmN0aW9uLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8ZnVuY3Rpb25zPlxuICAgIDxmdW5jdGlvbj5cbiAgICAgICAgPHR5cGU+ZXhlY3V0YWJsZTwvdHlwZT5cbiAgICAgICAgPG5hbWU+aGlzdG9ncmFtUXVhbnRpbGU8L25hbWU+XG4gICAgICAgIDxyZXR1cm5fdHlwZT5GbG9hdDY0PC9yZXR1cm5fdHlwZT5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+QXJyYXkoRmxvYXQ2NCk8L3R5cGU+XG4gICAgICAgICAgICA8bmFtZT5idWNrZXRzPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8YXJndW1lbnQ+XG4gICAgICAgICAgICA8dHlwZT5BcnJheShGbG9hdDY0KTwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPmNvdW50czwvbmFtZT5cbiAgICAgICAgPC9hcmd1bWVudD5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+RmxvYXQ2NDwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPnF1YW50aWxlPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8Zm9ybWF0PkNTVjwvZm9ybWF0PlxuICAgICAgICA8Y29tbWFuZD4uL2hpc3RvZ3JhbVF1YW50aWxlPC9jb21tYW5kPlxuICAgIDwvZnVuY3Rpb24+XG48L2Z1bmN0aW9ucz5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9jbHVzdGVyLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9jbHVzdGVyLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8P3htbCB2ZXJzaW9uPVwiMS4wXCI/PlxuPGNsaWNraG91c2U+XG4gICAgPHpvb2tlZXBlcj5cbiAgICAgICAgPG5vZGUgaW5kZXg9XCIxXCI+XG4gICAgICAgICAgICA8aG9zdD56b29rZWVwZXI8L2hvc3Q+XG4gICAgICAgICAgICA8cG9ydD4yMTgxPC9wb3J0PlxuICAgICAgICA8L25vZGU+XG4gICAgPC96b29rZWVwZXI+XG4gICAgPHJlbW90ZV9zZXJ2ZXJzPlxuICAgICAgICA8Y2x1c3Rlcj5cbiAgICAgICAgICAgIDxzaGFyZD5cbiAgICAgICAgICAgICAgICA8cmVwbGljYT5cbiAgICAgICAgICAgICAgICAgICAgPGhvc3Q+Y2xpY2tob3VzZTwvaG9zdD5cbiAgICAgICAgICAgICAgICAgICAgPHBvcnQ+OTAwMDwvcG9ydD5cbiAgICAgICAgICAgICAgICA8L3JlcGxpY2E+XG4gICAgICAgICAgICA8L3NoYXJkPlxuICAgICAgICA8L2NsdXN0ZXI+XG4gICAgPC9yZW1vdGVfc2VydmVycz5cbiAgICA8bWFjcm9zPlxuICAgICAgICA8c2hhcmQ+MDE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMDE8L3JlcGxpY2E+XG4gICAgPC9tYWNyb3M+XG4gICAgPHVzZXJfc2NyaXB0c19wYXRoPi92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzLzwvdXNlcl9zY3JpcHRzX3BhdGg+XG4gICAgPHVzZXJfZGVmaW5lZF9leGVjdXRhYmxlX2Z1bmN0aW9uc19jb25maWc+KmZ1bmN0aW9uLnhtbDwvdXNlcl9kZWZpbmVkX2V4ZWN1dGFibGVfZnVuY3Rpb25zX2NvbmZpZz5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9zeXN0ZW0tbG9ncy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvc3lzdGVtLWxvZ3MueG1sCiAgICAgICAgY29udGVudDogIjw/eG1sIHZlcnNpb249XCIxLjBcIj8+XG48Y2xpY2tob3VzZT5cbiAgICA8cXVlcnlfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvcXVlcnlfbG9nPlxuICAgIDxxdWVyeV90aHJlYWRfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICA8L3F1ZXJ5X3RocmVhZF9sb2c+XG4gICAgPHF1ZXJ5X21ldHJpY19sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvcXVlcnlfbWV0cmljX2xvZz5cbiAgICA8cXVlcnlfdmlld3NfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICA8L3F1ZXJ5X3ZpZXdzX2xvZz5cbiAgICA8cGFydF9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvcGFydF9sb2c+XG4gICAgPG1ldHJpY19sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgICAgICA8Zmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPjMwMDAwPC9mbHVzaF9pbnRlcnZhbF9taWxsaXNlY29uZHM+XG4gICAgPC9tZXRyaWNfbG9nPlxuICAgIDxhc3luY2hyb25vdXNfbWV0cmljX2xvZz5cbiAgICAgICAgPHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgMSBEQVkgREVMRVRFPC90dGw+XG4gICAgPC9hc3luY2hyb25vdXNfbWV0cmljX2xvZz5cbiAgICA8dHJhY2VfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvdHJhY2VfbG9nPlxuICAgIDxlcnJvcl9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvZXJyb3JfbG9nPlxuICAgIDxwcm9jZXNzb3JzX3Byb2ZpbGVfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvcHJvY2Vzc29yc19wcm9maWxlX2xvZz5cbiAgICA8dGV4dF9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgICAgICA8Zmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPjMwMDAwPC9mbHVzaF9pbnRlcnZhbF9taWxsaXNlY29uZHM+XG4gICAgPC90ZXh0X2xvZz5cbjwvY2xpY2tob3VzZT5cbiIKICBzaWdub3o6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3o6djAuMTM3LjEnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBzcWxpdGUKICAgICAgICB0YXJnZXQ6IC92YXIvbGliL3NpZ25vei8KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1NJR05PWl84MDgwCiAgICAgIC0gJ1NJR05PWl9UT0tFTklaRVJfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1RTRUNSRVR9JwogICAgICAtIFNJR05PWl9TUUxTVE9SRV9QUk9WSURFUj1zcWxpdGUKICAgICAgLSBTSUdOT1pfU1FMU1RPUkVfU1FMSVRFX1BBVEg9L3Zhci9saWIvc2lnbm96L3NpZ25vei5kYgogICAgICAtIFNJR05PWl9URUxFTUVUUllTVE9SRV9QUk9WSURFUj1jbGlja2hvdXNlCiAgICAgIC0gJ1NJR05PWl9URUxFTUVUUllTVE9SRV9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gR09ERUJVRz1uZXRkbnM9Z28KICAgICAgLSBERVBMT1lNRU5UX1RZUEU9ZG9ja2VyLXN0YW5kYWxvbmUtYW1kCiAgICAgIC0gJ1NJR05PWl9TVEFUU1JFUE9SVEVSX0VOQUJMRUQ9JHtTSUdOT1pfU1RBVFNSRVBPUlRFUl9FTkFCTEVEOi10cnVlfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX0VOQUJMRUQ9JHtTSUdOT1pfRU1BSUxJTkdfRU5BQkxFRDotZmFsc2V9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9BRERSRVNTPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfQUREUkVTU30nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19TTVRQX0ZST009JHtTSUdOT1pfRU1BSUxJTkdfU01UUF9GUk9NfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9VU0VSTkFNRT0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfVVNFUk5BTUV9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9BVVRIX1BBU1NXT1JEPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9QQVNTV09SRH0nCiAgICAgIC0gU0lHTk9aX0FMRVJUTUFOQUdFUl9QUk9WSURFUj1zaWdub3oKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1BBU1NXT1JEPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1BBU1NXT1JEfScKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1VTRVJOQU1FPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0FVVEhfX1VTRVJOQU1FfScKICAgICAgLSAnU0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0ZST009JHtTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fRlJPTX0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19TTUFSVEhPU1Q9JHtTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fU01BUlRIT1NUfScKICAgICAgLSBET1RfTUVUUklDU19FTkFCTEVEPXRydWUKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnLXEnCiAgICAgICAgLSAnbG9jYWxob3N0OjgwODAvYXBpL3YxL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgdGVsZW1ldHJ5c3RvcmUtbWlncmF0b3I6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3otb3RlbC1jb2xsZWN0b3I6djAuMTQ0LjgnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWMnCiAgICBjb21tYW5kOgogICAgICAtICIvc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgYm9vdHN0cmFwIC0tY2xpY2tob3VzZS1kc24gdGNwOi8vY2xpY2tob3VzZTo5MDAwICYmXG4vc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgc3luYyB1cCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMCAmJlxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIGFzeW5jIHVwIC0tY2xpY2tob3VzZS1kc24gdGNwOi8vY2xpY2tob3VzZTo5MDAwXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfRFNOPXRjcDovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9DTFVTVEVSPWNsdXN0ZXIKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9SRVBMSUNBVElPTj10cnVlCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX1RJTUVPVVQ9MTBtCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICBvdGVsLWNvbGxlY3RvcjoKICAgIGltYWdlOiAnc2lnbm96L3NpZ25vei1vdGVsLWNvbGxlY3Rvcjp2MC4xNDQuOCcKICAgIGRlcGVuZHNfb246CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgdGVsZW1ldHJ5c3RvcmUtbWlncmF0b3I6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgc2lnbm96OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICBlbnRyeXBvaW50OgogICAgICAtIC9iaW4vc2gKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtICJzZXQgLWVcbmNhdCA+IC90bXAvb3RlbC1jb25maWcueWFtbCA8PCdTSUdOT1pfQ0ZHJ1xuY29ubmVjdG9yczpcbiAgc2lnbm96bWV0ZXI6XG4gICAgbWV0cmljc19mbHVzaF9pbnRlcnZhbDogMWhcbiAgICBkaW1lbnNpb25zOlxuICAgICAgLSBuYW1lOiBzZXJ2aWNlLm5hbWVcbiAgICAgIC0gbmFtZTogZGVwbG95bWVudC5lbnZpcm9ubWVudFxuICAgICAgLSBuYW1lOiBob3N0Lm5hbWVcbnJlY2VpdmVyczpcbiAgb3RscDpcbiAgICBwcm90b2NvbHM6XG4gICAgICBncnBjOlxuICAgICAgICBlbmRwb2ludDogMC4wLjAuMDo0MzE3XG4gICAgICBodHRwOlxuICAgICAgICBlbmRwb2ludDogMC4wLjAuMDo0MzE4XG4gICAgICAgIGF1dGg6XG4gICAgICAgICAgYXV0aGVudGljYXRvcjogYmFzaWNhdXRoL3NlcnZlclxucHJvY2Vzc29yczpcbiAgYmF0Y2g6XG4gICAgc2VuZF9iYXRjaF9zaXplOiA1MDAwMFxuICAgIHNlbmRfYmF0Y2hfbWF4X3NpemU6IDU1MDAwXG4gICAgdGltZW91dDogNXNcbiAgYmF0Y2gvbWV0ZXI6XG4gICAgc2VuZF9iYXRjaF9zaXplOiAyMDAwMFxuICAgIHNlbmRfYmF0Y2hfbWF4X3NpemU6IDI1MDAwXG4gICAgdGltZW91dDogNXNcbiAgc2lnbm96c3Bhbm1ldHJpY3MvZGVsdGE6XG4gICAgbWV0cmljc19leHBvcnRlcjogc2lnbm96Y2xpY2tob3VzZW1ldHJpY3NcbiAgICBtZXRyaWNzX2ZsdXNoX2ludGVydmFsOiA2MHNcbiAgICBsYXRlbmN5X2hpc3RvZ3JhbV9idWNrZXRzOiBbMTAwdXMsIDFtcywgMm1zLCA2bXMsIDEwbXMsIDUwbXMsIDEwMG1zLCAyNTBtcywgNTAwbXMsIDEwMDBtcywgMTQwMG1zLCAyMDAwbXMsIDVzLCAxMHMsIDIwcywgNDBzLCA2MHNdXG4gICAgZGltZW5zaW9uc19jYWNoZV9zaXplOiAxMDAwMDBcbiAgICBhZ2dyZWdhdGlvbl90ZW1wb3JhbGl0eTogQUdHUkVHQVRJT05fVEVNUE9SQUxJVFlfREVMVEFcbiAgICBlbmFibGVfZXhwX2hpc3RvZ3JhbTogdHJ1ZVxuICAgIGRpbWVuc2lvbnM6XG4gICAgICAtIG5hbWU6IHNlcnZpY2UubmFtZXNwYWNlXG4gICAgICAgIGRlZmF1bHQ6IGRlZmF1bHRcbiAgICAgIC0gbmFtZTogZGVwbG95bWVudC5lbnZpcm9ubWVudFxuICAgICAgICBkZWZhdWx0OiBkZWZhdWx0XG4gICAgICAjIFRoaXMgaXMgYWRkZWQgdG8gZW5zdXJlIHRoZSB1bmlxdWVuZXNzIG9mIHRoZSB0aW1lc2VyaWVzXG4gICAgICAjIE90aGVyd2lzZSwgaWRlbnRpY2FsIHRpbWVzZXJpZXMgcHJvZHVjZWQgYnkgbXVsdGlwbGUgcmVwbGljYXMgb2ZcbiAgICAgICMgY29sbGVjdG9ycyByZXN1bHQgaW4gaW5jb3JyZWN0IEFQTSBtZXRyaWNzXG4gICAgICAtIG5hbWU6IHNpZ25vei5jb2xsZWN0b3IuaWRcbiAgICAgIC0gbmFtZTogc2VydmljZS52ZXJzaW9uXG5leHBvcnRlcnM6XG4gIGNsaWNraG91c2V0cmFjZXM6XG4gICAgZGF0YXNvdXJjZTogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel90cmFjZXNcbiAgICBsb3dfY2FyZGluYWxfZXhjZXB0aW9uX2dyb3VwaW5nOiBmYWxzZVxuICAgIHVzZV9uZXdfc2NoZW1hOiB0cnVlXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIHNpZ25vemNsaWNraG91c2VtZXRyaWNzOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRyaWNzXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIGNsaWNraG91c2Vsb2dzZXhwb3J0ZXI6XG4gICAgZHNuOiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAvc2lnbm96X2xvZ3NcbiAgICB1c2VfbmV3X3NjaGVtYTogdHJ1ZVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBzaWdub3pjbGlja2hvdXNlbWV0ZXI6XG4gICAgZHNuOiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAvc2lnbm96X21ldGVyXG4gICAgdGltZW91dDogNDVzXG4gICAgc2VuZGluZ19xdWV1ZTpcbiAgICAgIGVuYWJsZWQ6IGZhbHNlXG4gIG1ldGFkYXRhZXhwb3J0ZXI6XG4gICAgZW5hYmxlZDogdHJ1ZVxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRhZGF0YVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIGNhY2hlOlxuICAgICAgcHJvdmlkZXI6IGluX21lbW9yeVxuZXh0ZW5zaW9uczpcbiAgc2lnbm96X2hlYWx0aF9jaGVjazpcbiAgICBlbmRwb2ludDogMC4wLjAuMDoxMzEzM1xuICBwcHJvZjpcbiAgICBlbmRwb2ludDogMC4wLjAuMDoxNzc3XG4gIGJhc2ljYXV0aC9zZXJ2ZXI6XG4gICAgaHRwYXNzd2Q6XG4gICAgICBpbmxpbmU6IFwiJCR7ZW52Ok9URUxfQkFTSUNfQVVUSH1cXG5cIlxuc2VydmljZTpcbiAgdGVsZW1ldHJ5OlxuICAgIGxvZ3M6XG4gICAgICBlbmNvZGluZzoganNvblxuICBleHRlbnNpb25zOlxuICAgIC0gc2lnbm96X2hlYWx0aF9jaGVja1xuICAgIC0gcHByb2ZcbiAgICAtIGJhc2ljYXV0aC9zZXJ2ZXJcbiAgcGlwZWxpbmVzOlxuICAgIHRyYWNlczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbc2lnbm96c3Bhbm1ldHJpY3MvZGVsdGEsIGJhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbY2xpY2tob3VzZXRyYWNlcywgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbWV0cmljczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2hdXG4gICAgICBleHBvcnRlcnM6IFtzaWdub3pjbGlja2hvdXNlbWV0cmljcywgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbG9nczpcbiAgICAgIHJlY2VpdmVyczogW290bHBdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2hdXG4gICAgICBleHBvcnRlcnM6IFtjbGlja2hvdXNlbG9nc2V4cG9ydGVyLCBzaWdub3ptZXRlciwgbWV0YWRhdGFleHBvcnRlcl1cbiAgICBtZXRyaWNzL21ldGVyOlxuICAgICAgcmVjZWl2ZXJzOiBbc2lnbm96bWV0ZXJdXG4gICAgICBwcm9jZXNzb3JzOiBbYmF0Y2gvbWV0ZXJdXG4gICAgICBleHBvcnRlcnM6IFtzaWdub3pjbGlja2hvdXNlbWV0ZXJdXG5TSUdOT1pfQ0ZHXG5jYXQgPiAvdG1wL29wYW1wLWNvbmZpZy55YW1sIDw8J1NJR05PWl9PUEFNUCdcbnNlcnZlcl9lbmRwb2ludDogd3M6Ly9zaWdub3o6NDMyMC92MS9vcGFtcFxuU0lHTk9aX09QQU1QXG4vc2lnbm96LW90ZWwtY29sbGVjdG9yIG1pZ3JhdGUgc3luYyBjaGVja1xuZXhlYyAvc2lnbm96LW90ZWwtY29sbGVjdG9yIC0tY29uZmlnPS90bXAvb3RlbC1jb25maWcueWFtbCAtLW1hbmFnZXItY29uZmlnPS90bXAvb3BhbXAtY29uZmlnLnlhbWwgLS1jb3B5LXBhdGg9L3Zhci90bXAvY29sbGVjdG9yLWNvbmZpZy55YW1sXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9PVEVMQ09MTEVDVE9SSFRUUF80MzE4CiAgICAgIC0gJ09URUxfUkVTT1VSQ0VfQVRUUklCVVRFUz1ob3N0Lm5hbWU9c2lnbm96LWhvc3Qsb3MudHlwZT1saW51eCcKICAgICAgLSBMT1dfQ0FSRElOQUxfRVhDRVBUSU9OX0dST1VQSU5HPWZhbHNlCiAgICAgIC0gJ1NJR05PWl9PVEVMX0NPTExFQ1RPUl9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfQ0xVU1RFUj1jbHVzdGVyCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfUkVQTElDQVRJT049dHJ1ZQogICAgICAtIFNJR05PWl9PVEVMX0NPTExFQ1RPUl9USU1FT1VUPTEwbQogICAgICAtICdPVEVMX0JBU0lDX0FVVEg9c2lnbm96OiR7U0VSVklDRV9QQVNTV09SRF82NF9PVEVMQVVUSH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ2Jhc2ggLWMgImV4ZWMgNjw+IC9kZXYvdGNwL2xvY2FsaG9zdC8xMzEzMyInCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwo=", "tags": [ "telemetry", "server", @@ -5113,7 +4332,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-08T19:22:39+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "silverbullet": { @@ -5165,38 +4384,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5231" }, - "snapdrop": { - "documentation": "https://github.com/RobinLinus/snapdrop?utm_source=coolify.io", - "slogan": "A self-hosted file-sharing service for secure and convenient file transfers, whether on a local network or the internet.", - "compose": "c2VydmljZXM6CiAgc25hcGRyb3A6CiAgICBpbWFnZTogJ2xpbnV4c2VydmVyL3NuYXBkcm9wOnZlcnNpb24tYjhiNzhjYzInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TTkFQRFJPUAogICAgICAtIFBVSUQ9MTAwMAogICAgICAtIFBHSUQ9MTAwMAogICAgICAtIFRaPUV1cm9wZS9NYWRyaWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NuYXBkcm9wLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", - "tags": [ - "file", - "sharing", - "transfer", - "local", - "network", - "internet" - ], - "category": "productivity", - "logo": "svgs/default.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "snipe-it": { - "documentation": "https://snipe-it.readme.io/docs/introduction?utm_source=coolify.io", - "slogan": "Snipe-IT is an open-source asset management platform built for IT departments.", - "compose": "c2VydmljZXM6CiAgYXBwOgogICAgaW1hZ2U6ICdzbmlwZS9zbmlwZS1pdDp2OC43LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TTklQRUlUXzgwCiAgICAgIC0gQVBQX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gQVBQX0RFQlVHPWZhbHNlCiAgICAgIC0gJ0FQUF9LRVk9YmFzZTY0OiR7U0VSVklDRV9SRUFMQkFTRTY0XzMyX0FQUEtFWX0nCiAgICAgIC0gJ0FQUF9VUkw9JHtTRVJWSUNFX1VSTF9TTklQRUlUfScKICAgICAgLSAnQVBQX1RJTUVaT05FPSR7QVBQX1RJTUVaT05FOi1VVEN9JwogICAgICAtICdBUFBfTE9DQUxFPSR7QVBQX0xPQ0FMRTotZW4tVVN9JwogICAgICAtIERCX0NPTk5FQ1RJT049bXlzcWwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSAnREJfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotc25pcGVpdH0nCiAgICAgIC0gJ0RCX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BSUxfTUFJTEVSPSR7TUFJTF9NQUlMRVI6LXNtdHB9JwogICAgICAtICdNQUlMX0hPU1Q9JHtNQUlMX0hPU1Q6LW51bGx9JwogICAgICAtICdNQUlMX1BPUlQ9JHtNQUlMX1BPUlQ6LTEwMjV9JwogICAgICAtICdNQUlMX1VTRVJOQU1FPSR7TUFJTF9VU0VSTkFNRTotbnVsbH0nCiAgICAgIC0gJ01BSUxfUEFTU1dPUkQ9JHtNQUlMX1BBU1NXT1JEOi1udWxsfScKICAgICAgLSAnTUFJTF9GUk9NX0FERFI9JHtNQUlMX0ZST01fQUREUjoteW91QGV4YW1wbGUuY29tfScKICAgICAgLSAnTUFJTF9GUk9NX05BTUU9JHtNQUlMX0ZST01fTkFNRTotU25pcGUtSVR9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3RvcmFnZTovdmFyL2xpYi9zbmlwZWl0JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LXNuaXBlaXR9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgdm9sdW1lczoKICAgICAgLSAnZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDUK", - "tags": [ - "inventory", - "open-source" - ], - "category": "productivity", - "logo": "svgs/snipe-it.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-17T14:08:10+02:00", - "port": "80" - }, "soju": { "documentation": "https://soju.im/?utm_source=coolify.io", "slogan": "A user-friendly IRC bouncer with a modern web interface", @@ -5214,22 +4401,6 @@ "template_last_updated_at": "2025-12-09T00:35:53+03:00", "port": "80" }, - "soketi-app-manager": { - "documentation": "https://github.com/rahulhaque/soketi-app-manager-filament?utm_source=coolify.io", - "slogan": "Manage soketi websocket server and apps with ease.", - "compose": "c2VydmljZXM6CiAgc29rZXRpLWFwcC1tYW5hZ2VyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3JhaHVsaGFxdWUvc29rZXRpLWFwcC1tYW5hZ2VyLWZpbGFtZW50LWFscGluZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU09LRVRJQVBQTUFOQUdFUl84MDgwCiAgICAgIC0gQVVUT1JVTl9FTkFCTEVEPXRydWUKICAgICAgLSAnQVVUT1JVTl9MQVJBVkVMX01JR1JBVElPTj0ke0FVVE9SVU5fTEFSQVZFTF9NSUdSQVRJT046LXRydWV9JwogICAgICAtICdBUFBfREVCVUc9JHtBUFBfREVCVUc6LWZhbHNlfScKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fU09LRVRJQVBQTUFOQUdFUgogICAgICAtIEFQUF9LRVk9JEFQUF9LRVkKICAgICAgLSBEQl9DT05ORUNUSU9OPSREQl9DT05ORUNUSU9OCiAgICAgIC0gREJfSE9TVD0kREJfSE9TVAogICAgICAtIERCX1BPUlQ9JERCX1BPUlQKICAgICAgLSBEQl9EQVRBQkFTRT0kREJfREFUQUJBU0UKICAgICAgLSBEQl9VU0VSTkFNRT0kREJfVVNFUk5BTUUKICAgICAgLSBEQl9QQVNTV09SRD0kREJfUEFTU1dPUkQKICAgICAgLSBQVVNIRVJfSE9TVD0kUFVTSEVSX0hPU1QKICAgICAgLSBQVVNIRVJfUE9SVD0kUFVTSEVSX1BPUlQKICAgICAgLSBQVVNIRVJfU0NIRU1FPSRQVVNIRVJfU0NIRU1FCiAgICAgIC0gUFVTSEVSX0FQUF9DTFVTVEVSPSRQVVNIRVJfQVBQX0NMVVNURVIKICAgICAgLSBTT0tFVElfREJfUkVESVNfVVNFUk5BTUU9JFNPS0VUSV9EQl9SRURJU19VU0VSTkFNRQogICAgICAtIFNPS0VUSV9EQl9SRURJU19QQVNTV09SRD0kU09LRVRJX0RCX1JFRElTX1BBU1NXT1JECiAgICAgIC0gTUVUUklDU19IT1NUPSRNRVRSSUNTX0hPU1QKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwaHAtZnBtLWhlYWx0aGNoZWNrCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCg==", - "tags": [ - "soketi", - "websockets", - "app-manager", - "dashboard" - ], - "category": "devtools", - "logo": "svgs/soketi-app-manager.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "8080" - }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.", @@ -5377,7 +4548,7 @@ "supabase": { "documentation": "https://supabase.io?utm_source=coolify.io", "slogan": "The open source Firebase alternative.", - "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZy9rb25nOjMuOS4xJwogICAgZW50cnlwb2ludDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGtvbmcKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1VQQUJBU0VLT05HXzgwMDAKICAgICAgLSAnS09OR19QT1JUX01BUFM9NDQzOjgwMDAnCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gS09OR19EQVRBQkFTRT1vZmYKICAgICAgLSBLT05HX0RFQ0xBUkFUSVZFX0NPTkZJRz0vdXNyL2xvY2FsL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtIEtPTkdfRE5TX05PVF9GT1VORF9UVEw9MQogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoLHJlcXVlc3QtdGVybWluYXRpb24saXAtcmVzdHJpY3Rpb24scG9zdC1mdW5jdGlvbicKICAgICAgLSBLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUl9TSVpFPTE2MGsKICAgICAgLSAnS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJTPTY0IDE2MGsnCiAgICAgIC0gJ0tPTkdfUFJPWFlfQUNDRVNTX0xPRz0vZGV2L3N0ZG91dCBjb21iaW5lZCcKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWT0ke1NVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWTotfScKICAgICAgLSAnU1VQQUJBU0VfU0VDUkVUX0tFWT0ke1NVUEFCQVNFX1NFQ1JFVF9LRVk6LX0nCiAgICAgIC0gJ0FOT05fS0VZX0FTWU1NRVRSSUM9JHtBTk9OX0tFWV9BU1lNTUVUUklDOi19JwogICAgICAtICdTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUM9JHtTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUM6LX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdLT05HX1NUT1JBR0VfQ09OTkVDVF9USU1FT1VUPSR7S09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVDotNjB9JwogICAgICAtICdLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9XUklURV9USU1FT1VUOi0zNjAwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFQURfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVQ6LTM2MDB9JwogICAgICAtICdLT05HX1NUT1JBR0VfUkVRVUVTVF9CVUZGRVJJTkc9JHtLT05HX1NUT1JBR0VfUkVRVUVTVF9CVUZGRVJJTkc6LWZhbHNlfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFU1BPTlNFX0JVRkZFUklORz0ke0tPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkc6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvYXBpL2tvbmctZW50cnlwb2ludC5zaAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL2Jhc2hcbiMgQ3VzdG9tIGVudHJ5cG9pbnQgZm9yIEtvbmcgdGhhdCBidWlsZHMgTHVhIGV4cHJlc3Npb25zIGZvciByZXF1ZXN0LXRyYW5zZm9ybWVyXG4jIGFuZCBwZXJmb3JtcyBlbnZpcm9ubWVudCB2YXJpYWJsZSBzdWJzdGl0dXRpb24gaW4gdGhlIGRlY2xhcmF0aXZlIGNvbmZpZy5cblxuaWYgWyAtbiBcIiRTVVBBQkFTRV9TRUNSRVRfS0VZXCIgXSAmJiBbIC1uIFwiJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWVwiIF07IHRoZW5cbiAgICBleHBvcnQgTFVBX0FVVEhfRVhQUj1cIlxcJCgoaGVhZGVycy5hdXRob3JpemF0aW9uIH49IG5pbCBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uOnN1YigxLCAxMCkgfj0gJ0JlYXJlciBzYl8nIGFuZCBoZWFkZXJzLmF1dGhvcml6YXRpb24pIG9yIChoZWFkZXJzLmFwaWtleSA9PSAnJFNVUEFCQVNFX1NFQ1JFVF9LRVknIGFuZCAnQmVhcmVyICRTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUMnKSBvciAoaGVhZGVycy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVknIGFuZCAnQmVhcmVyICRBTk9OX0tFWV9BU1lNTUVUUklDJykgb3IgaGVhZGVycy5hcGlrZXkpXCJcbiAgICBleHBvcnQgTFVBX1JUX1dTX0VYUFI9XCJcXCQoKHF1ZXJ5X3BhcmFtcy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9TRUNSRVRfS0VZJyBhbmQgJyRTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUMnKSBvciAocXVlcnlfcGFyYW1zLmFwaWtleSA9PSAnJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWScgYW5kICckQU5PTl9LRVlfQVNZTU1FVFJJQycpIG9yIHF1ZXJ5X3BhcmFtcy5hcGlrZXkpXCJcbmVsc2VcbiAgICBleHBvcnQgTFVBX0FVVEhfRVhQUj1cIlxcJCgoaGVhZGVycy5hdXRob3JpemF0aW9uIH49IG5pbCBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uOnN1YigxLCAxMCkgfj0gJ0JlYXJlciBzYl8nIGFuZCBoZWFkZXJzLmF1dGhvcml6YXRpb24pIG9yIGhlYWRlcnMuYXBpa2V5KVwiXG4gICAgZXhwb3J0IExVQV9SVF9XU19FWFBSPVwiXFwkKHF1ZXJ5X3BhcmFtcy5hcGlrZXkpXCJcbmZpXG5cbmF3ayAne1xuICByZXN1bHQgPSBcIlwiXG4gIHJlc3QgPSAkMFxuICB3aGlsZSAobWF0Y2gocmVzdCwgL1xcJFtBLVphLXpfXVtBLVphLXpfMC05XSovKSkge1xuICAgIHZhcm5hbWUgPSBzdWJzdHIocmVzdCwgUlNUQVJUICsgMSwgUkxFTkdUSCAtIDEpXG4gICAgaWYgKHZhcm5hbWUgaW4gRU5WSVJPTikge1xuICAgICAgcmVzdWx0ID0gcmVzdWx0IHN1YnN0cihyZXN0LCAxLCBSU1RBUlQgLSAxKSBFTlZJUk9OW3Zhcm5hbWVdXG4gICAgfSBlbHNlIHtcbiAgICAgIHJlc3VsdCA9IHJlc3VsdCBzdWJzdHIocmVzdCwgMSwgUlNUQVJUICsgUkxFTkdUSCAtIDEpXG4gICAgfVxuICAgIHJlc3QgPSBzdWJzdHIocmVzdCwgUlNUQVJUICsgUkxFTkdUSClcbiAgfVxuICBwcmludCByZXN1bHQgcmVzdFxufScgL2hvbWUva29uZy90ZW1wLnltbCA+IFwiJEtPTkdfREVDTEFSQVRJVkVfQ09ORklHXCJcblxuc2VkIC1pICcvXltbOnNwYWNlOl1dKi0ga2V5OltbOnNwYWNlOl1dKiQvZCcgXCIkS09OR19ERUNMQVJBVElWRV9DT05GSUdcIlxuXG5leGVjIC9lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvYXBpL2tvbmcueW1sCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9rb25nL3RlbXAueW1sCiAgICAgICAgY29udGVudDogIl9mb3JtYXRfdmVyc2lvbjogJzIuMSdcbl90cmFuc2Zvcm06IHRydWVcblxuIyMjXG4jIyMgQ29uc3VtZXJzIC8gVXNlcnNcbiMjI1xuY29uc3VtZXJzOlxuICAtIHVzZXJuYW1lOiBEQVNIQk9BUkRcbiAgLSB1c2VybmFtZTogYW5vblxuICAgIGtleWF1dGhfY3JlZGVudGlhbHM6XG4gICAgICAtIGtleTogJFNVUEFCQVNFX0FOT05fS0VZXG4gICAgICAtIGtleTogJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRUNSRVRfS0VZXG5cbiMjI1xuIyMjIEFjY2VzcyBDb250cm9sIExpc3RcbiMjI1xuYWNsczpcbiAgLSBjb25zdW1lcjogYW5vblxuICAgIGdyb3VwOiBhbm9uXG4gIC0gY29uc3VtZXI6IHNlcnZpY2Vfcm9sZVxuICAgIGdyb3VwOiBhZG1pblxuXG4jIyNcbiMjIyBEYXNoYm9hcmQgY3JlZGVudGlhbHNcbiMjI1xuYmFzaWNhdXRoX2NyZWRlbnRpYWxzOlxuLSBjb25zdW1lcjogREFTSEJPQVJEXG4gIHVzZXJuYW1lOiAnJERBU0hCT0FSRF9VU0VSTkFNRSdcbiAgcGFzc3dvcmQ6ICckREFTSEJPQVJEX1BBU1NXT1JEJ1xuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1qd2tzXG4gICAgX2NvbW1lbnQ6ICdBdXRoOiAvYXV0aC92MS8ud2VsbC1rbm93bi9qd2tzLmpzb24gLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9qd2tzLmpzb24nXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5Ly53ZWxsLWtub3duL2p3a3MuanNvblxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWp3a3NcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvblxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tYWNzXG4gICAgdXJsOiBcImh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvc3NvL3NhbWwvYWNzXCJcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tYWNzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgIC0gL3Nzby9zYW1sL2Fjc1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tbWV0YWRhdGFcbiAgICB1cmw6IFwiaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9zc28vc2FtbC9tZXRhZGF0YVwiXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLW1ldGFkYXRhXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgIC0gL3Nzby9zYW1sL21ldGFkYXRhXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQ29udGVudC1Qcm9maWxlOiBncmFwaHFsX3B1YmxpY1wiXG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJDb250ZW50LVByb2ZpbGU6IGdyYXBocWxfcHVibGljXCJcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgcXVlcnlzdHJpbmc6XG4gICAgICAgICAgICAgIC0gXCJhcGlrZXk6ICRMVUFfUlRfV1NfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIHF1ZXJ5c3RyaW5nOlxuICAgICAgICAgICAgICAtIFwiYXBpa2V5OiAkTFVBX1JUX1dTX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtcmVzdFxuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS9hcGkvKiAtPiBodHRwOi8vcmVhbHRpbWU6NDAwMC9hcGkvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9hcGlcbiAgICBwcm90b2NvbDogaHR0cFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogcmVhbHRpbWUtdjEtcmVzdFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL2FwaVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTdG9yYWdlIEFQSSBlbmRwb2ludFxuICAjIyBObyBrZXktYXV0aCAtIFMzIHByb3RvY29sIHJlcXVlc3RzIGRvbid0IGNhcnJ5IGFuIGFwaWtleSBoZWFkZXIuXG4gIC0gbmFtZTogc3RvcmFnZS12MVxuICAgIF9jb21tZW50OiAnU3RvcmFnZTogL3N0b3JhZ2UvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2Utc3RvcmFnZTo1MDAwLyonXG4gICAgY29ubmVjdF90aW1lb3V0OiAkS09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVFxuICAgIHdyaXRlX3RpbWVvdXQ6ICRLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVFxuICAgIHJlYWRfdGltZW91dDogJEtPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVRcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgICAgIHJlcXVlc3RfYnVmZmVyaW5nOiAkS09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HXG4gICAgICAgIHJlc3BvbnNlX2J1ZmZlcmluZzogJEtPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkdcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IHBvc3QtZnVuY3Rpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFjY2VzczpcbiAgICAgICAgICAgIC0gfFxuICAgICAgICAgICAgICBsb2NhbCBhdXRoID0ga29uZy5yZXF1ZXN0LmdldF9oZWFkZXIoXCJhdXRob3JpemF0aW9uXCIpXG4gICAgICAgICAgICAgIGlmIGF1dGggPT0gbmlsIG9yIGF1dGggPT0gXCJcIiBvciBhdXRoOmZpbmQoXCJeJXMqJFwiKSB0aGVuXG4gICAgICAgICAgICAgICAga29uZy5zZXJ2aWNlLnJlcXVlc3QuY2xlYXJfaGVhZGVyKFwiYXV0aG9yaXphdGlvblwiKVxuICAgICAgICAgICAgICBlbmRcblxuICAjIyBFZGdlIEZ1bmN0aW9ucyByb3V0ZXNcbiAgLSBuYW1lOiBmdW5jdGlvbnMtdjFcbiAgICBfY29tbWVudDogJ0VkZ2UgRnVuY3Rpb25zOiAvZnVuY3Rpb25zL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwL1xuICAgIHJlYWRfdGltZW91dDogMTUwMDAwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBPQXV0aCAyLjAgQXV0aG9yaXphdGlvbiBTZXJ2ZXIgTWV0YWRhdGEgKFJGQyA4NDE0KVxuICAtIG5hbWU6IHdlbGwta25vd24tb2F1dGhcbiAgICBfY29tbWVudDogJ0F1dGg6IC8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlciAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5Ly53ZWxsLWtub3duL29hdXRoLWF1dGhvcml6YXRpb24tc2VydmVyJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlclxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogd2VsbC1rbm93bi1vYXV0aFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gLy53ZWxsLWtub3duL29hdXRoLWF1dGhvcml6YXRpb24tc2VydmVyXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgIyMgTm90IHVzZWQgLSBTdHVkaW8gYW5kIFZlY3RvciB0YWxrIGRpcmVjdGx5IHRvIGFuYWx5dGljcyB2aWEgRG9ja2VyIG5ldHdvcmtpbmcuXG4gICMgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgIyAgIF9jb21tZW50OiAnQW5hbHl0aWNzOiAvYW5hbHl0aWNzL3YxLyogLT4gaHR0cDovL2xvZ2ZsYXJlOjQwMDAvKidcbiAgIyAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAjICAgcm91dGVzOlxuICAjICAgICAtIG5hbWU6IGFuYWx5dGljcy12MS1hbGxcbiAgIyAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICMgICAgICAgcGF0aHM6XG4gICMgICAgICAgICAtIC9hbmFseXRpY3MvdjEvXG5cbiAgIyMgU2VjdXJlIERhdGFiYXNlIHJvdXRlc1xuICAtIG5hbWU6IG1ldGFcbiAgICBfY29tbWVudDogJ3BnLW1ldGE6IC9wZy8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtZXRhLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3BnL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuXG4gICMjIEJsb2NrIGFjY2VzcyB0byAvYXBpL21jcFxuICAtIG5hbWU6IG1jcC1ibG9ja2VyXG4gICAgX2NvbW1lbnQ6ICdCbG9jayBkaXJlY3QgYWNjZXNzIHRvIC9hcGkvbWNwJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL2FwaS9tY3BcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1jcC1ibG9ja2VyLXJvdXRlXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXBpL21jcFxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdGVybWluYXRpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIHN0YXR1c19jb2RlOiA0MDNcbiAgICAgICAgICBtZXNzYWdlOiBcIkFjY2VzcyBpcyBmb3JiaWRkZW4uXCJcblxuICAjIyBNQ1AgZW5kcG9pbnQgLSBsb2NhbCBhY2Nlc3NcbiAgLSBuYW1lOiBtY3BcbiAgICBfY29tbWVudDogJ01DUDogL21jcCAtPiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvYXBpL21jcCAobG9jYWwgYWNjZXNzKSdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9hcGkvbWNwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtY3BcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9tY3BcbiAgICBwbHVnaW5zOlxuICAgICAgIyBCbG9jayBhY2Nlc3MgdG8gL21jcCBieSBkZWZhdWx0XG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdGVybWluYXRpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIHN0YXR1c19jb2RlOiA0MDNcbiAgICAgICAgICBtZXNzYWdlOiBcIkFjY2VzcyBpcyBmb3JiaWRkZW4uXCJcbiAgICAgICMgRW5hYmxlIGxvY2FsIGFjY2VzcyAoZGFuZ2VyIHpvbmUhKVxuICAgICAgIyAxLiBDb21tZW50IG91dCB0aGUgJ3JlcXVlc3QtdGVybWluYXRpb24nIHNlY3Rpb24gYWJvdmVcbiAgICAgICMgMi4gVW5jb21tZW50IHRoZSBlbnRpcmUgc2VjdGlvbiBiZWxvdywgaW5jbHVkaW5nICdkZW55J1xuICAgICAgIyAzLiBBZGQgeW91ciBsb2NhbCBJUHMgdG8gdGhlICdhbGxvdycgbGlzdFxuICAgICAgIy0gbmFtZTogY29yc1xuICAgICAgIy0gbmFtZTogaXAtcmVzdHJpY3Rpb25cbiAgICAgICMgIGNvbmZpZzpcbiAgICAgICMgICAgYWxsb3c6XG4gICAgICAjICAgICAgLSAxMjcuMC4wLjFcbiAgICAgICMgICAgICAtIDo6MVxuICAgICAgIyAgICBkZW55OiBbXVxuXG4gICMjIFByb3RlY3RlZCBEYXNoYm9hcmQgLSBjYXRjaCBhbGwgcmVtYWluaW5nIHJvdXRlc1xuICAtIG5hbWU6IGRhc2hib2FyZFxuICAgIF9jb21tZW50OiAnU3R1ZGlvOiAvKiAtPiBodHRwOi8vc3R1ZGlvOjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGRhc2hib2FyZC1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGJhc2ljLWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiIKICBzdXBhYmFzZS1zdHVkaW86CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0dWRpbzoyMDI2LjAzLjE2LXNoYS01NTI4ODE3JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJmZXRjaCgnaHR0cDovLzEyNy4wLjAuMTozMDAwL2FwaS9wbGF0Zm9ybS9wcm9maWxlJykudGhlbigocikgPT4ge2lmIChyLnN0YXR1cyAhPT0gMjAwKSB0aHJvdyBuZXcgRXJyb3Ioci5zdGF0dXMpfSkiCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIEhPU1ROQU1FPTAuMC4wLjAKICAgICAgLSAnU1RVRElPX1BHX01FVEFfVVJMPWh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAnCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9VUkxfU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdfTUVUQV9DUllQVE9fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9QR01FVEFDUllQVE99JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfUFJJVkFURV9BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFUFJJVkFURX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSBORVhUX1BVQkxJQ19FTkFCTEVfTE9HUz10cnVlCiAgICAgIC0gTkVYVF9BTkFMWVRJQ1NfQkFDS0VORF9QUk9WSURFUj1wb3N0Z3JlcwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSBTTklQUEVUU19NQU5BR0VNRU5UX0ZPTERFUj0vYXBwL3NuaXBwZXRzCiAgICAgIC0gRURHRV9GVU5DVElPTlNfTUFOQUdFTUVOVF9GT0xERVI9L2FwcC9lZGdlLWZ1bmN0aW9ucwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3NuaXBwZXRzOi9hcHAvc25pcHBldHMnCiAgICAgIC0gJy4vdm9sdW1lcy9mdW5jdGlvbnM6L2FwcC9lZGdlLWZ1bmN0aW9ucycKICBzdXBhYmFzZS1kYjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXM6MTUuOC4xLjA4NScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncGdfaXNyZWFkeSAtVSBwb3N0Z3JlcyAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtdmVjdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBjb21tYW5kOgogICAgICAtIHBvc3RncmVzCiAgICAgIC0gJy1jJwogICAgICAtIGNvbmZpZ19maWxlPS9ldGMvcG9zdGdyZXNxbC9wb3N0Z3Jlc3FsLmNvbmYKICAgICAgLSAnLWMnCiAgICAgIC0gbG9nX21pbl9tZXNzYWdlcz1mYXRhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfSE9TVD0vdmFyL3J1bi9wb3N0Z3Jlc3FsCiAgICAgIC0gJ1BHUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUEdEQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwYWJhc2UtZGItZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcmVhbHRpbWUuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85OS1yZWFsdGltZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9yZWFsdGltZTtcbmFsdGVyIHNjaGVtYSBfcmVhbHRpbWUgb3duZXIgdG8gOnBndXNlcjtcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9fc3VwYWJhc2Uuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85Ny1fc3VwYWJhc2Uuc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cbkNSRUFURSBEQVRBQkFTRSBfc3VwYWJhc2UgV0lUSCBPV05FUiA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3Bvb2xlci5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXBvb2xlci5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfc3VwYXZpc29yO1xuYWx0ZXIgc2NoZW1hIF9zdXBhdmlzb3Igb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3dlYmhvb2tzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OC13ZWJob29rcy5zcWwKICAgICAgICBjb250ZW50OiAiQkVHSU47XG4tLSBDcmVhdGUgcGdfbmV0IGV4dGVuc2lvblxuQ1JFQVRFIEVYVEVOU0lPTiBJRiBOT1QgRVhJU1RTIHBnX25ldCBTQ0hFTUEgZXh0ZW5zaW9ucztcbi0tIENyZWF0ZSBzdXBhYmFzZV9mdW5jdGlvbnMgc2NoZW1hXG5DUkVBVEUgU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBBVVRIT1JJWkFUSU9OIHN1cGFiYXNlX2FkbWluO1xuR1JBTlQgVVNBR0UgT04gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFRBQkxFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIEZVTkNUSU9OUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFNFUVVFTkNFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zIChcbiAgdmVyc2lvbiB0ZXh0IFBSSU1BUlkgS0VZLFxuICBpbnNlcnRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpXG4pO1xuLS0gSW5pdGlhbCBzdXBhYmFzZV9mdW5jdGlvbnMgbWlncmF0aW9uXG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnaW5pdGlhbCcpO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIGRlZmluaXRpb25cbkNSRUFURSBUQUJMRSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgKFxuICBpZCBiaWdzZXJpYWwgUFJJTUFSWSBLRVksXG4gIGhvb2tfdGFibGVfaWQgaW50ZWdlciBOT1QgTlVMTCxcbiAgaG9va19uYW1lIHRleHQgTk9UIE5VTEwsXG4gIGNyZWF0ZWRfYXQgdGltZXN0YW1wdHogTk9UIE5VTEwgREVGQVVMVCBOT1coKSxcbiAgcmVxdWVzdF9pZCBiaWdpbnRcbik7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX3JlcXVlc3RfaWRfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAocmVxdWVzdF9pZCk7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX2hfdGFibGVfaWRfaF9uYW1lX2lkeCBPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgVVNJTkcgYnRyZWUgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSk7XG5DT01NRU5UIE9OIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBJUyAnU3VwYWJhc2UgRnVuY3Rpb25zIEhvb2tzOiBBdWRpdCB0cmFpbCBmb3IgdHJpZ2dlcmVkIGhvb2tzLic7XG5DUkVBVEUgRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpXG4gIFJFVFVSTlMgdHJpZ2dlclxuICBMQU5HVUFHRSBwbHBnc3FsXG4gIEFTICRmdW5jdGlvbiRcbiAgREVDTEFSRVxuICAgIHJlcXVlc3RfaWQgYmlnaW50O1xuICAgIHBheWxvYWQganNvbmI7XG4gICAgdXJsIHRleHQgOj0gVEdfQVJHVlswXTo6dGV4dDtcbiAgICBtZXRob2QgdGV4dCA6PSBUR19BUkdWWzFdOjp0ZXh0O1xuICAgIGhlYWRlcnMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICBwYXJhbXMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICB0aW1lb3V0X21zIGludGVnZXIgREVGQVVMVCAxMDAwO1xuICBCRUdJTlxuICAgIElGIHVybCBJUyBOVUxMIE9SIHVybCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ3VybCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBtZXRob2QgSVMgTlVMTCBPUiBtZXRob2QgPSAnbnVsbCcgVEhFTlxuICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgaXMgbWlzc2luZyc7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlsyXSBJUyBOVUxMIE9SIFRHX0FSR1ZbMl0gPSAnbnVsbCcgVEhFTlxuICAgICAgaGVhZGVycyA9ICd7XCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCJ9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgaGVhZGVycyA9IFRHX0FSR1ZbMl06Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbM10gSVMgTlVMTCBPUiBUR19BUkdWWzNdID0gJ251bGwnIFRIRU5cbiAgICAgIHBhcmFtcyA9ICd7fSc6Ompzb25iO1xuICAgIEVMU0VcbiAgICAgIHBhcmFtcyA9IFRHX0FSR1ZbM106Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbNF0gSVMgTlVMTCBPUiBUR19BUkdWWzRdID0gJ251bGwnIFRIRU5cbiAgICAgIHRpbWVvdXRfbXMgPSAxMDAwO1xuICAgIEVMU0VcbiAgICAgIHRpbWVvdXRfbXMgPSBUR19BUkdWWzRdOjppbnRlZ2VyO1xuICAgIEVORCBJRjtcblxuICAgIENBU0VcbiAgICAgIFdIRU4gbWV0aG9kID0gJ0dFVCcgVEhFTlxuICAgICAgICBTRUxFQ1QgaHR0cF9nZXQgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfZ2V0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBXSEVOIG1ldGhvZCA9ICdQT1NUJyBUSEVOXG4gICAgICAgIHBheWxvYWQgPSBqc29uYl9idWlsZF9vYmplY3QoXG4gICAgICAgICAgJ29sZF9yZWNvcmQnLCBPTEQsXG4gICAgICAgICAgJ3JlY29yZCcsIE5FVyxcbiAgICAgICAgICAndHlwZScsIFRHX09QLFxuICAgICAgICAgICd0YWJsZScsIFRHX1RBQkxFX05BTUUsXG4gICAgICAgICAgJ3NjaGVtYScsIFRHX1RBQkxFX1NDSEVNQVxuICAgICAgICApO1xuXG4gICAgICAgIFNFTEVDVCBodHRwX3Bvc3QgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfcG9zdChcbiAgICAgICAgICB1cmwsXG4gICAgICAgICAgcGF5bG9hZCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBFTFNFXG4gICAgICAgIFJBSVNFIEVYQ0VQVElPTiAnbWV0aG9kIGFyZ3VtZW50ICUgaXMgaW52YWxpZCcsIG1ldGhvZDtcbiAgICBFTkQgQ0FTRTtcblxuICAgIElOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rc1xuICAgICAgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSwgcmVxdWVzdF9pZClcbiAgICBWQUxVRVNcbiAgICAgIChUR19SRUxJRCwgVEdfTkFNRSwgcmVxdWVzdF9pZCk7XG5cbiAgICBSRVRVUk4gTkVXO1xuICBFTkRcbiRmdW5jdGlvbiQ7XG4tLSBTdXBhYmFzZSBzdXBlciBhZG1pblxuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfcm9sZXNcbiAgICBXSEVSRSByb2xuYW1lID0gJ3N1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbidcbiAgKVxuICBUSEVOXG4gICAgQ1JFQVRFIFVTRVIgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIE5PSU5IRVJJVCBDUkVBVEVST0xFIExPR0lOIE5PUkVQTElDQVRJT047XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBUQUJMRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBBTEwgU0VRVUVOQ0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gU0VUIHNlYXJjaF9wYXRoID0gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIubWlncmF0aW9ucyBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiB0YWJsZSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiLmhvb2tzIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIGZ1bmN0aW9uIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaHR0cF9yZXF1ZXN0KCkgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIFRPIHBvc3RncmVzO1xuLS0gUmVtb3ZlIHVudXNlZCBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gcm9sZVxuRE9cbiQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfcGdfbmV0X2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBSRUFTU0lHTiBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gVE8gc3VwYWJhc2VfYWRtaW47XG4gICAgRFJPUCBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW47XG4gICAgRFJPUCBST0xFIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIHBnX25ldCBncmFudHMgd2hlbiBleHRlbnNpb24gaXMgYWxyZWFkeSBlbmFibGVkXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V4dGVuc2lvblxuICAgIFdIRVJFIGV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG4tLSBFdmVudCB0cmlnZ2VyIGZvciBwZ19uZXRcbkNSRUFURSBPUiBSRVBMQUNFIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcygpXG5SRVRVUk5TIGV2ZW50X3RyaWdnZXJcbkxBTkdVQUdFIHBscGdzcWxcbkFTICQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19ldmVudF90cmlnZ2VyX2RkbF9jb21tYW5kcygpIEFTIGV2XG4gICAgSk9JTiBwZ19leHRlbnNpb24gQVMgZXh0XG4gICAgT04gZXYub2JqaWQgPSBleHQub2lkXG4gICAgV0hFUkUgZXh0LmV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORDtcbiQkO1xuQ09NTUVOVCBPTiBGVU5DVElPTiBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MgSVMgJ0dyYW50cyBhY2Nlc3MgdG8gcGdfbmV0JztcbkRPXG4kJFxuQkVHSU5cbiAgSUYgTk9UIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJcbiAgICBXSEVSRSBldnRuYW1lID0gJ2lzc3VlX3BnX25ldF9hY2Nlc3MnXG4gICkgVEhFTlxuICAgIENSRUFURSBFVkVOVCBUUklHR0VSIGlzc3VlX3BnX25ldF9hY2Nlc3MgT04gZGRsX2NvbW1hbmRfZW5kIFdIRU4gVEFHIElOICgnQ1JFQVRFIEVYVEVOU0lPTicpXG4gICAgRVhFQ1VURSBQUk9DRURVUkUgZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKCk7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnMjAyMTA4MDkxODM0MjNfdXBkYXRlX2dyYW50cycpO1xuQUxURVIgZnVuY3Rpb24gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFNFQ1VSSVRZIERFRklORVI7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VUIHNlYXJjaF9wYXRoID0gc3VwYWJhc2VfZnVuY3Rpb25zO1xuUkVWT0tFIEFMTCBPTiBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgRlJPTSBQVUJMSUM7XG5HUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQ09NTUlUO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3JvbGVzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1yb2xlcy5zcWwKICAgICAgICBjb250ZW50OiAiLS0gTk9URTogY2hhbmdlIHRvIHlvdXIgb3duIHBhc3N3b3JkcyBmb3IgcHJvZHVjdGlvbiBlbnZpcm9ubWVudHNcbiBcXHNldCBwZ3Bhc3MgYGVjaG8gXCIkUE9TVEdSRVNfUEFTU1dPUkRcImBcblxuIEFMVEVSIFVTRVIgYXV0aGVudGljYXRvciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHBnYm91bmNlciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2F1dGhfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9zdG9yYWdlX2FkbWluIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL2p3dC5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9pbml0LXNjcmlwdHMvOTktand0LnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBqd3Rfc2VjcmV0IGBlY2hvIFwiJEpXVF9TRUNSRVRcImBcblxcc2V0IGp3dF9leHAgYGVjaG8gXCIkSldUX0VYUFwiYFxuXFxzZXQgZGJfbmFtZSBgZWNobyBcIiR7UE9TVEdSRVNfREI6LXBvc3RncmVzfVwiYFxuXG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X3NlY3JldFwiIFRPIDonand0X3NlY3JldCc7XG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X2V4cFwiIFRPIDonand0X2V4cCc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvbG9ncy5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LWxvZ3Muc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cXGMgX3N1cGFiYXNlXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX2FuYWx5dGljcztcbmFsdGVyIHNjaGVtYSBfYW5hbHl0aWNzIG93bmVyIHRvIDpwZ3VzZXI7XG5cXGMgcG9zdGdyZXNcbiIKICAgICAgLSAnc3VwYWJhc2UtZGItY29uZmlnOi9ldGMvcG9zdGdyZXNxbC1jdXN0b20nCiAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9sb2dmbGFyZToxLjMxLjInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIExPR0ZMQVJFX05PREVfSE9TVD0xMjcuMC4wLjEKICAgICAgLSBEQl9VU0VSTkFNRT1zdXBhYmFzZV9hZG1pbgogICAgICAtIERCX0RBVEFCQVNFPV9zdXBhYmFzZQogICAgICAtICdEQl9IT1NUTkFNRT0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSBEQl9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtICdMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BSSVZBVEVfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRVBSSVZBVEV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NVUEFCQVNFX01PREU9dHJ1ZQogICAgICAtICdQT1NUR1JFU19CQUNLRU5EX1VSTD1wb3N0Z3Jlc3FsOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gUE9TVEdSRVNfQkFDS0VORF9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtIExPR0ZMQVJFX0ZFQVRVUkVfRkxBR19PVkVSUklERT1tdWx0aWJhY2tlbmQ9dHJ1ZQogIHN1cGFiYXNlLXZlY3RvcjoKICAgIGltYWdlOiAndGltYmVyaW8vdmVjdG9yOjAuNTMuMC1hbHBpbmUnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovL3N1cGFiYXNlLXZlY3Rvcjo5MDAxL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvbG9ncy92ZWN0b3IueW1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL3ZlY3Rvci92ZWN0b3IueW1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogImFwaTpcbiAgZW5hYmxlZDogdHJ1ZVxuICBhZGRyZXNzOiAwLjAuMC4wOjkwMDFcblxuc291cmNlczpcbiAgZG9ja2VyX2hvc3Q6XG4gICAgdHlwZTogZG9ja2VyX2xvZ3NcbiAgICBleGNsdWRlX2NvbnRhaW5lcnM6XG4gICAgICAtIHN1cGFiYXNlLXZlY3RvclxuXG50cmFuc2Zvcm1zOlxuICBwcm9qZWN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIGRvY2tlcl9ob3N0XG4gICAgc291cmNlOiB8LVxuICAgICAgLnByb2plY3QgPSBcImRlZmF1bHRcIlxuICAgICAgLmV2ZW50X21lc3NhZ2UgPSBkZWwoLm1lc3NhZ2UpXG4gICAgICAuYXBwbmFtZSA9IGRlbCguY29udGFpbmVyX25hbWUpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9jcmVhdGVkX2F0KVxuICAgICAgZGVsKC5jb250YWluZXJfaWQpXG4gICAgICBkZWwoLnNvdXJjZV90eXBlKVxuICAgICAgZGVsKC5zdHJlYW0pXG4gICAgICBkZWwoLmxhYmVsKVxuICAgICAgZGVsKC5pbWFnZSlcbiAgICAgIGRlbCguaG9zdClcbiAgICAgIGRlbCguc3RyZWFtKVxuICByb3V0ZXI6XG4gICAgdHlwZTogcm91dGVcbiAgICBpbnB1dHM6XG4gICAgICAtIHByb2plY3RfbG9nc1xuICAgIHJvdXRlOlxuICAgICAga29uZzogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWtvbmdcIiknXG4gICAgICBhdXRoOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtYXV0aFwiKSdcbiAgICAgIHJlc3Q6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1yZXN0XCIpJ1xuICAgICAgcmVhbHRpbWU6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJyZWFsdGltZS1kZXZcIiknXG4gICAgICBzdG9yYWdlOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Utc3RvcmFnZVwiKSdcbiAgICAgIGZ1bmN0aW9uczogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgICAgICB1cmwsIHNwbGl0X2VyciA9IHNwbGl0KHJlcS5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBzcGxpdF9lcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIElnbm9yZXMgbm9uIG5naW54IGVycm9ycyBzaW5jZSB0aGV5IGFyZSByZWxhdGVkIHdpdGgga29uZyBib290aW5nIHVwXG4gIGtvbmdfZXJyOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIua29uZ1xuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IFwiR0VUXCJcbiAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IDIwMFxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiZXJyb3JcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcGFyc2VkLnRpbWVzdGFtcFxuICAgICAgICAgIC5zZXZlcml0eSA9IHBhcnNlZC5zZXZlcml0eVxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lmhvc3QgPSBwYXJzZWQuaG9zdFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMuY2ZfY29ubmVjdGluZ19pcCA9IHBhcnNlZC5jbGllbnRcbiAgICAgICAgICB1cmwsIGVyciA9IHNwbGl0KHBhcnNlZC5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIEdvdHJ1ZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIGpzb24gc3RyaW5ncyB3aGljaCBmcm9udGVuZCBwYXJzZXMgZGlyZWN0bHkuIEJ1dCB3ZSBrZWVwIG1ldGFkYXRhIGZvciBjb25zaXN0ZW5jeS5cbiAgYXV0aF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuYXV0aFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEgPSBtZXJnZSEoLm1ldGFkYXRhLCBwYXJzZWQpXG4gICAgICB9XG4gICMgUG9zdGdSRVNUIGxvZ3MgYXJlIHN0cnVjdHVyZWQgc28gd2Ugc2VwYXJhdGUgdGltZXN0YW1wIGZyb20gbWVzc2FnZSB1c2luZyByZWdleFxuICByZXN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZXN0XG4gICAgc291cmNlOiB8LVxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcideKD9QPHRpbWU+LiopOiAoP1A8bXNnPi4qKSQnKVxuICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgIC5ldmVudF9tZXNzYWdlID0gcGFyc2VkLm1zZ1xuICAgICAgICAgIC50aW1lc3RhbXAgPSBwYXJzZV90aW1lc3RhbXAhKHZhbHVlOiBwYXJzZWQudGltZSwgZm9ybWF0OiBcIiVkLyViLyVZOiVIOiVNOiVTICV6XCIpXG4gICAgICAgICAgLm1ldGFkYXRhLmhvc3QgPSAucHJvamVjdFxuICAgICAgfVxuICAjIEZpbHRlciBvdXQgaGVhbHRoY2hlY2sgbG9ncyBmcm9tIFJlYWx0aW1lXG4gIHJlYWx0aW1lX2xvZ3NfZmlsdGVyZWQ6XG4gICAgdHlwZTogZmlsdGVyXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVhbHRpbWVcbiAgICBjb25kaXRpb246ICchY29udGFpbnMoc3RyaW5nISguZXZlbnRfbWVzc2FnZSksIFwiL2hlYWx0aFwiKSdcbiAgIyBSZWFsdGltZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHBhcnNlIHRoZSBzZXZlcml0eSBsZXZlbCB1c2luZyByZWdleCAoaWdub3JlIHRpbWUgYmVjYXVzZSBpdCBoYXMgbm8gZGF0ZSlcbiAgcmVhbHRpbWVfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc19maWx0ZXJlZFxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIEZ1bmN0aW9uIGxvZ3MgYXJlIHVuc3RydWN0dXJlZCBtZXNzYWdlcyBvbiBzdGRlcnJcbiAgZnVuY3Rpb25zX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5mdW5jdGlvbnNcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucHJvamVjdF9yZWYgPSBkZWwoLnByb2plY3QpXG4gICMgUG9zdGdyZXMgbG9ncyBzb21lIG1lc3NhZ2VzIHRvIHN0ZGVyciB3aGljaCB3ZSBtYXAgdG8gd2FybmluZyBzZXZlcml0eSBsZXZlbFxuICBkYl9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZGJcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEuaG9zdCA9IFwiZGItZGVmYXVsdFwiXG4gICAgICAubWV0YWRhdGEucGFyc2VkLnRpbWVzdGFtcCA9IC50aW1lc3RhbXBcblxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcicuKig/UDxsZXZlbD5JTkZPfE5PVElDRXxXQVJOSU5HfEVSUk9SfExPR3xGQVRBTHxQQU5JQz8pOi4qJywgbnVtZXJpY19ncm91cHM6IHRydWUpXG5cbiAgICAgIGlmIGVyciAhPSBudWxsIHx8IHBhcnNlZCA9PSBudWxsIHtcbiAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwiaW5mb1wiXG4gICAgICB9XG4gICAgICBpZiBwYXJzZWQubGV2ZWwgIT0gbnVsbCB7XG4gICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1nb3RydWUubG9ncy5wcm9kJ1xuICBsb2dmbGFyZV9yZWFsdGltZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlYWx0aW1lX2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMzBcbiAgICAgIHJldHJ5X2luaXRpYWxfYmFja29mZl9zZWNzOiAxXG4gICAgICBoZWFkZXJzOlxuICAgICAgICB4LWFwaS1rZXk6ICcke0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4/TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTiBpcyByZXF1aXJlZH0nXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPXJlYWx0aW1lLmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfcmVzdDpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlc3RfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJ1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBmdW5jdGlvbnNfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9ZGVuby1yZWxheS1sb2dzJ1xuICBsb2dmbGFyZV9zdG9yYWdlOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gc3RvcmFnZV9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJ1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1jbG91ZGZsYXJlLmxvZ3MucHJvZCdcbiIKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jb25maWcnCiAgICAgIC0gL2V0Yy92ZWN0b3IvdmVjdG9yLnltbAogIHN1cGFiYXNlLXJlc3Q6CiAgICBpbWFnZTogJ3Bvc3RncmVzdC9wb3N0Z3Jlc3Q6djE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQR1JTVF9EQl9VUkk9cG9zdGdyZXM6Ly9hdXRoZW50aWNhdG9yOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE4Ni4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke0dPVFJVRV9TSVRFX1VSTDotJHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9fScKICAgICAgLSAnR09UUlVFX1VSSV9BTExPV19MSVNUPSR7QURESVRJT05BTF9SRURJUkVDVF9VUkxTfScKICAgICAgLSAnR09UUlVFX0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSBHT1RSVUVfSldUX0FETUlOX1JPTEVTPXNlcnZpY2Vfcm9sZQogICAgICAtIEdPVFJVRV9KV1RfQVVEPWF1dGhlbnRpY2F0ZWQKICAgICAgLSBHT1RSVUVfSldUX0RFRkFVTFRfR1JPVVBfTkFNRT1hdXRoZW50aWNhdGVkCiAgICAgIC0gJ0dPVFJVRV9KV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICAgIC0gJ0dPVFJVRV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfRU1BSUxfRU5BQkxFRD0ke0VOQUJMRV9FTUFJTF9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfQU5PTllNT1VTX1VTRVJTX0VOQUJMRUQ9JHtFTkFCTEVfQU5PTllNT1VTX1VTRVJTOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfQVVUT0NPTkZJUk09JHtFTkFCTEVfRU1BSUxfQVVUT0NPTkZJUk06LWZhbHNlfScKICAgICAgLSAnR09UUlVFX1NNVFBfQURNSU5fRU1BSUw9JHtTTVRQX0FETUlOX0VNQUlMfScKICAgICAgLSAnR09UUlVFX1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BPUlQ9JHtTTVRQX1BPUlQ6LTU4N30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdHT1RSVUVfU01UUF9QQVNTPSR7U01UUF9QQVNTfScKICAgICAgLSAnR09UUlVFX1NNVFBfU0VOREVSX05BTUU9JHtTTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19JTlZJVEU9JHtNQUlMRVJfVVJMUEFUSFNfSU5WSVRFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT046LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfUkVDT1ZFUlk9JHtNQUlMRVJfVVJMUEFUSFNfUkVDT1ZFUlk6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfSU5WSVRFPSR7TUFJTEVSX1RFTVBMQVRFU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT049JHtNQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTn0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZPSR7TUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX01BR0lDX0xJTks9JHtNQUlMRVJfVEVNUExBVEVTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTn0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfUkVDT1ZFUlk9JHtNQUlMRVJfU1VCSkVDVFNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTks9JHtNQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfSU5WSVRFPSR7TUFJTEVSX1NVQkpFQ1RTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9QSE9ORV9FTkFCTEVEPSR7RU5BQkxFX1BIT05FX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVNfQVVUT0NPTkZJUk09JHtFTkFCTEVfUEhPTkVfQVVUT0NPTkZJUk06LXRydWV9JwogIHJlYWx0aW1lLWRldjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcmVhbHRpbWU6djIuNzYuNScKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRUFMVElNRX0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSAiRE5TX05PREVTPScnIgogICAgICAtIFJMSU1JVF9OT0ZJTEU9MTAwMDAKICAgICAgLSBBUFBfTkFNRT1yZWFsdGltZQogICAgICAtIFNFRURfU0VMRl9IT1NUPXRydWUKICAgICAgLSBMT0dfTEVWRUw9ZXJyb3IKICAgICAgLSBSVU5fSkFOSVRPUj10cnVlCiAgICAgIC0gSkFOSVRPUl9JTlRFUlZBTD02MDAwMAogICAgICAtIERJU0FCTEVfSEVBTFRIQ0hFQ0tfTE9HR0lORz10cnVlCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ3Bnc3R5L3NpbG86UkVMRUFTRS4yMDI2LTEwLTE1VDAwLTAwLTAwWicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdNSU5JT19ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICBjb21tYW5kOiAnc2VydmVyIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSIgL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovZGF0YScKICBtaW5pby1jcmVhdGVidWNrZXQ6CiAgICBpbWFnZTogJ3Bnc3R5L3NpbG86UkVMRUFTRS4yMDI2LTEwLTE1VDAwLTAwLTAwWicKICAgIHJlc3RhcnQ6ICdubycKICAgIGVudmlyb25tZW50OgogICAgICAtICdNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdNSU5JT19ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1taW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW50cnlwb2ludDoKICAgICAgLSAvZW50cnlwb2ludC5zaAogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZW50cnlwb2ludC5zaAogICAgICAgIHRhcmdldDogL2VudHJ5cG9pbnQuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL3NoXG4vdXNyL2Jpbi9tYyBhbGlhcyBzZXQgc3VwYWJhc2UtbWluaW8gaHR0cDovL3N1cGFiYXNlLW1pbmlvOjkwMDAgJHtNSU5JT19ST09UX1VTRVJ9ICR7TUlOSU9fUk9PVF9QQVNTV09SRH07XG4vdXNyL2Jpbi9tYyBtYiAtLWlnbm9yZS1leGlzdGluZyBzdXBhYmFzZS1taW5pby9zdHViO1xuZXhpdCAwXG4iCiAgc3VwYWJhc2Utc3RvcmFnZToKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3RvcmFnZS1hcGk6djEuNDQuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLXJlc3Q6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgICAgaW1ncHJveHk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAvc3RhdHVzJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVkVSX1BPUlQ9NTAwMAogICAgICAtIFNFUlZFUl9SRUdJT049bG9jYWwKICAgICAgLSBNVUxUSV9URU5BTlQ9ZmFsc2UKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly9zdXBhYmFzZV9zdG9yYWdlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtIERCX0lOU1RBTExfUk9MRVM9ZmFsc2UKICAgICAgLSBTVE9SQUdFX0JBQ0tFTkQ9czMKICAgICAgLSBTVE9SQUdFX1MzX0JVQ0tFVD1zdHViCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9aHR0cDovL3N1cGFiYXNlLW1pbmlvOjkwMDAnCiAgICAgIC0gU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPXRydWUKICAgICAgLSBTVE9SQUdFX1MzX1JFR0lPTj11cy1lYXN0LTEKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVRfU1RBTkRBUkQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX1NJR05FRF9VUkxfRVhQSVJBVElPTl9USU1FPTEyMAogICAgICAtIFRVU19VUkxfUEFUSD0vdXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgICAgLSAnQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1BPU1RHUkVTVF9VUkw9aHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMCcKICAgICAgLSAnUEdSU1RfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnU1RPUkFHRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9VUkxfU1VQQUJBU0VLT05HfScKICAgICAgLSAnVEVOQU5UX0lEPSR7U1RPUkFHRV9URU5BTlRfSUQ6LXN0b3JhZ2Utc2luZ2xlLXRlbmFudH0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My4zMC4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGltZ3Byb3h5CiAgICAgICAgLSBoZWFsdGgKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdJTUdQUk9YWV9CSU5EPTo4MDgwJwogICAgICAtIElNR1BST1hZX0xPQ0FMX0ZJTEVTWVNURU1fUk9PVD0vCiAgICAgIC0gSU1HUFJPWFlfVVNFX0VUQUc9dHJ1ZQogICAgICAtICdJTUdQUk9YWV9BVVRPX1dFQlA9JHtJTUdQUk9YWV9BVVRPX1dFQlA6LXRydWV9JwogICAgICAtIElNR1BST1hZX01BWF9TUkNfUkVTT0xVVElPTj0xNi44CiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuOTUuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JZUFRPX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUEdNRVRBQ1JZUFRPfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjcxLjInCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9aHR0cDovL3N1cGFiYXNlLWtvbmc6ODAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLSAnZGVuby1jYWNoZTovcm9vdC8uY2FjaGUvZGVubycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBTVVBBQkFTRV9VUkwgPSBEZW5vLmVudi5nZXQoJ1NVUEFCQVNFX1VSTCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG4vLyBDcmVhdGUgSldLUyBmb3IgRVMyNTYvUlMyNTYgdG9rZW5zIChuZXdlciB0b2tlbnMpXG5sZXQgU1VQQUJBU0VfSldUX0tFWVM6IFJldHVyblR5cGU8dHlwZW9mIGpvc2UuY3JlYXRlUmVtb3RlSldLU2V0PiB8IG51bGwgPSBudWxsXG5pZiAoU1VQQUJBU0VfVVJMKSB7XG4gIHRyeSB7XG4gICAgU1VQQUJBU0VfSldUX0tFWVMgPSBqb3NlLmNyZWF0ZVJlbW90ZUpXS1NldChcbiAgICAgIG5ldyBVUkwoJy9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvbicsIFNVUEFCQVNFX1VSTClcbiAgICApXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gZmV0Y2ggSldLUyBmcm9tIFNVUEFCQVNFX1VSTDonLCBlKVxuICB9XG59XG5cbmZ1bmN0aW9uIGdldEF1dGhUb2tlbihyZXE6IFJlcXVlc3QpIHtcbiAgY29uc3QgYXV0aEhlYWRlciA9IHJlcS5oZWFkZXJzLmdldCgnYXV0aG9yaXphdGlvbicpXG4gIGlmICghYXV0aEhlYWRlcikge1xuICAgIHRocm93IG5ldyBFcnJvcignTWlzc2luZyBhdXRob3JpemF0aW9uIGhlYWRlcicpXG4gIH1cbiAgY29uc3QgW2JlYXJlciwgdG9rZW5dID0gYXV0aEhlYWRlci5zcGxpdCgnICcpXG4gIGlmIChiZWFyZXIgIT09ICdCZWFyZXInKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKGBBdXRoIGhlYWRlciBpcyBub3QgJ0JlYXJlciB7dG9rZW59J2ApXG4gIH1cbiAgcmV0dXJuIHRva2VuXG59XG5cbmFzeW5jIGZ1bmN0aW9uIGlzVmFsaWRMZWdhY3lKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgaWYgKCFKV1RfU0VDUkVUKSB7XG4gICAgY29uc29sZS5lcnJvcignSldUX1NFQ1JFVCBub3QgYXZhaWxhYmxlIGZvciBIUzI1NiB0b2tlbiB2ZXJpZmljYXRpb24nKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG5cbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpO1xuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc29sZS5lcnJvcignU3ltbWV0cmljIExlZ2FjeSBKV1QgdmVyaWZpY2F0aW9uIGVycm9yJywgZSk7XG4gICAgcmV0dXJuIGZhbHNlO1xuICB9XG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGlmICghU1VQQUJBU0VfSldUX0tFWVMpIHtcbiAgICBjb25zb2xlLmVycm9yKCdKV0tTIG5vdCBhdmFpbGFibGUgZm9yIEVTMjU2L1JTMjU2IHRva2VuIHZlcmlmaWNhdGlvbicpXG4gICAgcmV0dXJuIGZhbHNlXG4gIH1cblxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgU1VQQUJBU0VfSldUX0tFWVMpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdBc3ltbWV0cmljIEpXVCB2ZXJpZmljYXRpb24gZXJyb3InLCBlKTtcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSHlicmlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IHsgYWxnOiBqd3RBbGdvcml0aG0gfSA9IGpvc2UuZGVjb2RlUHJvdGVjdGVkSGVhZGVyKGp3dClcblxuICBpZiAoand0QWxnb3JpdGhtID09PSAnSFMyNTYnKSB7XG4gICAgY29uc29sZS5sb2coYExlZ2FjeSB0b2tlbiB0eXBlIGRldGVjdGVkLCBhdHRlbXB0aW5nICR7and0QWxnb3JpdGhtfSB2ZXJpZmljYXRpb24uYClcblxuICAgIHJldHVybiBhd2FpdCBpc1ZhbGlkTGVnYWN5SldUKGp3dClcbiAgfVxuXG4gIGlmIChqd3RBbGdvcml0aG0gPT09ICdFUzI1NicgfHwgand0QWxnb3JpdGhtID09PSAnUlMyNTYnKSB7XG4gICAgcmV0dXJuIGF3YWl0IGlzVmFsaWRKV1Qoand0KVxuICB9XG5cbiAgcmV0dXJuIGZhbHNlO1xufVxuXG5EZW5vLnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCBpc1ZhbGlkSHlicmlkSldUKHRva2VuKTtcblxuICAgICAgaWYgKCFpc1ZhbGlkSldUKSB7XG4gICAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6ICdJbnZhbGlkIEpXVCcgfSksIHtcbiAgICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgICAgfSlcbiAgICAgIH1cbiAgICB9IGNhdGNoIChlKSB7XG4gICAgICBjb25zb2xlLmVycm9yKGUpXG4gICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiBlLnRvU3RyaW5nKCkgfSksIHtcbiAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgfSlcbiAgICB9XG4gIH1cblxuICBjb25zdCB1cmwgPSBuZXcgVVJMKHJlcS51cmwpXG4gIGNvbnN0IHsgcGF0aG5hbWUgfSA9IHVybFxuICBjb25zdCBwYXRoX3BhcnRzID0gcGF0aG5hbWUuc3BsaXQoJy8nKVxuICBjb25zdCBzZXJ2aWNlX25hbWUgPSBwYXRoX3BhcnRzWzFdXG5cbiAgaWYgKCFzZXJ2aWNlX25hbWUgfHwgc2VydmljZV9uYW1lID09PSAnJykge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6ICdtaXNzaW5nIGZ1bmN0aW9uIG5hbWUgaW4gcmVxdWVzdCcgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDQwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cblxuICBjb25zdCBzZXJ2aWNlUGF0aCA9IGAvaG9tZS9kZW5vL2Z1bmN0aW9ucy8ke3NlcnZpY2VfbmFtZX1gXG4gIGNvbnNvbGUuZXJyb3IoYHNlcnZpbmcgdGhlIHJlcXVlc3Qgd2l0aCAke3NlcnZpY2VQYXRofWApXG5cbiAgY29uc3QgbWVtb3J5TGltaXRNYiA9IDE1MFxuICBjb25zdCB3b3JrZXJUaW1lb3V0TXMgPSAxICogNjAgKiAxMDAwXG4gIGNvbnN0IG5vTW9kdWxlQ2FjaGUgPSBmYWxzZVxuICBjb25zdCBpbXBvcnRNYXBQYXRoID0gbnVsbFxuICBjb25zdCBlbnZWYXJzT2JqID0gRGVuby5lbnYudG9PYmplY3QoKVxuICBjb25zdCBlbnZWYXJzID0gT2JqZWN0LmtleXMoZW52VmFyc09iaikubWFwKChrKSA9PiBbaywgZW52VmFyc09ialtrXV0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCB3b3JrZXIgPSBhd2FpdCBFZGdlUnVudGltZS51c2VyV29ya2Vycy5jcmVhdGUoe1xuICAgICAgc2VydmljZVBhdGgsXG4gICAgICBtZW1vcnlMaW1pdE1iLFxuICAgICAgd29ya2VyVGltZW91dE1zLFxuICAgICAgbm9Nb2R1bGVDYWNoZSxcbiAgICAgIGltcG9ydE1hcFBhdGgsXG4gICAgICBlbnZWYXJzLFxuICAgIH0pXG4gICAgcmV0dXJuIGF3YWl0IHdvcmtlci5mZXRjaChyZXEpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiBlLnRvU3RyaW5nKCkgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDUwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cbn0pIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICBjb250ZW50OiAiLy8gRm9sbG93IHRoaXMgc2V0dXAgZ3VpZGUgdG8gaW50ZWdyYXRlIHRoZSBEZW5vIGxhbmd1YWdlIHNlcnZlciB3aXRoIHlvdXIgZWRpdG9yOlxuLy8gaHR0cHM6Ly9kZW5vLmxhbmQvbWFudWFsL2dldHRpbmdfc3RhcnRlZC9zZXR1cF95b3VyX2Vudmlyb25tZW50XG4vLyBUaGlzIGVuYWJsZXMgYXV0b2NvbXBsZXRlLCBnbyB0byBkZWZpbml0aW9uLCBldGMuXG5cbkRlbm8uc2VydmUoYXN5bmMgKCkgPT4ge1xuICByZXR1cm4gbmV3IFJlc3BvbnNlKFxuICAgIGBcIkhlbGxvIGZyb20gRWRnZSBGdW5jdGlvbnMhXCJgLFxuICAgIHsgaGVhZGVyczogeyBcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIiB9IH0sXG4gIClcbn0pXG5cbi8vIFRvIGludm9rZTpcbi8vIGN1cmwgJ2h0dHA6Ly9sb2NhbGhvc3Q6PEtPTkdfSFRUUF9QT1JUPi9mdW5jdGlvbnMvdjEvaGVsbG8nIFxcXG4vLyAgIC0taGVhZGVyICdBdXRob3JpemF0aW9uOiBCZWFyZXIgPGFub24vc2VydmljZV9yb2xlIEFQSSBrZXk+J1xuIgogICAgY29tbWFuZDoKICAgICAgLSBzdGFydAogICAgICAtICctLW1haW4tc2VydmljZScKICAgICAgLSAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluCiAgc3VwYWJhc2Utc3VwYXZpc29yOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdXBhdmlzb3I6Mi43LjQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy1vJwogICAgICAgIC0gL2Rldi9udWxsCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDMwcwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9PTEVSX1RFTkFOVF9JRD0ke1BPT0xFUl9URU5BTlRfSUQ6LWRldl90ZW5hbnR9JwogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSAnREJfUE9PTF9TSVpFPSR7UE9PTEVSX0RCX1BPT0xfU0laRTotNX0nCiAgICAgIC0gR0xPQkFMX0RPV05TVFJFQU1fQ0VSVF9QQVRIPS9ldGMvc3NsL3NlcnZlci5jcnQKICAgICAgLSBHTE9CQUxfRE9XTlNUUkVBTV9LRVlfUEFUSD0vZXRjL3NzbC9zZXJ2ZXIua2V5CiAgICBjb21tYW5kOgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImlmIFsgISAtZiAvZXRjL3NzbC9zZXJ2ZXIuY3J0IF07IHRoZW5cbiAgb3BlbnNzbCByZXEgLXg1MDkgLW5vZGVzIC1kYXlzIDM2NTAgLW5ld2tleSByc2E6MjA0OCBcXFxuICAgIC1rZXlvdXQgL2V0Yy9zc2wvc2VydmVyLmtleSAtb3V0IC9ldGMvc3NsL3NlcnZlci5jcnQgXFxcbiAgICAtc3ViaiBcIi9DTj1zdXBhYmFzZS1wb29sZXJcIlxuZmlcbi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vc3VwYXZpc29yIGV2YWwgXCIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cylcIiAmJiAvYXBwL2Jpbi9zZXJ2ZXJcbiIKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK", + "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZy9rb25nOjMuOS4xJwogICAgZW50cnlwb2ludDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGtvbmcKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfU1VQQUJBU0VLT05HXzgwMDAKICAgICAgLSAnS09OR19QT1JUX01BUFM9NDQzOjgwMDAnCiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gS09OR19EQVRBQkFTRT1vZmYKICAgICAgLSBLT05HX0RFQ0xBUkFUSVZFX0NPTkZJRz0vdXNyL2xvY2FsL2tvbmcva29uZy55bWwKICAgICAgLSAnS09OR19ETlNfT1JERVI9TEFTVCxBLENOQU1FJwogICAgICAtIEtPTkdfRE5TX05PVF9GT1VORF9UVEw9MQogICAgICAtICdLT05HX1BMVUdJTlM9cmVxdWVzdC10cmFuc2Zvcm1lcixjb3JzLGtleS1hdXRoLGFjbCxiYXNpYy1hdXRoLHJlcXVlc3QtdGVybWluYXRpb24saXAtcmVzdHJpY3Rpb24scG9zdC1mdW5jdGlvbicKICAgICAgLSBLT05HX05HSU5YX1BST1hZX1BST1hZX0JVRkZFUl9TSVpFPTE2MGsKICAgICAgLSAnS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJTPTY0IDE2MGsnCiAgICAgIC0gJ0tPTkdfUFJPWFlfQUNDRVNTX0xPRz0vZGV2L3N0ZG91dCBjb21iaW5lZCcKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWT0ke1NVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWTotfScKICAgICAgLSAnU1VQQUJBU0VfU0VDUkVUX0tFWT0ke1NVUEFCQVNFX1NFQ1JFVF9LRVk6LX0nCiAgICAgIC0gJ0FOT05fS0VZX0FTWU1NRVRSSUM9JHtBTk9OX0tFWV9BU1lNTUVUUklDOi19JwogICAgICAtICdTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUM9JHtTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUM6LX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9BRE1JTn0nCiAgICAgIC0gJ0RBU0hCT0FSRF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU59JwogICAgICAtICdLT05HX1NUT1JBR0VfQ09OTkVDVF9USU1FT1VUPSR7S09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVDotNjB9JwogICAgICAtICdLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9XUklURV9USU1FT1VUOi0zNjAwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFQURfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVQ6LTM2MDB9JwogICAgICAtICdLT05HX1NUT1JBR0VfUkVRVUVTVF9CVUZGRVJJTkc9JHtLT05HX1NUT1JBR0VfUkVRVUVTVF9CVUZGRVJJTkc6LWZhbHNlfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFU1BPTlNFX0JVRkZFUklORz0ke0tPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkc6LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvYXBpL2tvbmctZW50cnlwb2ludC5zaAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL2Jhc2hcbiMgQ3VzdG9tIGVudHJ5cG9pbnQgZm9yIEtvbmcgdGhhdCBidWlsZHMgTHVhIGV4cHJlc3Npb25zIGZvciByZXF1ZXN0LXRyYW5zZm9ybWVyXG4jIGFuZCBwZXJmb3JtcyBlbnZpcm9ubWVudCB2YXJpYWJsZSBzdWJzdGl0dXRpb24gaW4gdGhlIGRlY2xhcmF0aXZlIGNvbmZpZy5cblxuaWYgWyAtbiBcIiRTVVBBQkFTRV9TRUNSRVRfS0VZXCIgXSAmJiBbIC1uIFwiJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWVwiIF07IHRoZW5cbiAgICBleHBvcnQgTFVBX0FVVEhfRVhQUj1cIlxcJCgoaGVhZGVycy5hdXRob3JpemF0aW9uIH49IG5pbCBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uOnN1YigxLCAxMCkgfj0gJ0JlYXJlciBzYl8nIGFuZCBoZWFkZXJzLmF1dGhvcml6YXRpb24pIG9yIChoZWFkZXJzLmFwaWtleSA9PSAnJFNVUEFCQVNFX1NFQ1JFVF9LRVknIGFuZCAnQmVhcmVyICRTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUMnKSBvciAoaGVhZGVycy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVknIGFuZCAnQmVhcmVyICRBTk9OX0tFWV9BU1lNTUVUUklDJykgb3IgaGVhZGVycy5hcGlrZXkpXCJcbiAgICBleHBvcnQgTFVBX1JUX1dTX0VYUFI9XCJcXCQoKHF1ZXJ5X3BhcmFtcy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9TRUNSRVRfS0VZJyBhbmQgJyRTRVJWSUNFX1JPTEVfS0VZX0FTWU1NRVRSSUMnKSBvciAocXVlcnlfcGFyYW1zLmFwaWtleSA9PSAnJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWScgYW5kICckQU5PTl9LRVlfQVNZTU1FVFJJQycpIG9yIHF1ZXJ5X3BhcmFtcy5hcGlrZXkpXCJcbmVsc2VcbiAgICBleHBvcnQgTFVBX0FVVEhfRVhQUj1cIlxcJCgoaGVhZGVycy5hdXRob3JpemF0aW9uIH49IG5pbCBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uOnN1YigxLCAxMCkgfj0gJ0JlYXJlciBzYl8nIGFuZCBoZWFkZXJzLmF1dGhvcml6YXRpb24pIG9yIGhlYWRlcnMuYXBpa2V5KVwiXG4gICAgZXhwb3J0IExVQV9SVF9XU19FWFBSPVwiXFwkKHF1ZXJ5X3BhcmFtcy5hcGlrZXkpXCJcbmZpXG5cbmF3ayAne1xuICByZXN1bHQgPSBcIlwiXG4gIHJlc3QgPSAkMFxuICB3aGlsZSAobWF0Y2gocmVzdCwgL1xcJFtBLVphLXpfXVtBLVphLXpfMC05XSovKSkge1xuICAgIHZhcm5hbWUgPSBzdWJzdHIocmVzdCwgUlNUQVJUICsgMSwgUkxFTkdUSCAtIDEpXG4gICAgaWYgKHZhcm5hbWUgaW4gRU5WSVJPTikge1xuICAgICAgcmVzdWx0ID0gcmVzdWx0IHN1YnN0cihyZXN0LCAxLCBSU1RBUlQgLSAxKSBFTlZJUk9OW3Zhcm5hbWVdXG4gICAgfSBlbHNlIHtcbiAgICAgIHJlc3VsdCA9IHJlc3VsdCBzdWJzdHIocmVzdCwgMSwgUlNUQVJUICsgUkxFTkdUSCAtIDEpXG4gICAgfVxuICAgIHJlc3QgPSBzdWJzdHIocmVzdCwgUlNUQVJUICsgUkxFTkdUSClcbiAgfVxuICBwcmludCByZXN1bHQgcmVzdFxufScgL2hvbWUva29uZy90ZW1wLnltbCA+IFwiJEtPTkdfREVDTEFSQVRJVkVfQ09ORklHXCJcblxuc2VkIC1pICcvXltbOnNwYWNlOl1dKi0ga2V5OltbOnNwYWNlOl1dKiQvZCcgXCIkS09OR19ERUNMQVJBVElWRV9DT05GSUdcIlxuXG5leGVjIC9lbnRyeXBvaW50LnNoIGtvbmcgZG9ja2VyLXN0YXJ0XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvYXBpL2tvbmcueW1sCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9rb25nL3RlbXAueW1sCiAgICAgICAgY29udGVudDogIl9mb3JtYXRfdmVyc2lvbjogJzIuMSdcbl90cmFuc2Zvcm06IHRydWVcblxuIyMjXG4jIyMgQ29uc3VtZXJzIC8gVXNlcnNcbiMjI1xuY29uc3VtZXJzOlxuICAtIHVzZXJuYW1lOiBEQVNIQk9BUkRcbiAgLSB1c2VybmFtZTogYW5vblxuICAgIGtleWF1dGhfY3JlZGVudGlhbHM6XG4gICAgICAtIGtleTogJFNVUEFCQVNFX0FOT05fS0VZXG4gICAgICAtIGtleTogJFNVUEFCQVNFX1BVQkxJU0hBQkxFX0tFWVxuICAtIHVzZXJuYW1lOiBzZXJ2aWNlX3JvbGVcbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRVJWSUNFX0tFWVxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9TRUNSRVRfS0VZXG5cbiMjI1xuIyMjIEFjY2VzcyBDb250cm9sIExpc3RcbiMjI1xuYWNsczpcbiAgLSBjb25zdW1lcjogYW5vblxuICAgIGdyb3VwOiBhbm9uXG4gIC0gY29uc3VtZXI6IHNlcnZpY2Vfcm9sZVxuICAgIGdyb3VwOiBhZG1pblxuXG4jIyNcbiMjIyBEYXNoYm9hcmQgY3JlZGVudGlhbHNcbiMjI1xuYmFzaWNhdXRoX2NyZWRlbnRpYWxzOlxuLSBjb25zdW1lcjogREFTSEJPQVJEXG4gIHVzZXJuYW1lOiAnJERBU0hCT0FSRF9VU0VSTkFNRSdcbiAgcGFzc3dvcmQ6ICckREFTSEJPQVJEX1BBU1NXT1JEJ1xuXG5cbiMjI1xuIyMjIEFQSSBSb3V0ZXNcbiMjI1xuc2VydmljZXM6XG5cbiAgIyMgT3BlbiBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS92ZXJpZnlcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3BlblxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvdmVyaWZ5XG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9jYWxsYmFja1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWNhbGxiYWNrXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9jYWxsYmFja1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tYXV0aG9yaXplXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L2F1dGhvcml6ZVxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvYXV0aG9yaXplXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1qd2tzXG4gICAgX2NvbW1lbnQ6ICdBdXRoOiAvYXV0aC92MS8ud2VsbC1rbm93bi9qd2tzLmpzb24gLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9qd2tzLmpzb24nXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5Ly53ZWxsLWtub3duL2p3a3MuanNvblxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLWp3a3NcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvblxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tYWNzXG4gICAgdXJsOiBcImh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvc3NvL3NhbWwvYWNzXCJcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tYWNzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgIC0gL3Nzby9zYW1sL2Fjc1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1zc28tbWV0YWRhdGFcbiAgICB1cmw6IFwiaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9zc28vc2FtbC9tZXRhZGF0YVwiXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLW1ldGFkYXRhXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgIC0gL3Nzby9zYW1sL21ldGFkYXRhXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIFNlY3VyZSBBdXRoIHJvdXRlc1xuICAtIG5hbWU6IGF1dGgtdjFcbiAgICBfY29tbWVudDogJ0dvVHJ1ZTogL2F1dGgvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5LyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUkVTVCByb3V0ZXNcbiAgLSBuYW1lOiByZXN0LXYxXG4gICAgX2NvbW1lbnQ6ICdQb3N0Z1JFU1Q6IC9yZXN0L3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlc3QtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVzdC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIEdyYXBoUUwgcm91dGVzXG4gIC0gbmFtZTogZ3JhcGhxbC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvZ3JhcGhxbC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvcnBjL2dyYXBocWwnXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBncmFwaHFsLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2dyYXBocWwvdjFcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQ29udGVudC1Qcm9maWxlOiBncmFwaHFsX3B1YmxpY1wiXG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJDb250ZW50LVByb2ZpbGU6IGdyYXBocWxfcHVibGljXCJcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBSZWFsdGltZSByb3V0ZXNcbiAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS8qIC0+IHdzOi8vcmVhbHRpbWU6NDAwMC9zb2NrZXQvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9zb2NrZXRcbiAgICBwcm90b2NvbDogd3NcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXdzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvcmVhbHRpbWUvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgcXVlcnlzdHJpbmc6XG4gICAgICAgICAgICAgIC0gXCJhcGlrZXk6ICRMVUFfUlRfV1NfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIHF1ZXJ5c3RyaW5nOlxuICAgICAgICAgICAgICAtIFwiYXBpa2V5OiAkTFVBX1JUX1dTX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtcmVzdFxuICAgIF9jb21tZW50OiAnUmVhbHRpbWU6IC9yZWFsdGltZS92MS9hcGkvKiAtPiBodHRwOi8vcmVhbHRpbWU6NDAwMC9hcGkvKidcbiAgICB1cmw6IGh0dHA6Ly9yZWFsdGltZS1kZXY6NDAwMC9hcGlcbiAgICBwcm90b2NvbDogaHR0cFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogcmVhbHRpbWUtdjEtcmVzdFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL2FwaVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAgICAgcmVwbGFjZTpcbiAgICAgICAgICAgIGhlYWRlcnM6XG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTdG9yYWdlIEFQSSBlbmRwb2ludFxuICAjIyBObyBrZXktYXV0aCAtIFMzIHByb3RvY29sIHJlcXVlc3RzIGRvbid0IGNhcnJ5IGFuIGFwaWtleSBoZWFkZXIuXG4gIC0gbmFtZTogc3RvcmFnZS12MVxuICAgIF9jb21tZW50OiAnU3RvcmFnZTogL3N0b3JhZ2UvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2Utc3RvcmFnZTo1MDAwLyonXG4gICAgY29ubmVjdF90aW1lb3V0OiAkS09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVFxuICAgIHdyaXRlX3RpbWVvdXQ6ICRLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVFxuICAgIHJlYWRfdGltZW91dDogJEtPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVRcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdG9yYWdlOjUwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBzdG9yYWdlLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3N0b3JhZ2UvdjEvXG4gICAgICAgIHJlcXVlc3RfYnVmZmVyaW5nOiAkS09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HXG4gICAgICAgIHJlc3BvbnNlX2J1ZmZlcmluZzogJEtPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkdcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IHBvc3QtZnVuY3Rpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFjY2VzczpcbiAgICAgICAgICAgIC0gfFxuICAgICAgICAgICAgICBsb2NhbCBhdXRoID0ga29uZy5yZXF1ZXN0LmdldF9oZWFkZXIoXCJhdXRob3JpemF0aW9uXCIpXG4gICAgICAgICAgICAgIGlmIGF1dGggPT0gbmlsIG9yIGF1dGggPT0gXCJcIiBvciBhdXRoOmZpbmQoXCJeJXMqJFwiKSB0aGVuXG4gICAgICAgICAgICAgICAga29uZy5zZXJ2aWNlLnJlcXVlc3QuY2xlYXJfaGVhZGVyKFwiYXV0aG9yaXphdGlvblwiKVxuICAgICAgICAgICAgICBlbmRcblxuICAjIyBFZGdlIEZ1bmN0aW9ucyByb3V0ZXNcbiAgLSBuYW1lOiBmdW5jdGlvbnMtdjFcbiAgICBfY29tbWVudDogJ0VkZ2UgRnVuY3Rpb25zOiAvZnVuY3Rpb25zL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOjkwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwL1xuICAgIHJlYWRfdGltZW91dDogMTUwMDAwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBmdW5jdGlvbnMtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvZnVuY3Rpb25zL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBPQXV0aCAyLjAgQXV0aG9yaXphdGlvbiBTZXJ2ZXIgTWV0YWRhdGEgKFJGQyA4NDE0KVxuICAtIG5hbWU6IHdlbGwta25vd24tb2F1dGhcbiAgICBfY29tbWVudDogJ0F1dGg6IC8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlciAtPiBodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5Ly53ZWxsLWtub3duL29hdXRoLWF1dGhvcml6YXRpb24tc2VydmVyJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlclxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogd2VsbC1rbm93bi1vYXV0aFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gLy53ZWxsLWtub3duL29hdXRoLWF1dGhvcml6YXRpb24tc2VydmVyXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuXG4gICMjIEFuYWx5dGljcyByb3V0ZXNcbiAgIyMgTm90IHVzZWQgLSBTdHVkaW8gYW5kIFZlY3RvciB0YWxrIGRpcmVjdGx5IHRvIGFuYWx5dGljcyB2aWEgRG9ja2VyIG5ldHdvcmtpbmcuXG4gICMgLSBuYW1lOiBhbmFseXRpY3MtdjFcbiAgIyAgIF9jb21tZW50OiAnQW5hbHl0aWNzOiAvYW5hbHl0aWNzL3YxLyogLT4gaHR0cDovL2xvZ2ZsYXJlOjQwMDAvKidcbiAgIyAgIHVybDogaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL1xuICAjICAgcm91dGVzOlxuICAjICAgICAtIG5hbWU6IGFuYWx5dGljcy12MS1hbGxcbiAgIyAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICMgICAgICAgcGF0aHM6XG4gICMgICAgICAgICAtIC9hbmFseXRpY3MvdjEvXG5cbiAgIyMgU2VjdXJlIERhdGFiYXNlIHJvdXRlc1xuICAtIG5hbWU6IG1ldGFcbiAgICBfY29tbWVudDogJ3BnLW1ldGE6IC9wZy8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtZXRhLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3BnL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuXG4gICMjIEJsb2NrIGFjY2VzcyB0byAvYXBpL21jcFxuICAtIG5hbWU6IG1jcC1ibG9ja2VyXG4gICAgX2NvbW1lbnQ6ICdCbG9jayBkaXJlY3QgYWNjZXNzIHRvIC9hcGkvbWNwJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL2FwaS9tY3BcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IG1jcC1ibG9ja2VyLXJvdXRlXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXBpL21jcFxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdGVybWluYXRpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIHN0YXR1c19jb2RlOiA0MDNcbiAgICAgICAgICBtZXNzYWdlOiBcIkFjY2VzcyBpcyBmb3JiaWRkZW4uXCJcblxuICAjIyBNQ1AgZW5kcG9pbnQgLSBsb2NhbCBhY2Nlc3NcbiAgLSBuYW1lOiBtY3BcbiAgICBfY29tbWVudDogJ01DUDogL21jcCAtPiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvYXBpL21jcCAobG9jYWwgYWNjZXNzKSdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9hcGkvbWNwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtY3BcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9tY3BcbiAgICBwbHVnaW5zOlxuICAgICAgIyBCbG9jayBhY2Nlc3MgdG8gL21jcCBieSBkZWZhdWx0XG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdGVybWluYXRpb25cbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIHN0YXR1c19jb2RlOiA0MDNcbiAgICAgICAgICBtZXNzYWdlOiBcIkFjY2VzcyBpcyBmb3JiaWRkZW4uXCJcbiAgICAgICMgRW5hYmxlIGxvY2FsIGFjY2VzcyAoZGFuZ2VyIHpvbmUhKVxuICAgICAgIyAxLiBDb21tZW50IG91dCB0aGUgJ3JlcXVlc3QtdGVybWluYXRpb24nIHNlY3Rpb24gYWJvdmVcbiAgICAgICMgMi4gVW5jb21tZW50IHRoZSBlbnRpcmUgc2VjdGlvbiBiZWxvdywgaW5jbHVkaW5nICdkZW55J1xuICAgICAgIyAzLiBBZGQgeW91ciBsb2NhbCBJUHMgdG8gdGhlICdhbGxvdycgbGlzdFxuICAgICAgIy0gbmFtZTogY29yc1xuICAgICAgIy0gbmFtZTogaXAtcmVzdHJpY3Rpb25cbiAgICAgICMgIGNvbmZpZzpcbiAgICAgICMgICAgYWxsb3c6XG4gICAgICAjICAgICAgLSAxMjcuMC4wLjFcbiAgICAgICMgICAgICAtIDo6MVxuICAgICAgIyAgICBkZW55OiBbXVxuXG4gICMjIFByb3RlY3RlZCBEYXNoYm9hcmQgLSBjYXRjaCBhbGwgcmVtYWluaW5nIHJvdXRlc1xuICAtIG5hbWU6IGRhc2hib2FyZFxuICAgIF9jb21tZW50OiAnU3R1ZGlvOiAvKiAtPiBodHRwOi8vc3R1ZGlvOjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGRhc2hib2FyZC1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGJhc2ljLWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IHRydWVcbiIKICBzdXBhYmFzZS1zdHVkaW86CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0dWRpbzoyMDI2LjAzLjE2LXNoYS01NTI4ODE3JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJmZXRjaCgnaHR0cDovLzEyNy4wLjAuMTozMDAwL2FwaS9wbGF0Zm9ybS9wcm9maWxlJykudGhlbigocikgPT4ge2lmIChyLnN0YXR1cyAhPT0gMjAwKSB0aHJvdyBuZXcgRXJyb3Ioci5zdGF0dXMpfSkiCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAzCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIEhPU1ROQU1FPTAuMC4wLjAKICAgICAgLSAnU1RVRElPX1BHX01FVEFfVVJMPWh0dHA6Ly9zdXBhYmFzZS1tZXRhOjgwODAnCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1Q9JHtQT1NUR1JFU19IT1NUOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSAnREVGQVVMVF9PUkdBTklaQVRJT05fTkFNRT0ke1NUVURJT19ERUZBVUxUX09SR0FOSVpBVElPTjotRGVmYXVsdCBPcmdhbml6YXRpb259JwogICAgICAtICdERUZBVUxUX1BST0pFQ1RfTkFNRT0ke1NUVURJT19ERUZBVUxUX1BST0pFQ1Q6LURlZmF1bHQgUHJvamVjdH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9VUkxfU1VQQUJBU0VLT05HfScKICAgICAgLSAnU1VQQUJBU0VfQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTVVBBQkFTRV9TRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdfTUVUQV9DUllQVE9fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9QR01FVEFDUllQVE99JwogICAgICAtICdMT0dGTEFSRV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfUFJJVkFURV9BQ0NFU1NfVE9LRU49JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFUFJJVkFURX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1VSTD1odHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAnCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSBORVhUX1BVQkxJQ19FTkFCTEVfTE9HUz10cnVlCiAgICAgIC0gTkVYVF9BTkFMWVRJQ1NfQkFDS0VORF9QUk9WSURFUj1wb3N0Z3JlcwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSBTTklQUEVUU19NQU5BR0VNRU5UX0ZPTERFUj0vYXBwL3NuaXBwZXRzCiAgICAgIC0gRURHRV9GVU5DVElPTlNfTUFOQUdFTUVOVF9GT0xERVI9L2FwcC9lZGdlLWZ1bmN0aW9ucwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3NuaXBwZXRzOi9hcHAvc25pcHBldHMnCiAgICAgIC0gJy4vdm9sdW1lcy9mdW5jdGlvbnM6L2FwcC9lZGdlLWZ1bmN0aW9ucycKICBzdXBhYmFzZS1kYjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXM6MTUuOC4xLjA4NScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncGdfaXNyZWFkeSAtVSBwb3N0Z3JlcyAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtdmVjdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBjb21tYW5kOgogICAgICAtIHBvc3RncmVzCiAgICAgIC0gJy1jJwogICAgICAtIGNvbmZpZ19maWxlPS9ldGMvcG9zdGdyZXNxbC9wb3N0Z3Jlc3FsLmNvbmYKICAgICAgLSAnLWMnCiAgICAgIC0gbG9nX21pbl9tZXNzYWdlcz1mYXRhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfSE9TVD0vdmFyL3J1bi9wb3N0Z3Jlc3FsCiAgICAgIC0gJ1BHUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUEdEQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwYWJhc2UtZGItZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcmVhbHRpbWUuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85OS1yZWFsdGltZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9yZWFsdGltZTtcbmFsdGVyIHNjaGVtYSBfcmVhbHRpbWUgb3duZXIgdG8gOnBndXNlcjtcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9fc3VwYWJhc2Uuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85Ny1fc3VwYWJhc2Uuc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cbkNSRUFURSBEQVRBQkFTRSBfc3VwYWJhc2UgV0lUSCBPV05FUiA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3Bvb2xlci5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXBvb2xlci5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfc3VwYXZpc29yO1xuYWx0ZXIgc2NoZW1hIF9zdXBhdmlzb3Igb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3dlYmhvb2tzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OC13ZWJob29rcy5zcWwKICAgICAgICBjb250ZW50OiAiQkVHSU47XG4tLSBDcmVhdGUgcGdfbmV0IGV4dGVuc2lvblxuQ1JFQVRFIEVYVEVOU0lPTiBJRiBOT1QgRVhJU1RTIHBnX25ldCBTQ0hFTUEgZXh0ZW5zaW9ucztcbi0tIENyZWF0ZSBzdXBhYmFzZV9mdW5jdGlvbnMgc2NoZW1hXG5DUkVBVEUgU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBBVVRIT1JJWkFUSU9OIHN1cGFiYXNlX2FkbWluO1xuR1JBTlQgVVNBR0UgT04gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFRBQkxFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIEZVTkNUSU9OUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFNFUVVFTkNFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zIChcbiAgdmVyc2lvbiB0ZXh0IFBSSU1BUlkgS0VZLFxuICBpbnNlcnRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpXG4pO1xuLS0gSW5pdGlhbCBzdXBhYmFzZV9mdW5jdGlvbnMgbWlncmF0aW9uXG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnaW5pdGlhbCcpO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIGRlZmluaXRpb25cbkNSRUFURSBUQUJMRSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgKFxuICBpZCBiaWdzZXJpYWwgUFJJTUFSWSBLRVksXG4gIGhvb2tfdGFibGVfaWQgaW50ZWdlciBOT1QgTlVMTCxcbiAgaG9va19uYW1lIHRleHQgTk9UIE5VTEwsXG4gIGNyZWF0ZWRfYXQgdGltZXN0YW1wdHogTk9UIE5VTEwgREVGQVVMVCBOT1coKSxcbiAgcmVxdWVzdF9pZCBiaWdpbnRcbik7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX3JlcXVlc3RfaWRfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAocmVxdWVzdF9pZCk7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX2hfdGFibGVfaWRfaF9uYW1lX2lkeCBPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgVVNJTkcgYnRyZWUgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSk7XG5DT01NRU5UIE9OIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBJUyAnU3VwYWJhc2UgRnVuY3Rpb25zIEhvb2tzOiBBdWRpdCB0cmFpbCBmb3IgdHJpZ2dlcmVkIGhvb2tzLic7XG5DUkVBVEUgRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpXG4gIFJFVFVSTlMgdHJpZ2dlclxuICBMQU5HVUFHRSBwbHBnc3FsXG4gIEFTICRmdW5jdGlvbiRcbiAgREVDTEFSRVxuICAgIHJlcXVlc3RfaWQgYmlnaW50O1xuICAgIHBheWxvYWQganNvbmI7XG4gICAgdXJsIHRleHQgOj0gVEdfQVJHVlswXTo6dGV4dDtcbiAgICBtZXRob2QgdGV4dCA6PSBUR19BUkdWWzFdOjp0ZXh0O1xuICAgIGhlYWRlcnMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICBwYXJhbXMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICB0aW1lb3V0X21zIGludGVnZXIgREVGQVVMVCAxMDAwO1xuICBCRUdJTlxuICAgIElGIHVybCBJUyBOVUxMIE9SIHVybCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ3VybCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBtZXRob2QgSVMgTlVMTCBPUiBtZXRob2QgPSAnbnVsbCcgVEhFTlxuICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgaXMgbWlzc2luZyc7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlsyXSBJUyBOVUxMIE9SIFRHX0FSR1ZbMl0gPSAnbnVsbCcgVEhFTlxuICAgICAgaGVhZGVycyA9ICd7XCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCJ9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgaGVhZGVycyA9IFRHX0FSR1ZbMl06Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbM10gSVMgTlVMTCBPUiBUR19BUkdWWzNdID0gJ251bGwnIFRIRU5cbiAgICAgIHBhcmFtcyA9ICd7fSc6Ompzb25iO1xuICAgIEVMU0VcbiAgICAgIHBhcmFtcyA9IFRHX0FSR1ZbM106Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbNF0gSVMgTlVMTCBPUiBUR19BUkdWWzRdID0gJ251bGwnIFRIRU5cbiAgICAgIHRpbWVvdXRfbXMgPSAxMDAwO1xuICAgIEVMU0VcbiAgICAgIHRpbWVvdXRfbXMgPSBUR19BUkdWWzRdOjppbnRlZ2VyO1xuICAgIEVORCBJRjtcblxuICAgIENBU0VcbiAgICAgIFdIRU4gbWV0aG9kID0gJ0dFVCcgVEhFTlxuICAgICAgICBTRUxFQ1QgaHR0cF9nZXQgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfZ2V0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBXSEVOIG1ldGhvZCA9ICdQT1NUJyBUSEVOXG4gICAgICAgIHBheWxvYWQgPSBqc29uYl9idWlsZF9vYmplY3QoXG4gICAgICAgICAgJ29sZF9yZWNvcmQnLCBPTEQsXG4gICAgICAgICAgJ3JlY29yZCcsIE5FVyxcbiAgICAgICAgICAndHlwZScsIFRHX09QLFxuICAgICAgICAgICd0YWJsZScsIFRHX1RBQkxFX05BTUUsXG4gICAgICAgICAgJ3NjaGVtYScsIFRHX1RBQkxFX1NDSEVNQVxuICAgICAgICApO1xuXG4gICAgICAgIFNFTEVDVCBodHRwX3Bvc3QgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfcG9zdChcbiAgICAgICAgICB1cmwsXG4gICAgICAgICAgcGF5bG9hZCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBFTFNFXG4gICAgICAgIFJBSVNFIEVYQ0VQVElPTiAnbWV0aG9kIGFyZ3VtZW50ICUgaXMgaW52YWxpZCcsIG1ldGhvZDtcbiAgICBFTkQgQ0FTRTtcblxuICAgIElOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rc1xuICAgICAgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSwgcmVxdWVzdF9pZClcbiAgICBWQUxVRVNcbiAgICAgIChUR19SRUxJRCwgVEdfTkFNRSwgcmVxdWVzdF9pZCk7XG5cbiAgICBSRVRVUk4gTkVXO1xuICBFTkRcbiRmdW5jdGlvbiQ7XG4tLSBTdXBhYmFzZSBzdXBlciBhZG1pblxuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfcm9sZXNcbiAgICBXSEVSRSByb2xuYW1lID0gJ3N1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbidcbiAgKVxuICBUSEVOXG4gICAgQ1JFQVRFIFVTRVIgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIE5PSU5IRVJJVCBDUkVBVEVST0xFIExPR0lOIE5PUkVQTElDQVRJT047XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBUQUJMRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBBTEwgU0VRVUVOQ0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gU0VUIHNlYXJjaF9wYXRoID0gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIubWlncmF0aW9ucyBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiB0YWJsZSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiLmhvb2tzIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIGZ1bmN0aW9uIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaHR0cF9yZXF1ZXN0KCkgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIFRPIHBvc3RncmVzO1xuLS0gUmVtb3ZlIHVudXNlZCBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gcm9sZVxuRE9cbiQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfcGdfbmV0X2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBSRUFTU0lHTiBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gVE8gc3VwYWJhc2VfYWRtaW47XG4gICAgRFJPUCBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW47XG4gICAgRFJPUCBST0xFIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIHBnX25ldCBncmFudHMgd2hlbiBleHRlbnNpb24gaXMgYWxyZWFkeSBlbmFibGVkXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V4dGVuc2lvblxuICAgIFdIRVJFIGV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG4tLSBFdmVudCB0cmlnZ2VyIGZvciBwZ19uZXRcbkNSRUFURSBPUiBSRVBMQUNFIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcygpXG5SRVRVUk5TIGV2ZW50X3RyaWdnZXJcbkxBTkdVQUdFIHBscGdzcWxcbkFTICQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19ldmVudF90cmlnZ2VyX2RkbF9jb21tYW5kcygpIEFTIGV2XG4gICAgSk9JTiBwZ19leHRlbnNpb24gQVMgZXh0XG4gICAgT04gZXYub2JqaWQgPSBleHQub2lkXG4gICAgV0hFUkUgZXh0LmV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORDtcbiQkO1xuQ09NTUVOVCBPTiBGVU5DVElPTiBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MgSVMgJ0dyYW50cyBhY2Nlc3MgdG8gcGdfbmV0JztcbkRPXG4kJFxuQkVHSU5cbiAgSUYgTk9UIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJcbiAgICBXSEVSRSBldnRuYW1lID0gJ2lzc3VlX3BnX25ldF9hY2Nlc3MnXG4gICkgVEhFTlxuICAgIENSRUFURSBFVkVOVCBUUklHR0VSIGlzc3VlX3BnX25ldF9hY2Nlc3MgT04gZGRsX2NvbW1hbmRfZW5kIFdIRU4gVEFHIElOICgnQ1JFQVRFIEVYVEVOU0lPTicpXG4gICAgRVhFQ1VURSBQUk9DRURVUkUgZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKCk7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnMjAyMTA4MDkxODM0MjNfdXBkYXRlX2dyYW50cycpO1xuQUxURVIgZnVuY3Rpb24gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFNFQ1VSSVRZIERFRklORVI7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VUIHNlYXJjaF9wYXRoID0gc3VwYWJhc2VfZnVuY3Rpb25zO1xuUkVWT0tFIEFMTCBPTiBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgRlJPTSBQVUJMSUM7XG5HUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQ09NTUlUO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3JvbGVzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1yb2xlcy5zcWwKICAgICAgICBjb250ZW50OiAiLS0gTk9URTogY2hhbmdlIHRvIHlvdXIgb3duIHBhc3N3b3JkcyBmb3IgcHJvZHVjdGlvbiBlbnZpcm9ubWVudHNcbiBcXHNldCBwZ3Bhc3MgYGVjaG8gXCIkUE9TVEdSRVNfUEFTU1dPUkRcImBcblxuIEFMVEVSIFVTRVIgYXV0aGVudGljYXRvciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHBnYm91bmNlciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2F1dGhfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9zdG9yYWdlX2FkbWluIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL2p3dC5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9pbml0LXNjcmlwdHMvOTktand0LnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBqd3Rfc2VjcmV0IGBlY2hvIFwiJEpXVF9TRUNSRVRcImBcblxcc2V0IGp3dF9leHAgYGVjaG8gXCIkSldUX0VYUFwiYFxuXFxzZXQgZGJfbmFtZSBgZWNobyBcIiR7UE9TVEdSRVNfREI6LXBvc3RncmVzfVwiYFxuXG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X3NlY3JldFwiIFRPIDonand0X3NlY3JldCc7XG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X2V4cFwiIFRPIDonand0X2V4cCc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvbG9ncy5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LWxvZ3Muc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cXGMgX3N1cGFiYXNlXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX2FuYWx5dGljcztcbmFsdGVyIHNjaGVtYSBfYW5hbHl0aWNzIG93bmVyIHRvIDpwZ3VzZXI7XG5cXGMgcG9zdGdyZXNcbiIKICAgICAgLSAnc3VwYWJhc2UtZGItY29uZmlnOi9ldGMvcG9zdGdyZXNxbC1jdXN0b20nCiAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9sb2dmbGFyZToxLjMxLjInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIExPR0ZMQVJFX05PREVfSE9TVD0xMjcuMC4wLjEKICAgICAgLSBEQl9VU0VSTkFNRT1zdXBhYmFzZV9hZG1pbgogICAgICAtIERCX0RBVEFCQVNFPV9zdXBhYmFzZQogICAgICAtICdEQl9IT1NUTkFNRT0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSBEQl9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtICdMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BSSVZBVEVfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRVBSSVZBVEV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NVUEFCQVNFX01PREU9dHJ1ZQogICAgICAtICdQT1NUR1JFU19CQUNLRU5EX1VSTD1wb3N0Z3Jlc3FsOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gUE9TVEdSRVNfQkFDS0VORF9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtIExPR0ZMQVJFX0ZFQVRVUkVfRkxBR19PVkVSUklERT1tdWx0aWJhY2tlbmQ9dHJ1ZQogIHN1cGFiYXNlLXZlY3RvcjoKICAgIGltYWdlOiAndGltYmVyaW8vdmVjdG9yOjAuNTMuMC1hbHBpbmUnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovL3N1cGFiYXNlLXZlY3Rvcjo5MDAxL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvbG9ncy92ZWN0b3IueW1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL3ZlY3Rvci92ZWN0b3IueW1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogImFwaTpcbiAgZW5hYmxlZDogdHJ1ZVxuICBhZGRyZXNzOiAwLjAuMC4wOjkwMDFcblxuc291cmNlczpcbiAgZG9ja2VyX2hvc3Q6XG4gICAgdHlwZTogZG9ja2VyX2xvZ3NcbiAgICBleGNsdWRlX2NvbnRhaW5lcnM6XG4gICAgICAtIHN1cGFiYXNlLXZlY3RvclxuXG50cmFuc2Zvcm1zOlxuICBwcm9qZWN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIGRvY2tlcl9ob3N0XG4gICAgc291cmNlOiB8LVxuICAgICAgLnByb2plY3QgPSBcImRlZmF1bHRcIlxuICAgICAgLmV2ZW50X21lc3NhZ2UgPSBkZWwoLm1lc3NhZ2UpXG4gICAgICAuYXBwbmFtZSA9IGRlbCguY29udGFpbmVyX25hbWUpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9jcmVhdGVkX2F0KVxuICAgICAgZGVsKC5jb250YWluZXJfaWQpXG4gICAgICBkZWwoLnNvdXJjZV90eXBlKVxuICAgICAgZGVsKC5zdHJlYW0pXG4gICAgICBkZWwoLmxhYmVsKVxuICAgICAgZGVsKC5pbWFnZSlcbiAgICAgIGRlbCguaG9zdClcbiAgICAgIGRlbCguc3RyZWFtKVxuICByb3V0ZXI6XG4gICAgdHlwZTogcm91dGVcbiAgICBpbnB1dHM6XG4gICAgICAtIHByb2plY3RfbG9nc1xuICAgIHJvdXRlOlxuICAgICAga29uZzogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWtvbmdcIiknXG4gICAgICBhdXRoOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtYXV0aFwiKSdcbiAgICAgIHJlc3Q6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1yZXN0XCIpJ1xuICAgICAgcmVhbHRpbWU6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJyZWFsdGltZS1kZXZcIiknXG4gICAgICBzdG9yYWdlOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Utc3RvcmFnZVwiKSdcbiAgICAgIGZ1bmN0aW9uczogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgICAgICB1cmwsIHNwbGl0X2VyciA9IHNwbGl0KHJlcS5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBzcGxpdF9lcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIElnbm9yZXMgbm9uIG5naW54IGVycm9ycyBzaW5jZSB0aGV5IGFyZSByZWxhdGVkIHdpdGgga29uZyBib290aW5nIHVwXG4gIGtvbmdfZXJyOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIua29uZ1xuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IFwiR0VUXCJcbiAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IDIwMFxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiZXJyb3JcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcGFyc2VkLnRpbWVzdGFtcFxuICAgICAgICAgIC5zZXZlcml0eSA9IHBhcnNlZC5zZXZlcml0eVxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lmhvc3QgPSBwYXJzZWQuaG9zdFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMuY2ZfY29ubmVjdGluZ19pcCA9IHBhcnNlZC5jbGllbnRcbiAgICAgICAgICB1cmwsIGVyciA9IHNwbGl0KHBhcnNlZC5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIEdvdHJ1ZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIGpzb24gc3RyaW5ncyB3aGljaCBmcm9udGVuZCBwYXJzZXMgZGlyZWN0bHkuIEJ1dCB3ZSBrZWVwIG1ldGFkYXRhIGZvciBjb25zaXN0ZW5jeS5cbiAgYXV0aF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuYXV0aFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEgPSBtZXJnZSEoLm1ldGFkYXRhLCBwYXJzZWQpXG4gICAgICB9XG4gICMgUG9zdGdSRVNUIGxvZ3MgYXJlIHN0cnVjdHVyZWQgc28gd2Ugc2VwYXJhdGUgdGltZXN0YW1wIGZyb20gbWVzc2FnZSB1c2luZyByZWdleFxuICByZXN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZXN0XG4gICAgc291cmNlOiB8LVxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcideKD9QPHRpbWU+LiopOiAoP1A8bXNnPi4qKSQnKVxuICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgIC5ldmVudF9tZXNzYWdlID0gcGFyc2VkLm1zZ1xuICAgICAgICAgIC50aW1lc3RhbXAgPSBwYXJzZV90aW1lc3RhbXAhKHZhbHVlOiBwYXJzZWQudGltZSwgZm9ybWF0OiBcIiVkLyViLyVZOiVIOiVNOiVTICV6XCIpXG4gICAgICAgICAgLm1ldGFkYXRhLmhvc3QgPSAucHJvamVjdFxuICAgICAgfVxuICAjIEZpbHRlciBvdXQgaGVhbHRoY2hlY2sgbG9ncyBmcm9tIFJlYWx0aW1lXG4gIHJlYWx0aW1lX2xvZ3NfZmlsdGVyZWQ6XG4gICAgdHlwZTogZmlsdGVyXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVhbHRpbWVcbiAgICBjb25kaXRpb246ICchY29udGFpbnMoc3RyaW5nISguZXZlbnRfbWVzc2FnZSksIFwiL2hlYWx0aFwiKSdcbiAgIyBSZWFsdGltZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHBhcnNlIHRoZSBzZXZlcml0eSBsZXZlbCB1c2luZyByZWdleCAoaWdub3JlIHRpbWUgYmVjYXVzZSBpdCBoYXMgbm8gZGF0ZSlcbiAgcmVhbHRpbWVfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc19maWx0ZXJlZFxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIEZ1bmN0aW9uIGxvZ3MgYXJlIHVuc3RydWN0dXJlZCBtZXNzYWdlcyBvbiBzdGRlcnJcbiAgZnVuY3Rpb25zX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5mdW5jdGlvbnNcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucHJvamVjdF9yZWYgPSBkZWwoLnByb2plY3QpXG4gICMgUG9zdGdyZXMgbG9ncyBzb21lIG1lc3NhZ2VzIHRvIHN0ZGVyciB3aGljaCB3ZSBtYXAgdG8gd2FybmluZyBzZXZlcml0eSBsZXZlbFxuICBkYl9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZGJcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEuaG9zdCA9IFwiZGItZGVmYXVsdFwiXG4gICAgICAubWV0YWRhdGEucGFyc2VkLnRpbWVzdGFtcCA9IC50aW1lc3RhbXBcblxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcicuKig/UDxsZXZlbD5JTkZPfE5PVElDRXxXQVJOSU5HfEVSUk9SfExPR3xGQVRBTHxQQU5JQz8pOi4qJywgbnVtZXJpY19ncm91cHM6IHRydWUpXG5cbiAgICAgIGlmIGVyciAhPSBudWxsIHx8IHBhcnNlZCA9PSBudWxsIHtcbiAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwiaW5mb1wiXG4gICAgICB9XG4gICAgICBpZiBwYXJzZWQubGV2ZWwgIT0gbnVsbCB7XG4gICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1nb3RydWUubG9ncy5wcm9kJ1xuICBsb2dmbGFyZV9yZWFsdGltZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlYWx0aW1lX2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMzBcbiAgICAgIHJldHJ5X2luaXRpYWxfYmFja29mZl9zZWNzOiAxXG4gICAgICBoZWFkZXJzOlxuICAgICAgICB4LWFwaS1rZXk6ICcke0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4/TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTiBpcyByZXF1aXJlZH0nXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPXJlYWx0aW1lLmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfcmVzdDpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlc3RfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJ1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBmdW5jdGlvbnNfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9ZGVuby1yZWxheS1sb2dzJ1xuICBsb2dmbGFyZV9zdG9yYWdlOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gc3RvcmFnZV9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJ1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1jbG91ZGZsYXJlLmxvZ3MucHJvZCdcbiIKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jb25maWcnCiAgICAgIC0gL2V0Yy92ZWN0b3IvdmVjdG9yLnltbAogIHN1cGFiYXNlLXJlc3Q6CiAgICBpbWFnZTogJ3Bvc3RncmVzdC9wb3N0Z3Jlc3Q6djE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQR1JTVF9EQl9VUkk9cG9zdGdyZXM6Ly9hdXRoZW50aWNhdG9yOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE4Ni4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke0dPVFJVRV9TSVRFX1VSTDotJHtTRVJWSUNFX1VSTF9TVVBBQkFTRUtPTkd9fScKICAgICAgLSAnR09UUlVFX1VSSV9BTExPV19MSVNUPSR7QURESVRJT05BTF9SRURJUkVDVF9VUkxTfScKICAgICAgLSAnR09UUlVFX0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSBHT1RSVUVfSldUX0FETUlOX1JPTEVTPXNlcnZpY2Vfcm9sZQogICAgICAtIEdPVFJVRV9KV1RfQVVEPWF1dGhlbnRpY2F0ZWQKICAgICAgLSBHT1RSVUVfSldUX0RFRkFVTFRfR1JPVVBfTkFNRT1hdXRoZW50aWNhdGVkCiAgICAgIC0gJ0dPVFJVRV9KV1RfRVhQPSR7SldUX0VYUElSWTotMzYwMH0nCiAgICAgIC0gJ0dPVFJVRV9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfRU1BSUxfRU5BQkxFRD0ke0VOQUJMRV9FTUFJTF9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfQU5PTllNT1VTX1VTRVJTX0VOQUJMRUQ9JHtFTkFCTEVfQU5PTllNT1VTX1VTRVJTOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfQVVUT0NPTkZJUk09JHtFTkFCTEVfRU1BSUxfQVVUT0NPTkZJUk06LWZhbHNlfScKICAgICAgLSAnR09UUlVFX1NNVFBfQURNSU5fRU1BSUw9JHtTTVRQX0FETUlOX0VNQUlMfScKICAgICAgLSAnR09UUlVFX1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1BPUlQ9JHtTTVRQX1BPUlQ6LTU4N30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdHT1RSVUVfU01UUF9QQVNTPSR7U01UUF9QQVNTfScKICAgICAgLSAnR09UUlVFX1NNVFBfU0VOREVSX05BTUU9JHtTTVRQX1NFTkRFUl9OQU1FfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19JTlZJVEU9JHtNQUlMRVJfVVJMUEFUSFNfSU5WSVRFOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT046LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfUkVDT1ZFUlk9JHtNQUlMRVJfVVJMUEFUSFNfUkVDT1ZFUlk6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfSU5WSVRFPSR7TUFJTEVSX1RFTVBMQVRFU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT049JHtNQUlMRVJfVEVNUExBVEVTX0NPTkZJUk1BVElPTn0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX1JFQ09WRVJZPSR7TUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX01BR0lDX0xJTks9JHtNQUlMRVJfVEVNUExBVEVTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0U9JHtNQUlMRVJfVEVNUExBVEVTX0VNQUlMX0NIQU5HRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTn0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfUkVDT1ZFUlk9JHtNQUlMRVJfU1VCSkVDVFNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTks9JHtNQUlMRVJfU1VCSkVDVFNfTUFHSUNfTElOS30nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfU1VCSkVDVFNfSU5WSVRFPSR7TUFJTEVSX1NVQkpFQ1RTX0lOVklURX0nCiAgICAgIC0gJ0dPVFJVRV9FWFRFUk5BTF9QSE9ORV9FTkFCTEVEPSR7RU5BQkxFX1BIT05FX1NJR05VUDotdHJ1ZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVNfQVVUT0NPTkZJUk09JHtFTkFCTEVfUEhPTkVfQVVUT0NPTkZJUk06LXRydWV9JwogIHJlYWx0aW1lLWRldjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcmVhbHRpbWU6djIuNzYuNScKICAgIGNvbnRhaW5lcl9uYW1lOiByZWFsdGltZS1kZXYuc3VwYWJhc2UtcmVhbHRpbWUKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctc1NmTCcKICAgICAgICAtICctLWhlYWQnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICctSCcKICAgICAgICAtICdBdXRob3JpemF0aW9uOiBCZWFyZXIgJHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9hcGkvdGVuYW50cy9yZWFsdGltZS1kZXYvaGVhbHRoJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdEQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gREJfVVNFUj1zdXBhYmFzZV9hZG1pbgogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnREJfQUZURVJfQ09OTkVDVF9RVUVSWT1TRVQgc2VhcmNoX3BhdGggVE8gX3JlYWx0aW1lJwogICAgICAtIERCX0VOQ19LRVk9c3VwYWJhc2VyZWFsdGltZQogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnU0VDUkVUX0tFWV9CQVNFPSR7U0VSVklDRV9QQVNTV09SRF82NF9SRUFMVElNRX0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSAiRE5TX05PREVTPScnIgogICAgICAtIFJMSU1JVF9OT0ZJTEU9MTAwMDAKICAgICAgLSBBUFBfTkFNRT1yZWFsdGltZQogICAgICAtIFNFRURfU0VMRl9IT1NUPXRydWUKICAgICAgLSBMT0dfTEVWRUw9ZXJyb3IKICAgICAgLSBSVU5fSkFOSVRPUj10cnVlCiAgICAgIC0gSkFOSVRPUl9JTlRFUlZBTD02MDAwMAogICAgICAtIERJU0FCTEVfSEVBTFRIQ0hFQ0tfTE9HR0lORz10cnVlCiAgICBjb21tYW5kOiAic2ggLWMgXCIvYXBwL2Jpbi9taWdyYXRlICYmIC9hcHAvYmluL3JlYWx0aW1lIGV2YWwgJ1JlYWx0aW1lLlJlbGVhc2Uuc2VlZHMoUmVhbHRpbWUuUmVwbyknICYmIC9hcHAvYmluL3NlcnZlclwiXG4iCiAgc3VwYWJhc2UtbWluaW86CiAgICBpbWFnZTogJ3Bnc3R5L3NpbG86UkVMRUFTRS4yMDI2LTEwLTE1VDAwLTAwLTAwWicKICAgIGVudmlyb25tZW50OgogICAgICAtICdNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdNSU5JT19ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICBjb21tYW5kOiAnc2VydmVyIC0tY29uc29sZS1hZGRyZXNzICI6OTAwMSIgL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWMKICAgICAgICAtIHJlYWR5CiAgICAgICAgLSBsb2NhbAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovZGF0YScKICBtaW5pby1jcmVhdGVidWNrZXQ6CiAgICBpbWFnZTogJ3Bnc3R5L3NpbG86UkVMRUFTRS4yMDI2LTEwLTE1VDAwLTAwLTAwWicKICAgIHJlc3RhcnQ6ICdubycKICAgIGVudmlyb25tZW50OgogICAgICAtICdNSU5JT19ST09UX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdNSU5JT19ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1taW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW50cnlwb2ludDoKICAgICAgLSAvZW50cnlwb2ludC5zaAogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZW50cnlwb2ludC5zaAogICAgICAgIHRhcmdldDogL2VudHJ5cG9pbnQuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL3NoXG4vdXNyL2Jpbi9tYyBhbGlhcyBzZXQgc3VwYWJhc2UtbWluaW8gaHR0cDovL3N1cGFiYXNlLW1pbmlvOjkwMDAgJHtNSU5JT19ST09UX1VTRVJ9ICR7TUlOSU9fUk9PVF9QQVNTV09SRH07XG4vdXNyL2Jpbi9tYyBtYiAtLWlnbm9yZS1leGlzdGluZyBzdXBhYmFzZS1taW5pby9zdHViO1xuZXhpdCAwXG4iCiAgc3VwYWJhc2Utc3RvcmFnZToKICAgIGltYWdlOiAnc3VwYWJhc2Uvc3RvcmFnZS1hcGk6djEuNDQuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLXJlc3Q6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgICAgaW1ncHJveHk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjUwMDAvc3RhdHVzJwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVkVSX1BPUlQ9NTAwMAogICAgICAtIFNFUlZFUl9SRUdJT049bG9jYWwKICAgICAgLSBNVUxUSV9URU5BTlQ9ZmFsc2UKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly9zdXBhYmFzZV9zdG9yYWdlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtIERCX0lOU1RBTExfUk9MRVM9ZmFsc2UKICAgICAgLSBTVE9SQUdFX0JBQ0tFTkQ9czMKICAgICAgLSBTVE9SQUdFX1MzX0JVQ0tFVD1zdHViCiAgICAgIC0gJ1NUT1JBR0VfUzNfRU5EUE9JTlQ9aHR0cDovL3N1cGFiYXNlLW1pbmlvOjkwMDAnCiAgICAgIC0gU1RPUkFHRV9TM19GT1JDRV9QQVRIX1NUWUxFPXRydWUKICAgICAgLSBTVE9SQUdFX1MzX1JFR0lPTj11cy1lYXN0LTEKICAgICAgLSAnQVdTX0FDQ0VTU19LRVlfSUQ9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX01JTklPfScKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9GSUxFX1NJWkVfTElNSVRfU1RBTkRBUkQ9NTI0Mjg4MDAwCiAgICAgIC0gVVBMT0FEX1NJR05FRF9VUkxfRVhQSVJBVElPTl9USU1FPTEyMAogICAgICAtIFRVU19VUkxfUEFUSD0vdXBsb2FkL3Jlc3VtYWJsZQogICAgICAtIFRVU19NQVhfU0laRT0zNjAwMDAwCiAgICAgIC0gRU5BQkxFX0lNQUdFX1RSQU5TRk9STUFUSU9OPXRydWUKICAgICAgLSAnSU1HUFJPWFlfVVJMPWh0dHA6Ly9pbWdwcm94eTo4MDgwJwogICAgICAtIElNR1BST1hZX1JFUVVFU1RfVElNRU9VVD0xNQogICAgICAtIERBVEFCQVNFX1NFQVJDSF9QQVRIPXN0b3JhZ2UKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gUkVRVUVTVF9BTExPV19YX0ZPUldBUkRFRF9QQVRIPXRydWUKICAgICAgLSAnQU5PTl9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFQU5PTl9LRVl9JwogICAgICAtICdTRVJWSUNFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1BPU1RHUkVTVF9VUkw9aHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMCcKICAgICAgLSAnUEdSU1RfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnU1RPUkFHRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9VUkxfU1VQQUJBU0VLT05HfScKICAgICAgLSAnVEVOQU5UX0lEPSR7U1RPUkFHRV9URU5BTlRfSUQ6LXN0b3JhZ2Utc2luZ2xlLXRlbmFudH0nCiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIGltZ3Byb3h5OgogICAgaW1hZ2U6ICdkYXJ0aHNpbS9pbWdwcm94eTp2My4zMC4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGltZ3Byb3h5CiAgICAgICAgLSBoZWFsdGgKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdJTUdQUk9YWV9CSU5EPTo4MDgwJwogICAgICAtIElNR1BST1hZX0xPQ0FMX0ZJTEVTWVNURU1fUk9PVD0vCiAgICAgIC0gSU1HUFJPWFlfVVNFX0VUQUc9dHJ1ZQogICAgICAtICdJTUdQUk9YWV9BVVRPX1dFQlA9JHtJTUdQUk9YWV9BVVRPX1dFQlA6LXRydWV9JwogICAgICAtIElNR1BST1hZX01BWF9TUkNfUkVTT0xVVElPTj0xNi44CiAgICB2b2x1bWVzOgogICAgICAtICcuL3ZvbHVtZXMvc3RvcmFnZTovdmFyL2xpYi9zdG9yYWdlJwogIHN1cGFiYXNlLW1ldGE6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3Bvc3RncmVzLW1ldGE6djAuOTUuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUEdfTUVUQV9QT1JUPTgwODAKICAgICAgLSAnUEdfTUVUQV9EQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUEdfTUVUQV9EQl9QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHX01FVEFfREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gUEdfTUVUQV9EQl9VU0VSPXN1cGFiYXNlX2FkbWluCiAgICAgIC0gJ1BHX01FVEFfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQ1JZUFRPX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfUEdNRVRBQ1JZUFRPfScKICBzdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczoKICAgIGltYWdlOiAnc3VwYWJhc2UvZWRnZS1ydW50aW1lOnYxLjcxLjInCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBlY2hvCiAgICAgICAgLSAnRWRnZSBGdW5jdGlvbnMgaXMgaGVhbHRoeScKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9aHR0cDovL3N1cGFiYXNlLWtvbmc6ODAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfVVJMX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLSAnZGVuby1jYWNoZTovcm9vdC8uY2FjaGUvZGVubycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBTVVBBQkFTRV9VUkwgPSBEZW5vLmVudi5nZXQoJ1NVUEFCQVNFX1VSTCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG4vLyBDcmVhdGUgSldLUyBmb3IgRVMyNTYvUlMyNTYgdG9rZW5zIChuZXdlciB0b2tlbnMpXG5sZXQgU1VQQUJBU0VfSldUX0tFWVM6IFJldHVyblR5cGU8dHlwZW9mIGpvc2UuY3JlYXRlUmVtb3RlSldLU2V0PiB8IG51bGwgPSBudWxsXG5pZiAoU1VQQUJBU0VfVVJMKSB7XG4gIHRyeSB7XG4gICAgU1VQQUJBU0VfSldUX0tFWVMgPSBqb3NlLmNyZWF0ZVJlbW90ZUpXS1NldChcbiAgICAgIG5ldyBVUkwoJy9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvbicsIFNVUEFCQVNFX1VSTClcbiAgICApXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gZmV0Y2ggSldLUyBmcm9tIFNVUEFCQVNFX1VSTDonLCBlKVxuICB9XG59XG5cbmZ1bmN0aW9uIGdldEF1dGhUb2tlbihyZXE6IFJlcXVlc3QpIHtcbiAgY29uc3QgYXV0aEhlYWRlciA9IHJlcS5oZWFkZXJzLmdldCgnYXV0aG9yaXphdGlvbicpXG4gIGlmICghYXV0aEhlYWRlcikge1xuICAgIHRocm93IG5ldyBFcnJvcignTWlzc2luZyBhdXRob3JpemF0aW9uIGhlYWRlcicpXG4gIH1cbiAgY29uc3QgW2JlYXJlciwgdG9rZW5dID0gYXV0aEhlYWRlci5zcGxpdCgnICcpXG4gIGlmIChiZWFyZXIgIT09ICdCZWFyZXInKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKGBBdXRoIGhlYWRlciBpcyBub3QgJ0JlYXJlciB7dG9rZW59J2ApXG4gIH1cbiAgcmV0dXJuIHRva2VuXG59XG5cbmFzeW5jIGZ1bmN0aW9uIGlzVmFsaWRMZWdhY3lKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgaWYgKCFKV1RfU0VDUkVUKSB7XG4gICAgY29uc29sZS5lcnJvcignSldUX1NFQ1JFVCBub3QgYXZhaWxhYmxlIGZvciBIUzI1NiB0b2tlbiB2ZXJpZmljYXRpb24nKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG5cbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpO1xuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc29sZS5lcnJvcignU3ltbWV0cmljIExlZ2FjeSBKV1QgdmVyaWZpY2F0aW9uIGVycm9yJywgZSk7XG4gICAgcmV0dXJuIGZhbHNlO1xuICB9XG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGlmICghU1VQQUJBU0VfSldUX0tFWVMpIHtcbiAgICBjb25zb2xlLmVycm9yKCdKV0tTIG5vdCBhdmFpbGFibGUgZm9yIEVTMjU2L1JTMjU2IHRva2VuIHZlcmlmaWNhdGlvbicpXG4gICAgcmV0dXJuIGZhbHNlXG4gIH1cblxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgU1VQQUJBU0VfSldUX0tFWVMpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdBc3ltbWV0cmljIEpXVCB2ZXJpZmljYXRpb24gZXJyb3InLCBlKTtcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSHlicmlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IHsgYWxnOiBqd3RBbGdvcml0aG0gfSA9IGpvc2UuZGVjb2RlUHJvdGVjdGVkSGVhZGVyKGp3dClcblxuICBpZiAoand0QWxnb3JpdGhtID09PSAnSFMyNTYnKSB7XG4gICAgY29uc29sZS5sb2coYExlZ2FjeSB0b2tlbiB0eXBlIGRldGVjdGVkLCBhdHRlbXB0aW5nICR7and0QWxnb3JpdGhtfSB2ZXJpZmljYXRpb24uYClcblxuICAgIHJldHVybiBhd2FpdCBpc1ZhbGlkTGVnYWN5SldUKGp3dClcbiAgfVxuXG4gIGlmIChqd3RBbGdvcml0aG0gPT09ICdFUzI1NicgfHwgand0QWxnb3JpdGhtID09PSAnUlMyNTYnKSB7XG4gICAgcmV0dXJuIGF3YWl0IGlzVmFsaWRKV1Qoand0KVxuICB9XG5cbiAgcmV0dXJuIGZhbHNlO1xufVxuXG5EZW5vLnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCBpc1ZhbGlkSHlicmlkSldUKHRva2VuKTtcblxuICAgICAgaWYgKCFpc1ZhbGlkSldUKSB7XG4gICAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6ICdJbnZhbGlkIEpXVCcgfSksIHtcbiAgICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgICAgfSlcbiAgICAgIH1cbiAgICB9IGNhdGNoIChlKSB7XG4gICAgICBjb25zb2xlLmVycm9yKGUpXG4gICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiBlLnRvU3RyaW5nKCkgfSksIHtcbiAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgfSlcbiAgICB9XG4gIH1cblxuICBjb25zdCB1cmwgPSBuZXcgVVJMKHJlcS51cmwpXG4gIGNvbnN0IHsgcGF0aG5hbWUgfSA9IHVybFxuICBjb25zdCBwYXRoX3BhcnRzID0gcGF0aG5hbWUuc3BsaXQoJy8nKVxuICBjb25zdCBzZXJ2aWNlX25hbWUgPSBwYXRoX3BhcnRzWzFdXG5cbiAgaWYgKCFzZXJ2aWNlX25hbWUgfHwgc2VydmljZV9uYW1lID09PSAnJykge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6ICdtaXNzaW5nIGZ1bmN0aW9uIG5hbWUgaW4gcmVxdWVzdCcgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDQwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cblxuICBjb25zdCBzZXJ2aWNlUGF0aCA9IGAvaG9tZS9kZW5vL2Z1bmN0aW9ucy8ke3NlcnZpY2VfbmFtZX1gXG4gIGNvbnNvbGUuZXJyb3IoYHNlcnZpbmcgdGhlIHJlcXVlc3Qgd2l0aCAke3NlcnZpY2VQYXRofWApXG5cbiAgY29uc3QgbWVtb3J5TGltaXRNYiA9IDE1MFxuICBjb25zdCB3b3JrZXJUaW1lb3V0TXMgPSAxICogNjAgKiAxMDAwXG4gIGNvbnN0IG5vTW9kdWxlQ2FjaGUgPSBmYWxzZVxuICBjb25zdCBpbXBvcnRNYXBQYXRoID0gbnVsbFxuICBjb25zdCBlbnZWYXJzT2JqID0gRGVuby5lbnYudG9PYmplY3QoKVxuICBjb25zdCBlbnZWYXJzID0gT2JqZWN0LmtleXMoZW52VmFyc09iaikubWFwKChrKSA9PiBbaywgZW52VmFyc09ialtrXV0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCB3b3JrZXIgPSBhd2FpdCBFZGdlUnVudGltZS51c2VyV29ya2Vycy5jcmVhdGUoe1xuICAgICAgc2VydmljZVBhdGgsXG4gICAgICBtZW1vcnlMaW1pdE1iLFxuICAgICAgd29ya2VyVGltZW91dE1zLFxuICAgICAgbm9Nb2R1bGVDYWNoZSxcbiAgICAgIGltcG9ydE1hcFBhdGgsXG4gICAgICBlbnZWYXJzLFxuICAgIH0pXG4gICAgcmV0dXJuIGF3YWl0IHdvcmtlci5mZXRjaChyZXEpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiBlLnRvU3RyaW5nKCkgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDUwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cbn0pXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuRGVuby5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjcuNCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT09MRVJfVEVOQU5UX0lEPSR7UE9PTEVSX1RFTkFOVF9JRDotZGV2X3RlbmFudH0nCiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgICAtICdEQl9QT09MX1NJWkU9JHtQT09MRVJfREJfUE9PTF9TSVpFOi01fScKICAgICAgLSBHTE9CQUxfRE9XTlNUUkVBTV9DRVJUX1BBVEg9L2V0Yy9zc2wvc2VydmVyLmNydAogICAgICAtIEdMT0JBTF9ET1dOU1RSRUFNX0tFWV9QQVRIPS9ldGMvc3NsL3NlcnZlci5rZXkKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAiaWYgWyAhIC1mIC9ldGMvc3NsL3NlcnZlci5jcnQgXTsgdGhlblxuICBvcGVuc3NsIHJlcSAteDUwOSAtbm9kZXMgLWRheXMgMzY1MCAtbmV3a2V5IHJzYToyMDQ4IFxcXG4gICAgLWtleW91dCAvZXRjL3NzbC9zZXJ2ZXIua2V5IC1vdXQgL2V0Yy9zc2wvc2VydmVyLmNydCBcXFxuICAgIC1zdWJqIFwiL0NOPXN1cGFiYXNlLXBvb2xlclwiXG5maVxuL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCBcIiQkKGNhdCAvZXRjL3Bvb2xlci9wb29sZXIuZXhzKVwiICYmIC9hcHAvYmluL3NlcnZlclxuIgogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9wb29sZXIvcG9vbGVyLmV4cwogICAgICAgIHRhcmdldDogL2V0Yy9wb29sZXIvcG9vbGVyLmV4cwogICAgICAgIGNvbnRlbnQ6ICJ7Om9rLCBffSA9IEFwcGxpY2F0aW9uLmVuc3VyZV9hbGxfc3RhcnRlZCg6c3VwYXZpc29yKVxuezpvaywgdmVyc2lvbn0gPVxuICAgIGNhc2UgU3VwYXZpc29yLlJlcG8ucXVlcnkhKFwic2VsZWN0IHZlcnNpb24oKVwiKSBkb1xuICAgICV7cm93czogW1t2ZXJdXX0gLT4gU3VwYXZpc29yLkhlbHBlcnMucGFyc2VfcGdfdmVyc2lvbih2ZXIpXG4gICAgXyAtPiBuaWxcbiAgICBlbmRcbnBhcmFtcyA9ICV7XG4gICAgXCJleHRlcm5hbF9pZFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1RFTkFOVF9JRFwiKSxcbiAgICBcImRiX2hvc3RcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX0hPU1ROQU1FXCIpLFxuICAgIFwiZGJfcG9ydFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfUE9SVFwiKSB8PiBTdHJpbmcudG9faW50ZWdlcigpLFxuICAgIFwiZGJfZGF0YWJhc2VcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX0RCXCIpLFxuICAgIFwicmVxdWlyZV91c2VyXCIgPT4gZmFsc2UsXG4gICAgXCJhdXRoX3F1ZXJ5XCIgPT4gXCJTRUxFQ1QgKiBGUk9NIHBnYm91bmNlci5nZXRfYXV0aCgkMSlcIixcbiAgICBcImRlZmF1bHRfbWF4X2NsaWVudHNcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9NQVhfQ0xJRU5UX0NPTk5cIiksXG4gICAgXCJkZWZhdWx0X3Bvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiZGVmYXVsdF9wYXJhbWV0ZXJfc3RhdHVzXCIgPT4gJXtcInNlcnZlcl92ZXJzaW9uXCIgPT4gdmVyc2lvbn0sXG4gICAgXCJ1c2Vyc1wiID0+IFsle1xuICAgIFwiZGJfdXNlclwiID0+IFwicGdib3VuY2VyXCIsXG4gICAgXCJkYl9wYXNzd29yZFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfUEFTU1dPUkRcIiksXG4gICAgXCJtb2RlX3R5cGVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9QT09MX01PREVcIiksXG4gICAgXCJwb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImlzX21hbmFnZXJcIiA9PiB0cnVlXG4gICAgfV1cbn1cblxudGVuYW50ID0gU3VwYXZpc29yLlRlbmFudHMuZ2V0X3RlbmFudF9ieV9leHRlcm5hbF9pZChwYXJhbXNbXCJleHRlcm5hbF9pZFwiXSlcblxuaWYgdGVuYW50IGRvXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMudXBkYXRlX3RlbmFudCh0ZW5hbnQsIHBhcmFtcylcbmVsc2VcbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy5jcmVhdGVfdGVuYW50KHBhcmFtcylcbmVuZFxuIgo=", "tags": [ "firebase", "alternative", @@ -5386,13 +4557,13 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-10-15T23:15:19+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8000" }, "superset-with-postgresql": { "documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io", "slogan": "Modern data exploration and visualization platform (unofficial community docker image)", - "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TVVBFUlNFVF84MDg4CiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TVVBFUlNFVFNFQ1JFVEtFWX0nCiAgICAgIC0gJ01BUEJPWF9BUElfS0VZPSR7TUFQQk9YX0FQSV9LRVl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgdGFyZ2V0OiAvZXRjL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIGNvbnRlbnQ6ICJcIlwiXCJcbkZvciBtb3JlIGNvbmZpZ3VyYXRpb24gb3B0aW9ucywgc2VlOlxuLSBodHRwczovL3N1cGVyc2V0LmFwYWNoZS5vcmcvZG9jcy9jb25maWd1cmF0aW9uL2NvbmZpZ3VyaW5nLXN1cGVyc2V0XG5cIlwiXCJcblxuaW1wb3J0IG9zXG5cblNFQ1JFVF9LRVkgPSBvcy5nZXRlbnYoXCJTRUNSRVRfS0VZXCIpXG5NQVBCT1hfQVBJX0tFWSA9IG9zLmdldGVudihcIk1BUEJPWF9BUElfS0VZXCIsIFwiXCIpXG5cbkNBQ0hFX0NPTkZJRyA9IHtcbiAgXCJDQUNIRV9UWVBFXCI6IFwiUmVkaXNDYWNoZVwiLFxuICBcIkNBQ0hFX0RFRkFVTFRfVElNRU9VVFwiOiAzMDAsXG4gIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X1wiLFxuICBcIkNBQ0hFX1JFRElTX0hPU1RcIjogXCJyZWRpc1wiLFxuICBcIkNBQ0hFX1JFRElTX1BPUlRcIjogNjM3OSxcbiAgXCJDQUNIRV9SRURJU19EQlwiOiAxLFxuICBcIkNBQ0hFX1JFRElTX1VSTFwiOiBmXCJyZWRpczovLzp7b3MuZ2V0ZW52KCdSRURJU19QQVNTV09SRCcpfUByZWRpczo2Mzc5LzFcIixcbn1cblxuRklMVEVSX1NUQVRFX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZmlsdGVyX1wifVxuRVhQTE9SRV9GT1JNX0RBVEFfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9leHBsb3JlX2Zvcm1fXCJ9XG5cblNRTEFMQ0hFTVlfVFJBQ0tfTU9ESUZJQ0FUSU9OUyA9IFRydWVcblNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJID0gZlwicG9zdGdyZXNxbCtwc3ljb3BnMjovL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX1VTRVInKX06e29zLmdldGVudignUE9TVEdSRVNfUEFTU1dPUkQnKX1AcG9zdGdyZXM6NTQzMi97b3MuZ2V0ZW52KCdQT1NUR1JFU19EQicpfVwiXG5cbiMgVW5jb21tZW50IGlmIHlvdSB3YW50IHRvIGxvYWQgZXhhbXBsZSBkYXRhICh1c2luZyBcInN1cGVyc2V0IGxvYWRfZXhhbXBsZXNcIikgYXQgdGhlXG4jIHNhbWUgbG9jYXRpb24gYXMgeW91ciBtZXRhZGF0YSBwb3N0Z3Jlc3FsIGluc3RhbmNlLiBPdGhlcndpc2UsIHRoZSBkZWZhdWx0IHNxbGl0ZVxuIyB3aWxsIGJlIHVzZWQsIHdoaWNoIHdpbGwgbm90IHBlcnNpc3QgaW4gdm9sdW1lIHdoZW4gcmVzdGFydGluZyBzdXBlcnNldCBieSBkZWZhdWx0LlxuI1NRTEFMQ0hFTVlfRVhBTVBMRVNfVVJJID0gU1FMQUxDSEVNWV9EQVRBQkFTRV9VUkkiCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODgvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", + "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9TVVBFUlNFVF84MDg4CiAgICAgIC0gJ1NFQ1JFVF9LRVk9JHtTRVJWSUNFX0JBU0U2NF82NF9TVVBFUlNFVFNFQ1JFVEtFWX0nCiAgICAgIC0gJ01BUEJPWF9BUElfS0VZPSR7TUFQQk9YX0FQSV9LRVl9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vc3VwZXJzZXQvc3VwZXJzZXRfY29uZmlnLnB5CiAgICAgICAgdGFyZ2V0OiAvZXRjL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIGNvbnRlbnQ6ICJcIlwiXCJcbkZvciBtb3JlIGNvbmZpZ3VyYXRpb24gb3B0aW9ucywgc2VlOlxuLSBodHRwczovL3N1cGVyc2V0LmFwYWNoZS5vcmcvZG9jcy9jb25maWd1cmF0aW9uL2NvbmZpZ3VyaW5nLXN1cGVyc2V0XG5cIlwiXCJcblxuaW1wb3J0IG9zXG5cblNFQ1JFVF9LRVkgPSBvcy5nZXRlbnYoXCJTRUNSRVRfS0VZXCIpXG5NQVBCT1hfQVBJX0tFWSA9IG9zLmdldGVudihcIk1BUEJPWF9BUElfS0VZXCIsIFwiXCIpXG5cbkNBQ0hFX0NPTkZJRyA9IHtcbiAgXCJDQUNIRV9UWVBFXCI6IFwiUmVkaXNDYWNoZVwiLFxuICBcIkNBQ0hFX0RFRkFVTFRfVElNRU9VVFwiOiAzMDAsXG4gIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X1wiLFxuICBcIkNBQ0hFX1JFRElTX0hPU1RcIjogXCJyZWRpc1wiLFxuICBcIkNBQ0hFX1JFRElTX1BPUlRcIjogNjM3OSxcbiAgXCJDQUNIRV9SRURJU19EQlwiOiAxLFxuICBcIkNBQ0hFX1JFRElTX1VSTFwiOiBmXCJyZWRpczovLzp7b3MuZ2V0ZW52KCdSRURJU19QQVNTV09SRCcpfUByZWRpczo2Mzc5LzFcIixcbn1cblxuRklMVEVSX1NUQVRFX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZmlsdGVyX1wifVxuRVhQTE9SRV9GT1JNX0RBVEFfQ0FDSEVfQ09ORklHID0geyoqQ0FDSEVfQ09ORklHLCBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9leHBsb3JlX2Zvcm1fXCJ9XG5cblNRTEFMQ0hFTVlfVFJBQ0tfTU9ESUZJQ0FUSU9OUyA9IFRydWVcblNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJID0gZlwicG9zdGdyZXNxbCtwc3ljb3BnMjovL3tvcy5nZXRlbnYoJ1BPU1RHUkVTX1VTRVInKX06e29zLmdldGVudignUE9TVEdSRVNfUEFTU1dPUkQnKX1AcG9zdGdyZXM6NTQzMi97b3MuZ2V0ZW52KCdQT1NUR1JFU19EQicpfVwiXG5cbiMgVW5jb21tZW50IGlmIHlvdSB3YW50IHRvIGxvYWQgZXhhbXBsZSBkYXRhICh1c2luZyBcInN1cGVyc2V0IGxvYWRfZXhhbXBsZXNcIikgYXQgdGhlXG4jIHNhbWUgbG9jYXRpb24gYXMgeW91ciBtZXRhZGF0YSBwb3N0Z3Jlc3FsIGluc3RhbmNlLiBPdGhlcndpc2UsIHRoZSBkZWZhdWx0IHNxbGl0ZVxuIyB3aWxsIGJlIHVzZWQsIHdoaWNoIHdpbGwgbm90IHBlcnNpc3QgaW4gdm9sdW1lIHdoZW4gcmVzdGFydGluZyBzdXBlcnNldCBieSBkZWZhdWx0LlxuI1NRTEFMQ0hFTVlfRVhBTVBMRVNfVVJJID0gU1FMQUxDSEVNWV9EQVRBQkFTRV9VUklcbiIKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4OC9oZWFsdGgnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3Bvc3RncmVzX2RhdGE6L3Zhci9saWIvcG9zdGdyZXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3N1cGVyc2V0X3JlZGlzX2RhdGE6L2RhdGEnCiAgICBjb21tYW5kOiAncmVkaXMtc2VydmVyIC0tcmVxdWlyZXBhc3MgJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncmVkaXMtY2xpIHBpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", "tags": [ "analytics", "bi", @@ -5467,7 +4638,7 @@ "category": "finance", "logo": "svgs/sure.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-05T06:44:13+01:00", + "template_last_updated_at": "2026-10-19T21:30:29+02:00", "port": "3000" }, "swetrix": { @@ -5636,7 +4807,7 @@ "trigger": { "documentation": "https://trigger.dev/docs/self-hosting/overview?utm_source=coolify.io", "slogan": "The open source background jobs platform for developers", - "compose": "c2VydmljZXM6CiAgdHJpZ2dlcjoKICAgIGltYWdlOiAnZ2hjci5pby90cmlnZ2VyZG90ZGV2L3RyaWdnZXIuZGV2OnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC4vc2NyaXB0cy9lbnRyeXBvaW50LnNoXCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1RSSUdHRVJfMzAwMAogICAgICAtICdBUFBfT1JJR0lOPSR7U0VSVklDRV9VUkxfVFJJR0dFUl8zMDAwfScKICAgICAgLSAnTE9HSU5fT1JJR0lOPSR7U0VSVklDRV9VUkxfVFJJR0dFUl8zMDAwfScKICAgICAgLSAnQVBJX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1RSSUdHRVJfMzAwMH0nCiAgICAgIC0gJ1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9TRVNTSU9OfScKICAgICAgLSAnTUFHSUNfTElOS19TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X01BR0lDTElOS30nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnTUFOQUdFRF9XT1JLRVJfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9XT1JLRVJTRUNSRVR9JwogICAgICAtICdUUklHR0VSX1dPUktFUl9UT0tFTj0ke1RSSUdHRVJfV09SS0VSX1RPS0VOfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gQVBQX0xPR19MRVZFTD1pbmZvCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NjaGVtYT1wdWJsaWMmc3NsbW9kZT1kaXNhYmxlJwogICAgICAtICdESVJFQ1RfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQjotdHJpZ2dlci1kYn0/c2NoZW1hPXB1YmxpYyZzc2xtb2RlPWRpc2FibGUnCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX1RMU19ESVNBQkxFRD10cnVlCiAgICAgIC0gJ0VMRUNUUklDX09SSUdJTj1odHRwOi8vZWxlY3RyaWM6MzAwMCcKICAgICAgLSAnREVWX09URUxfRVhQT1JURVJfT1RMUF9FTkRQT0lOVD0ke0RFVl9PVEVMX0VYUE9SVEVSX09UTFBfRU5EUE9JTlQ6LX0nCiAgICAgIC0gJ0RFUExPWV9SRUdJU1RSWV9IT1NUPSR7REVQTE9ZX1JFR0lTVFJZX0hPU1Q6LWdoY3IuaW99JwogICAgICAtICdERVBMT1lfUkVHSVNUUllfUFJPVE9DT0w9JHtERVBMT1lfUkVHSVNUUllfUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnREVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRT0ke0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U6LXRyaWdnZXJ9JwogICAgICAtICdWNF9ERVBMT1lfUkVHSVNUUllfTkFNRVNQQUNFPSR7REVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRTotdHJpZ2dlcn0nCiAgICAgIC0gJ09CSkVDVF9TVE9SRV9CQVNFX1VSTD0ke09CSkVDVF9TVE9SRV9CQVNFX1VSTH0nCiAgICAgIC0gJ09CSkVDVF9TVE9SRV9BQ0NFU1NfS0VZX0lEPSR7T0JKRUNUX1NUT1JFX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfU0VDUkVUX0FDQ0VTU19LRVk9JHtPQkpFQ1RfU1RPUkVfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vJHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX06JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9QGNsaWNraG91c2U6ODEyMz9zZWN1cmU9ZmFsc2UnCiAgICAgIC0gQ0xJQ0tIT1VTRV9MT0dfTEVWRUw9aW5mbwogICAgICAtIFJVTl9SRVBMSUNBVElPTl9FTkFCTEVEPTEKICAgICAgLSAnUlVOX1JFUExJQ0FUSU9OX0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly8ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfQ0xJQ0tIT1VTRX1AY2xpY2tob3VzZTo4MTIzJwogICAgICAtIFJVTl9SRVBMSUNBVElPTl9MT0dfTEVWRUw9aW5mbwogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX0VOQUJMRUQ9MQogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX1dPUktFUl9HUk9VUF9OQU1FPWJvb3RzdHJhcAogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX1dPUktFUl9UT0tFTl9QQVRIPS9ob21lL25vZGUvc2hhcmVkL3dvcmtlcl90b2tlbgogICAgICAtICdERUZBVUxUX09SR19FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ9JHtERUZBVUxUX09SR19FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ6LTMwMH0nCiAgICAgIC0gJ0RFRkFVTFRfRU5WX0VYRUNVVElPTl9DT05DVVJSRU5DWV9MSU1JVD0ke0RFRkFVTFRfRU5WX0VYRUNVVElPTl9DT05DVVJSRU5DWV9MSU1JVDotMTAwfScKICAgICAgLSAnQVVUSF9HSVRIVUJfQ0xJRU5UX0lEPSR7QVVUSF9HSVRIVUJfQ0xJRU5UX0lEOi19JwogICAgICAtICdBVVRIX0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7QVVUSF9HSVRIVUJfQ0xJRU5UX1NFQ1JFVDotfScKICAgICAgLSAnRU1BSUxfVFJBTlNQT1JUPSR7RU1BSUxfVFJBTlNQT1JUOi1zbXRwfScKICAgICAgLSAnUkVTRU5EX0FQSV9LRVk9JHtSRVNFTkRfQVBJX0tFWX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ0ZST01fRU1BSUw9JHtGUk9NX0VNQUlMfScKICAgICAgLSAnUkVQTFlfVE9fRU1BSUw9JHtSRVBMWV9UT19FTUFJTH0nCiAgICAgIC0gJ1dISVRFTElTVEVEX0VNQUlMUz0ke1dISVRFTElTVEVEX0VNQUlMU30nCiAgICAgIC0gJ0FETUlOX0VNQUlMUz0ke0FETUlOX0VNQUlMU30nCiAgICAgIC0gR1JBQ0VGVUxfU0hVVERPV05fVElNRU9VVD0xMDAwCiAgICAgIC0gJ0lOVEVSTkFMX09URUxfVFJBQ0VfTE9HR0lOR19FTkFCTEVEPSR7SU5URVJOQUxfT1RFTF9UUkFDRV9MT0dHSU5HX0VOQUJMRUQ6LTB9JwogICAgICAtICdUUklHR0VSX1RFTEVNRVRSWV9ESVNBQkxFRD0ke1RSSUdHRVJfVEVMRU1FVFJZX0RJU0FCTEVEOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NoYXJlZC1kYXRhOi9ob21lL25vZGUvc2hhcmVkJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvaGVhbHRoY2hlY2snLChyKT0+cHJvY2Vzcy5leGl0KHIuc3RhdHVzQ29kZT09PTIwMD8wOjEpKS5vbignZXJyb3InLCgpPT5wcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgZWxlY3RyaWM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHN1cGVydmlzb3I6CiAgICBpbWFnZTogJ2doY3IuaW8vdHJpZ2dlcmRvdGRldi9zdXBlcnZpc29yOnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC91c3IvYmluL2R1bWItaW5pdCAtLSBwbnBtIHJ1biAtLWZpbHRlciBzdXBlcnZpc29yIHN0YXJ0XCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtICdUUklHR0VSX0FQSV9VUkw9aHR0cDovL3RyaWdnZXI6MzAwMCcKICAgICAgLSAnT1RFTF9FWFBPUlRFUl9PVExQX0VORFBPSU5UPWh0dHA6Ly90cmlnZ2VyOjMwMDAvb3RlbCcKICAgICAgLSAnVFJJR0dFUl9XT1JLRVJfVE9LRU49JHtUUklHR0VSX1dPUktFUl9UT0tFTjotZmlsZTovLy9ob21lL25vZGUvc2hhcmVkL3dvcmtlcl90b2tlbn0nCiAgICAgIC0gJ01BTkFHRURfV09SS0VSX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfV09SS0VSU0VDUkVUfScKICAgICAgLSAnVFJJR0dFUl9XT1JLTE9BRF9BUElfRE9NQUlOPSR7VFJJR0dFUl9XT1JLTE9BRF9BUElfRE9NQUlOfScKICAgICAgLSBUUklHR0VSX1dPUktMT0FEX0FQSV9QT1JUX0VYVEVSTkFMPTgwMjAKICAgICAgLSAnRE9DS0VSX0hPU1Q9dGNwOi8vZG9ja2VyLXByb3h5OjIzNzUnCiAgICAgIC0gJ0RPQ0tFUl9SVU5ORVJfTkVUV09SS1M9JHtET0NLRVJfUlVOTkVSX05FVFdPUktTfScKICAgICAgLSAnRE9DS0VSX0FVVE9SRU1PVkVfRVhJVEVEX0NPTlRBSU5FUlM9JHtET0NLRVJfQVVUT1JFTU9WRV9FWElURURfQ09OVEFJTkVSUzotMX0nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9VUkw9aHR0cHM6Ly8ke0RFUExPWV9SRUdJU1RSWV9IT1NUOi1naGNyLmlvfScKICAgICAgLSAnRE9DS0VSX1JFR0lTVFJZX1VTRVJOQU1FPSR7REVQTE9ZX1JFR0lTVFJZX1VTRVJOQU1FfScKICAgICAgLSAnRE9DS0VSX1JFR0lTVFJZX1BBU1NXT1JEPSR7REVQTE9ZX1JFR0lTVFJZX1BBU1NXT1JEfScKICAgICAgLSBFTkZPUkNFX01BQ0hJTkVfUFJFU0VUUz0xCiAgICAgIC0gVFJJR0dFUl9ERVFVRVVFX0lOVEVSVkFMX01TPTEwMDAKICAgICAgLSAnREVCVUc9JHtTVVBFUlZJU09SX0RFQlVHOi0wfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NoYXJlZC1kYXRhOi9ob21lL25vZGUvc2hhcmVkJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vMTI3LjAuMC4xOjgwMjAvaGVhbHRoJywocik9PnByb2Nlc3MuZXhpdChyLnN0YXR1c0NvZGU9PT0yMDA/MDoxKSkub24oJ2Vycm9yJywoKT0+cHJvY2Vzcy5leGl0KDEpKSIKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICBkZXBlbmRzX29uOgogICAgICB0cmlnZ2VyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGRvY2tlci1wcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGRvY2tlci1wcm94eToKICAgIGltYWdlOiAndGVjbmF0aXZhL2RvY2tlci1zb2NrZXQtcHJveHk6djAuNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dfTEVWRUw9aW5mbwogICAgICAtICdQT1NUPSR7UE9TVDotMX0nCiAgICAgIC0gJ0NPTlRBSU5FUlM9JHtDT05UQUlORVJTOi0xfScKICAgICAgLSAnSU1BR0VTPSR7SU1BR0VTOi0xfScKICAgICAgLSAnSU5GTz0ke0lORk86LTF9JwogICAgICAtICdORVRXT1JLUz0ke05FVFdPUktTOi0xfScKICAgICAgLSAnQVVUSD0ke0FVVEg6LTF9JwogICAgICAtICdESVNUUklCVVRJT049JHtESVNUUklCVVRJT046LTF9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiAxMjcuMC4wLjEgMjM3NSB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtIHdhbF9sZXZlbD1sb2dpY2FsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBlbGVjdHJpYzoKICAgIGltYWdlOiAnZWxlY3RyaWNzcWwvZWxlY3RyaWM6MS4yLjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQjotdHJpZ2dlci1kYn0/c3NsbW9kZT1kaXNhYmxlJwogICAgICAtICdFTEVDVFJJQ19TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0VMRUNUUklDfScKICAgICAgLSBFTEVDVFJJQ19VU0FHRV9SRVBPUlRJTkc9ZmFsc2UKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDozMDAwL3YxL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9ERUZBVUxUX0FDQ0VTU19NQU5BR0VNRU5UPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsaWNraG91c2UtZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdjbGlja2hvdXNlLWxvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", + "compose": "c2VydmljZXM6CiAgdHJpZ2dlcjoKICAgIGltYWdlOiAnZ2hjci5pby90cmlnZ2VyZG90ZGV2L3RyaWdnZXIuZGV2OnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC4vc2NyaXB0cy9lbnRyeXBvaW50LnNoXCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1RSSUdHRVJfMzAwMAogICAgICAtICdBUFBfT1JJR0lOPSR7U0VSVklDRV9VUkxfVFJJR0dFUn0nCiAgICAgIC0gJ0xPR0lOX09SSUdJTj0ke1NFUlZJQ0VfVVJMX1RSSUdHRVJ9JwogICAgICAtICdBUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfVFJJR0dFUn0nCiAgICAgIC0gJ1NFU1NJT05fU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9TRVNTSU9OfScKICAgICAgLSAnTUFHSUNfTElOS19TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X01BR0lDTElOS30nCiAgICAgIC0gJ0VOQ1JZUFRJT05fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9FTkNSWVBUSU9OS0VZfScKICAgICAgLSAnTUFOQUdFRF9XT1JLRVJfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9XT1JLRVJTRUNSRVR9JwogICAgICAtICdUUklHR0VSX1dPUktFUl9UT0tFTj0ke1RSSUdHRVJfV09SS0VSX1RPS0VOfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gQVBQX0xPR19MRVZFTD1pbmZvCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NjaGVtYT1wdWJsaWMmc3NsbW9kZT1kaXNhYmxlJwogICAgICAtICdESVJFQ1RfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQjotdHJpZ2dlci1kYn0/c2NoZW1hPXB1YmxpYyZzc2xtb2RlPWRpc2FibGUnCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgICAtIFJFRElTX1RMU19ESVNBQkxFRD10cnVlCiAgICAgIC0gJ0VMRUNUUklDX09SSUdJTj1odHRwOi8vZWxlY3RyaWM6MzAwMCcKICAgICAgLSAnREVWX09URUxfRVhQT1JURVJfT1RMUF9FTkRQT0lOVD0ke0RFVl9PVEVMX0VYUE9SVEVSX09UTFBfRU5EUE9JTlQ6LX0nCiAgICAgIC0gJ0RFUExPWV9SRUdJU1RSWV9IT1NUPSR7REVQTE9ZX1JFR0lTVFJZX0hPU1Q6LWdoY3IuaW99JwogICAgICAtICdERVBMT1lfUkVHSVNUUllfUFJPVE9DT0w9JHtERVBMT1lfUkVHSVNUUllfUFJPVE9DT0w6LWh0dHBzfScKICAgICAgLSAnREVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRT0ke0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U6LXRyaWdnZXJ9JwogICAgICAtICdWNF9ERVBMT1lfUkVHSVNUUllfTkFNRVNQQUNFPSR7REVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRTotdHJpZ2dlcn0nCiAgICAgIC0gJ09CSkVDVF9TVE9SRV9CQVNFX1VSTD0ke09CSkVDVF9TVE9SRV9CQVNFX1VSTH0nCiAgICAgIC0gJ09CSkVDVF9TVE9SRV9BQ0NFU1NfS0VZX0lEPSR7T0JKRUNUX1NUT1JFX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfU0VDUkVUX0FDQ0VTU19LRVk9JHtPQkpFQ1RfU1RPUkVfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vJHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX06JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9QGNsaWNraG91c2U6ODEyMz9zZWN1cmU9ZmFsc2UnCiAgICAgIC0gQ0xJQ0tIT1VTRV9MT0dfTEVWRUw9aW5mbwogICAgICAtIFJVTl9SRVBMSUNBVElPTl9FTkFCTEVEPTEKICAgICAgLSAnUlVOX1JFUExJQ0FUSU9OX0NMSUNLSE9VU0VfVVJMPWh0dHA6Ly8ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfToke1NFUlZJQ0VfUEFTU1dPUkRfNjRfQ0xJQ0tIT1VTRX1AY2xpY2tob3VzZTo4MTIzJwogICAgICAtIFJVTl9SRVBMSUNBVElPTl9MT0dfTEVWRUw9aW5mbwogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX0VOQUJMRUQ9MQogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX1dPUktFUl9HUk9VUF9OQU1FPWJvb3RzdHJhcAogICAgICAtIFRSSUdHRVJfQk9PVFNUUkFQX1dPUktFUl9UT0tFTl9QQVRIPS9ob21lL25vZGUvc2hhcmVkL3dvcmtlcl90b2tlbgogICAgICAtICdERUZBVUxUX09SR19FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ9JHtERUZBVUxUX09SR19FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ6LTMwMH0nCiAgICAgIC0gJ0RFRkFVTFRfRU5WX0VYRUNVVElPTl9DT05DVVJSRU5DWV9MSU1JVD0ke0RFRkFVTFRfRU5WX0VYRUNVVElPTl9DT05DVVJSRU5DWV9MSU1JVDotMTAwfScKICAgICAgLSAnQVVUSF9HSVRIVUJfQ0xJRU5UX0lEPSR7QVVUSF9HSVRIVUJfQ0xJRU5UX0lEOi19JwogICAgICAtICdBVVRIX0dJVEhVQl9DTElFTlRfU0VDUkVUPSR7QVVUSF9HSVRIVUJfQ0xJRU5UX1NFQ1JFVDotfScKICAgICAgLSAnRU1BSUxfVFJBTlNQT1JUPSR7RU1BSUxfVFJBTlNQT1JUOi1zbXRwfScKICAgICAgLSAnUkVTRU5EX0FQSV9LRVk9JHtSRVNFTkRfQVBJX0tFWX0nCiAgICAgIC0gJ1NNVFBfSE9TVD0ke1NNVFBfSE9TVH0nCiAgICAgIC0gJ1NNVFBfUE9SVD0ke1NNVFBfUE9SVH0nCiAgICAgIC0gJ1NNVFBfVVNFUj0ke1NNVFBfVVNFUn0nCiAgICAgIC0gJ1NNVFBfUEFTU1dPUkQ9JHtTTVRQX1BBU1NXT1JEfScKICAgICAgLSAnU01UUF9TRUNVUkU9JHtTTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ0ZST01fRU1BSUw9JHtGUk9NX0VNQUlMfScKICAgICAgLSAnUkVQTFlfVE9fRU1BSUw9JHtSRVBMWV9UT19FTUFJTH0nCiAgICAgIC0gJ1dISVRFTElTVEVEX0VNQUlMUz0ke1dISVRFTElTVEVEX0VNQUlMU30nCiAgICAgIC0gJ0FETUlOX0VNQUlMUz0ke0FETUlOX0VNQUlMU30nCiAgICAgIC0gR1JBQ0VGVUxfU0hVVERPV05fVElNRU9VVD0xMDAwCiAgICAgIC0gJ0lOVEVSTkFMX09URUxfVFJBQ0VfTE9HR0lOR19FTkFCTEVEPSR7SU5URVJOQUxfT1RFTF9UUkFDRV9MT0dHSU5HX0VOQUJMRUQ6LTB9JwogICAgICAtICdUUklHR0VSX1RFTEVNRVRSWV9ESVNBQkxFRD0ke1RSSUdHRVJfVEVMRU1FVFJZX0RJU0FCTEVEOi10cnVlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NoYXJlZC1kYXRhOi9ob21lL25vZGUvc2hhcmVkJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vMTI3LjAuMC4xOjMwMDAvaGVhbHRoY2hlY2snLChyKT0+cHJvY2Vzcy5leGl0KHIuc3RhdHVzQ29kZT09PTIwMD8wOjEpKS5vbignZXJyb3InLCgpPT5wcm9jZXNzLmV4aXQoMSkpIgogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgZWxlY3RyaWM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIHN1cGVydmlzb3I6CiAgICBpbWFnZTogJ2doY3IuaW8vdHJpZ2dlcmRvdGRldi9zdXBlcnZpc29yOnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC91c3IvYmluL2R1bWItaW5pdCAtLSBwbnBtIHJ1biAtLWZpbHRlciBzdXBlcnZpc29yIHN0YXJ0XCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtICdUUklHR0VSX0FQSV9VUkw9aHR0cDovL3RyaWdnZXI6MzAwMCcKICAgICAgLSAnT1RFTF9FWFBPUlRFUl9PVExQX0VORFBPSU5UPWh0dHA6Ly90cmlnZ2VyOjMwMDAvb3RlbCcKICAgICAgLSAnVFJJR0dFUl9XT1JLRVJfVE9LRU49JHtUUklHR0VSX1dPUktFUl9UT0tFTjotZmlsZTovLy9ob21lL25vZGUvc2hhcmVkL3dvcmtlcl90b2tlbn0nCiAgICAgIC0gJ01BTkFHRURfV09SS0VSX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfV09SS0VSU0VDUkVUfScKICAgICAgLSAnVFJJR0dFUl9XT1JLTE9BRF9BUElfRE9NQUlOPSR7VFJJR0dFUl9XT1JLTE9BRF9BUElfRE9NQUlOfScKICAgICAgLSBUUklHR0VSX1dPUktMT0FEX0FQSV9QT1JUX0VYVEVSTkFMPTgwMjAKICAgICAgLSAnRE9DS0VSX0hPU1Q9dGNwOi8vZG9ja2VyLXByb3h5OjIzNzUnCiAgICAgIC0gJ0RPQ0tFUl9SVU5ORVJfTkVUV09SS1M9JHtET0NLRVJfUlVOTkVSX05FVFdPUktTfScKICAgICAgLSAnRE9DS0VSX0FVVE9SRU1PVkVfRVhJVEVEX0NPTlRBSU5FUlM9JHtET0NLRVJfQVVUT1JFTU9WRV9FWElURURfQ09OVEFJTkVSUzotMX0nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9VUkw9aHR0cHM6Ly8ke0RFUExPWV9SRUdJU1RSWV9IT1NUOi1naGNyLmlvfScKICAgICAgLSAnRE9DS0VSX1JFR0lTVFJZX1VTRVJOQU1FPSR7REVQTE9ZX1JFR0lTVFJZX1VTRVJOQU1FfScKICAgICAgLSAnRE9DS0VSX1JFR0lTVFJZX1BBU1NXT1JEPSR7REVQTE9ZX1JFR0lTVFJZX1BBU1NXT1JEfScKICAgICAgLSBFTkZPUkNFX01BQ0hJTkVfUFJFU0VUUz0xCiAgICAgIC0gVFJJR0dFUl9ERVFVRVVFX0lOVEVSVkFMX01TPTEwMDAKICAgICAgLSAnREVCVUc9JHtTVVBFUlZJU09SX0RFQlVHOi0wfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3NoYXJlZC1kYXRhOi9ob21lL25vZGUvc2hhcmVkJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5vZGUKICAgICAgICAtICctZScKICAgICAgICAtICJyZXF1aXJlKCdodHRwJykuZ2V0KCdodHRwOi8vMTI3LjAuMC4xOjgwMjAvaGVhbHRoJywocik9PnByb2Nlc3MuZXhpdChyLnN0YXR1c0NvZGU9PT0yMDA/MDoxKSkub24oJ2Vycm9yJywoKT0+cHJvY2Vzcy5leGl0KDEpKSIKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICBkZXBlbmRzX29uOgogICAgICB0cmlnZ2VyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGRvY2tlci1wcm94eToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGRvY2tlci1wcm94eToKICAgIGltYWdlOiAndGVjbmF0aXZhL2RvY2tlci1zb2NrZXQtcHJveHk6djAuNC4yJwogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBMT0dfTEVWRUw9aW5mbwogICAgICAtICdQT1NUPSR7UE9TVDotMX0nCiAgICAgIC0gJ0NPTlRBSU5FUlM9JHtDT05UQUlORVJTOi0xfScKICAgICAgLSAnSU1BR0VTPSR7SU1BR0VTOi0xfScKICAgICAgLSAnSU5GTz0ke0lORk86LTF9JwogICAgICAtICdORVRXT1JLUz0ke05FVFdPUktTOi0xfScKICAgICAgLSAnQVVUSD0ke0FVVEg6LTF9JwogICAgICAtICdESVNUUklCVVRJT049JHtESVNUUklCVVRJT046LTF9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiAxMjcuMC4wLjEgMjM3NSB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzcWw6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2LWFscGluZScKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtIHdhbF9sZXZlbD1sb2dpY2FsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdyZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBlbGVjdHJpYzoKICAgIGltYWdlOiAnZWxlY3RyaWNzcWwvZWxlY3RyaWM6MS4yLjknCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzcWw6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlc3FsOjU0MzIvJHtQT1NUR1JFU19EQjotdHJpZ2dlci1kYn0/c3NsbW9kZT1kaXNhYmxlJwogICAgICAtICdFTEVDVFJJQ19TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0VMRUNUUklDfScKICAgICAgLSBFTEVDVFJJQ19VU0FHRV9SRVBPUlRJTkc9ZmFsc2UKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDozMDAwL3YxL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNS44JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9DTElDS0hPVVNFfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gQ0xJQ0tIT1VTRV9ERUZBVUxUX0FDQ0VTU19NQU5BR0VNRU5UPTEKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsaWNraG91c2UtZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdjbGlja2hvdXNlLWxvZ3M6L3Zhci9sb2cvY2xpY2tob3VzZS1zZXJ2ZXInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1uby12ZXJib3NlIC0tdHJpZXM9MSAtLXNwaWRlciBodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "background-jobs", "typescript", @@ -5650,7 +4821,7 @@ "category": "automation", "logo": "svgs/trigger.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-07T19:01:32+01:00", + "template_last_updated_at": "2026-10-19T21:05:49+02:00", "port": "3000" }, "triliumnext": { @@ -5683,7 +4854,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-03T17:29:34+02:00", + "template_last_updated_at": "2026-10-19T21:18:52+02:00", "port": "3000" }, "typesense": { @@ -5926,72 +5097,6 @@ "template_last_updated_at": "2026-08-17T11:10:01+02:00", "port": "3456" }, - "vvveb-with-mariadb": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1ZWVkVCXzgwCiAgICAgIC0gREJfRU5HSU5FPW15c3FsaQogICAgICAtIERCX0hPU1Q9bWFyaWFkYgogICAgICAtICdEQl9VU0VSPSR7U0VSVklDRV9VU0VSX1ZWVkVCfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZWVkVCfScKICAgICAgLSAnREJfTkFNRT0ke01BUklBREJfREFUQUJBU0U6LXZ2dmVifScKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICd2dnZlYi1tYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotdnZ2ZWJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX1ZWVkVCfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1ZWVkVCfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "vvveb-with-mysql": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1ZWVkVCXzgwCiAgICAgIC0gREJfRU5HSU5FPW15c3FsaQogICAgICAtIERCX0hPU1Q9bXlzcWwKICAgICAgLSAnREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9WVlZFQn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WVlZFQn0nCiAgICAgIC0gJ0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotdnZ2ZWJ9JwogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4LjQuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLW15c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LXZ2dmVifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9WVlZFQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WVlZFQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "vvveb": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1ZWVkVCXzgwCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "wakapi": { "documentation": "https://wakapi.dev/?utm_source=coolify.io", "slogan": "A minimalist, self-hosted WakaTime-compatible backend for coding statistics", @@ -6064,7 +5169,7 @@ "category": "productivity", "logo": "svgs/weblate.webp", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-31T17:36:41+02:00", + "template_last_updated_at": "2026-10-19T21:18:38+02:00", "port": "8080" }, "whoogle": { @@ -6096,23 +5201,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, - "windmill": { - "documentation": "https://www.windmill.dev/docs/?utm_source=coolify.io", - "slogan": "Windmill is a developer platform to build production-grade multi-steps automations and internal apps.", - "compose": "c2VydmljZXM6CiAgZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgc2htX3NpemU6IDFnCiAgICB2b2x1bWVzOgogICAgICAtICdkYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotd2luZG1pbGwtZGJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgd2luZG1pbGwtc2VydmVyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dpbmRtaWxsLWxhYnMvd2luZG1pbGw6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX1dJTkRNSUxMXzgwMDAKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AZGIvJHtQT1NUR1JFU19EQjotd2luZG1pbGwtZGJ9JwogICAgICAtIE1PREU9c2VydmVyCiAgICAgIC0gJ0JBU0VfVVJMPSR7U0VSVklDRV9VUkxfV0lORE1JTEx9JwogICAgZGVwZW5kc19vbjoKICAgICAgZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3dvcmtlci1sb2dzOi90bXAvd2luZG1pbGwvbG9ncycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDAwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHdpbmRtaWxsLXdvcmtlci0xOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dpbmRtaWxsLWxhYnMvd2luZG1pbGw6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBkYi8ke1BPU1RHUkVTX0RCOi13aW5kbWlsbC1kYn0nCiAgICAgIC0gTU9ERT13b3JrZXIKICAgICAgLSBXT1JLRVJfR1JPVVA9ZGVmYXVsdAogICAgZGVwZW5kc19vbjoKICAgICAgZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICd3b3JrZXItZGVwZW5kZW5jeS1jYWNoZTovdG1wL3dpbmRtaWxsL2NhY2hlJwogICAgICAtICd3b3JrZXItbG9nczovdG1wL3dpbmRtaWxsL2xvZ3MnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHdpbmRtaWxsLXdvcmtlci0yOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dpbmRtaWxsLWxhYnMvd2luZG1pbGw6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBkYi8ke1BPU1RHUkVTX0RCOi13aW5kbWlsbC1kYn0nCiAgICAgIC0gTU9ERT13b3JrZXIKICAgICAgLSBXT1JLRVJfR1JPVVA9ZGVmYXVsdAogICAgZGVwZW5kc19vbjoKICAgICAgZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICd3b3JrZXItZGVwZW5kZW5jeS1jYWNoZTovdG1wL3dpbmRtaWxsL2NhY2hlJwogICAgICAtICd3b3JrZXItbG9nczovdG1wL3dpbmRtaWxsL2xvZ3MnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHdpbmRtaWxsLXdvcmtlci0zOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dpbmRtaWxsLWxhYnMvd2luZG1pbGw6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBkYi8ke1BPU1RHUkVTX0RCOi13aW5kbWlsbC1kYn0nCiAgICAgIC0gTU9ERT13b3JrZXIKICAgICAgLSBXT1JLRVJfR1JPVVA9ZGVmYXVsdAogICAgZGVwZW5kc19vbjoKICAgICAgZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgICAtICd3b3JrZXItZGVwZW5kZW5jeS1jYWNoZTovdG1wL3dpbmRtaWxsL2NhY2hlJwogICAgICAtICd3b3JrZXItbG9nczovdG1wL3dpbmRtaWxsL2xvZ3MnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHdpbmRtaWxsLXdvcmtlci1uYXRpdmU6CiAgICBpbWFnZTogJ2doY3IuaW8vd2luZG1pbGwtbGFicy93aW5kbWlsbDptYWluJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGRiLyR7UE9TVEdSRVNfREI6LXdpbmRtaWxsLWRifScKICAgICAgLSBNT0RFPXdvcmtlcgogICAgICAtIFdPUktFUl9HUk9VUD1uYXRpdmUKICAgICAgLSBOVU1fV09SS0VSUz04CiAgICAgIC0gU0xFRVBfUVVFVUU9MjAwCiAgICBkZXBlbmRzX29uOgogICAgICBkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnd29ya2VyLWxvZ3M6L3RtcC93aW5kbWlsbC9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICBsc3A6CiAgICBpbWFnZTogJ2doY3IuaW8vd2luZG1pbGwtbGFicy93aW5kbWlsbC1sc3A6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnbHNwLWNhY2hlOi9yb290Ly5jYWNoZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnZXhpdCAwJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMjBzCg==", - "tags": [ - "windmill", - "workflow", - "automation", - "developer", - "platform" - ], - "category": "automation", - "logo": "svgs/windmill.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8000" - }, "wings": { "documentation": "https://pterodactyl.io/?utm_source=coolify.io", "slogan": "The server control plane for Pterodactyl Panel. Written from the ground-up with security, speed, and stability in mind.", @@ -6146,56 +5234,6 @@ "template_last_updated_at": "2026-01-05T15:54:25+01:00", "port": "8000" }, - "wordpress-with-mariadb": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gV09SRFBSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX05BTUU9d29yZHByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT13b3JkcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "mariadb" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "wordpress-with-mysql": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfSE9TVD1teXNxbAogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBteXNxbAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdteXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "mysql" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "wordpress-without-database": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX1VSTF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "yamtrack-with-postgresql": { "documentation": "https://github.com/FuzzyGrim/Yamtrack/wiki?utm_source=coolify.io", "slogan": "Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games and books.", diff --git a/templates/service-templates.json b/templates/service-templates.json index 86dc5e10f6..3016116668 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -14,7 +14,7 @@ "category": "ai", "logo": "svgs/9router.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-04T18:02:06+02:00", + "template_last_updated_at": "2026-10-05T11:08:52+02:00", "port": "20128" }, "activepieces": { @@ -125,7 +125,7 @@ "category": "productivity", "logo": "svgs/appflowy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-16T11:26:29-05:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "apprise-api": { @@ -159,21 +159,6 @@ "template_last_updated_at": "2026-09-02T19:39:19+02:00", "port": "80" }, - "appwrite": { - "documentation": "https://appwrite.io/docs?utm_source=coolify.io", - "slogan": "A backend-as-a-service platform that simplifies the web & mobile app development.", - "compose": "c2VydmljZXM6CiAgYXBwd3JpdGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL2FwcHdyaXRlOjIuNS4wJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FQUFdSSVRFPS92MQogICAgICAtICdfQVBQX0ROU19FWFRFUk5BTD0ke19BUFBfRE5TX0VYVEVSTkFMfScKICAgICAgLSAnX0FQUF9BTExPV0VEX0lOVEVSTkFMX0FERFJFU1NFUz0ke19BUFBfQUxMT1dFRF9JTlRFUk5BTF9BRERSRVNTRVN9JwogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtIF9BUFBfVVNBR0VfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREJfVVNBR0U9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9VU0FHRT0ke19BUFBfUE9PTF9TSVpFX1VTQUdFOi0yfScKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9FWEVDVVRJT05TPWh0dHA6Ly9hcHB3cml0ZTokU0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFQGFwcHdyaXRlLWNsaWNraG91c2U6ODEyMy9hcHB3cml0ZScKICAgICAgLSAnX0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUz0ke19BUFBfUE9PTF9TSVpFX0VYRUNVVElPTlM6LTJ9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9UVEw9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9VU0FHRV9UVEw6LTE4MH0nCiAgICAgIC0gJ19BUFBfU1RBVFNfVVNBR0VfUVVFVUVfTkFNRT0ke19BUFBfU1RBVFNfVVNBR0VfUVVFVUVfTkFNRTotdjEtc3RhdHMtdXNhZ2V9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19SRVNPVVJDRVNfUVVFVUVfTkFNRTotdjEtc3RhdHMtcmVzb3VyY2VzfScKICAgICAgLSAnX0FQUF9TVEFUU19SRVNPVVJDRVNfSU5URVJWQUw9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTDotMzYwMH0nCiAgICAgIC0gJ19BUFBfRURJVElPTj0ke19BUFBfRURJVElPTjotc2VsZi1ob3N0ZWR9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9MT0NBTEU9JHtfQVBQX0xPQ0FMRTotZW59JwogICAgICAtICdfQVBQX0NPTVBSRVNTSU9OX0VOQUJMRUQ9JHtfQVBQX0NPTVBSRVNTSU9OX0VOQUJMRUQ6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX0NPTVBSRVNTSU9OX01JTl9TSVpFX0JZVEVTPSR7X0FQUF9DT01QUkVTU0lPTl9NSU5fU0laRV9CWVRFU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVD0ke19BUFBfQ09OU09MRV9XSElURUxJU1RfUk9PVDotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9XSElURUxJU1RfRU1BSUxTPSR7X0FQUF9DT05TT0xFX1dISVRFTElTVF9FTUFJTFN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFM9JHtfQVBQX0NPTlNPTEVfU0VTU0lPTl9BTEVSVFM6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1dISVRFTElTVF9JUFM9JHtfQVBQX0NPTlNPTEVfV0hJVEVMSVNUX0lQU30nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9IT1NUTkFNRVM9JHtfQVBQX0NPTlNPTEVfSE9TVE5BTUVTfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1NDSEVNQT0ke19BUFBfQ09OU09MRV9TQ0hFTUE6LWFwcHdyaXRlaW99JwogICAgICAtICdfQVBQX0NPTlNPTEVfR0lUSFVCX0FQUF9JRD0ke19BUFBfQ09OU09MRV9HSVRIVUJfQVBQX0lEfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0dJVEhVQl9TRUNSRVQ9JHtfQVBQX0NPTlNPTEVfR0lUSFVCX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9HSVRMQUJfQVBQX0lEPSR7X0FQUF9DT05TT0xFX0dJVExBQl9BUFBfSUR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfR0lUTEFCX1NFQ1JFVD0ke19BUFBfQ09OU09MRV9HSVRMQUJfU0VDUkVUfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0dJVExBQl9FTkRQT0lOVD0ke19BUFBfQ09OU09MRV9HSVRMQUJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfQklUQlVDS0VUX0FQUF9JRD0ke19BUFBfQ09OU09MRV9CSVRCVUNLRVRfQVBQX0lEfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0JJVEJVQ0tFVF9TRUNSRVQ9JHtfQVBQX0NPTlNPTEVfQklUQlVDS0VUX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9HT09HTEVfQVBQX0lEPSR7X0FQUF9DT05TT0xFX0dPT0dMRV9BUFBfSUR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfR09PR0xFX1NFQ1JFVD0ke19BUFBfQ09OU09MRV9HT09HTEVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfTkFNRT0ke19BUFBfU1lTVEVNX0VNQUlMX05BTUU6LUFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9TWVNURU1fRU1BSUxfQUREUkVTUz0ke19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M6LW5vcmVwbHlAYXBwd3JpdGUuaW99JwogICAgICAtICdfQVBQX1NZU1RFTV9URUFNX0VNQUlMPSR7X0FQUF9TWVNURU1fVEVBTV9FTUFJTDotdGVhbUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1zZWN1cml0eUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfU1lTVEVNX1JFU1BPTlNFX0ZPUk1BVD0ke19BUFBfU1lTVEVNX1JFU1BPTlNFX0ZPUk1BVH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRT0ke19BUFBfT1BUSU9OU19BQlVTRTotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRV9JTkNSRUFTRURfTElNSVRfUFJPSkVDVFM9JHtfQVBQX09QVElPTlNfQUJVU0VfSU5DUkVBU0VEX0xJTUlUX1BST0pFQ1RTfScKICAgICAgLSAnX0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9QUk9URUNUSU9OOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX1JPVVRFUl9GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1JPVVRFUl9BVVRPX0NFUlRJRklDQVRFUz0ke19BUFBfUk9VVEVSX0FVVE9fQ0VSVElGSUNBVEVTOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtIF9BUFBfTk9USUZJQ0FUSU9OU19UUkFDS0lOR19TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfTk9USUZJQ0FUSU9OUwogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0RPTUFJTj0ke19BUFBfQ09OU09MRV9ET01BSU59JwogICAgICAtICdfQVBQX0NPTlNPTEVfVVJMPSR7X0FQUF9DT05TT0xFX1VSTH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9UUlVTVEVEX1BST0pFQ1RTPSR7X0FQUF9DT05TT0xFX1RSVVNURURfUFJPSkVDVFN9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU6LWxvY2FsaG9zdH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUE6LTo6MX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0E6LTEyNy4wLjAuMX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSPSR7X0FQUF9EQl9BREFQVEVSOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI9JHtfQVBQX0RCX0FEQVBURVJfVkVDVE9SU0RCOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQj0ke19BUFBfREJfSE9TVF9WRUNUT1JTREI6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlRfVkVDVE9SU0RCPSR7X0FQUF9EQl9QT1JUX1ZFQ1RPUlNEQjotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQj0ke19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQjotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUl9WRUNUT1JTREI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTU19WRUNUT1JTREI9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCOi1tb25nb2RifScKICAgICAgLSAnX0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCOi0yNzAxN30nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfVkVDVE9SU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREJ9JwogICAgICAtICdfQVBQX1NNVFBfSE9TVD0ke19BUFBfU01UUF9IT1NUfScKICAgICAgLSAnX0FQUF9TTVRQX1BPUlQ9JHtfQVBQX1NNVFBfUE9SVH0nCiAgICAgIC0gJ19BUFBfU01UUF9TRUNVUkU9JHtfQVBQX1NNVFBfU0VDVVJFfScKICAgICAgLSAnX0FQUF9TTVRQX1VTRVJOQU1FPSR7X0FQUF9TTVRQX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9TTVRQX1BBU1NXT1JEPSR7X0FQUF9TTVRQX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTUlUPSR7X0FQUF9TVE9SQUdFX0xJTUlUOi0zMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9QUkVWSUVXX0xJTUlUPSR7X0FQUF9TVE9SQUdFX1BSRVZJRVdfTElNSVQ6LTIwMDAwMDAwfScKICAgICAgLSAnX0FQUF9BVVRPR1JBVklUWV9IT1NUPSR7X0FQUF9BVVRPR1JBVklUWV9IT1NUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVUz0ke19BUFBfU1RPUkFHRV9BTlRJVklSVVM6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVU19IT1NUPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVU19IT1NUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0FOVElWSVJVU19QT1JUPSR7X0FQUF9TVE9SQUdFX0FOVElWSVJVU19QT1JUOi0zMzEwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9TSVpFX0xJTUlUPSR7X0FQUF9DT01QVVRFX1NJWkVfTElNSVQ6LTMwMDAwMDAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfU0laRV9MSU1JVDotMjAwMDAwMDAwMH0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ9JHtfQVBQX0ZVTkNUSU9OU19USU1FT1VUOi05MDB9JwogICAgICAtICdfQVBQX1NJVEVTX1RJTUVPVVQ9JHtfQVBQX1NJVEVTX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9CVUlMRF9USU1FT1VUPSR7X0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ6LTI3MDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQ1BVUz0ke19BUFBfQ09NUFVURV9DUFVTOi0wfScKICAgICAgLSAnX0FQUF9DT01QVVRFX01FTU9SWT0ke19BUFBfQ09NUFVURV9NRU1PUlk6LTB9JwogICAgICAtICdfQVBQX0ZVTkNUSU9OU19SVU5USU1FUz0ke19BUFBfRlVOQ1RJT05TX1JVTlRJTUVTOi1ub2RlLTIyfScKICAgICAgLSAnX0FQUF9TSVRFU19SVU5USU1FUz0ke19BUFBfU0lURVNfUlVOVElNRVM6LXN0YXRpYy0xLG5vZGUtMjJ9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1zaXRlcy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSBfQVBQX0VYRUNVVE9SX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9FWEVDVVRPUgogICAgICAtICdfQVBQX0VYRUNVVE9SX0hPU1Q9JHtfQVBQX0VYRUNVVE9SX0hPU1Q6LWh0dHA6Ly9vcGVucnVudGltZXMtZXhlY3V0b3IvdjF9JwogICAgICAtICdfQVBQX0JVSUxEU19WT0xVTUU9JHtDT01QT1NFX1BST0pFQ1RfTkFNRX1fYXBwd3JpdGUtYnVpbGRzJwogICAgICAtICdfQVBQX0pPQlNfSE9TVD0ke19BUFBfSk9CU19IT1NUOi1odHRwOi8vb3JjaGVzdHJhdG9yOjgwODB9JwogICAgICAtIF9BUFBfSk9CU19TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSk9CUwogICAgICAtICdfQVBQX0pPQlNfRU5EUE9JTlQ9JHtfQVBQX0pPQlNfRU5EUE9JTlQ6LWh0dHA6Ly9hcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19DT05GSUc9JHtfQVBQX0xPR0dJTkdfQ09ORklHfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0ZPUk1BVD0ke19BUFBfTE9HR0lOR19GT1JNQVQ6LXByZXR0eX0nCiAgICAgIC0gJ19BUFBfTE9DS0lOR19FTkFCTEVEPSR7X0FQUF9MT0NLSU5HX0VOQUJMRUQ6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9NQUlOVEVOQU5DRV9JTlRFUlZBTDotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT049JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9FWEVDVVRJT046LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9DQUNIRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFOi0yNTkyMDAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQUJVU0U9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRTotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUOi0xMjA5NjAwfScKICAgICAgLSAnX0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUX0NPTlNPTEV9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM9JHtfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9TQ0hFRFVMRVM6LTg2NDAwfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9HUkFQSFFMX0lOVFJPU1BFQ1RJT049JHtfQVBQX0dSQVBIUUxfSU5UUk9TUEVDVElPTjotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfQkFUQ0hfU0laRT0ke19BUFBfR1JBUEhRTF9NQVhfQkFUQ0hfU0laRTotMTB9JwogICAgICAtICdfQVBQX0dSQVBIUUxfTUFYX0NPTVBMRVhJVFk9JHtfQVBQX0dSQVBIUUxfTUFYX0NPTVBMRVhJVFk6LTI1MH0nCiAgICAgIC0gJ19BUFBfR1JBUEhRTF9NQVhfREVQVEg9JHtfQVBQX0dSQVBIUUxfTUFYX0RFUFRIOi0zfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZPSR7X0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9JRD0ke19BUFBfVkNTX0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRIVUJfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke19BUFBfVkNTX0dJVEhVQl9DTElFTlRfU0VDUkVUfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0NMSUVOVF9JRD0ke19BUFBfVkNTX0dJVEhVQl9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9WQ1NfR0lURUFfQlJPV1NFUl9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0JST1dTRVJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUQ9JHtfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX1dFQkhPT0tfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlQ9JHtfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0JJVEJVQ0tFVF9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19PUklHSU5fQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfT1JJR0lOX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWT0ke19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ19BUFBfVkNTX1dFQkhPT0tfVVJMPSR7X0FQUF9WQ1NfV0VCSE9PS19VUkx9JwogICAgICAtICdfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfU0VDUkVUPSR7X0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfQ09VTlRSSUVTX0RFTllMSVNUPSR7X0FQUF9DT05TT0xFX0NPVU5UUklFU19ERU5ZTElTVH0nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFUz0ke19BUFBfREFUQUJBU0VfU0hBUkVEX1RBQkxFU30nCiAgICAgIC0gJ19BUFBfREFUQUJBU0VfU0hBUkVEX05BTUVTUEFDRT0ke19BUFBfREFUQUJBU0VfU0hBUkVEX05BTUVTUEFDRX0nCiAgICAgIC0gJ19BUFBfRlVOQ1RJT05TX0NSRUFUSU9OX0FCVVNFX0xJTUlUPSR7X0FQUF9GVU5DVElPTlNfQ1JFQVRJT05fQUJVU0VfTElNSVR9JwogICAgICAtICdfQVBQX0NVU1RPTV9ET01BSU5fREVOWV9MSVNUPSR7X0FQUF9DVVNUT01fRE9NQUlOX0RFTllfTElTVDotZXhhbXBsZS5jb20sdGVzdC5jb20sYXBwLmV4YW1wbGUuY29tfScKICAgICAgLSAnX0FQUF9UUlVTVEVEX0hFQURFUlM9JHtfQVBQX1RSVVNURURfSEVBREVSUzoteC1mb3J3YXJkZWQtZm9yfScKICAgICAgLSAnX0FQUF9UUlVTVEVEX1BST1hJRVM9JHtfQVBQX1RSVVNURURfUFJPWElFUzotMTI3LjAuMC4xLDo6MSwxMC4wLjAuMC84LDEwMC42NC4wLjAvMTAsMTcyLjE2LjAuMC8xMiwxOTIuMTY4LjAuMC8xNn0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OX0hPU1Q9JHtfQVBQX01JR1JBVElPTl9IT1NUOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9HRU9fU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0dFTwogICAgICAtICdfQVBQX0dFT19FTkRQT0lOVD0ke19BUFBfR0VPX0VORFBPSU5UOi1odHRwOi8vYXBwd3JpdGUtZ2VvL3YxfScKICAgICAgLSAnX0FQUF9QV05FRF9QQVNTV09SRFNfRFNOPSR7X0FQUF9QV05FRF9QQVNTV09SRFNfRFNOOi1ub25lOi8vbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9MSU1JVF9EQVRBQkFTRV9CQVRDSD0ke19BUFBfTElNSVRfREFUQUJBU0VfQkFUQ0g6LTEwMH0nCiAgICAgIC0gJ19BUFBfRE9DVU1FTlRTREI9JHtfQVBQX0RPQ1VNRU5UU0RCOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfVkVDVE9SU0RCPSR7X0FQUF9WRUNUT1JTREI6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9FTUJFRERJTkc9JHtfQVBQX0VNQkVERElORzotZGlzYWJsZWR9JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtdXBsb2Fkczovc3RvcmFnZS91cGxvYWRzOnJ3JwogICAgICAtICdhcHB3cml0ZS1pbXBvcnRzOi9zdG9yYWdlL2ltcG9ydHM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWNhY2hlOi9zdG9yYWdlL2NhY2hlOnJ3JwogICAgICAtICdhcHB3cml0ZS1jb25maWc6L3N0b3JhZ2UvY29uZmlnOnJ3JwogICAgICAtICdhcHB3cml0ZS1jZXJ0aWZpY2F0ZXM6L3N0b3JhZ2UvY2VydGlmaWNhdGVzOnJ3JwogICAgICAtICdhcHB3cml0ZS1mdW5jdGlvbnM6L3N0b3JhZ2UvZnVuY3Rpb25zOnJ3JwogICAgICAtICdhcHB3cml0ZS1zaXRlczovc3RvcmFnZS9zaXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgIGRlcGVuZHNfb246CiAgICAgIGFwcHdyaXRlLXBvc3RncmVzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgYXBwd3JpdGUtcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgYXBwd3JpdGUtY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1nZW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIC1vIC9kZXYvbnVsbCBodHRwOi8vbG9jYWxob3N0L3YxL2hlYWx0aC92ZXJzaW9uIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA2CiAgICAgIHN0YXJ0X3BlcmlvZDogMTIwcwogIGFwcHdyaXRlLWNvbnNvbGU6CiAgICBpbWFnZTogJ2FwcHdyaXRlL25ldzoxLjIuNzQtc2VsZi1ob3N0ZWQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQVBQV1JJVEVfMzAwMD0vCiAgICAgIC0gVklURV9DT05TT0xFX1BST0ZJTEU9c2VsZi1ob3N0ZWQKICAgICAgLSBWSVRFX0FQUFdSSVRFX1ZFUlNJT049Mi41LjAKICAgICAgLSBBUFBXUklURV9FTkRQT0lOVF9TQU1FX09SSUdJTj10cnVlCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdidW4gLWUgImZldGNoKCcnaHR0cDovL2xvY2FsaG9zdDozMDAwJycpLnRoZW4ociA9PiBwcm9jZXNzLmV4aXQoci5zdGF0dXMgPCA1MDAgPyAwIDogMSkpLmNhdGNoKCgpID0+IHByb2Nlc3MuZXhpdCgxKSkiJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS1yZWFsdGltZToKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6Mi41LjAnCiAgICBlbnRyeXBvaW50OiByZWFsdGltZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0FQUFdSSVRFPS92MS9yZWFsdGltZQogICAgICAtICdfQVBQX0VOVj0ke19BUFBfRU5WOi1wcm9kdWN0aW9ufScKICAgICAgLSAnX0FQUF9VU0FHRV9TVEFUUz0ke19BUFBfVVNBR0VfU1RBVFM6LWVuYWJsZWR9JwogICAgICAtICdfQVBQX1NUQVRTX1VTQUdFX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1VTQUdFX1FVRVVFX05BTUU6LXYxLXN0YXRzLXVzYWdlfScKICAgICAgLSAnX0FQUF9XT1JLRVJfUEVSX0NPUkU9JHtfQVBQX1dPUktFUl9QRVJfQ09SRTotNn0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19BQlVTRT0ke19BUFBfT1BUSU9OU19BQlVTRTotZW5hYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTj0ke19BUFBfT1BUSU9OU19ST1VURVJfUFJPVEVDVElPTjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0NPTlNPTEVfSE9TVE5BTUVTPSR7X0FQUF9DT05TT0xFX0hPU1ROQU1FU30nCiAgICAgIC0gJ19BUFBfUkVBTFRJTUVfVEFJTF9SQVRFPSR7X0FQUF9SRUFMVElNRV9UQUlMX1JBVEU6LTUwfScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfVFJVU1RFRF9IRUFERVJTPSR7X0FQUF9UUlVTVEVEX0hFQURFUlM6LXgtZm9yd2FyZGVkLWZvcn0nCiAgICAgIC0gJ19BUFBfVFJVU1RFRF9QUk9YSUVTPSR7X0FQUF9UUlVTVEVEX1BST1hJRVM6LTEyNy4wLjAuMSw6OjEsMTAuMC4wLjAvOCwxMDAuNjQuMC4wLzEwLDE3Mi4xNi4wLjAvMTIsMTkyLjE2OC4wLjAvMTZ9JwogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSPSR7X0FQUF9EQl9BREFQVEVSOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI9JHtfQVBQX0RCX0FEQVBURVJfVkVDVE9SU0RCOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQj0ke19BUFBfREJfSE9TVF9WRUNUT1JTREI6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlRfVkVDVE9SU0RCPSR7X0FQUF9EQl9QT1JUX1ZFQ1RPUlNEQjotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQj0ke19BUFBfREJfU0NIRU1BX1ZFQ1RPUlNEQjotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUl9WRUNUT1JTREI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTU19WRUNUT1JTREI9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9BREFQVEVSX0RPQ1VNRU5UU0RCOi1tb25nb2RifScKICAgICAgLSAnX0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9IT1NUX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9QT1JUX0RPQ1VNRU5UU0RCOi0yNzAxN30nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BX0RPQ1VNRU5UU0RCPSR7X0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1VTRVJfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BBU1NfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfVkVDVE9SU0RCPSR7X0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREJ9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0xJTUlUX0RBVEFCQVNFX0JBVENIPSR7X0FQUF9MSU1JVF9EQVRBQkFTRV9CQVRDSDotMTAwfScKICAgICAgLSAnX0FQUF9ET0NVTUVOVFNEQj0ke19BUFBfRE9DVU1FTlRTREI6LWRpc2FibGVkfScKICAgICAgLSAnX0FQUF9WRUNUT1JTREI9JHtfQVBQX1ZFQ1RPUlNEQjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0VNQkVERElORz0ke19BUFBfRU1CRURESU5HOi1kaXNhYmxlZH0nCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgbG9jYWxob3N0ID4gL2Rldi9udWxsIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgYXBwd3JpdGUtd29ya2VyOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IHdvcmtlcgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX0ROU19FWFRFUk5BTD0ke19BUFBfRE5TX0VYVEVSTkFMfScKICAgICAgLSAnX0FQUF9BTExPV0VEX0lOVEVSTkFMX0FERFJFU1NFUz0ke19BUFBfQUxMT1dFRF9JTlRFUk5BTF9BRERSRVNTRVN9JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gX0FQUF9VU0FHRV9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0UKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9VU0FHRT1odHRwOi8vYXBwd3JpdGU6JFNFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRUBhcHB3cml0ZS1jbGlja2hvdXNlOjgxMjMvYXBwd3JpdGUnCiAgICAgIC0gJ19BUFBfUE9PTF9TSVpFX1VTQUdFPSR7X0FQUF9QT09MX1NJWkVfVVNBR0U6LTJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RCX0VYRUNVVElPTlM9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9FWEVDVVRJT05TPSR7X0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUzotMn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTDotMTgwfScKICAgICAgLSAnX0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FOi12MS1zdGF0cy11c2FnZX0nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FOi12MS1zdGF0cy1yZXNvdXJjZXN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMOi0zNjAwfScKICAgICAgLSBfQVBQX1dPUktFUlNfTlVNPTEKICAgICAgLSBfQVBQX1dPUktFUl9NQVhfQ09ST1VUSU5FUz03OAogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfRU1BSUxfU0VDVVJJVFk9JHtfQVBQX0VNQUlMX1NFQ1VSSVRZOi1zZWN1cml0eUBhcHB3cml0ZS5pb30nCiAgICAgIC0gJ19BUFBfRU1BSUxfQ0VSVElGSUNBVEVTPSR7X0FQUF9FTUFJTF9DRVJUSUZJQ0FURVN9JwogICAgICAtICdfQVBQX0RCX0FEQVBURVI9JHtfQVBQX0RCX0FEQVBURVI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1Q9JHtfQVBQX0RCX0hPU1Q6LWFwcHdyaXRlLXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX1BPUlQ9JHtfQVBQX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdfQVBQX0RCX1NDSEVNQT0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgICAgLSBfQVBQX0RCX1BBU1M9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSX1ZFQ1RPUlNEQj0ke19BUFBfREJfQURBUFRFUl9WRUNUT1JTREI6LXBvc3RncmVzcWx9JwogICAgICAtICdfQVBQX0RCX0hPU1RfVkVDVE9SU0RCPSR7X0FQUF9EQl9IT1NUX1ZFQ1RPUlNEQjotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVF9WRUNUT1JTREI9JHtfQVBQX0RCX1BPUlRfVkVDVE9SU0RCOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCPSR7X0FQUF9EQl9TQ0hFTUFfVkVDVE9SU0RCOi1hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9EQl9VU0VSX1ZFQ1RPUlNEQj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTX1ZFQ1RPUlNEQj0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0FEQVBURVJfRE9DVU1FTlRTREI6LW1vbmdvZGJ9JwogICAgICAtICdfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREI9JHtfQVBQX0RCX0hPU1RfRE9DVU1FTlRTREJ9JwogICAgICAtICdfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1BPUlRfRE9DVU1FTlRTREI6LTI3MDE3fScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUFfRE9DVU1FTlRTREI9JHtfQVBQX0RCX1NDSEVNQV9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQj0ke19BUFBfREJfVVNFUl9ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfREJfUEFTU19ET0NVTUVOVFNEQj0ke19BUFBfREJfUEFTU19ET0NVTUVOVFNEQn0nCiAgICAgIC0gJ19BUFBfQ09OTkVDVElPTlNfREFUQUJBU0VfRE9DVU1FTlRTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX0RPQ1VNRU5UU0RCfScKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQVRBQkFTRV9WRUNUT1JTREI9JHtfQVBQX0NPTk5FQ1RJT05TX0RBVEFCQVNFX1ZFQ1RPUlNEQn0nCiAgICAgIC0gJ19BUFBfUkVESVNfSE9TVD0ke19BUFBfUkVESVNfSE9TVDotYXBwd3JpdGUtcmVkaXN9JwogICAgICAtICdfQVBQX1JFRElTX1BPUlQ9JHtfQVBQX1JFRElTX1BPUlQ6LTYzNzl9JwogICAgICAtICdfQVBQX1JFRElTX1VTRVI9JHtfQVBQX1JFRElTX1VTRVJ9JwogICAgICAtICdfQVBQX1JFRElTX1BBU1M9JHtfQVBQX1JFRElTX1BBU1N9JwogICAgICAtICdfQVBQX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1FVRVVFX05BTUU9JHtfQVBQX1FVRVVFX05BTUV9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0NPTlNPTEVfVVJMPSR7X0FQUF9DT05TT0xFX1VSTH0nCiAgICAgIC0gJ19BUFBfQ09OU09MRV9ET01BSU49JHtfQVBQX0NPTlNPTEVfRE9NQUlOfScKICAgICAgLSAnX0FQUF9DT05TT0xFX1RSVVNURURfUFJPSkVDVFM9JHtfQVBQX0NPTlNPTEVfVFJVU1RFRF9QUk9KRUNUU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOPSR7X0FQUF9ET01BSU46LSRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ05BTUU6LWxvY2FsaG9zdH0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BQUFBPSR7X0FQUF9ET01BSU5fVEFSR0VUX0FBQUE6LTo6MX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9BPSR7X0FQUF9ET01BSU5fVEFSR0VUX0E6LTEyNy4wLjAuMX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQ0FBfScKICAgICAgLSAnX0FQUF9ETlM9JHtfQVBQX0ROU30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1zaXRlcy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTPSR7X0FQUF9PUFRJT05TX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfT1BUSU9OU19ST1VURVJfRk9SQ0VfSFRUUFM9JHtfQVBQX09QVElPTlNfUk9VVEVSX0ZPUkNFX0hUVFBTOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfV0VCSE9PS19NQVhfRkFJTEVEX0FUVEVNUFRTPSR7X0FQUF9XRUJIT09LX01BWF9GQUlMRURfQVRURU1QVFM6LTEwfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RFVklDRT0ke19BUFBfU1RPUkFHRV9ERVZJQ0U6LWxvY2FsfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfQUNDRVNTX0tFWX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX1JFR0lPTj0ke19BUFBfU1RPUkFHRV9TM19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9TM19CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfUzNfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0RPX1NQQUNFU19TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfRE9fU1BBQ0VTX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9ET19TUEFDRVNfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0RPX1NQQUNFU19CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0JBQ0tCTEFaRV9SRUdJT049JHtfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX1JFR0lPTjotdXMtd2VzdC0wMDR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfQkFDS0JMQVpFX0JVQ0tFVD0ke19BUFBfU1RPUkFHRV9CQUNLQkxBWkVfQlVDS0VUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9BQ0NFU1NfS0VZfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX0xJTk9ERV9TRUNSRVQ9JHtfQVBQX1NUT1JBR0VfTElOT0RFX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9SRUdJT046LWV1LWNlbnRyYWwtMX0nCiAgICAgIC0gJ19BUFBfU1RPUkFHRV9MSU5PREVfQlVDS0VUPSR7X0FQUF9TVE9SQUdFX0xJTk9ERV9CVUNLRVR9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0FDQ0VTU19LRVl9JwogICAgICAtICdfQVBQX1NUT1JBR0VfV0FTQUJJX1NFQ1JFVD0ke19BUFBfU1RPUkFHRV9XQVNBQklfU0VDUkVUfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9SRUdJT049JHtfQVBQX1NUT1JBR0VfV0FTQUJJX1JFR0lPTjotZXUtY2VudHJhbC0xfScKICAgICAgLSAnX0FQUF9TVE9SQUdFX1dBU0FCSV9CVUNLRVQ9JHtfQVBQX1NUT1JBR0VfV0FTQUJJX0JVQ0tFVH0nCiAgICAgIC0gX0FQUF9FWEVDVVRPUl9TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfRVhFQ1VUT1IKICAgICAgLSAnX0FQUF9FWEVDVVRPUl9IT1NUPSR7X0FQUF9FWEVDVVRPUl9IT1NUOi1odHRwOi8vb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yL3YxfScKICAgICAgLSAnX0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnX0FQUF9ET0NLRVJfSFVCX1BBU1NXT1JEPSR7X0FQUF9ET0NLRVJfSFVCX1BBU1NXT1JEfScKICAgICAgLSAnX0FQUF9GVU5DVElPTlNfVElNRU9VVD0ke19BUFBfRlVOQ1RJT05TX1RJTUVPVVQ6LTkwMH0nCiAgICAgIC0gJ19BUFBfU0lURVNfVElNRU9VVD0ke19BUFBfU0lURVNfVElNRU9VVDotOTAwfScKICAgICAgLSAnX0FQUF9DT01QVVRFX0JVSUxEX1RJTUVPVVQ9JHtfQVBQX0NPTVBVVEVfQlVJTERfVElNRU9VVDotMjcwMH0nCiAgICAgIC0gJ19BUFBfQ09NUFVURV9DUFVTPSR7X0FQUF9DT01QVVRFX0NQVVM6LTB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfTUVNT1JZPSR7X0FQUF9DT01QVVRFX01FTU9SWTotMH0nCiAgICAgIC0gJ19BUFBfT1BFTl9SVU5USU1FU19ORlQ9JHtfQVBQX09QRU5fUlVOVElNRVNfTkZUOi1lbmFibGVkfScKICAgICAgLSAnX0FQUF9DT01QVVRFX1NJWkVfTElNSVQ9JHtfQVBQX0NPTVBVVEVfU0laRV9MSU1JVDotMzAwMDAwMDB9JwogICAgICAtICdfQVBQX0NPTVBVVEVfQlVJTERfU0laRV9MSU1JVD0ke19BUFBfQ09NUFVURV9CVUlMRF9TSVpFX0xJTUlUOi0yMDAwMDAwMDAwfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FPSR7X0FQUF9WQ1NfR0lUSFVCX0FQUF9OQU1FfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZPSR7X0FQUF9WQ1NfR0lUSFVCX1BSSVZBVEVfS0VZfScKICAgICAgLSAnX0FQUF9WQ1NfR0lUSFVCX0FQUF9JRD0ke19BUFBfVkNTX0dJVEhVQl9BUFBfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0VORFBPSU5UfScKICAgICAgLSAnX0FQUF9WQ1NfR0lURUFfQlJPV1NFUl9FTkRQT0lOVD0ke19BUFBfVkNTX0dJVEVBX0JST1dTRVJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUQ9JHtfQVBQX1ZDU19HSVRFQV9DTElFTlRfSUR9JwogICAgICAtICdfQVBQX1ZDU19HSVRFQV9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVEVBX1dFQkhPT0tfU0VDUkVUPSR7X0FQUF9WQ1NfR0lURUFfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlQ9JHtfQVBQX1ZDU19HSVRMQUJfRU5EUE9JTlR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0dJVExBQl9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfR0lUTEFCX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19HSVRMQUJfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX0JJVEJVQ0tFVF9DTElFTlRfU0VDUkVUPSR7X0FQUF9WQ1NfQklUQlVDS0VUX0NMSUVOVF9TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVQ9JHtfQVBQX1ZDU19CSVRCVUNLRVRfV0VCSE9PS19TRUNSRVR9JwogICAgICAtICdfQVBQX1ZDU19PUklHSU5fQ0xJRU5UX0lEPSR7X0FQUF9WQ1NfT1JJR0lOX0NMSUVOVF9JRH0nCiAgICAgIC0gJ19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWT0ke19BUFBfVkNTX09SSUdJTl9QUklWQVRFX0tFWX0nCiAgICAgIC0gJ19BUFBfVkNTX1dFQkhPT0tfVVJMPSR7X0FQUF9WQ1NfV0VCSE9PS19VUkx9JwogICAgICAtICdfQVBQX0JVSUxEU19WT0xVTUU9JHtDT01QT1NFX1BST0pFQ1RfTkFNRX1fYXBwd3JpdGUtYnVpbGRzJwogICAgICAtICdfQVBQX0pPQlNfSE9TVD0ke19BUFBfSk9CU19IT1NUOi1odHRwOi8vb3JjaGVzdHJhdG9yOjgwODB9JwogICAgICAtIF9BUFBfSk9CU19TRUNSRVQ9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfSk9CUwogICAgICAtICdfQVBQX0pPQlNfRU5EUE9JTlQ9JHtfQVBQX0pPQlNfRU5EUE9JTlQ6LWh0dHA6Ly9hcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfQlJPV1NFUl9IT1NUPSR7X0FQUF9CUk9XU0VSX0hPU1Q6LWh0dHA6Ly9hcHB3cml0ZS1icm93c2VyOjMwMDAvdjF9JwogICAgICAtICdfQVBQX1dPUktFUl9TQ1JFRU5TSE9UU19ST1VURVI9JHtfQVBQX1dPUktFUl9TQ1JFRU5TSE9UU19ST1VURVI6LWh0dHA6Ly9hcHB3cml0ZX0nCiAgICAgIC0gX0FQUF9OT1RJRklDQVRJT05TX1RSQUNLSU5HX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9OT1RJRklDQVRJT05TCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX05BTUU9JHtfQVBQX1NZU1RFTV9FTUFJTF9OQU1FOi1BcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfU1lTVEVNX0VNQUlMX0FERFJFU1M9JHtfQVBQX1NZU1RFTV9FTUFJTF9BRERSRVNTOi1ub3JlcGx5QGFwcHdyaXRlLmlvfScKICAgICAgLSAnX0FQUF9TTVRQX0hPU1Q9JHtfQVBQX1NNVFBfSE9TVH0nCiAgICAgIC0gJ19BUFBfU01UUF9QT1JUPSR7X0FQUF9TTVRQX1BPUlR9JwogICAgICAtICdfQVBQX1NNVFBfU0VDVVJFPSR7X0FQUF9TTVRQX1NFQ1VSRX0nCiAgICAgIC0gJ19BUFBfU01UUF9VU0VSTkFNRT0ke19BUFBfU01UUF9VU0VSTkFNRX0nCiAgICAgIC0gJ19BUFBfU01UUF9QQVNTV09SRD0ke19BUFBfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ19BUFBfU01TX0ZST009JHtfQVBQX1NNU19GUk9NfScKICAgICAgLSAnX0FQUF9TTVNfUFJPVklERVI9JHtfQVBQX1NNU19QUk9WSURFUn0nCiAgICAgIC0gJ19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVD0ke19BUFBfU01TX1BST0pFQ1RTX0RFTllfTElTVH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OU19GSVJFQkFTRV9DTElFTlRfSUQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX0lEfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05TX0ZJUkVCQVNFX0NMSUVOVF9TRUNSRVQ9JHtfQVBQX01JR1JBVElPTlNfRklSRUJBU0VfQ0xJRU5UX1NFQ1JFVH0nCiAgICAgIC0gJ19BUFBfTUlHUkFUSU9OX0hPU1Q9JHtfQVBQX01JR1JBVElPTl9IT1NUOi1hcHB3cml0ZX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTElNSVRfREFUQUJBU0VfQkFUQ0g9JHtfQVBQX0xJTUlUX0RBVEFCQVNFX0JBVENIOi0xMDB9JwogICAgICAtICdfQVBQX0RPQ1VNRU5UU0RCPSR7X0FQUF9ET0NVTUVOVFNEQjotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX1ZFQ1RPUlNEQj0ke19BUFBfVkVDVE9SU0RCOi1kaXNhYmxlZH0nCiAgICAgIC0gJ19BUFBfRU1CRURESU5HPSR7X0FQUF9FTUJFRERJTkc6LWRpc2FibGVkfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXVwbG9hZHM6L3N0b3JhZ2UvdXBsb2FkczpydycKICAgICAgLSAnYXBwd3JpdGUtaW1wb3J0czovc3RvcmFnZS9pbXBvcnRzOnJ3JwogICAgICAtICdhcHB3cml0ZS1jYWNoZTovc3RvcmFnZS9jYWNoZTpydycKICAgICAgLSAnYXBwd3JpdGUtY29uZmlnOi9zdG9yYWdlL2NvbmZpZzpydycKICAgICAgLSAnYXBwd3JpdGUtY2VydGlmaWNhdGVzOi9zdG9yYWdlL2NlcnRpZmljYXRlczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJ2FwcHdyaXRlLWJ1aWxkczovc3RvcmFnZS9idWlsZHM6cncnCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9yY2hlc3RyYXRvcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLXNjaGVkdWxlcjoKICAgIGltYWdlOiAnYXBwd3JpdGUvYXBwd3JpdGU6Mi41LjAnCiAgICBlbnRyeXBvaW50OiBzY2hlZHVsZQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ19BUFBfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdfQVBQX1VTQUdFX1NUQVRTPSR7X0FQUF9VU0FHRV9TVEFUUzotZW5hYmxlZH0nCiAgICAgIC0gX0FQUF9VU0FHRV9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0UKICAgICAgLSAnX0FQUF9DT05ORUNUSU9OU19EQl9VU0FHRT1odHRwOi8vYXBwd3JpdGU6JFNFUlZJQ0VfUEFTU1dPUkRfQ0xJQ0tIT1VTRUBhcHB3cml0ZS1jbGlja2hvdXNlOjgxMjMvYXBwd3JpdGUnCiAgICAgIC0gJ19BUFBfUE9PTF9TSVpFX1VTQUdFPSR7X0FQUF9QT09MX1NJWkVfVVNBR0U6LTJ9JwogICAgICAtICdfQVBQX0NPTk5FQ1RJT05TX0RCX0VYRUNVVElPTlM9aHR0cDovL2FwcHdyaXRlOiRTRVJWSUNFX1BBU1NXT1JEX0NMSUNLSE9VU0VAYXBwd3JpdGUtY2xpY2tob3VzZTo4MTIzL2FwcHdyaXRlJwogICAgICAtICdfQVBQX1BPT0xfU0laRV9FWEVDVVRJT05TPSR7X0FQUF9QT09MX1NJWkVfRVhFQ1VUSU9OUzotMn0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTD0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1VTQUdFX1RUTDotMTgwfScKICAgICAgLSAnX0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FPSR7X0FQUF9TVEFUU19VU0FHRV9RVUVVRV9OQU1FOi12MS1zdGF0cy11c2FnZX0nCiAgICAgIC0gJ19BUFBfU1RBVFNfUkVTT1VSQ0VTX1FVRVVFX05BTUU9JHtfQVBQX1NUQVRTX1JFU09VUkNFU19RVUVVRV9OQU1FOi12MS1zdGF0cy1yZXNvdXJjZXN9JwogICAgICAtICdfQVBQX1NUQVRTX1JFU09VUkNFU19JTlRFUlZBTD0ke19BUFBfU1RBVFNfUkVTT1VSQ0VTX0lOVEVSVkFMOi0zNjAwfScKICAgICAgLSAnX0FQUF9MT0dHSU5HX0ZPUk1BVD0ke19BUFBfTE9HR0lOR19GT1JNQVQ6LXByZXR0eX0nCiAgICAgIC0gX0FQUF9XT1JLRVJfTUFYX0NPUk9VVElORVM9OAogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSBfQVBQX09QRU5TU0xfS0VZX1YxPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0FQUFdSSVRFCiAgICAgIC0gJ19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUz0ke19BUFBfT1BUSU9OU19GT1JDRV9IVFRQUzotZGlzYWJsZWR9JwogICAgICAtICdfQVBQX0RPTUFJTj0ke19BUFBfRE9NQUlOOi0kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9DT05TT0xFX0RPTUFJTj0ke19BUFBfQ09OU09MRV9ET01BSU59JwogICAgICAtICdfQVBQX0NPTlNPTEVfU0NIRU1BPSR7X0FQUF9DT05TT0xFX1NDSEVNQTotYXBwd3JpdGVpb30nCiAgICAgIC0gJ19BUFBfRE9NQUlOX0ZVTkNUSU9OUz0ke19BUFBfRE9NQUlOX0ZVTkNUSU9OUzotZnVuY3Rpb25zLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtICdfQVBQX0RPTUFJTl9TSVRFUz0ke19BUFBfRE9NQUlOX1NJVEVTOi1zaXRlcy4kU0VSVklDRV9GUUROX0FQUFdSSVRFfScKICAgICAgLSAnX0FQUF9NSUdSQVRJT05fSE9TVD0ke19BUFBfTUlHUkFUSU9OX0hPU1Q6LWFwcHdyaXRlfScKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUj0ke19BUFBfREJfQURBUFRFUjotcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0ZVTkNUSU9OU19TQ0hFRFVMRV9TUFJFQUQ9JHtfQVBQX0ZVTkNUSU9OU19TQ0hFRFVMRV9TUFJFQUQ6LTB9JwogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGUtcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1yZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLW1haW50ZW5hbmNlOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IG1haW50ZW5hbmNlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9ET01BSU5fRlVOQ1RJT05TPSR7X0FQUF9ET01BSU5fRlVOQ1RJT05TOi1mdW5jdGlvbnMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRURJVElPTj0ke19BUFBfRURJVElPTjotc2VsZi1ob3N0ZWR9JwogICAgICAtICdfQVBQX1JPVVRFUl9BVVRPX0NFUlRJRklDQVRFUz0ke19BUFBfUk9VVEVSX0FVVE9fQ0VSVElGSUNBVEVTOi1kaXNhYmxlZH0nCiAgICAgIC0gX0FQUF9PUEVOU1NMX0tFWV9WMT0kU0VSVklDRV9QQVNTV09SRF82NF9BUFBXUklURQogICAgICAtICdfQVBQX1JFRElTX0hPU1Q9JHtfQVBQX1JFRElTX0hPU1Q6LWFwcHdyaXRlLXJlZGlzfScKICAgICAgLSAnX0FQUF9SRURJU19QT1JUPSR7X0FQUF9SRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnX0FQUF9SRURJU19VU0VSPSR7X0FQUF9SRURJU19VU0VSfScKICAgICAgLSAnX0FQUF9SRURJU19QQVNTPSR7X0FQUF9SRURJU19QQVNTfScKICAgICAgLSAnX0FQUF9EQl9BREFQVEVSPSR7X0FQUF9EQl9BREFQVEVSOi1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9IT1NUPSR7X0FQUF9EQl9IT1NUOi1hcHB3cml0ZS1wb3N0Z3Jlc3FsfScKICAgICAgLSAnX0FQUF9EQl9QT1JUPSR7X0FQUF9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnX0FQUF9EQl9TQ0hFTUE9JHtfQVBQX0RCX1NDSEVNQTotYXBwd3JpdGV9JwogICAgICAtIF9BUFBfREJfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gX0FQUF9EQl9QQVNTPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfSU5URVJWQUw9JHtfQVBQX01BSU5URU5BTkNFX0lOVEVSVkFMOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTj0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0VYRUNVVElPTjotMTIwOTYwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0NBQ0hFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQ0FDSEU6LTI1OTIwMDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BQlVTRT0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FCVVNFOi04NjQwMH0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX0FVRElUPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVQ6LTEyMDk2MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1JFVEVOVElPTl9BVURJVF9DT05TT0xFPSR7X0FQUF9NQUlOVEVOQU5DRV9SRVRFTlRJT05fQVVESVRfQ09OU09MRX0nCiAgICAgIC0gJ19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUz0ke19BUFBfTUFJTlRFTkFOQ0VfUkVURU5USU9OX1NDSEVEVUxFUzotODY0MDB9JwogICAgICAtICdfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUU9JHtfQVBQX01BSU5URU5BTkNFX1NUQVJUX1RJTUV9JwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgZGVwZW5kc19vbjoKICAgICAgYXBwd3JpdGUtcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBhcHB3cml0ZS1yZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwcyBhdXggfCBncmVwIC1xICdbcF1ocCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICBhcHB3cml0ZS10YXNrLWludGVydmFsOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9hcHB3cml0ZToyLjUuMCcKICAgIGVudHJ5cG9pbnQ6IGludGVydmFsCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnX0FQUF9FTlY9JHtfQVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ19BUFBfTE9HR0lOR19GT1JNQVQ9JHtfQVBQX0xPR0dJTkdfRk9STUFUOi1wcmV0dHl9JwogICAgICAtICdfQVBQX1dPUktFUl9QRVJfQ09SRT0ke19BUFBfV09SS0VSX1BFUl9DT1JFOi02fScKICAgICAgLSAnX0FQUF9ET01BSU49JHtfQVBQX0RPTUFJTjotJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DTkFNRTotbG9jYWxob3N0fScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0FBQUE9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQUFBQTotOjoxfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0E9JHtfQVBQX0RPTUFJTl9UQVJHRVRfQTotMTI3LjAuMC4xfScKICAgICAgLSAnX0FQUF9ET01BSU5fVEFSR0VUX0NBQT0ke19BUFBfRE9NQUlOX1RBUkdFVF9DQUF9JwogICAgICAtICdfQVBQX0ROUz0ke19BUFBfRE5TfScKICAgICAgLSAnX0FQUF9ET01BSU5fRlVOQ1RJT05TPSR7X0FQUF9ET01BSU5fRlVOQ1RJT05TOi1mdW5jdGlvbnMuJFNFUlZJQ0VfRlFETl9BUFBXUklURX0nCiAgICAgIC0gJ19BUFBfRE9NQUlOX1NJVEVTPSR7X0FQUF9ET01BSU5fU0lURVM6LXNpdGVzLiRTRVJWSUNFX0ZRRE5fQVBQV1JJVEV9JwogICAgICAtIF9BUFBfT1BFTlNTTF9LRVlfVjE9JFNFUlZJQ0VfUEFTU1dPUkRfNjRfQVBQV1JJVEUKICAgICAgLSAnX0FQUF9SRURJU19IT1NUPSR7X0FQUF9SRURJU19IT1NUOi1hcHB3cml0ZS1yZWRpc30nCiAgICAgIC0gJ19BUFBfUkVESVNfUE9SVD0ke19BUFBfUkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ19BUFBfUkVESVNfVVNFUj0ke19BUFBfUkVESVNfVVNFUn0nCiAgICAgIC0gJ19BUFBfUkVESVNfUEFTUz0ke19BUFBfUkVESVNfUEFTU30nCiAgICAgIC0gJ19BUFBfREJfQURBUFRFUj0ke19BUFBfREJfQURBUFRFUjotcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfSE9TVD0ke19BUFBfREJfSE9TVDotYXBwd3JpdGUtcG9zdGdyZXNxbH0nCiAgICAgIC0gJ19BUFBfREJfUE9SVD0ke19BUFBfREJfUE9SVDotNTQzMn0nCiAgICAgIC0gJ19BUFBfREJfU0NIRU1BPSR7X0FQUF9EQl9TQ0hFTUE6LWFwcHdyaXRlfScKICAgICAgLSBfQVBQX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9QT1NUR1JFUwogICAgICAtIF9BUFBfREJfUEFTUz0kU0VSVklDRV9QQVNTV09SRF9QT1NUR1JFUwogICAgICAtICdfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVM9JHtfQVBQX0RBVEFCQVNFX1NIQVJFRF9UQUJMRVN9JwogICAgICAtICdfQVBQX0lOVEVSVkFMX0RPTUFJTl9WRVJJRklDQVRJT049JHtfQVBQX0lOVEVSVkFMX0RPTUFJTl9WRVJJRklDQVRJT046LTEyMH0nCiAgICAgIC0gJ19BUFBfSU5URVJWQUxfQ0xFQU5VUF9TVEFMRV9FWEVDVVRJT05TPSR7X0FQUF9JTlRFUlZBTF9DTEVBTlVQX1NUQUxFX0VYRUNVVElPTlM6LTMwMH0nCiAgICBkZXBlbmRzX29uOgogICAgICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGFwcHdyaXRlLXJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gInBzIGF1eCB8IGdyZXAgLXEgJ1twXWhwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIGFwcHdyaXRlLWdlbzoKICAgIGltYWdlOiAnYXBwd3JpdGUvZ2VvOjAuMy4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gR0VPX1NFQ1JFVD0kU0VSVklDRV9QQVNTV09SRF82NF9HRU8KICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAicGhwIC1yIFwiZXhpdChqc29uX2RlY29kZShmaWxlX2dldF9jb250ZW50cygnaHR0cDovL2xvY2FsaG9zdC92MS9oZWFsdGgnKSktPnN0YXR1cyA9PT0gJ29rJyA/IDAgOiAxKTtcIiIKICAgICAgaW50ZXJ2YWw6IDIwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgYXBwd3JpdGUtYnJvd3NlcjoKICAgIGltYWdlOiAnYXBwd3JpdGUvYnJvd3NlcjowLjMuNicKICAgIGVudmlyb25tZW50OgogICAgICAtIEFMTE9XRURfSE9TVFM9YXBwd3JpdGUKICAgIGRuczoKICAgICAgLSAxLjEuMS4xCiAgICAgIC0gOC44LjguOAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yOgogICAgaW1hZ2U6ICdvcGVucnVudGltZXMvZXhlY3V0b3I6MS4xLjInCiAgICBob3N0bmFtZTogb3BlbnJ1bnRpbWVzLWV4ZWN1dG9yCiAgICBzdG9wX3NpZ25hbDogU0lHSU5UCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnT1BSX0VYRUNVVE9SX0lNQUdFUz0ke19BUFBfRVhFQ1VUT1JfSU1BR0VTOi1vcGVucnVudGltZXMvbm9kZTp2NS0yMixvcGVucnVudGltZXMvc3RhdGljOnY1LTF9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfSU5BQ1RJVkVfVEhSRVNIT0xEPSR7X0FQUF9DT01QVVRFX0lOQUNUSVZFX1RIUkVTSE9MRDotNjAwfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX01BSU5URU5BTkNFX0lOVEVSVkFMPSR7X0FQUF9DT01QVVRFX01BSU5URU5BTkNFX0lOVEVSVkFMOi02MDB9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfTkVUV09SSz0ke19BUFBfQ09NUFVURV9SVU5USU1FU19ORVRXT1JLOi1ydW50aW1lc30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9ET0NLRVJfSFVCX1VTRVJOQU1FPSR7X0FQUF9ET0NLRVJfSFVCX1VTRVJOQU1FfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0RPQ0tFUl9IVUJfUEFTU1dPUkQ9JHtfQVBQX0RPQ0tFUl9IVUJfUEFTU1dPUkR9JwogICAgICAtICdPUFJfRVhFQ1VUT1JfRU5WPSR7X0FQUF9FTlY6LXByb2R1Y3Rpb259JwogICAgICAtICdPUFJfRVhFQ1VUT1JfUlVOVElNRVM9JHtfQVBQX0ZVTkNUSU9OU19SVU5USU1FUzotbm9kZS0yMn0sJHtfQVBQX1NJVEVTX1JVTlRJTUVTOi1zdGF0aWMtMSxub2RlLTIyfScKICAgICAgLSBPUFJfRVhFQ1VUT1JfU0VDUkVUPSRTRVJWSUNFX1BBU1NXT1JEXzY0X0VYRUNVVE9SCiAgICAgIC0gT1BSX0VYRUNVVE9SX1JVTlRJTUVfVkVSU0lPTlM9djUKICAgICAgLSAnT1BFTl9SVU5USU1FU19ORlQ9JHtfQVBQX09QRU5fUlVOVElNRVNfTkZUOi1lbmFibGVkfScKICAgICAgLSAnT1BSX0VYRUNVVE9SX0xPR0dJTkdfQ09ORklHPSR7X0FQUF9MT0dHSU5HX0NPTkZJR30nCiAgICAgIC0gJ09QUl9FWEVDVVRPUl9DT05ORUNUSU9OX1NUT1JBR0U9JHtfQVBQX0VYRUNVVE9SX0NPTk5FQ1RJT05fU1RPUkFHRTotbG9jYWw6Ly9sb2NhbGhvc3R9JwogICAgICAtIE9QUl9FWEVDVVRPUl9DT05ORUNUSU9OX0JVSUxEX0NBQ0hFX1NUT1JBR0U9CiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnYXBwd3JpdGUtYnVpbGRzOi9zdG9yYWdlL2J1aWxkczpydycKICAgICAgLSAnYXBwd3JpdGUtZnVuY3Rpb25zOi9zdG9yYWdlL2Z1bmN0aW9uczpydycKICAgICAgLSAnYXBwd3JpdGUtc2l0ZXM6L3N0b3JhZ2Uvc2l0ZXM6cncnCiAgICAgIC0gJy90bXA6L3RtcDpydycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZnNTIC1IICJBdXRob3JpemF0aW9uOiBCZWFyZXIgJCRPUFJfRVhFQ1VUT1JfU0VDUkVUIiBodHRwOi8vbG9jYWxob3N0L3YxL2hlYWx0aCA+IC9kZXYvbnVsbCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIG9yY2hlc3RyYXRvcjoKICAgIGltYWdlOiAnZ2hjci5pby9vcGVuLXJ1bnRpbWVzL29yY2hlc3RyYXRvci9vcmNoZXN0cmF0b3I6Mi4yLjInCiAgICBob3N0bmFtZTogb3JjaGVzdHJhdG9yCiAgICB1c2VyOiByb290CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBPUkNIRVNUUkFUT1JfQkFDS0VORD1kb2NrZXIKICAgICAgLSAnT1JDSEVTVFJBVE9SX05FVFdPUks9JHtDT01QT1NFX1BST0pFQ1RfTkFNRX0nCiAgICAgIC0gJ0RPQ0tFUl9ORVRXT1JLPSR7Q09NUE9TRV9QUk9KRUNUX05BTUV9JwogICAgICAtICdBUlRJRkFDVF9FTkRQT0lOVD1odHRwOi8vb3JjaGVzdHJhdG9yOjgwODAnCiAgICAgIC0gJ1MzX0VORFBPSU5UPSR7X0FQUF9TVE9SQUdFX1MzX0VORFBPSU5UfScKICAgICAgLSAnUzNfUkVHSU9OPSR7X0FQUF9TVE9SQUdFX1MzX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnUzNfQUNDRVNTX0tFWV9JRD0ke19BUFBfU1RPUkFHRV9TM19BQ0NFU1NfS0VZfScKICAgICAgLSAnUzNfU0VDUkVUX0FDQ0VTU19LRVk9JHtfQVBQX1NUT1JBR0VfUzNfU0VDUkVUfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIC9rby1hcHAvb3JjaGVzdHJhdG9yCiAgICAgICAgLSAnLWNoZWNrLXJlYWR5JwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBhcHB3cml0ZS1wb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdhcHB3cml0ZS9wb3N0Z3JlczowLjEuMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19EQj0ke19BUFBfREJfU0NIRU1BOi1hcHB3cml0ZX0nCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwcHdyaXRlLXBvc3RncmVzcWw6L3Zhci9saWIvcG9zdGdyZXNxbDpydycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICBhcHB3cml0ZS1yZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny40LjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLW1heG1lbW9yeSA1MTJtYiAtLW1heG1lbW9yeS1wb2xpY3kgYWxsa2V5cy1scnUgLS1tYXhtZW1vcnktc2FtcGxlcyA1JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtcmVkaXM6L2RhdGE6cncnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICBhcHB3cml0ZS1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI2LjQuMy1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBDTElDS0hPVVNFX0RCPWFwcHdyaXRlCiAgICAgIC0gQ0xJQ0tIT1VTRV9VU0VSPWFwcHdyaXRlCiAgICAgIC0gQ0xJQ0tIT1VTRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFCiAgICAgIC0gQ0xJQ0tIT1VTRV9ERUZBVUxUX0FDQ0VTU19NQU5BR0VNRU5UPTEKICAgICAgLSBDTElDS0hPVVNFX0FMV0FZU19SVU5fSU5JVERCX1NDUklQVFM9MQogICAgdm9sdW1lczoKICAgICAgLSAnYXBwd3JpdGUtY2xpY2tob3VzZTovdmFyL2xpYi9jbGlja2hvdXNlOnJ3JwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLWNvbmZpZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvYXBwd3JpdGUueG1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogIjw/eG1sIHZlcnNpb249XCIxLjBcIj8+XG48IS0tXG4gICAgUmV0ZW50aW9uIGZvciBDbGlja0hvdXNlJ3Mgb3duIHN5c3RlbSBsb2cgdGFibGVzLlxuXG4gICAgRXZlcnkgPHR0bD4gaW4gdGhlIGltYWdlJ3MgY29uZmlnLnhtbCBzaXRzIGluc2lkZSBhbiBYTUwgY29tbWVudCwgc28gdGhlc2VcbiAgICB0YWJsZXMga2VlcCBldmVyeSByb3cgZm9yZXZlciwgYW5kIHRoZSBtZXRyaWMgb25lcyBhZGQgYSByb3cgcGVyIHNlY29uZFxuICAgIHdoZXRoZXIgb3Igbm90IHRoZSBpbnN0YW5jZSBzZXJ2ZXMgdHJhZmZpYy4gTGVmdCBhbG9uZSB0aGV5IG91dGdyb3cgdGhlXG4gICAgdXNhZ2UgZGF0YSB0aGV5IHNpdCBuZXh0IHRvIGFuZCBmaWxsIHRoZSB2b2x1bWUuXG5cbiAgICBXaGVuIGEgd2luZG93IGhlcmUgY2hhbmdlcywgQ2xpY2tIb3VzZSByZXBsYWNlcyB0aGUgdGFibGUgb24gdGhlIG5leHRcbiAgICByZXN0YXJ0IGFuZCBrZWVwcyB0aGUgb2xkIG9uZSwgcm93cyBhbmQgYWxsLCBhcyA8bG9nPl88Tj4uXG4gICAgY2xpY2tob3VzZS1pbml0LnNoIGdpdmVzIHRob3NlIG9sZCB0YWJsZXMgdGhlIHNhbWUgd2luZG93LlxuLS0+XG48Y2xpY2tob3VzZT5cbiAgICA8IS0tIFRoZSBtZXRyaWMsIHRyYWNlIGFuZCBwcm9maWxlIGxvZ3Mgc2FtcGxlIHRoZSBzZXJ2ZXIgZXZlcnkgc2Vjb25kIHdoZXRoZXJcbiAgICAgICAgIG9yIG5vdCBBcHB3cml0ZSBzZXJ2ZXMgdHJhZmZpYywgYW5kIG5vdGhpbmcgcmVhZHMgdGhlbSBiYWNrLCBzbyB0aGVpclxuICAgICAgICAgd3JpdGVzIGFuZCB0aGUgbWVyZ2VzIGJlaGluZCB0aGVtIGFyZSBwdXJlIG92ZXJoZWFkLiAtLT5cbiAgICA8YXN5bmNocm9ub3VzX21ldHJpY19sb2cgcmVtb3ZlPVwiMVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCIxXCIvPlxuICAgIDxoaXN0b2dyYW1fbWV0cmljX2xvZyByZW1vdmU9XCIxXCIvPlxuICAgIDxxdWVyeV9tZXRyaWNfbG9nIHJlbW92ZT1cIjFcIi8+XG4gICAgPHByb2Nlc3NvcnNfcHJvZmlsZV9sb2cgcmVtb3ZlPVwiMVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cIjFcIi8+XG4gICAgPCEtLSB0ZXh0X2xvZyByZXBlYXRzIHRoZSBzZXJ2ZXIgbG9nIHRoZSBjb250YWluZXIgYWxyZWFkeSB3cml0ZXMuIC0tPlxuICAgIDx0ZXh0X2xvZyByZW1vdmU9XCIxXCIvPlxuXG4gICAgPCEtLSBLZXB0IGZvciBkZWJ1Z2dpbmcsIGNhcHBlZCBzbyB0aGV5IGNhbm5vdCBmaWxsIHRoZSB2b2x1bWUuIC0tPlxuICAgIDxhc3luY2hyb25vdXNfaW5zZXJ0X2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAzIERBWSBERUxFVEU8L3R0bD48L2FzeW5jaHJvbm91c19pbnNlcnRfbG9nPlxuICAgIDxxdWVyeV9sb2c+PHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgNyBEQVkgREVMRVRFPC90dGw+PC9xdWVyeV9sb2c+XG4gICAgPHF1ZXJ5X3ZpZXdzX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEU8L3R0bD48L3F1ZXJ5X3ZpZXdzX2xvZz5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEU8L3R0bD48L3F1ZXJ5X3RocmVhZF9sb2c+XG4gICAgPHBhcnRfbG9nPjx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDcgREFZIERFTEVURTwvdHRsPjwvcGFydF9sb2c+XG4gICAgPGJhY2tncm91bmRfc2NoZWR1bGVfcG9vbF9sb2c+PHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgNyBEQVkgREVMRVRFPC90dGw+PC9iYWNrZ3JvdW5kX3NjaGVkdWxlX3Bvb2xfbG9nPlxuXG4gICAgPCEtLSBMb25nZXIsIHNvIGl0IHN0aWxsIGNvdmVycyBhIHJlcG9ydCB0aGF0IHJlYWNoZXMgdXMgbGF0ZS4gY3Jhc2hfbG9nIGlzXG4gICAgICAgICBkZWxpYmVyYXRlbHkgbGVmdCBhbG9uZTogaXQgaXMgdGlueSBhbmQgd29ydGgga2VlcGluZy4gLS0+XG4gICAgPGVycm9yX2xvZz48dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxNCBEQVkgREVMRVRFPC90dGw+PC9lcnJvcl9sb2c+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2UtaW5pdC5zaAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2NsaWNraG91c2UtaW5pdC5zaAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vYmFzaFxuIyBUaGUgQ2xpY2tIb3VzZSBlbnRyeXBvaW50IHNvdXJjZXMgdGhpcyBmaWxlLCBzbyBhIHN1YnNoZWxsIGtlZXBzIHRoZXNlIG9wdGlvbnMgb3V0IG9mIGl0LlxuKFxuICBzZXQgLWV1byBwaXBlZmFpbFxuXG4gIGNsaWVudCgpIHtcbiAgICBjbGlja2hvdXNlLWNsaWVudCAtLWhvc3QgMTI3LjAuMC4xIC0tdXNlciBcIiRDTElDS0hPVVNFX1VTRVJcIiAtLXBhc3N3b3JkIFwiJENMSUNLSE9VU0VfUEFTU1dPUkRcIiBcIiRAXCJcbiAgfVxuXG4gICMgRmx1c2hpbmcgcmVwbGFjZXMgbG9nIHRhYmxlcyB3aG9zZSBkZWZpbml0aW9uIGNoYW5nZWQsIGtlZXBpbmcgZWFjaCBvbGQgdGFibGUgYXMgPGxvZz5fPE4+LlxuICBjbGllbnQgLS1xdWVyeSAnU1lTVEVNIEZMVVNIIExPR1MnXG5cbiAgIyBUYWJsZXMgbGVmdCBiZWhpbmQgbmV2ZXIgZXhwaXJlIG9uIHRoZWlyIG93bjogdGFrZSB0aGUgVFRMIG9mIHRoZSBsb2cgdGhhdFxuICAjIHJlcGxhY2VkIHRoaXMgb25lLCBvciBhIHdlZWsgd2hlbiB0aGUgbG9nIGlzIG5vIGxvbmdlciB3cml0dGVuIGF0IGFsbC5cbiAgIyBUaGUgbmFtZWQgbG9ncyBtdXN0IG1hdGNoIHRoZSBvbmVzIGNsaWNraG91c2UtY29uZmlnLnhtbCByZW1vdmVzOiBhIGxvZyB0aGF0XG4gICMgaXMgc3RpbGwgd3JpdHRlbiBnZXRzIHJlcGxhY2VkIG9uIHRoZSBuZXh0IHJlc3RhcnQgaWYgaXRzIFRUTCBpcyBhbHRlcmVkIGhlcmUuXG4gIGNsaWVudCAtLWZvcm1hdCBUU1ZSYXcgPDwnU1FMJyB8IGNsaWVudCAtLW11bHRpcXVlcnlcblNFTEVDVCBjb25jYXQoJ0FMVEVSIFRBQkxFIHN5c3RlbS4nLCBzdGFsZS5uYW1lLCAnIE1PRElGWSBUVEwgJywgaWYoYWN0aXZlLm5hbWUgPSAnJywgJ2V2ZW50X2RhdGUgKyBJTlRFUlZBTCA3IERBWSBERUxFVEUnLCBzcGxpdEJ5U3RyaW5nKCcgU0VUVElOR1MgJywgc3BsaXRCeVN0cmluZygnIFRUTCAnLCBhY3RpdmUuZW5naW5lX2Z1bGwpWzJdKVsxXSksICc7JylcbkZST00gKFxuICBTRUxFQ1QgbmFtZSwgc3BsaXRCeUNoYXIoJ18nLCBuYW1lKVstMV0gQVMgc3VmZml4LCBhcnJheVN0cmluZ0NvbmNhdChhcnJheVBvcEJhY2soc3BsaXRCeUNoYXIoJ18nLCBuYW1lKSksICdfJykgQVMgbG9nXG4gIEZST00gc3lzdGVtLnRhYmxlc1xuICBXSEVSRSBkYXRhYmFzZSA9ICdzeXN0ZW0nXG4gICAgQU5EIHBvc2l0aW9uKGVuZ2luZV9mdWxsLCAnIFRUTCAnKSA9IDBcbiAgICBBTkQgZW5kc1dpdGgoZW5naW5lLCAnTWVyZ2VUcmVlJylcbiAgICBBTkQgbmFtZSBJTiAoU0VMRUNUIHRhYmxlIEZST00gc3lzdGVtLmNvbHVtbnMgV0hFUkUgZGF0YWJhc2UgPSAnc3lzdGVtJyBBTkQgbmFtZSA9ICdldmVudF9kYXRlJyBBTkQgdHlwZSA9ICdEYXRlJylcbikgQVMgc3RhbGVcbkxFRlQgSk9JTiBzeXN0ZW0udGFibGVzIEFTIGFjdGl2ZVxuICBPTiBhY3RpdmUuZGF0YWJhc2UgPSAnc3lzdGVtJ1xuICBBTkQgYWN0aXZlLm5hbWUgPSBzdGFsZS5sb2dcbiAgQU5EIHBvc2l0aW9uKGFjdGl2ZS5lbmdpbmVfZnVsbCwgJyBUVEwgJykgPiAwXG5XSEVSRSAoZW5kc1dpdGgoc3RhbGUubG9nLCAnX2xvZycpIEFORCB0b1VJbnQ2NE9yTnVsbChzdGFsZS5zdWZmaXgpIElTIE5PVCBOVUxMKVxuICBPUiBzdGFsZS5uYW1lIElOICgnYXN5bmNocm9ub3VzX21ldHJpY19sb2cnLCAnbWV0cmljX2xvZycsICdoaXN0b2dyYW1fbWV0cmljX2xvZycsICdxdWVyeV9tZXRyaWNfbG9nJywgJ3Byb2Nlc3NvcnNfcHJvZmlsZV9sb2cnLCAndHJhY2VfbG9nJywgJ3RleHRfbG9nJylcblNRTFxuKSIKICAgIHVsaW1pdHM6CiAgICAgIG5vZmlsZToKICAgICAgICBzb2Z0OiAyNjIxNDQKICAgICAgICBoYXJkOiAyNjIxNDQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY2xpY2tob3VzZS1jbGllbnQgLS11c2VyICIkJENMSUNLSE9VU0VfVVNFUiIgLS1wYXNzd29yZCAiJCRDTElDS0hPVVNFX1BBU1NXT1JEIiAtLXF1ZXJ5ICJTRUxFQ1QgMSIgPiAvZGV2L251bGwgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDIwCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCg==", - "tags": [ - "backend", - "backend-as-a-service", - "platform" - ], - "category": "backend", - "logo": "svgs/appwrite.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-08T19:41:04+02:00", - "port": "80" - }, "argilla": { "documentation": "https://docs.argilla.io/latest/?utm_source=coolify.io", "slogan": "Argilla is a collaboration tool for AI engineers and domain experts who need to build high-quality datasets for their projects.", @@ -254,23 +239,6 @@ "template_last_updated_at": "2026-09-27T23:49:51+02:00", "port": "80" }, - "babybuddy": { - "documentation": "https://docs.baby-buddy.net?utm_source=coolify.io", - "slogan": "It helps parents track their baby's daily activities, growth, and health with ease.", - "compose": "c2VydmljZXM6CiAgYmFieWJ1ZGR5OgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL2JhYnlidWRkeTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQkFCWUJVRERZCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgICAtIENTUkZfVFJVU1RFRF9PUklHSU5TPSRTRVJWSUNFX0ZRRE5fQkFCWUJVRERZCiAgICB2b2x1bWVzOgogICAgICAtICdiYWJ5YnVkZHktY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAwMCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "baby", - "parents", - "health", - "growth", - "activities" - ], - "category": "productivity", - "logo": "svgs/babybuddy.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "8000" - }, "bento-pdf": { "documentation": "https://bentopdf.com/docs?utm_source=coolify.io", "slogan": "Process PDFs entirely in your browser. No uploads. No servers. Complete privacy.", @@ -350,26 +318,6 @@ "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, - "bookstack": { - "documentation": "https://www.bookstackapp.com/docs/?utm_source=coolify.io", - "slogan": "BookStack is a simple, self-hosted, easy-to-use platform for organising and storing information", - "compose": "c2VydmljZXM6CiAgYm9va3N0YWNrOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL2Jvb2tzdGFjazpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fQk9PS1NUQUNLXzgwCiAgICAgIC0gJ0FQUF9VUkw9JHtTRVJWSUNFX0ZRRE5fQk9PS1NUQUNLfScKICAgICAgLSAnQVBQX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfQVBQS0VZfScKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gJ0RCX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnREJfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotYm9va3N0YWNrYXBwfScKICAgICAgLSAnUVVFVUVfQ09OTkVDVElPTj0ke1FVRVVFX0NPTk5FQ1RJT059JwogICAgICAtICdHSVRIVUJfQVBQX0lEPSR7R0lUSFVCX0FQUF9JRH0nCiAgICAgIC0gJ0dJVEhVQl9BUFBfU0VDUkVUPSR7R0lUSFVCX0FQUF9TRUNSRVR9JwogICAgICAtICdNQUlMX0RSSVZFUj0ke01BSUxfRFJJVkVSOi1zbXRwfScKICAgICAgLSAnTUFJTF9IT1NUPSR7TUFJTF9IT1NUfScKICAgICAgLSAnTUFJTF9QT1JUPSR7TUFJTF9QT1JUOi01ODd9JwogICAgICAtICdNQUlMX0VOQ1JZUFRJT049JHtNQUlMX0VOQ1JZUFRJT046LXRsc30nCiAgICAgIC0gJ01BSUxfVVNFUk5BTUU9JHtNQUlMX1VTRVJOQU1FfScKICAgICAgLSAnTUFJTF9QQVNTV09SRD0ke01BSUxfUEFTU1dPUkR9JwogICAgICAtICdNQUlMX0ZST009JHtNQUlMX0ZST019JwogICAgICAtICdNQUlMX0ZST01fTkFNRT0ke01BSUxfRlJPTV9OQU1FOi1Cb29rU3RhY2t9JwogICAgdm9sdW1lczoKICAgICAgLSAnYm9va3N0YWNrLWRhdGE6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vMTI3LjAuMC4xOjgwLycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIG1hcmlhZGI6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvbWFyaWFkYjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSAnVFo9JHtUWjotRXVyb3BlL0Jlcmxpbn0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWJvb2tzdGFja30nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgdm9sdW1lczoKICAgICAgLSAnYm9va3N0YWNrLW1hcmlhZGItZGF0YTovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "free-and-open-source", - "mfa", - "dark-light-themes", - "searchable", - "connected", - "simple-interface", - "diagramms", - "notes" - ], - "category": "cms", - "logo": "svgs/bookstack.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "browserless": { "documentation": "https://docs.browserless.io/?utm_source=coolify.io", "slogan": "A headless Chrome browser as a service.", @@ -386,21 +334,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "budge": { - "documentation": "https://github.com/linuxserver/budge?utm_source=coolify.io", - "slogan": "A budgeting personal finance app.", - "compose": "c2VydmljZXM6CiAgYnVkZ2U6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvYnVkZ2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0JVREdFCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnYnVkZ2UtY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", - "tags": [ - "personal finance", - "budgeting", - "expense tracking" - ], - "category": "finance", - "logo": "svgs/budge.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", "slogan": "Low code platform for building business apps and workflows in minutes. Supports PostgreSQL, MySQL, MSSQL, MongoDB, Rest API, Docker, K8s, and more.", @@ -421,7 +354,7 @@ "category": "automation", "logo": "svgs/budibase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-17T10:54:40+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "10000" }, "bugsink": { @@ -454,7 +387,7 @@ "category": "Mail", "logo": "svgs/bulwark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T17:21:51+02:00", + "template_last_updated_at": "2026-10-01T20:31:00+02:00", "port": "3000" }, "buzz": { @@ -474,7 +407,7 @@ "category": "messaging", "logo": "svgs/buzz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T19:27:01+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "calibre-web-automated-book-downloader": { @@ -555,26 +488,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5679" }, - "castopod": { - "documentation": "https://docs.castopod.org/main/en/?utm_source=coolify.io", - "slogan": "Castopod is a free & open-source hosting platform made for podcasters who want engage and interact with their audience.", - "compose": "c2VydmljZXM6CiAgY2FzdG9wb2Q6CiAgICBpbWFnZTogJ2Nhc3RvcG9kL2Nhc3RvcG9kOjEuMTUuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLW1lZGlhOi92YXIvd3d3L2Nhc3RvcG9kL3B1YmxpYy9tZWRpYScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DQVNUT1BPRF84MDgwCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtICdDUF9ESVNBQkxFX0hUVFBTPSR7Q1BfRElTQUJMRV9IVFRQUzotMX0nCiAgICAgIC0gQ1BfQkFTRVVSTD0kU0VSVklDRV9GUUROX0NBU1RPUE9ECiAgICAgIC0gQ1BfQU5BTFlUSUNTX1NBTFQ9JFNFUlZJQ0VfUkVBTEJBU0U2NF82NF9TQUxUCiAgICAgIC0gQ1BfQ0FDSEVfSEFORExFUj1yZWRpcwogICAgICAtIENQX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBDUF9SRURJU19QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9SRURJUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwODAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4yJwogICAgdm9sdW1lczoKICAgICAgLSAnY2FzdG9wb2QtZGI6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01ZU1FMCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9Y2FzdG9wb2QKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuMi1hbHBpbmUnCiAgICBjb21tYW5kOiAnLS1yZXF1aXJlcGFzcyAkU0VSVklDRV9QQVNTV09SRF9SRURJUycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Nhc3RvcG9kLWNhY2hlOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgLWEgJFNFUlZJQ0VfUEFTU1dPUkRfUkVESVMgcGluZyB8IGdyZXAgUE9ORycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "podcast", - "media", - "audio", - "video", - "streaming", - "hosting", - "platform", - "castopod" - ], - "category": "media", - "logo": "svgs/castopod.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-03-13T12:05:06+05:30", - "port": "8080" - }, "celld": { "documentation": "https://celld.dev/docs?utm_source=coolify.io", "slogan": "Self-hosted, distributed Durable Objects powered by Deno.", @@ -705,54 +618,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8000" }, - "classicpress-with-mariadb": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NMQVNTSUNQUkVTUwogICAgICAtIENMQVNTSUNQUkVTU19EQl9IT1NUPW1hcmlhZGIKICAgICAgLSBDTEFTU0lDUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX0NMQVNTSUNQUkVTUwogICAgICAtIENMQVNTSUNQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTEFTU0lDUFJFU1MKICAgICAgLSBDTEFTU0lDUFJFU1NfREJfTkFNRT1jbGFzc2ljcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbWFyaWFkYgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPWNsYXNzaWNwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9DTEFTU0lDUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9DTEFTU0lDUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "classicpress-with-mysql": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NMQVNTSUNQUkVTUwogICAgICAtIENMQVNTSUNQUkVTU19EQl9IT1NUPW15c3FsCiAgICAgIC0gQ0xBU1NJQ1BSRVNTX0RCX1VTRVI9JFNFUlZJQ0VfVVNFUl9DTEFTU0lDUFJFU1MKICAgICAgLSBDTEFTU0lDUFJFU1NfREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfQ0xBU1NJQ1BSRVNTCiAgICAgIC0gQ0xBU1NJQ1BSRVNTX0RCX05BTUU9Y2xhc3NpY3ByZXNzCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ215c3FsLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1jbGFzc2ljcHJlc3MKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfQ0xBU1NJQ1BSRVNTCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfQ0xBU1NJQ1BSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "classicpress-without-database": { - "documentation": "https://www.classicpress.net/?utm_source=coolify.io", - "slogan": "A lightweight, stable, instantly familiar free open-source content management system, based on WordPress without the block editor (Gutenberg).", - "compose": "c2VydmljZXM6CiAgY2xhc3NpY3ByZXNzOgogICAgaW1hZ2U6ICdjbGFzc2ljcHJlc3MvY2xhc3NpY3ByZXNzOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2NsYXNzaWNwcmVzcy1maWxlczovdmFyL3d3dy9odG1sJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0NMQVNTSUNQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/classicpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "cloudbeaver": { "documentation": "https://dbeaver.com/docs/cloudbeaver/?utm_source=coolify.io", "slogan": "CloudBeaver is a lightweight web application designed for comprehensive data management.", @@ -813,23 +678,6 @@ "template_last_updated_at": "2026-03-03T11:20:40+05:30", "port": "5212" }, - "cockpit": { - "documentation": "https://getcockpit.com/documentation/?utm_source=coolify.io", - "slogan": "Cockpit is a headless content platform that is lightweight, fast and ready for takeoff.", - "compose": "c2VydmljZXM6CiAgY29ja3BpdDoKICAgIGltYWdlOiAnY29ja3BpdGhxL2NvY2twaXQ6Y29yZS1sYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdjb2NrcGl0LWNvbmZpZzovdmFyL3d3dy9odG1sL2NvbmZpZycKICAgICAgLSAnY29ja3BpdC1zcGFjZXM6L3Zhci93d3cvaHRtbC8uc3BhY2VzJwogICAgICAtICdjb2NrcGl0LXN0b3JhZ2U6L3Zhci93d3cvaHRtbC9zdG9yYWdlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xL2FwaS9zeXN0ZW0vaGVhbHRoY2hlY2snCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cockpit", - "headless", - "cms", - "database", - "nosql" - ], - "category": "cms", - "logo": "svgs/cockpit.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "code-server": { "documentation": "https://coder.com/docs/code-server/latest?utm_source=coolify.io", "slogan": "Code-Server is a web-based code editor that enables remote coding and collaboration from any device, anywhere.", @@ -1120,36 +968,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "dokuwiki": { - "documentation": "https://www.dokuwiki.org/?utm_source=coolify.io", - "slogan": "A lightweight and easy-to-use wiki platform for creating and managing documentation and knowledge bases.", - "compose": "c2VydmljZXM6CiAgZG9rdXdpa2k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZG9rdXdpa2k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RPS1VXSUtJCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnZG9rdXdpa2ktY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", - "tags": [ - "wiki", - "documentation", - "knowledge", - "base" - ], - "category": "cms", - "logo": "svgs/dokuwiki.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "dolibarr": { - "documentation": "https://www.dolibarr.org/documentation-home.php?utm_source=coolify.io", - "slogan": "Dolibarr is a modern software package to manage your organization's activity (contacts, quotes, invoices, orders, stocks, agenda, hr, expense reports, accountancy, ecm, manufacturing, ...).", - "compose": "c2VydmljZXM6CiAgZG9saWJhcnI6CiAgICBpbWFnZTogJ2RvbGliYXJyL2RvbGliYXJyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9ET0xJQkFSUl84MAogICAgICAtICdXV1dfVVNFUl9JRD0ke1dXV19VU0VSX0lEOi0xMDAwfScKICAgICAgLSAnV1dXX0dST1VQX0lEPSR7V1dXX0dST1VQX0lEOi0xMDAwfScKICAgICAgLSBET0xJX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtICdET0xJX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotZG9saWJhcnItZGJ9JwogICAgICAtICdET0xJX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdET0xJX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ0RPTElfVVJMX1JPT1Q9JHtTRVJWSUNFX0ZRRE5fRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0FETUlOX0xPR0lOPSR7U0VSVklDRV9VU0VSX0RPTElCQVJSfScKICAgICAgLSAnRE9MSV9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRE9MSUJBUlJ9JwogICAgICAtICdET0xJX0NST049JHtET0xJX0NST046LTB9JwogICAgICAtICdET0xJX0lOSVRfREVNTz0ke0RPTElfSU5JVF9ERU1POi0wfScKICAgICAgLSAnRE9MSV9DT01QQU5ZX05BTUU9JHtET0xJX0NPTVBBTllfTkFNRTotTXlCaWdDb21wYW55fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX2RvY3M6L3Zhci93d3cvZG9jdW1lbnRzJwogICAgICAtICdkb2xpYmFycl9jdXN0b206L3Zhci93d3cvaHRtbC9jdXN0b20nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1kb2xpYmFyci1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2RvbGliYXJyX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "crm", - "erp" - ], - "category": "productivity", - "logo": "svgs/dolibarr.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "dozzle-with-auth": { "documentation": "https://dozzle.dev/?utm_source=coolify.io", "slogan": "Dozzle is a simple and lightweight web UI for Docker logs.", @@ -1199,23 +1017,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4983" }, - "drupal-with-postgresql": { - "documentation": "https://www.drupal.org/about?utm_source=coolify.io", - "slogan": "Drupal is a free and open-source web content management system written in PHP and distributed under the GNU General Public License.", - "compose": "c2VydmljZXM6CiAgZHJ1cGFsOgogICAgaW1hZ2U6ICdkcnVwYWw6MTAtYXBhY2hlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0RSVVBBTAogICAgICAtIERCX0hPU1Q9cG9zdGdyZXMKICAgICAgLSBEQl9OQU1FPXBvc3RncmVzCiAgICAgIC0gREJfVVNFUj1wb3N0Z3JlcwogICAgICAtIERCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBkcnVwYWxfbW9kdWxlcwogICAgICAgIHRhcmdldDogL3Zhci93d3cvaHRtbC9tb2R1bGVzCiAgICAgICAgaXNfZGlyZWN0b3J5OiB0cnVlCiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IGRydXBhbF9wcm9maWxlcwogICAgICAgIHRhcmdldDogL3Zhci93d3cvaHRtbC9wcm9maWxlcwogICAgICAgIGlzX2RpcmVjdG9yeTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBkcnVwYWxfdGhlbWVzCiAgICAgICAgdGFyZ2V0OiAvdmFyL3d3dy9odG1sL3RoZW1lcwogICAgICAgIGlzX2RpcmVjdG9yeTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBkcnVwYWxfc2l0ZXMKICAgICAgICB0YXJnZXQ6IC92YXIvd3d3L2h0bWwvc2l0ZXMKICAgICAgICBpc19kaXJlY3Rvcnk6IHRydWUKICAgIGRlcGVuZHNfb246CiAgICAgIC0gcG9zdGdyZXMKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjgwIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNicKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPU1RHUkVTX0RCPXBvc3RncmVzCiAgICAgIC0gUE9TVEdSRVNfVVNFUj1wb3N0Z3JlcwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "postgresql" - ], - "category": "cms", - "logo": "svgs/drupal.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "duplicati": { "documentation": "https://duplicati.readthedocs.io?utm_source=coolify.io", "slogan": "Duplicati is a backup solution, allowing you to make scheduled backups with encryption.", @@ -1230,21 +1031,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8200" }, - "easyappointments": { - "documentation": "https://easyappointments.org/?utm_source=coolify.io", - "slogan": "Schedule Anything. Let's start with easy! Get the best free online appointment scheduler on your server, today.", - "compose": "c2VydmljZXM6CiAgZWFzeWFwcG9pbnRtZW50czoKICAgIGltYWdlOiAnYWxleHRzZWxlZ2lkaXMvZWFzeWFwcG9pbnRtZW50czpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRUFTWUFQUE9JTlRNRU5UU184MAogICAgICAtICdCQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9FQVNZQVBQT0lOVE1FTlRTfScKICAgICAgLSBEQl9IT1NUPW15c3FsCiAgICAgIC0gREJfTkFNRT1lYXN5YXBwb2ludG1lbnRzCiAgICAgIC0gREJfVVNFUk5BTUU9cm9vdAogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUFTWUFQUE9JTlRNRU5UU30nCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMzAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2Vhc3lhcHBvaW50bWVudHMtbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9FQVNZQVBQT0lOVE1FTlRTfScKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1lYXN5YXBwb2ludG1lbnRzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "calendar", - "scheduling", - "database" - ], - "category": "productivity", - "logo": "svgs/easyappointments.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "elasticsearch-with-kibana": { "documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io", "slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack", @@ -1390,7 +1176,7 @@ "category": "media", "logo": "svgs/ente-photos.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-22T16:05:17+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "ente-photos": { @@ -1429,24 +1215,6 @@ "template_last_updated_at": "2026-01-05T14:20:17+01:00", "port": "6052" }, - "espocrm": { - "documentation": "https://docs.espocrm.com?utm_source=coolify.io", - "slogan": "EspoCRM is a free and open-source CRM platform.", - "compose": "c2VydmljZXM6CiAgZXNwb2NybToKICAgIGltYWdlOiAnZXNwb2NybS9lc3BvY3JtOjEwJwogICAgY29udGFpbmVyX25hbWU6IGVzcG9jcm0KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fVVNFUk5BTUU9JHtFU1BPQ1JNX0FETUlOX1VTRVJOQU1FOi1hZG1pbn0nCiAgICAgIC0gJ0VTUE9DUk1fQURNSU5fUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSBFU1BPQ1JNX0RBVEFCQVNFX1BMQVRGT1JNPU15c3FsCiAgICAgIC0gRVNQT0NSTV9EQVRBQkFTRV9IT1NUPWVzcG9jcm0tZGIKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9OQU1FPSR7TUFSSUFEQl9EQVRBQkFTRTotZXNwb2NybX0nCiAgICAgIC0gJ0VTUE9DUk1fREFUQUJBU0VfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnRVNQT0NSTV9EQVRBQkFTRV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ0VTUE9DUk1fU0lURV9VUkw9JHtTRVJWSUNFX0ZRRE5fRVNQT0NSTX0nCiAgICB2b2x1bWVzOgogICAgICAtICdlc3BvY3JtLWRhdGE6L3Zhci93d3cvaHRtbC9kYXRhJwogICAgICAtICdlc3BvY3JtLWN1c3RvbTovdmFyL3d3dy9odG1sL2N1c3RvbScKICAgICAgLSAnZXNwb2NybS1jdXN0b20tY2xpZW50Oi92YXIvd3d3L2h0bWwvY2xpZW50L2N1c3RvbScKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCiAgZXNwb2NybS1kYWVtb246CiAgICBpbWFnZTogJ2VzcG9jcm0vZXNwb2NybToxMCcKICAgIGNvbnRhaW5lcl9uYW1lOiBlc3BvY3JtLWRhZW1vbgogICAgdm9sdW1lc19mcm9tOgogICAgICAtIGVzcG9jcm0KICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnRyeXBvaW50OiBkb2NrZXItZGFlbW9uLnNoCiAgICBkZXBlbmRzX29uOgogICAgICBlc3BvY3JtOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gYmluL2NvbW1hbmQKICAgICAgICAtIGFwcC1jaGVjawogICAgICBzdGFydF9wZXJpb2Q6IDIwcwogICAgICBpbnRlcnZhbDogMTgwcwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGVzcG9jcm0td2Vic29ja2V0OgogICAgaW1hZ2U6ICdlc3BvY3JtL2VzcG9jcm06MTAnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS13ZWJzb2NrZXQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9FU1BPQ1JNX1dFQlNPQ0tFVF84MDgwCiAgICAgIC0gRVNQT0NSTV9DT05GSUdfVVNFX1dFQl9TT0NLRVQ9dHJ1ZQogICAgICAtIEVTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfVVJMPSRTRVJWSUNFX0ZRRE5fRVNQT0NSTV9XRUJTT0NLRVQKICAgICAgLSAnRVNQT0NSTV9DT05GSUdfV0VCX1NPQ0tFVF9aRVJPX01fUV9TVUJTQ1JJQkVSX0RTTj10Y3A6Ly8qOjc3NzcnCiAgICAgIC0gJ0VTUE9DUk1fQ09ORklHX1dFQl9TT0NLRVRfWkVST19NX1FfU1VCTUlTU0lPTl9EU049dGNwOi8vZXNwb2NybS13ZWJzb2NrZXQ6Nzc3NycKICAgIHZvbHVtZXNfZnJvbToKICAgICAgLSBlc3BvY3JtCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgZW50cnlwb2ludDogZG9ja2VyLXdlYnNvY2tldC5zaAogICAgZGVwZW5kc19vbjoKICAgICAgZXNwb2NybToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGJpbi9jb21tYW5kCiAgICAgICAgLSBhcHAtY2hlY2sKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMKICAgICAgaW50ZXJ2YWw6IDE4MHMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBlc3BvY3JtLWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjEyLjMnCiAgICBjb250YWluZXJfbmFtZTogZXNwb2NybS1kYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1lc3BvY3JtfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2VzcG9jcm0tZGI6L3Zhci9saWIvbXlzcWwnCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogMjBzCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "crm", - "self-hosted", - "open-source", - "workflow", - "automation", - "project management" - ], - "category": "productivity", - "logo": "svgs/espocrm.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-07-03T15:15:43+03:00", - "port": "80" - }, "evolution-api": { "documentation": "https://doc.evolution-api.com/v2/en/get-started/introduction?utm_source=coolify.io", "slogan": "Multi-platform messaging (whatsapp and more) integration API", @@ -1563,22 +1331,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5000" }, - "firefly": { - "documentation": "https://firefly-iii.org?utm_source=coolify.io", - "slogan": "A personal finances manager that can help you save money.", - "compose": "c2VydmljZXM6CiAgZmlyZWZseToKICAgIGltYWdlOiAnZmlyZWZseWlpaS9jb3JlOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GSVJFRkxZXzgwODAKICAgICAgLSBBUFBfS0VZPSRTRVJWSUNFX0JBU0U2NF9BUFBLRVkKICAgICAgLSBEQl9IT1NUPW15c3FsCiAgICAgIC0gREJfUE9SVD0zMzA2CiAgICAgIC0gREJfQ09OTkVDVElPTj1teXNxbAogICAgICAtICdEQl9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1maXJlZmx5fScKICAgICAgLSBEQl9VU0VSTkFNRT0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gREJfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgICAgLSBTVEFUSUNfQ1JPTl9UT0tFTj0kU0VSVklDRV9CQVNFNjRfQ1JPTlRPS0VOCiAgICAgIC0gJ1RSVVNURURfUFJPWElFUz0qJwogICAgdm9sdW1lczoKICAgICAgLSAnZmlyZWZseS11cGxvYWQ6L3Zhci93d3cvaHRtbC9zdG9yYWdlL3VwbG9hZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBteXNxbDoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotZmlyZWZseX0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbWFyaWFkYi1hZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgICAgLSAnLXVyb290JwogICAgICAgIC0gJy1wJHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZpcmVmbHktbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICBjcm9uOgogICAgaW1hZ2U6IGFscGluZQogICAgZW50cnlwb2ludDoKICAgICAgLSAvZW50cnlwb2ludC5zaAogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZW50cnlwb2ludC5zaAogICAgICAgIHRhcmdldDogL2VudHJ5cG9pbnQuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL3NoXG4jIFN1YnN0aXR1dGUgdGhlIGVudmlyb25tZW50IHZhcmlhYmxlIGludG8gdGhlIGNyb24gY29tbWFuZFxuQ1JPTl9DT01NQU5EPVwiMCAzICogKiAqIHdnZXQgLXFPLSBodHRwOi8vZmlyZWZseTo4MDgwL2FwaS92MS9jcm9uLyR7U1RBVElDX0NST05fVE9LRU59XCJcbiMgQWRkIHRoZSBjcm9uIGNvbW1hbmQgdG8gdGhlIGNyb250YWJcbmVjaG8gXCIkQ1JPTl9DT01NQU5EXCIgfCBjcm9udGFiIC1cbiMgU3RhcnQgdGhlIGNyb24gZGFlbW9uIGluIHRoZSBmb3JlZ3JvdW5kIHdpdGggbG9nZ2luZyB0byBzdGRvdXRcbmNyb25kIC1mIC1MIC9kZXYvc3Rkb3V0IgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU1RBVElDX0NST05fVE9LRU49JFNFUlZJQ0VfQkFTRTY0X0NST05UT0tFTgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdscyAvZW50cnlwb2ludC5zaCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMK", - "tags": [ - "finance", - "money", - "personal", - "manager" - ], - "category": "finance", - "logo": "svgs/firefly.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T10:43:04+02:00", - "port": "8080" - }, "firefox": { "documentation": "https://github.com/jlesage/docker-firefox?utm_source=coolify.io", "slogan": "Fast, private, and self-hosted secure browser for browsing without limits.", @@ -1696,7 +1448,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-mysql": { @@ -1714,7 +1466,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-postgresql": { @@ -1732,7 +1484,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-mariadb": { @@ -1754,7 +1506,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-mysql": { @@ -1776,7 +1528,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner-with-postgresql": { @@ -1798,7 +1550,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo-with-runner": { @@ -1819,7 +1571,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "forgejo": { @@ -1836,7 +1588,7 @@ "category": "git", "logo": "svgs/forgejo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T11:35:20+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "formbricks": { @@ -1852,7 +1604,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "foundryvtt": { @@ -1872,78 +1624,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "30000" }, - "freescout": { - "documentation": "https://github.com/freescout-help-desk/freescout/wiki/?utm_source=coolify.io", - "slogan": "FreeScout is the super lightweight and powerful free open source help desk and shared inbox written in PHP (Laravel framework).", - "compose": "c2VydmljZXM6CiAgZnJlZXNjb3V0OgogICAgaW1hZ2U6ICd0aXJlZG9maXQvZnJlZXNjb3V0OmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZWVzY291dC1kYXRhOi9kYXRhJwogICAgICAtICdmcmVlc2NvdXQtbG9nczovd3d3L2xvZ3MnCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fRlJFRVNDT1VUXzgwCiAgICAgIC0gREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0RCX05BTUU9JHtNQVJJQURCX0RBVEFCQVNFOi1mcmVlc2NvdXR9JwogICAgICAtICdEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREI6LWZyZWVzY291dH0nCiAgICAgIC0gJ0RCX1BBU1M9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdTSVRFX1VSTD0ke1NFUlZJQ0VfRlFETl9GUkVFU0NPVVR9JwogICAgICAtICdBRE1JTl9FTUFJTD0ke0FETUlOX0VNQUlMOi1hZG1pbkBleGFtcGxlLmNvbX0nCiAgICAgIC0gJ0FETUlOX1BBU1M9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnRElTUExBWV9FUlJPUlM9JHtESVNQTEFZX0VSUk9SUzotRkFMU0V9JwogICAgICAtICdUSU1FWk9ORT0ke1RJTUVaT05FOi1VVEN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbWFyaWFkYjoKICAgIGltYWdlOiBtYXJpYWRiCiAgICB2b2x1bWVzOgogICAgICAtICdtYXJpYWRiLWRhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgICAgLSAnTUFSSUFEQl9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0V9JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", - "tags": [ - "helpdesk", - "support", - "ticketing", - "customer-support" - ], - "category": "helpdesk", - "logo": "svgs/freescout.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-mariadb": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUkVTSFJTU184MAogICAgICAtICdDUk9OX01JTj0ke0NST05fTUlOOi0xLDMxfScKICAgICAgLSAnTUFSSUFEQl9EQj0ke01BUklBREJfREFUQUJBU0U6LWZyZXNocnNzfScKICAgICAgLSAnTUFSSUFEQl9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZXNocnNzLWRhdGE6L3Zhci93d3cvRnJlc2hSU1MvZGF0YScKICAgICAgLSAnZnJlc2hyc3MtZXh0ZW5zaW9uczovdmFyL3d3dy9GcmVzaFJTUy9leHRlbnNpb25zJwogICAgZGVwZW5kc19vbjoKICAgICAgZnJlc2hyc3MtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzgwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGZyZXNocnNzLWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotZnJlc2hyc3N9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-mysql": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUkVTSFJTU184MAogICAgICAtICdDUk9OX01JTj0ke0NST05fTUlOOi0xLDMxfScKICAgICAgLSAnTVlTUUxfREI9JHtNWVNRTF9EQVRBQkFTRTotZnJlc2hyc3N9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZXNocnNzLWRhdGE6L3Zhci93d3cvRnJlc2hSU1MvZGF0YScKICAgICAgLSAnZnJlc2hyc3MtZXh0ZW5zaW9uczovdmFyL3d3dy9GcmVzaFJTUy9leHRlbnNpb25zJwogICAgZGVwZW5kc19vbjoKICAgICAgZnJlc2hyc3MtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAiYmFzaCAtYyAnOj4gL2Rldi90Y3AvMTI3LjAuMC4xLzgwJyB8fCBleGl0IDEiCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMwogIGZyZXNocnNzLWRiOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1mcmVzaHJzc30nCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX01ZU1FMCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss-with-postgresql": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUkVTSFJTU184MAogICAgICAtICdDUk9OX01JTj0ke0NST05fTUlOOi0xLDMxfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1mcmVzaHJzc30nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSBQT1NUR1JFU19IT1NUPWZyZXNocnNzLWRiCiAgICB2b2x1bWVzOgogICAgICAtICdmcmVzaHJzcy1kYXRhOi92YXIvd3d3L0ZyZXNoUlNTL2RhdGEnCiAgICAgIC0gJ2ZyZXNocnNzLWV4dGVuc2lvbnM6L3Zhci93d3cvRnJlc2hSU1MvZXh0ZW5zaW9ucycKICAgIGRlcGVuZHNfb246CiAgICAgIGZyZXNocnNzLWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gImJhc2ggLWMgJzo+IC9kZXYvdGNwLzEyNy4wLjAuMS84MCcgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDMKICBmcmVzaHJzcy1kYjoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYnCiAgICB2b2x1bWVzOgogICAgICAtICdmcmVzaHJzcy1wb3N0Z3Jlc3FsLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1mcmVzaHJzc30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "freshrss": { - "documentation": "https://freshrss.org/index.html?utm_source=coolify.io", - "slogan": "A free, self-hostable feed aggregator.", - "compose": "c2VydmljZXM6CiAgZnJlc2hyc3M6CiAgICBpbWFnZTogJ2ZyZXNocnNzL2ZyZXNocnNzOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9GUkVTSFJTU184MAogICAgICAtICdDUk9OX01JTj0ke0NST05fTUlOOi0xLDMxfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ZyZXNocnNzLWRhdGE6L3Zhci93d3cvRnJlc2hSU1MvZGF0YScKICAgICAgLSAnZnJlc2hyc3MtZXh0ZW5zaW9uczovdmFyL3d3dy9GcmVzaFJTUy9leHRlbnNpb25zJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc6PiAvZGV2L3RjcC8xMjcuMC4wLjEvODAnIHx8IGV4aXQgMSIKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "rss", - "feed" - ], - "category": "RSS", - "logo": "svgs/freshrss.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "garage": { "documentation": "https://garagehq.deuxfleurs.fr/documentation/?utm_source=coolify.io", "slogan": "Garage is an S3-compatible distributed object storage service designed for self-hosting.", @@ -2168,22 +1848,6 @@ "template_last_updated_at": "2026-02-10T11:22:57+05:30", "port": "8000" }, - "glpi": { - "documentation": "https://help.glpi-project.org/documentation?utm_source=coolify.io", - "slogan": "GLPI (Gestionnaire Libre de Parc Informatique) is a free, open-source IT Service Management (ITSM) platform used for IT asset management, helpdesk, and service desk operations.", - "compose": "c2VydmljZXM6CiAgZ2xwaToKICAgIGltYWdlOiAnZ2xwaS9nbHBpOjExJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0dMUElfODAKICAgICAgLSBHTFBJX0RCX0hPU1Q9Z2xwaS1kYgogICAgICAtIEdMUElfREJfUE9SVD0zMzA2CiAgICAgIC0gJ0dMUElfREJfTkFNRT0ke01ZU1FMX0RBVEFCQVNFOi1nbHBpLWRifScKICAgICAgLSAnR0xQSV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnR0xQSV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgdm9sdW1lczoKICAgICAgLSAnZ2xwaS1kYXRhOi92YXIvZ2xwaTpydycKICAgIGRlcGVuZHNfb246CiAgICAgIGdscGktZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdC8nCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICBnbHBpLWRiOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotZ2xwaS1kYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "helpdesk", - "ticketing", - "support", - "open-source" - ], - "category": "helpdesk", - "logo": "svgs/glpi.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-01-13T21:07:54+01:00", - "port": "80" - }, "goatcounter": { "documentation": "https://www.goatcounter.com/help?utm_source=coolify.io", "slogan": "Lightweight web analytics platform.", @@ -2327,23 +1991,6 @@ "template_last_updated_at": "2026-02-16T15:13:50+02:00", "port": "8484" }, - "grocy": { - "documentation": "https://github.com/grocy/grocy?utm_source=coolify.io", - "slogan": "Grocy is a web-based household management and grocery list application.", - "compose": "c2VydmljZXM6CiAgZ3JvY3k6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvZ3JvY3k6NC42LjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fR1JPQ1kKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdncm9jeS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "groceries", - "household", - "management", - "grocery", - "shopping" - ], - "category": "productivity", - "logo": "svgs/grocy.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", "slogan": "Hatchet allows you to run background tasks at scale with a high-throughput, low-latency computing service built on an open-source, fault-tolerant queue.", @@ -2357,7 +2004,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-15T22:51:15+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "healthchecks": { @@ -2379,26 +2026,10 @@ "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, - "heimdall": { - "documentation": "https://github.com/linuxserver/Heimdall?utm_source=coolify.io", - "slogan": "Heimdall is a dashboard for managing and organizing your server applications.", - "compose": "c2VydmljZXM6CiAgaGVpbWRhbGw6CiAgICBpbWFnZTogJ2xzY3IuaW8vbGludXhzZXJ2ZXIvaGVpbWRhbGw6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0hFSU1EQUxMCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgdm9sdW1lczoKICAgICAgLSAnaGVpbWRhbGwtY29uZmlnOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODAnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", - "tags": [ - "dashboard", - "server", - "applications", - "interface" - ], - "category": "productivity", - "logo": "svgs/heimdall.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "hermes-agent-with-webui": { "documentation": "https://github.com/nesquena/hermes-webui?utm_source=coolify.io", "slogan": "Hermes Agent - autonomous AI agent with persistent memory, scheduling, and a self-hosted web chat UI.", - "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnYyMDI2LjYuNScKICAgIGNvbW1hbmQ6ICdnYXRld2F5IHJ1bicKICAgIGVudmlyb25tZW50OgogICAgICAtIEhFUk1FU19IT01FPS9ob21lL2hlcm1lcy8uaGVybWVzCiAgICAgIC0gV0lLSV9QQVRIPS9ob21lL2hlcm1lcy8uaGVybWVzL3dpa2kKICAgICAgLSBHQVRFV0FZX0FMTE9XX0FMTF9VU0VSUz10cnVlCiAgICAgIC0gSEVSTUVTX1JFREFDVF9TRUNSRVRTPXRydWUKICAgICAgLSBIRVJNRVNfVUlEPTEwMDAKICAgICAgLSBIRVJNRVNfR0lEPTEwMDAKICAgICAgLSAnSEVSTUVTX0VOQUJMRV9QUk9KRUNUX1BMVUdJTlM9JHtIRVJNRVNfRU5BQkxFX1BST0pFQ1RfUExVR0lOUzotdHJ1ZX0nCiAgICAgIC0gJ0hFUk1FU19PUFRJT05BTF9TS0lMTFM9JHtIRVJNRVNfT1BUSU9OQUxfU0tJTExTOi19JwogICAgICAtICdIRVJNRVNfQUxMT1dfUFJJVkFURV9VUkxTPSR7SEVSTUVTX0FMTE9XX1BSSVZBVEVfVVJMUzotZmFsc2V9JwogICAgICAtICdGSVJFQ1JBV0xfQVBJX0tFWT0ke0ZJUkVDUkFXTF9BUElfS0VZOi19JwogICAgICAtICdGSVJFQ1JBV0xfQVBJX1VSTD0ke0ZJUkVDUkFXTF9BUElfVVJMOi1odHRwczovL2FwaS5maXJlY3Jhd2wuZGV2fScKICAgICAgLSAnT1BFTlJPVVRFUl9BUElfS0VZPSR7T1BFTlJPVVRFUl9BUElfS0VZOi19JwogICAgICAtICdBTlRIUk9QSUNfQVBJX0tFWT0ke0FOVEhST1BJQ19BUElfS0VZOi19JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZOi19JwogICAgICAtICdHT09HTEVfQVBJX0tFWT0ke0dPT0dMRV9BUElfS0VZOi19JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1za2lsbHM6L2hvbWUvaGVybWVzLy5oZXJtZXMvc2tpbGxzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUxLjQ4OScKICAgIGRlcGVuZHNfb246CiAgICAgIC0gaGVybWVzLWFnZW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSEVSTUVTV0VCVUlfODc4NwogICAgICAtIFNFUlZJQ0VfVVNFUl9IRVJNRVNXRUJVSQogICAgICAtIFNFUlZJQ0VfUEFTU1dPUkRfSEVSTUVTV0VCVUkKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfSEVSTUVTV0VCVUl9JwogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2hlcm1lcy1ob21lOi9ob21lL2hlcm1lc3dlYnVpLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1hZ2VudC1zcmM6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy9oZXJtZXMtYWdlbnQ6cm8nCiAgICAgIC0gJ2hlcm1lcy13b3Jrc3BhY2U6L3dvcmtzcGFjZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4Nzg3L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgaGVybWVzLWFnZW50OgogICAgaW1hZ2U6ICdub3VzcmVzZWFyY2gvaGVybWVzLWFnZW50OnYyMDI2LjkuMjQnCiAgICBjb21tYW5kOiAnZ2F0ZXdheSBydW4nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIRVJNRVNfSE9NRT0vaG9tZS9oZXJtZXMvLmhlcm1lcwogICAgICAtIFdJS0lfUEFUSD0vaG9tZS9oZXJtZXMvLmhlcm1lcy93aWtpCiAgICAgIC0gR0FURVdBWV9BTExPV19BTExfVVNFUlM9dHJ1ZQogICAgICAtIEhFUk1FU19SRURBQ1RfU0VDUkVUUz10cnVlCiAgICAgIC0gSEVSTUVTX1VJRD0xMDAwCiAgICAgIC0gSEVSTUVTX0dJRD0xMDAwCiAgICAgIC0gJ0hFUk1FU19FTkFCTEVfUFJPSkVDVF9QTFVHSU5TPSR7SEVSTUVTX0VOQUJMRV9QUk9KRUNUX1BMVUdJTlM6LXRydWV9JwogICAgICAtICdIRVJNRVNfT1BUSU9OQUxfU0tJTExTPSR7SEVSTUVTX09QVElPTkFMX1NLSUxMUzotfScKICAgICAgLSAnSEVSTUVTX0FMTE9XX1BSSVZBVEVfVVJMUz0ke0hFUk1FU19BTExPV19QUklWQVRFX1VSTFM6LWZhbHNlfScKICAgICAgLSAnRklSRUNSQVdMX0FQSV9LRVk9JHtGSVJFQ1JBV0xfQVBJX0tFWTotfScKICAgICAgLSAnRklSRUNSQVdMX0FQSV9VUkw9JHtGSVJFQ1JBV0xfQVBJX1VSTDotaHR0cHM6Ly9hcGkuZmlyZWNyYXdsLmRldn0nCiAgICAgIC0gJ09QRU5ST1VURVJfQVBJX0tFWT0ke09QRU5ST1VURVJfQVBJX0tFWTotfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWTotfScKICAgICAgLSAnT1BFTkFJX0FQSV9LRVk9JHtPUEVOQUlfQVBJX0tFWTotfScKICAgICAgLSAnR09PR0xFX0FQSV9LRVk9JHtHT09HTEVfQVBJX0tFWTotfScKICAgICAgLSBBUElfU0VSVkVSX0VOQUJMRUQ9dHJ1ZQogICAgICAtIEFQSV9TRVJWRVJfSE9TVD0wLjAuMC4wCiAgICAgIC0gJ0FQSV9TRVJWRVJfS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9IRVJNRVNBUEl9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzLy5oZXJtZXMnCiAgICAgIC0gJ2hlcm1lcy1za2lsbHM6L2hvbWUvaGVybWVzLy5oZXJtZXMvc2tpbGxzJwogICAgICAtICdoZXJtZXMtYWdlbnQtc3JjOi9vcHQvaGVybWVzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd0ZXN0IC1kIC9ob21lL2hlcm1lcy8uaGVybWVzIHx8IGV4aXQgMScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgaGVybWVzLXdlYnVpOgogICAgaW1hZ2U6ICdnaGNyLmlvL25lc3F1ZW5hL2hlcm1lcy13ZWJ1aTowLjUyLjQ1OCcKICAgIGRlcGVuZHNfb246CiAgICAgIC0gaGVybWVzLWFnZW50CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSEVSTUVTV0VCVUlfODc4NwogICAgICAtIFNFUlZJQ0VfVVNFUl9IRVJNRVNXRUJVSQogICAgICAtIFNFUlZJQ0VfUEFTU1dPUkRfSEVSTUVTV0VCVUkKICAgICAgLSBIRVJNRVNfV0VCVUlfSE9TVD0wLjAuMC4wCiAgICAgIC0gSEVSTUVTX1dFQlVJX1BPUlQ9ODc4NwogICAgICAtIEhFUk1FU19XRUJVSV9TVEFURV9ESVI9L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcy93ZWJ1aQogICAgICAtIFdBTlRFRF9VSUQ9MTAwMAogICAgICAtIFdBTlRFRF9HSUQ9MTAwMAogICAgICAtICdIRVJNRVNfV0VCVUlfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfSEVSTUVTV0VCVUl9JwogICAgICAtICdIRVJNRVNfV0VCVUlfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0hFUk1FU1dFQlVJfScKICAgICAgLSAnSEVSTUVTX0FQSV9VUkw9aHR0cDovL2hlcm1lcy1hZ2VudDo4NjQyJwogICAgICAtICdIRVJNRVNfV0VCVUlfR0FURVdBWV9BUElfS0VZPSR7U0VSVklDRV9QQVNTV09SRF82NF9IRVJNRVNBUEl9JwogICAgdm9sdW1lczoKICAgICAgLSAnaGVybWVzLWhvbWU6L2hvbWUvaGVybWVzd2VidWkvLmhlcm1lcycKICAgICAgLSAnaGVybWVzLWFnZW50LXNyYzovaG9tZS9oZXJtZXN3ZWJ1aS8uaGVybWVzL2hlcm1lcy1hZ2VudDpybycKICAgICAgLSAnaGVybWVzLXdvcmtzcGFjZTovd29ya3NwYWNlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjg3ODcvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK", "tags": [ "ai", "agent", @@ -2412,7 +2043,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-12T16:39:52+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8787" }, "hermes-agent": { @@ -2467,13 +2098,13 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T19:03:43+02:00", + "template_last_updated_at": "2026-10-01T20:31:00+02:00", "port": "7575" }, "home-assistant": { "documentation": "https://www.home-assistant.io/installation/linux#docker-compose?utm_source=coolify.io", "slogan": "Open source home automation that puts local control and privacy first.", - "compose": "c2VydmljZXM6CiAgaG9tZWFzc2lzdGFudDoKICAgIGltYWdlOiAnZ2hjci5pby9ob21lLWFzc2lzdGFudC9ob21lLWFzc2lzdGFudDoyMDI1LjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSE9NRUFTU0lTVEFOVF84MTIzCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RJU0FCTEVfSkVNQUxMT0M9JHtESVNBQkxFX0pFTUFMTE9DOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdob21lYXNzaXN0YW50LWNvbmZpZzovY29uZmlnJwogICAgICAtICcvcnVuL2RidXM6L3J1bi9kYnVzOnJvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jb25maWd1cmF0aW9uLnlhbWwKICAgICAgICB0YXJnZXQ6IC9jb25maWcvY29uZmlndXJhdGlvbi55YW1sCiAgICAgICAgY29udGVudDogIiMgTG9hZHMgZGVmYXVsdCBzZXQgb2YgaW50ZWdyYXRpb25zLiBEbyBub3QgcmVtb3ZlLlxuZGVmYXVsdF9jb25maWc6XG5cbiMgQ29uZmlndXJhdGlvbiBmb3IgcmV2ZXJzZSBwcm94eSBzdXBwb3J0IChyZXF1aXJlZCBmb3IgQ29vbGlmeSlcbmh0dHA6XG4gIHVzZV94X2ZvcndhcmRlZF9mb3I6IHRydWVcbiAgdHJ1c3RlZF9wcm94aWVzOlxuICAgIC0gMTAuMC4wLjAvOFxuICAgIC0gMTcyLjE2LjAuMC8xMlxuICAgIC0gMTkyLjE2OC4wLjAvMTZcbiAgaXBfYmFuX2VuYWJsZWQ6IHRydWVcbiAgbG9naW5fYXR0ZW1wdHNfdGhyZXNob2xkOiA1IgogICAgcHJpdmlsZWdlZDogdHJ1ZQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgxMjMnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA2MHMK", + "compose": "c2VydmljZXM6CiAgaG9tZWFzc2lzdGFudDoKICAgIGltYWdlOiAnZ2hjci5pby9ob21lLWFzc2lzdGFudC9ob21lLWFzc2lzdGFudDoyMDI1LjEwLjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fSE9NRUFTU0lTVEFOVF84MTIzCiAgICAgIC0gJ1RaPSR7VFo6LVVUQ30nCiAgICAgIC0gJ0RJU0FCTEVfSkVNQUxMT0M9JHtESVNBQkxFX0pFTUFMTE9DOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdob21lYXNzaXN0YW50LWNvbmZpZzovY29uZmlnJwogICAgICAtICcvcnVuL2RidXM6L3J1bi9kYnVzOnJvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jb25maWd1cmF0aW9uLnlhbWwKICAgICAgICB0YXJnZXQ6IC9jb25maWcvY29uZmlndXJhdGlvbi55YW1sCiAgICAgICAgY29udGVudDogIiMgTG9hZHMgZGVmYXVsdCBzZXQgb2YgaW50ZWdyYXRpb25zLiBEbyBub3QgcmVtb3ZlLlxuZGVmYXVsdF9jb25maWc6XG5cbiMgQ29uZmlndXJhdGlvbiBmb3IgcmV2ZXJzZSBwcm94eSBzdXBwb3J0IChyZXF1aXJlZCBmb3IgQ29vbGlmeSlcbmh0dHA6XG4gIHVzZV94X2ZvcndhcmRlZF9mb3I6IHRydWVcbiAgdHJ1c3RlZF9wcm94aWVzOlxuICAgIC0gMTAuMC4wLjAvOFxuICAgIC0gMTcyLjE2LjAuMC8xMlxuICAgIC0gMTkyLjE2OC4wLjAvMTZcbiAgaXBfYmFuX2VuYWJsZWQ6IHRydWVcbiAgbG9naW5fYXR0ZW1wdHNfdGhyZXNob2xkOiA1XG4iCiAgICBwcml2aWxlZ2VkOiB0cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODEyMycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDYwcwo=", "tags": [ "home-automation", "iot", @@ -2516,7 +2147,7 @@ "category": "productivity", "logo": "svgs/homepage.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-10-19T21:18:41+02:00", "port": "3000" }, "honcho": { @@ -2651,23 +2282,6 @@ "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, - "invoice-ninja": { - "documentation": "https://invoiceninja.github.io/selfhost.html?utm_source=coolify.io", - "slogan": "The leading open-source invoicing platform", - "compose": "c2VydmljZXM6CiAgaW52b2ljZS1uaW5qYToKICAgIGltYWdlOiAnaW52b2ljZW5pbmphL2ludm9pY2VuaW5qYTo1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0lOVk9JQ0VOSU5KQQogICAgICAtICdBUFBfTkFNRT0ke0FQUF9OQU1FOi0iSW52b2ljZSBOaW5qYSJ9JwogICAgICAtICdBUFBfRU5WPSR7QVBQX0VOVjotcHJvZHVjdGlvbn0nCiAgICAgIC0gJ0FQUF9VUkw9JHtTRVJWSUNFX0ZRRE5fSU5WT0lDRU5JTkpBfScKICAgICAgLSAnQVBQX0tFWT1iYXNlNjQ6JHtTRVJWSUNFX1JFQUxCQVNFNjRfSU5WT0lDRU5JTkpBfScKICAgICAgLSAnQVBQX0RFQlVHPSR7QVBQX0RFQlVHOi1mYWxzZX0nCiAgICAgIC0gJ1JFUVVJUkVfSFRUUFM9JHtSRVFVSVJFX0hUVFBTOi1mYWxzZX0nCiAgICAgIC0gJ1BIQU5UT01KU19QREZfR0VORVJBVElPTj0ke1BIQU5UT01KU19QREZfR0VORVJBVElPTjotZmFsc2V9JwogICAgICAtICdQREZfR0VORVJBVE9SPSR7UERGX0dFTkVSQVRPUjotaG9zdGVkX25pbmphfScKICAgICAgLSAnVFJVU1RFRF9QUk9YSUVTPSR7VFJVU1RFRF9QUk9YSUVTOi0qfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSAnUVVFVUVfQ09OTkVDVElPTj0ke1FVRVVFX0NPTk5FQ1RJT046LXJlZGlzfScKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtICdSRURJU19IT1NUPSR7UkVESVNfSE9TVDotcmVkaXN9JwogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAtICdSRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0RCX0hPU1Q9JHtEQl9IT1NUOi1tYXJpYWRifScKICAgICAgLSAnREJfUE9SVD0ke0RCX1BPUlQ6LTMzMDZ9JwogICAgICAtICdEQl9EQVRBQkFTRT0ke0RCX0RBVEFCQVNFOi1pbnZvaWNlbmluamF9JwogICAgICAtICdEQl9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtICdJTl9VU0VSX0VNQUlMPSR7SU5fVVNFUl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdJTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSU5WT0lDRU5JTkpBVVNFUn0nCiAgICAgIC0gJ01BSUxfTUFJTEVSPSR7TUFJTF9NQUlMRVI6LWxvZ30nCiAgICAgIC0gJ01BSUxfSE9TVD0ke01BSUxfSE9TVH0nCiAgICAgIC0gJ01BSUxfUE9SVD0ke01BSUxfUE9SVH0nCiAgICAgIC0gJ01BSUxfVVNFUk5BTUU9JHtNQUlMX1VTRVJOQU1FfScKICAgICAgLSAnTUFJTF9QQVNTV09SRD0ke01BSUxfUEFTU1dPUkR9JwogICAgICAtICdNQUlMX0VOQ1JZUFRJT049JHtNQUlMX0VOQ1JZUFRJT059JwogICAgICAtICdNQUlMX0ZST01fQUREUkVTUz0ke01BSUxfRlJPTV9BRERSRVNTfScKICAgICAgLSAnTUFJTF9GUk9NX05BTUU9JHtNQUlMX0ZST01fTkFNRX0nCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7QVdTX0FDQ0VTU19LRVlfSUR9JwogICAgICAtICdBV1NfU0VDUkVUX0FDQ0VTU19LRVk9JHtBV1NfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICAtICdBV1NfREVGQVVMVF9SRUdJT049JHtBV1NfREVGQVVMVF9SRUdJT059JwogICAgICAtICdBV1NfQlVDS0VUPSR7QVdTX0JVQ0tFVH0nCiAgICAgIC0gJ0FXU19VUkw9JHtBV1NfVVJMfScKICAgICAgLSAnQVdTX0VORFBPSU5UPSR7QVdTX0VORFBPSU5UfScKICAgICAgLSAnTk9SRElHRU5fU0VDUkVUX0lEPSR7Tk9SRElHRU5fU0VDUkVUX0lEfScKICAgICAgLSAnTk9SRElHRU5fU0VDUkVUX0tFWT0ke05PUkRJR0VOX1NFQ1JFVF9LRVl9JwogICAgICAtIElTX0RPQ0tFUj10cnVlCiAgICAgIC0gJ1NDT1VUX0RSSVZFUj0ke1NDT1VUX0RSSVZFUn0nCiAgICAgIC0gJ0xJQ0VOU0VfS0VZPSR7TElDRU5TRV9LRVl9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtIG9rCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ludm9pY2UtbmluamEtcHVibGljOi92YXIvd3d3L2FwcC9wdWJsaWMnCiAgICAgIC0gJ2ludm9pY2UtbmluamEtc3RvcmFnZTovdmFyL3d3dy9hcHAvc3RvcmFnZScKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vc3VwZXJ2aXNvcmQuY29uZgogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnZpc29yZC5jb25mCiAgICAgICAgY29udGVudDogIltzdXBlcnZpc29yZF1cbm5vZGFlbW9uPXRydWVcbnBpZGZpbGU9L3RtcC9zdXBlcnZpc29yZC5waWRcbmxvZ2ZpbGU9L2Rldi9udWxsIDsgbm9kYWVtb24gd2lsbCBjYXVzZSBsb2dzIHRvIGdvIHRvIHN0ZG91dFxubG9nZmlsZV9tYXhieXRlcz0wXG5sb2dsZXZlbD1pbmZvXG5cbltwcm9ncmFtOnBocC1mcG1dXG5yZWRpcmVjdF9zdGRlcnI9dHJ1ZVxuc3Rkb3V0X2xvZ2ZpbGU9L2Rldi9zdGRvdXRcbnN0ZG91dF9sb2dmaWxlX21heGJ5dGVzPTBcbnN0ZGVycl9sb2dmaWxlPS9kZXYvc3RkZXJyXG5zdGRlcnJfbG9nZmlsZV9tYXhieXRlcz0wXG5jb21tYW5kPXBocCBhcnRpc2FuIHNlcnZlIC0taG9zdCAwLjAuMC4wIC0tcG9ydCA5MDAwXG5cbltwcm9ncmFtOnNjaGVkdWxlcl1cbmF1dG9yZXN0YXJ0PXRydWVcbnJlZGlyZWN0X3N0ZGVycj10cnVlXG5zdGRvdXRfbG9nZmlsZT0vZGV2L3N0ZG91dFxuc3Rkb3V0X2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxuc3RkZXJyX2xvZ2ZpbGU9L2Rldi9zdGRlcnJcbnN0ZGVycl9sb2dmaWxlX21heGJ5dGVzPTBcbmNvbW1hbmQ9cGhwIGFydGlzYW4gc2NoZWR1bGU6d29ya1xuXG5bcHJvZ3JhbTpxdWV1ZS13b3JrZXJdXG5wcm9jZXNzX25hbWU9JShwcm9ncmFtX25hbWUpc18lKHByb2Nlc3NfbnVtKTAyZFxuYXV0b3Jlc3RhcnQ9dHJ1ZVxucmVkaXJlY3Rfc3RkZXJyPXRydWVcbnN0ZG91dF9sb2dmaWxlPS9kZXYvc3Rkb3V0XG5zdGRvdXRfbG9nZmlsZV9tYXhieXRlcz0wXG5zdGRlcnJfbG9nZmlsZT0vZGV2L3N0ZGVyclxuc3RkZXJyX2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxubnVtcHJvY3M9MlxuY29tbWFuZD1waHAgYXJ0aXNhbiBxdWV1ZTp3b3JrIC0tc2xlZXA9MyAtLXRyaWVzPTEgLS1tZW1vcnk9MjU2IC0tdGltZW91dD0zNjAwXG5cbltldmVudGxpc3RlbmVyOnNodXRkb3duXVxuY29tbWFuZD1zaHV0ZG93bi5zaFxuZXZlbnRzPVBST0NFU1NfU1RBVEVfU1RPUFBFRCwgUFJPQ0VTU19TVEFURV9FWElURUQsIFBST0NFU1NfU1RBVEVfRkFUQUxcbnN0ZG91dF9sb2dmaWxlPS9kZXYvc3Rkb3V0XG5zdGRvdXRfbG9nZmlsZV9tYXhieXRlcz0wXG5zdGRlcnJfbG9nZmlsZT0vZGV2L3N0ZGVyclxuc3RkZXJyX2xvZ2ZpbGVfbWF4Ynl0ZXM9MFxuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9waHAuaW5pCiAgICAgICAgdGFyZ2V0OiAvdXNyL2xvY2FsL2V0Yy9waHAvcGhwLmluaQogICAgICAgIGNvbnRlbnQ6ICJzZXNzaW9uLmF1dG9fc3RhcnQgPSBPZmZcbnNob3J0X29wZW5fdGFnID0gT2ZmXG5cbmVycm9yX3JlcG9ydGluZyA9IEVfQUxMICYgfkVfTk9USUNFICYgfkVfV0FSTklORyAmIH5FX1NUUklDVCAmIH5FX0RFUFJFQ0FURURcblxuOyBvcGNhY2hlLmVuYWJsZT0xXG47IG9wY2FjaGUucHJlbG9hZD0vc3J2L3d3dy9pbnZvaWNlbmluamEvY3VycmVudC9wcmVsb2FkLnBocFxuOyBvcGNhY2hlLnByZWxvYWRfdXNlcj13d3ctZGF0YVxuXG47IDsgVGhlIE9QY2FjaGUgc2hhcmVkIG1lbW9yeSBzdG9yYWdlIHNpemUuXG47IG9wY2FjaGUubWF4X2FjY2VsZXJhdGVkX2ZpbGVzPTMwMDAwMFxuOyBvcGNhY2hlLnZhbGlkYXRlX3RpbWVzdGFtcHM9MVxuOyBvcGNhY2hlLnJldmFsaWRhdGVfZnJlcT0zMFxuOyBvcGNhY2hlLmppdF9idWZmZXJfc2l6ZT0yNTZNXG47IG9wY2FjaGUuaml0PTEyMDVcbjsgb3BjYWNoZS5tZW1vcnlfY29uc3VtcHRpb249MTAyNE1cblxucG9zdF9tYXhfc2l6ZSA9IDYwTVxudXBsb2FkX21heF9maWxlc2l6ZSA9IDUwTVxubWVtb3J5X2xpbWl0PTUxMk1cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vcGhwLWNsaS5pbmkKICAgICAgICB0YXJnZXQ6IC91c3IvbG9jYWwvZXRjL3BocC9waHAtY2xpLmluaQogICAgICAgIGNvbnRlbnQ6ICJzZXNzaW9uLmF1dG9fc3RhcnQgPSBPZmZcbnNob3J0X29wZW5fdGFnID0gT2ZmXG5cbmVycm9yX3JlcG9ydGluZyA9IEVfQUxMICYgfkVfTk9USUNFICYgfkVfV0FSTklORyAmIH5FX1NUUklDVCAmIH5FX0RFUFJFQ0FURURcblxuOyBvcGNhY2hlLmVuYWJsZV9jbGk9MVxuOyBvcGNhY2hlLmZhc3Rfc2h1dGRvd249MVxuOyBvcGNhY2hlLm1lbW9yeV9jb25zdW1wdGlvbj0yNTZcbjsgb3BjYWNoZS5pbnRlcm5lZF9zdHJpbmdzX2J1ZmZlcj04XG47IG9wY2FjaGUubWF4X2FjY2VsZXJhdGVkX2ZpbGVzPTQwMDBcbjsgb3BjYWNoZS5yZXZhbGlkYXRlX2ZyZXE9NjBcbjsgIyBodHRwOi8vc3ltZm9ueS5jb20vZG9jL2N1cnJlbnQvcGVyZm9ybWFuY2UuaHRtbFxuOyByZWFscGF0aF9jYWNoZV9zaXplID0gNDA5NktcbjsgcmVhbHBhdGhfY2FjaGVfdHRsID0gNjAwXG5cbm1lbW9yeV9saW1pdCA9IDJHXG5wb3N0X21heF9zaXplID0gNjBNXG51cGxvYWRfbWF4X2ZpbGVzaXplID0gNTBNXG4iCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7REJfREFUQUJBU0U6LWludm9pY2VuaW5qYX0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2ludm9pY2UtbmluamEtcmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctYScKICAgICAgICAtICcke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", - "tags": [ - "invoicing", - "billing", - "accounting", - "finance", - "self-hosted" - ], - "category": "finance", - "logo": "svgs/invoiceninja.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "9000" - }, "it-tools": { "documentation": "https://github.com/corentinth/it-tools?utm_source=coolify.io", "slogan": "IT Tools is a self-hosted solution for managing various IT tasks.", @@ -2752,23 +2366,6 @@ "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, - "joomla-with-mariadb": { - "documentation": "https://joomla.org?utm_source=coolify.io", - "slogan": "Joomla! is the mobile-ready and user-friendly way to build your website. Choose from thousands of features and designs. Joomla! is free and open source.", - "compose": "c2VydmljZXM6CiAgam9vbWxhOgogICAgaW1hZ2U6ICdqb29tbGE6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnam9vbWxhX2RhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9KT09NTEEKICAgICAgLSBKT09NTEFfREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ0pPT01MQV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX0pPT01MQX0nCiAgICAgIC0gJ0pPT01MQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfSk9PTUxBfScKICAgICAgLSAnSk9PTUxBX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotam9vbWxhLWRifScKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICB2b2x1bWVzOgogICAgICAtICdqb29tbGFfbWFyaWFkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1qb29tbGEtZGJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX0pPT01MQX0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9KT09NTEF9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "mariadb" - ], - "category": "cms", - "logo": "svgs/joomla.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "joplin": { "documentation": "https://github.com/laurent22/joplin/blob/dev/packages/server/README.md?utm_source=coolify.io", "slogan": "Self-hosted sync server for Joplin", @@ -2873,20 +2470,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8080" }, - "kimai": { - "documentation": "https://www.kimai.org/?utm_source=coolify.io", - "slogan": "Open source time-tracking app.", - "compose": "c2VydmljZXM6CiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdraW1haS1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LWtpbWFpfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgY29tbWFuZDogJy0tZGVmYXVsdC1zdG9yYWdlLWVuZ2luZSBpbm5vZGInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gbXlzcWxhZG1pbgogICAgICAgIC0gcGluZwogICAgICAgIC0gJy1oJwogICAgICAgIC0gMTI3LjAuMC4xCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBraW1haToKICAgIGltYWdlOiAna2ltYWkva2ltYWkyOmFwYWNoZScKICAgIGNvbnRhaW5lcl9uYW1lOiBraW1haQogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2tpbWFpLWRhdGE6L29wdC9raW1haS92YXIvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9LSU1BSV84MDAxCiAgICAgIC0gJ0FQUF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FQUFNFQ1JFVH0nCiAgICAgIC0gJ01BSUxFUl9GUk9NPSR7TUFJTEVSX0ZST006LWtpbWFpQGV4YW1wbGUuY29tfScKICAgICAgLSAnTUFJTEVSX1VSTD0ke01BSUxFUl9VUkw6LW51bGw6Ly9udWxsfScKICAgICAgLSAnQURNSU5NQUlMPSR7QURNSU5NQUlMOi1hZG1pbkBraW1haS5sb2NhbH0nCiAgICAgIC0gJ0FETUlOUEFTUz0ke1NFUlZJQ0VfUEFTU1dPUkRfQURNSU5QQVNTfScKICAgICAgLSAnREFUQUJBU0VfVVJMPW15c3FsOi8vJHtTRVJWSUNFX1VTRVJfTVlTUUx9OiR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH1AbXlzcWwvJHtNWVNRTF9EQVRBQkFTRX0/Y2hhcnNldD11dGY4bWI0JnNlcnZlclZlcnNpb249OC4zLjAnCiAgICAgIC0gJ1RSVVNURURfSE9TVFM9JHtUUlVTVEVEX0hPU1RTfXxsb2NhbGhvc3R8MTI3LjAuMC4xJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwMDEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTUK", - "tags": [ - "time-tracking", - "open-source" - ], - "category": "productivity", - "logo": "svgs/kimai.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:11:35+01:00", - "port": "8001" - }, "kuzzle": { "documentation": "https://kuzzle.io?utm_source=coolify.io", "slogan": "Kuzzle is a generic backend offering the basic building blocks common to every application.", @@ -2978,27 +2561,9 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-23T18:08:40+02:00", + "template_last_updated_at": "2026-10-19T17:18:16+02:00", "port": "3000" }, - "leantime": { - "documentation": "https://leantime.io?utm_source=coolify.io", - "slogan": "Leantime is a goals focused project management system for non-project managers.", - "compose": "c2VydmljZXM6CiAgbGVhbnRpbWU6CiAgICBpbWFnZTogJ2xlYW50aW1lL2xlYW50aW1lOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MRUFOVElNRV84MDgwCiAgICAgIC0gJ0xFQU5fQVBQX1VSTD0ke1NFUlZJQ0VfRlFETl9MRUFOVElNRX0nCiAgICAgIC0gTEVBTl9EQl9IT1NUPW15c3FsCiAgICAgIC0gJ0xFQU5fREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTERCfScKICAgICAgLSAnTEVBTl9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxEQn0nCiAgICAgIC0gJ0xFQU5fREJfREFUQUJBU0U9JHtEQl9OQU1FOi1sZWFudGltZS1kYn0nCiAgICAgIC0gJ0xFQU5fU0VTU0lPTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfU0FMVH0nCiAgICAgIC0gJ0xFQU5fU0VTU0lPTl9FWFBJUkFUSU9OPSR7U0VTU0lPTl9FWFBJUkFUSU9OOi0yODgwMH0nCiAgICAgIC0gJ0xFQU5fVVNFX1JFRElTPSR7VVNFX1JFRElTOi10cnVlfScKICAgICAgLSBMRUFOX1JFRElTX0hPU1Q9cmVkaXMKICAgICAgLSAnTEVBTl9SRURJU19QT1JUPSR7UkVESVNfUE9SVDotNjM3OX0nCiAgICAgIC0gJ0xFQU5fUkVESVNfU0NIRU1FPSR7UkVESVNfU0NIRU1FOi10Y3B9JwogICAgZGVwZW5kc19vbjoKICAgICAgbXlzcWw6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3B1YmxpYy11c2VyZmlsZXM6L3Zhci93d3cvaHRtbC9wdWJsaWMvdXNlcmZpbGVzJwogICAgICAtICd1c2VyZmlsZXM6L3Zhci93d3cvaHRtbC91c2VyZmlsZXMnCiAgICAgIC0gJ3BsdWdpbnM6L3Zhci93d3cvaHRtbC9hcHAvUGx1Z2lucycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgbXlzcWw6CiAgICBpbWFnZTogJ215c3FsOjguNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xlYW50aW1lX215c3FsX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTERCfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMREJ9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke0RCX05BTUU6LWxlYW50aW1lLWRifScKICAgIGNvbW1hbmQ6ICctLWNoYXJhY3Rlci1zZXQtc2VydmVyPVVURjhNQjQgLS1jb2xsYXRpb24tc2VydmVyPVVURjhNQjRfdW5pY29kZV9jaScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSBsb2NhbGhvc3QKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2xlYW50aW1lX3JlZGlzX2RhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", - "tags": [ - "project management", - "productivity", - "php", - "mysql", - "laravel", - "open source" - ], - "category": "productivity", - "logo": "svgs/leantime.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8080" - }, "librechat": { "documentation": "https://docs.librechat.ai/install/configuration/dotenv.html?utm_source=coolify.io", "slogan": "Self-hosted, powerful, and privacy-focused chat UI for multiple AI models", @@ -3039,20 +2604,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "librespeed": { - "documentation": "https://github.com/librespeed/speedtest?utm_source=coolify.io", - "slogan": "Self-hosted lightweight Speed Test.", - "compose": "c2VydmljZXM6CiAgbGlicmVzcGVlZDoKICAgIGNvbnRhaW5lcl9uYW1lOiBsaWJyZXNwZWVkCiAgICBpbWFnZTogJ2doY3IuaW8vbGlicmVzcGVlZC9zcGVlZHRlc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJQlJFU1BFRURfODIKICAgICAgLSBNT0RFPXN0YW5kYWxvbmUKICAgICAgLSBURUxFTUVUUlk9ZmFsc2UKICAgICAgLSBESVNUQU5DRT1rbQogICAgICAtIFdFQlBPUlQ9ODIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnY3VybCAxMjcuMC4wLjE6ODIgfHwgZXhpdCAxJwogICAgICB0aW1lb3V0OiAxcwogICAgICBpbnRlcnZhbDogMW0wcwogICAgICByZXRyaWVzOiAxCg==", - "tags": [ - "speedtest", - "internet-speed" - ], - "category": "devtools", - "logo": "svgs/librespeed.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-02-25T23:48:15Z", - "port": "82" - }, "libretranslate": { "documentation": "https://libretranslate.com/docs/?utm_source=coolify.io", "slogan": "Free and open-source machine translation API, entirely self-hosted.", @@ -3067,22 +2618,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5000" }, - "limesurvey": { - "documentation": "https://www.limesurvey.org/manual/?utm_source=coolify.io", - "slogan": "Simple, quick and anonymous online survey tool that's bursting with juicy insights.", - "compose": "c2VydmljZXM6CiAgbGltZXN1cnZleToKICAgIGltYWdlOiAnYWRhbXphbW1pdC9saW1lc3VydmV5OmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9MSU1FU1VSVkVZXzgwCiAgICAgIC0gTElNRVNVUlZFWV9EQl9IT1NUPW1hcmlhZGIKICAgICAgLSAnTElNRVNVUlZFWV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtICdMSU1FU1VSVkVZX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdMSU1FU1VSVkVZX0RCX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotbGltZXN1cnZleS1kYn0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfQURNSU5fVVNFUj0ke0xJTUVTVVJWRVlfQURNSU5fVVNFUjotYWRtaW59JwogICAgICAtICdMSU1FU1VSVkVZX0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfQURNSU5fTkFNRT0ke0xJTUVTVVJWRVlfQURNSU5fTkFNRTotQWRtaW59JwogICAgICAtICdMSU1FU1VSVkVZX0FETUlOX0VNQUlMPSR7TElNRVNVUlZFWV9BRE1JTl9FTUFJTDotYWRtaW5AZXhhbXBsZS5jb219JwogICAgICAtICdMSU1FU1VSVkVZX0ZST01fRU1BSUw9JHtMSU1FU1VSVkVZX0ZST01fRU1BSUx9JwogICAgICAtICdMSU1FU1VSVkVZX1NNVFBfSE9TVD0ke0xJTUVTVVJWRVlfU01UUF9IT1NUfScKICAgICAgLSAnTElNRVNVUlZFWV9TTVRQX1VTRVI9JHtMSU1FU1VSVkVZX1NNVFBfVVNFUn0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfU01UUF9QQVNTV09SRD0ke0xJTUVTVVJWRVlfU01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ0xJTUVTVVJWRVlfU01UUF9TU0w9JHtMSU1FU1VSVkVZX1NNVFBfU1NMfScKICAgICAgLSBMSU1FU1VSVkVZX1BIUF9TRVNTSU9OX1NBVkVfSEFORExFUj1yZWRpcwogICAgICAtICdMSU1FU1VSVkVZX1BIUF9TRVNTSU9OX1NBVkVfUEFUSD10Y3A6Ly9yZWRpczo2Mzc5JwogICAgICAtICdUWj0ke1RaOi1BbWVyaWNhL0phbWFpY2F9JwogICAgdm9sdW1lczoKICAgICAgLSAnbGltZXN1cnZleV9wbHVnaW5zX2RhdGE6L3Zhci93d3cvaHRtbC9wbHVnaW5zJwogICAgICAtICdsaW1lc3VydmV5X3VwbG9hZF9kYXRhOi92YXIvd3d3L2h0bWwvdXBsb2FkJwogICAgICAtICdsaW1lc3VydmV5X2NvbmZpZ19kYXRhOi92YXIvd3d3L2h0bWwvYXBwbGljYXRpb24vY29uZmlnJwogICAgICAtICdsaW1lc3VydmV5X3Nlc3Npb25zX2RhdGE6L3Zhci9saW1lL3Nlc3Npb25zJwogICAgZGVwZW5kc19vbjoKICAgICAgbWFyaWFkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMScKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotbGltZXN1cnZleS1kYn0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdsaW1lc3VydmV5X21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDNzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "online survey", - "forms", - "no code", - "open source" - ], - "category": "productivity", - "logo": "svgs/limesurvey.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "linkding-plus": { "documentation": "https://linkding.link/?utm_source=coolify.io", "slogan": "A self-hosted bookmark manager designed to be minimal, fast, and easy to set up. (Includes feature for archiving websites as HTML snapshots)", @@ -3147,7 +2682,7 @@ "litellm": { "documentation": "https://docs.litellm.ai?utm_source=coolify.io", "slogan": "Call all LLM APIs using the OpenAI format. Use Bedrock, Azure, OpenAI, Cohere, Anthropic, Ollama, Sagemaker, HuggingFace, Replicate, Groq (100+ LLMs)", - "compose": "c2VydmljZXM6CiAgbGl0ZWxsbToKICAgIGltYWdlOiAnZ2hjci5pby9iZXJyaWFpL2xpdGVsbG0tZGF0YWJhc2U6bWFpbi1zdGFibGUnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJVEVMTE1fNDAwMAogICAgICAtICdMSVRFTExNX0xPRz0ke0xJVEVMTE1fTE9HOi1FUlJPUn0nCiAgICAgIC0gJ0xJVEVMTE1fTU9ERT0ke0xJVEVMTE1fTU9ERTotUFJPRFVDVElPTn0nCiAgICAgIC0gJ0xJVEVMTE1fTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFTVEVSS0VZfScKICAgICAgLSAnVUlfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfVUl9JwogICAgICAtICdVSV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfVUl9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gJ1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxpdGVsbG19JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTkFJX0FQSV9CQVNFPSR7T1BFTkFJX0FQSV9CQVNFfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ0FOVEhST1BJQ19BUElfQkFTRT0ke0FOVEhST1BJQ19BUElfQkFTRX0nCiAgICAgIC0gJ1ZPWUFHRV9BUElfS0VZPSR7Vk9ZQUdFX0FQSV9LRVl9JwogICAgICAtICdWT1lBR0VfQVBJX0JBU0U9JHtWT1lBR0VfQVBJX0JBU0V9JwogICAgICAtICdTVE9SRV9NT0RFTF9JTl9EQj0ke1NUT1JFX01PREVMX0lOX0RCfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xpdGVsbG0tY29uZmlnLnlhbWwKICAgICAgICB0YXJnZXQ6IC9hcHAvY29uZmlnLnlhbWwKICAgICAgICBjb250ZW50OiAiZ2VuZXJhbF9zZXR0aW5nczpcbiAgcHJveHlfYmF0Y2hfd3JpdGVfYXQ6IDYwXG4gIHN0b3JlX3Byb21wdHNfaW5fc3BlbmRfbG9nczogZmFsc2Vcblxucm91dGVyX3NldHRpbmdzOlxuICByZWRpc19ob3N0OiBvcy5lbnZpcm9uL1JFRElTX0hPU1RcbiAgcmVkaXNfcG9ydDogb3MuZW52aXJvbi9SRURJU19QT1JUXG4gIHJlZGlzX3Bhc3N3b3JkOiBvcy5lbnZpcm9uL1JFRElTX1BBU1NXT1JEXG4gIGVuYWJsZV9wcmVfY2FsbF9jaGVjazogdHJ1ZVxuXG5saXRlbGxtX3NldHRpbmdzOlxuICBzZXRfdmVyYm9zZTogZmFsc2VcbiAganNvbl9sb2dzOiB0cnVlXG4gIGxvZ19yYXdfcmVxdWVzdF9yZXNwb25zZTogZmFsc2VcbiAgIyB0dXJuX29mZl9tZXNzYWdlX2xvZ2dpbmc6IGZhbHNlXG4gICMgcmVkYWN0X3VzZXJfYXBpX2tleV9pbmZvOiBmYWxzZVxuICBzZXJ2aWNlX2NhbGxiYWNrOiBbXCJwcm9tZXRoZXVzX3N5c3RlbVwiXVxuICBkcm9wX3BhcmFtczogdHJ1ZVxuICAjIG1heF9idWRnZXQ6IDEwMFxuICAjIGJ1ZGdldF9kdXJhdGlvbjogMzBkXG4gIG51bV9yZXRyaWVzOiAxXG4gIHJlcXVlc3RfdGltZW91dDogMTIwXG4gIHRlbGVtZXRyeTogZmFsc2VcbiAgY2FjaGU6IHRydWVcbiAgY2FjaGVfcGFyYW1zOlxuICAgIHR5cGU6IHJlZGlzXG4gICAgaG9zdDogb3MuZW52aXJvbi9SRURJU19IT1NUXG4gICAgcG9ydDogb3MuZW52aXJvbi9SRURJU19QT1JUXG4gICAgcGFzc3dvcmQ6IG9zLmVudmlyb24vUkVESVNfUEFTU1dPUkRcbiAgICBuYW1lc3BhY2U6IFwibGl0ZWxsbV9jYWNoZVwiXG4gICAgdHRsOiA2MDBcbiAgc3VjY2Vzc19jYWxsYmFjazpcbiAgICAjIC0gXCJsYW5nZnVzZVwiXG4gICAgLSBcInByb21ldGhldXNcIlxuICBmYWlsdXJlX2NhbGxiYWNrOlxuICAgICMgLSBcImxhbmdmdXNlXCJcbiAgICAtIFwicHJvbWV0aGV1c1wiXG5tb2RlbF9saXN0OlxuICAjIE9wZW5BSVxuICAtIG1vZGVsX25hbWU6IGdwdC00XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogb3BlbmFpL2dwdC00XG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgLSBtb2RlbF9uYW1lOiBncHQtNG9cbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBvcGVuYWkvZ3B0LTRvXG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgLSBtb2RlbF9uYW1lOiBncHQtNG8tbWluaVxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IG9wZW5haS9ncHQtNG8tbWluaVxuICAgICAgYXBpX2tleTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0tFWVxuICAgICAgYXBpX2Jhc2U6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9CQVNFXG4gICMgQW50aHJvcGljXG4gIC0gbW9kZWxfbmFtZTogY2xhdWRlLTMtaGFpa3VcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBjbGF1ZGUtMy1oYWlrdS0yMDI0MDMwN1xuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL0FOVEhST1BJQ19BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9CQVNFXCJcbiAgLSBtb2RlbF9uYW1lOiBjbGF1ZGUtMy41LXNvbm5ldFxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IGNsYXVkZS0zLTUtc29ubmV0LTIwMjQwNjIwXG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0JBU0VcIlxuICAjIFZveWFnZUFJXG4gIC0gbW9kZWxfbmFtZTogdm95YWdlLWxhdy0yXG4gICAgbW9kZWxfaW5mbzpcbiAgICAgIG91dHB1dF92ZWN0b3Jfc2l6ZTogMTAyNFxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IHZveWFnZS92b3lhZ2UtbGF3LTJcbiAgICAgIGFwaV9rZXk6IFwib3MuZW52aXJvbi9WT1lBR0VfQVBJX0tFWVwiXG4gICAgICBhcGlfYmFzZTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfQkFTRVwiXG4gICAgICAjIHJwbTogMzAwXG4gICAgICAjIHRwbTogMTAwMDAwMFxuICAtIG1vZGVsX25hbWU6IHZveWFnZS1tdWx0aWxpbmd1YWwtMlxuICAgIG1vZGVsX2luZm86XG4gICAgICBtb2RlOiBlbWJlZGRpbmdcbiAgICAgIG1heF90b2tlbnM6IDMyMDAwXG4gICAgICBtYXhfaW5wdXRfdG9rZW5zOiAzMjAwMFxuICAgICAgb3V0cHV0X3ZlY3Rvcl9zaXplOiAxMDI0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogdm95YWdlL3ZveWFnZS1tdWx0aWxpbmd1YWwtMlxuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9CQVNFXCJcbiAgICAgIGlucHV0X2Nvc3RfcGVyX3Rva2VuOiAwLjAwMDAwMDEyXG4gICAgICBvdXRwdXRfY29zdF9wZXJfdG9rZW46IDBcbiAgICAgICMgcnBtOiAzMDBcbiAgICAgICMgdHBtOiAxMDAwMDAwIgogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHB5dGhvbgogICAgICAgIC0gJy1jJwogICAgICAgIC0gImltcG9ydCB1cmxsaWIucmVxdWVzdDt1cmxsaWIucmVxdWVzdC51cmxvcGVuKCdodHRwOi8vMTI3LjAuMC4xOjQwMDAvaGVhbHRoL2xpdmVsaW5lc3MnLHRpbWVvdXQ9NSkiCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDMwcwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jb25maWcnCiAgICAgIC0gL2FwcC9jb25maWcueWFtbAogICAgICAtICctLXBvcnQnCiAgICAgIC0gJzQwMDAnCiAgICAgIC0gJy0tbnVtX3dvcmtlcnMnCiAgICAgIC0gJzInCiAgICAgIC0gJy0tcnVuX2d1bmljb3JuJwogICAgICAtICctLW1heF9yZXF1ZXN0c19iZWZvcmVfcmVzdGFydCcKICAgICAgLSAnMTAwMCcKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTYtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxpdGVsbG19JwogICAgICAtIFBPU1RHUkVTX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTCiAgICAgIC0gUE9TVEdSRVNfVVNFUj0kU0VSVklDRV9VU0VSX1BPU1RHUkVTCiAgICB2b2x1bWVzOgogICAgICAtICdwZy1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtaCBsb2NhbGhvc3QgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LWFscGluZScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1hcHBlbmRvbmx5IHllcycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3JlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBwaW5nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgbGl0ZWxsbToKICAgIGltYWdlOiAnZ2hjci5pby9iZXJyaWFpL2xpdGVsbG0tZGF0YWJhc2U6bWFpbi1zdGFibGUnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX0xJVEVMTE1fNDAwMAogICAgICAtICdMSVRFTExNX0xPRz0ke0xJVEVMTE1fTE9HOi1FUlJPUn0nCiAgICAgIC0gJ0xJVEVMTE1fTU9ERT0ke0xJVEVMTE1fTU9ERTotUFJPRFVDVElPTn0nCiAgICAgIC0gJ0xJVEVMTE1fTUFTVEVSX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFTVEVSS0VZfScKICAgICAgLSAnVUlfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfVUl9JwogICAgICAtICdVSV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfVUl9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzOjU0MzIvJHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gJ1JFRElTX0hPU1Q9JHtSRURJU19IT1NUOi1yZWRpc30nCiAgICAgIC0gJ1JFRElTX1BPUlQ9JHtSRURJU19QT1JUOi02Mzc5fScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWxpdGVsbG19JwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSAnT1BFTkFJX0FQSV9CQVNFPSR7T1BFTkFJX0FQSV9CQVNFfScKICAgICAgLSAnQU5USFJPUElDX0FQSV9LRVk9JHtBTlRIUk9QSUNfQVBJX0tFWX0nCiAgICAgIC0gJ0FOVEhST1BJQ19BUElfQkFTRT0ke0FOVEhST1BJQ19BUElfQkFTRX0nCiAgICAgIC0gJ1ZPWUFHRV9BUElfS0VZPSR7Vk9ZQUdFX0FQSV9LRVl9JwogICAgICAtICdWT1lBR0VfQVBJX0JBU0U9JHtWT1lBR0VfQVBJX0JBU0V9JwogICAgICAtICdTVE9SRV9NT0RFTF9JTl9EQj0ke1NUT1JFX01PREVMX0lOX0RCfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xpdGVsbG0tY29uZmlnLnlhbWwKICAgICAgICB0YXJnZXQ6IC9hcHAvY29uZmlnLnlhbWwKICAgICAgICBjb250ZW50OiAiZ2VuZXJhbF9zZXR0aW5nczpcbiAgcHJveHlfYmF0Y2hfd3JpdGVfYXQ6IDYwXG4gIHN0b3JlX3Byb21wdHNfaW5fc3BlbmRfbG9nczogZmFsc2Vcblxucm91dGVyX3NldHRpbmdzOlxuICByZWRpc19ob3N0OiBvcy5lbnZpcm9uL1JFRElTX0hPU1RcbiAgcmVkaXNfcG9ydDogb3MuZW52aXJvbi9SRURJU19QT1JUXG4gIHJlZGlzX3Bhc3N3b3JkOiBvcy5lbnZpcm9uL1JFRElTX1BBU1NXT1JEXG4gIGVuYWJsZV9wcmVfY2FsbF9jaGVjazogdHJ1ZVxuXG5saXRlbGxtX3NldHRpbmdzOlxuICBzZXRfdmVyYm9zZTogZmFsc2VcbiAganNvbl9sb2dzOiB0cnVlXG4gIGxvZ19yYXdfcmVxdWVzdF9yZXNwb25zZTogZmFsc2VcbiAgIyB0dXJuX29mZl9tZXNzYWdlX2xvZ2dpbmc6IGZhbHNlXG4gICMgcmVkYWN0X3VzZXJfYXBpX2tleV9pbmZvOiBmYWxzZVxuICBzZXJ2aWNlX2NhbGxiYWNrOiBbXCJwcm9tZXRoZXVzX3N5c3RlbVwiXVxuICBkcm9wX3BhcmFtczogdHJ1ZVxuICAjIG1heF9idWRnZXQ6IDEwMFxuICAjIGJ1ZGdldF9kdXJhdGlvbjogMzBkXG4gIG51bV9yZXRyaWVzOiAxXG4gIHJlcXVlc3RfdGltZW91dDogMTIwXG4gIHRlbGVtZXRyeTogZmFsc2VcbiAgY2FjaGU6IHRydWVcbiAgY2FjaGVfcGFyYW1zOlxuICAgIHR5cGU6IHJlZGlzXG4gICAgaG9zdDogb3MuZW52aXJvbi9SRURJU19IT1NUXG4gICAgcG9ydDogb3MuZW52aXJvbi9SRURJU19QT1JUXG4gICAgcGFzc3dvcmQ6IG9zLmVudmlyb24vUkVESVNfUEFTU1dPUkRcbiAgICBuYW1lc3BhY2U6IFwibGl0ZWxsbV9jYWNoZVwiXG4gICAgdHRsOiA2MDBcbiAgc3VjY2Vzc19jYWxsYmFjazpcbiAgICAjIC0gXCJsYW5nZnVzZVwiXG4gICAgLSBcInByb21ldGhldXNcIlxuICBmYWlsdXJlX2NhbGxiYWNrOlxuICAgICMgLSBcImxhbmdmdXNlXCJcbiAgICAtIFwicHJvbWV0aGV1c1wiXG5tb2RlbF9saXN0OlxuICAjIE9wZW5BSVxuICAtIG1vZGVsX25hbWU6IGdwdC00XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogb3BlbmFpL2dwdC00XG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgLSBtb2RlbF9uYW1lOiBncHQtNG9cbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBvcGVuYWkvZ3B0LTRvXG4gICAgICBhcGlfa2V5OiBvcy5lbnZpcm9uL09QRU5BSV9BUElfS0VZXG4gICAgICBhcGlfYmFzZTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0JBU0VcbiAgLSBtb2RlbF9uYW1lOiBncHQtNG8tbWluaVxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IG9wZW5haS9ncHQtNG8tbWluaVxuICAgICAgYXBpX2tleTogb3MuZW52aXJvbi9PUEVOQUlfQVBJX0tFWVxuICAgICAgYXBpX2Jhc2U6IG9zLmVudmlyb24vT1BFTkFJX0FQSV9CQVNFXG4gICMgQW50aHJvcGljXG4gIC0gbW9kZWxfbmFtZTogY2xhdWRlLTMtaGFpa3VcbiAgICBsaXRlbGxtX3BhcmFtczpcbiAgICAgIG1vZGVsOiBjbGF1ZGUtMy1oYWlrdS0yMDI0MDMwN1xuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL0FOVEhST1BJQ19BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9CQVNFXCJcbiAgLSBtb2RlbF9uYW1lOiBjbGF1ZGUtMy41LXNvbm5ldFxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IGNsYXVkZS0zLTUtc29ubmV0LTIwMjQwNjIwXG4gICAgICBhcGlfa2V5OiBcIm9zLmVudmlyb24vQU5USFJPUElDX0FQSV9LRVlcIlxuICAgICAgYXBpX2Jhc2U6IFwib3MuZW52aXJvbi9BTlRIUk9QSUNfQVBJX0JBU0VcIlxuICAjIFZveWFnZUFJXG4gIC0gbW9kZWxfbmFtZTogdm95YWdlLWxhdy0yXG4gICAgbW9kZWxfaW5mbzpcbiAgICAgIG91dHB1dF92ZWN0b3Jfc2l6ZTogMTAyNFxuICAgIGxpdGVsbG1fcGFyYW1zOlxuICAgICAgbW9kZWw6IHZveWFnZS92b3lhZ2UtbGF3LTJcbiAgICAgIGFwaV9rZXk6IFwib3MuZW52aXJvbi9WT1lBR0VfQVBJX0tFWVwiXG4gICAgICBhcGlfYmFzZTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfQkFTRVwiXG4gICAgICAjIHJwbTogMzAwXG4gICAgICAjIHRwbTogMTAwMDAwMFxuICAtIG1vZGVsX25hbWU6IHZveWFnZS1tdWx0aWxpbmd1YWwtMlxuICAgIG1vZGVsX2luZm86XG4gICAgICBtb2RlOiBlbWJlZGRpbmdcbiAgICAgIG1heF90b2tlbnM6IDMyMDAwXG4gICAgICBtYXhfaW5wdXRfdG9rZW5zOiAzMjAwMFxuICAgICAgb3V0cHV0X3ZlY3Rvcl9zaXplOiAxMDI0XG4gICAgbGl0ZWxsbV9wYXJhbXM6XG4gICAgICBtb2RlbDogdm95YWdlL3ZveWFnZS1tdWx0aWxpbmd1YWwtMlxuICAgICAgYXBpX2tleTogXCJvcy5lbnZpcm9uL1ZPWUFHRV9BUElfS0VZXCJcbiAgICAgIGFwaV9iYXNlOiBcIm9zLmVudmlyb24vVk9ZQUdFX0FQSV9CQVNFXCJcbiAgICAgIGlucHV0X2Nvc3RfcGVyX3Rva2VuOiAwLjAwMDAwMDEyXG4gICAgICBvdXRwdXRfY29zdF9wZXJfdG9rZW46IDBcbiAgICAgICMgcnBtOiAzMDBcbiAgICAgICMgdHBtOiAxMDAwMDAwXG4iCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcHl0aG9uCiAgICAgICAgLSAnLWMnCiAgICAgICAgLSAiaW1wb3J0IHVybGxpYi5yZXF1ZXN0O3VybGxpYi5yZXF1ZXN0LnVybG9wZW4oJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgvbGl2ZWxpbmVzcycsdGltZW91dD01KSIKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgICBjb21tYW5kOgogICAgICAtICctLWNvbmZpZycKICAgICAgLSAvYXBwL2NvbmZpZy55YW1sCiAgICAgIC0gJy0tcG9ydCcKICAgICAgLSAnNDAwMCcKICAgICAgLSAnLS1udW1fd29ya2VycycKICAgICAgLSAnMicKICAgICAgLSAnLS1ydW5fZ3VuaWNvcm4nCiAgICAgIC0gJy0tbWF4X3JlcXVlc3RzX2JlZm9yZV9yZXN0YXJ0JwogICAgICAtICcxMDAwJwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbGl0ZWxsbX0nCiAgICAgIC0gUE9TVEdSRVNfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVMKICAgICAgLSBQT1NUR1JFU19VU0VSPSRTRVJWSUNFX1VTRVJfUE9TVEdSRVMKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3BnLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1oIGxvY2FsaG9zdCAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLWFwcGVuZG9ubHkgeWVzJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK", "tags": [ "ai", "qdrant", @@ -3299,7 +2834,7 @@ "matrix-synapse-with-sqlite": { "documentation": "https://element-hq.github.io/synapse/latest/welcome_and_overview.html?utm_source=coolify.io", "slogan": "Chat securely with your family, friends, community, or build great apps with Matrix!", - "compose": "c2VydmljZXM6CiAgc3luYXBzZToKICAgIGltYWdlOiAnbWF0cml4ZG90b3JnL3N5bmFwc2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NZTkFQU0VfODAwOAogICAgICAtICdTWU5BUFNFX1NFUlZFUl9OQU1FPSR7U1lOQVBTRV9TRVJWRVJfTkFNRTo/fScKICAgICAgLSAnU1lOQVBTRV9SRVBPUlRfU1RBVFM9JHtTWU5BUFNFX1JFUE9SVF9TVEFUUzotbm99JwogICAgICAtICdFTkFCTEVfUkVHSVNUUkFUSU9OPSR7RU5BQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdTRVJWSUNFX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRVJWSUNFX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzeW5hcHNlLWRhdGE6L2RhdGEnCiAgICBlbnRyeXBvaW50OiAvYmluL2Jhc2gKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtICIjIEdlbmVyYXRlIGNvbmZpZyBvbiBmaXJzdCBydW5cbmlmIFsgISAtZiAvZGF0YS9ob21lc2VydmVyLnlhbWwgXTsgdGhlblxuICAjIEdlbmVyYXRlIGRlZmF1bHQgY29uZmlnIHVzaW5nIFNZTkFQU0VfU0VSVkVSX05BTUUgKHBlcm1hbmVudCwgdXNlZCBpbiB1c2VyIElEcylcbiAgL3N0YXJ0LnB5IGdlbmVyYXRlXG5cbiAgIyBTZXQgcHVibGljX2Jhc2V1cmwgdG8gYWN0dWFsIGRlcGxveW1lbnQgVVJMIChtYXkgZGlmZmVyIGZyb20gc2VydmVyX25hbWUpXG4gICMgVGhpcyBhbGxvd3MgaG9zdGluZyBhdCBtYXRyaXguZXhhbXBsZS5vcmcgd2hpbGUgc2VydmVyX25hbWUgaXMgZXhhbXBsZS5vcmdcbiAgaWYgISBncmVwIC1xIFwicHVibGljX2Jhc2V1cmxcIiAvZGF0YS9ob21lc2VydmVyLnlhbWw7IHRoZW5cbiAgICBzZWQgLWkgXCIvXnNlcnZlcl9uYW1lOi9hIHB1YmxpY19iYXNldXJsOiAke1NFUlZJQ0VfRlFETl9TWU5BUFNFfS9cIiAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgZmlcblxuICAjIENvbmZpZ3VyZSBsaXN0ZW5lciBmb3IgcmV2ZXJzZSBwcm94eVxuICBzZWQgLWkgJ3MvYmluZF9hZGRyZXNzZXM6IFxcWy4qXFxdL2JpbmRfYWRkcmVzc2VzOiBbXCIwLjAuMC4wXCJdLycgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIHNlZCAtaSAnL3hfZm9yd2FyZGVkOi9kJyAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgc2VkIC1pICcvdHlwZTogaHR0cC9hIFxcICAgIHhfZm9yd2FyZGVkOiB0cnVlJyAvZGF0YS9ob21lc2VydmVyLnlhbWxcblxuICAjIEVuYWJsZSByZWdpc3RyYXRpb24gaWYgcmVxdWVzdGVkXG4gIGlmIFsgXCIke0VOQUJMRV9SRUdJU1RSQVRJT059XCIgPSBcInRydWVcIiBdICYmICEgZ3JlcCAtcSBcImVuYWJsZV9yZWdpc3RyYXRpb25cIiAvZGF0YS9ob21lc2VydmVyLnlhbWw7IHRoZW5cbiAgICBlY2hvIFwiZW5hYmxlX3JlZ2lzdHJhdGlvbjogdHJ1ZVwiID4+IC9kYXRhL2hvbWVzZXJ2ZXIueWFtbFxuICBmaVxuZmlcblxuIyBSZWdpc3RlciBhZG1pbiB1c2VyIGlmIGNyZWRlbnRpYWxzIHByb3ZpZGVkXG5pZiBbIC1uIFwiJHtTRVJWSUNFX1VTRVJfQURNSU59XCIgXSAmJiBbIC1uIFwiJHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfVwiIF07IHRoZW5cbiAgKFxuICAgIHdoaWxlICEgY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MDA4L2hlYWx0aCA+IC9kZXYvbnVsbCAyPiYxOyBkb1xuICAgICAgc2xlZXAgMlxuICAgIGRvbmVcbiAgICByZWdpc3Rlcl9uZXdfbWF0cml4X3VzZXIgLWEgXFxcbiAgICAgIC11IFwiJHtTRVJWSUNFX1VTRVJfQURNSU59XCIgXFxcbiAgICAgIC1wIFwiJHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfVwiIFxcXG4gICAgICAtYyAvZGF0YS9ob21lc2VydmVyLnlhbWwgXFxcbiAgICAgIGh0dHA6Ly9sb2NhbGhvc3Q6ODAwOCAyPi9kZXYvbnVsbCB8fCB0cnVlXG4gICkgJlxuZmlcblxuZXhlYyAvc3RhcnQucHlcbiIKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovL2xvY2FsaG9zdDo4MDA4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogICAgICBzdGFydF9wZXJpb2Q6IDIwcwo=", + "compose": "c2VydmljZXM6CiAgc3luYXBzZToKICAgIGltYWdlOiAnbWF0cml4ZG90b3JnL3N5bmFwc2U6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NZTkFQU0VfODAwOAogICAgICAtICdTWU5BUFNFX1NFUlZFUl9OQU1FPSR7U1lOQVBTRV9TRVJWRVJfTkFNRTo/fScKICAgICAgLSAnU1lOQVBTRV9SRVBPUlRfU1RBVFM9JHtTWU5BUFNFX1JFUE9SVF9TVEFUUzotbm99JwogICAgICAtICdFTkFCTEVfUkVHSVNUUkFUSU9OPSR7RU5BQkxFX1JFR0lTVFJBVElPTjotZmFsc2V9JwogICAgICAtICdTRVJWSUNFX1VTRVJfQURNSU49JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdTRVJWSUNFX1BBU1NXT1JEX0FETUlOPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzeW5hcHNlLWRhdGE6L2RhdGEnCiAgICBlbnRyeXBvaW50OiAvYmluL2Jhc2gKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy1jJwogICAgICAtICIjIEdlbmVyYXRlIGNvbmZpZyBvbiBmaXJzdCBydW5cbmlmIFsgISAtZiAvZGF0YS9ob21lc2VydmVyLnlhbWwgXTsgdGhlblxuICAjIEdlbmVyYXRlIGRlZmF1bHQgY29uZmlnIHVzaW5nIFNZTkFQU0VfU0VSVkVSX05BTUUgKHBlcm1hbmVudCwgdXNlZCBpbiB1c2VyIElEcylcbiAgL3N0YXJ0LnB5IGdlbmVyYXRlXG5cbiAgIyBTZXQgcHVibGljX2Jhc2V1cmwgdG8gYWN0dWFsIGRlcGxveW1lbnQgVVJMIChtYXkgZGlmZmVyIGZyb20gc2VydmVyX25hbWUpXG4gICMgVGhpcyBhbGxvd3MgaG9zdGluZyBhdCBtYXRyaXguZXhhbXBsZS5vcmcgd2hpbGUgc2VydmVyX25hbWUgaXMgZXhhbXBsZS5vcmdcbiAgaWYgISBncmVwIC1xIFwicHVibGljX2Jhc2V1cmxcIiAvZGF0YS9ob21lc2VydmVyLnlhbWw7IHRoZW5cbiAgICBzZWQgLWkgXCIvXnNlcnZlcl9uYW1lOi9hIHB1YmxpY19iYXNldXJsOiAke1NFUlZJQ0VfRlFETl9TWU5BUFNFfS9cIiAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgZmlcblxuICAjIENvbmZpZ3VyZSBsaXN0ZW5lciBmb3IgcmV2ZXJzZSBwcm94eVxuICBzZWQgLWkgJ3MvYmluZF9hZGRyZXNzZXM6IFxcWy4qXFxdL2JpbmRfYWRkcmVzc2VzOiBbXCIwLjAuMC4wXCJdLycgL2RhdGEvaG9tZXNlcnZlci55YW1sXG4gIHNlZCAtaSAnL3hfZm9yd2FyZGVkOi9kJyAvZGF0YS9ob21lc2VydmVyLnlhbWxcbiAgc2VkIC1pICcvdHlwZTogaHR0cC9hIFxcICAgIHhfZm9yd2FyZGVkOiB0cnVlJyAvZGF0YS9ob21lc2VydmVyLnlhbWxcblxuICAjIEVuYWJsZSByZWdpc3RyYXRpb24gaWYgcmVxdWVzdGVkXG4gIGlmIFsgXCIke0VOQUJMRV9SRUdJU1RSQVRJT059XCIgPSBcInRydWVcIiBdICYmICEgZ3JlcCAtcSBcImVuYWJsZV9yZWdpc3RyYXRpb25cIiAvZGF0YS9ob21lc2VydmVyLnlhbWw7IHRoZW5cbiAgICBlY2hvIFwiZW5hYmxlX3JlZ2lzdHJhdGlvbjogdHJ1ZVwiID4+IC9kYXRhL2hvbWVzZXJ2ZXIueWFtbFxuICBmaVxuZmlcblxuIyBSZWdpc3RlciBhZG1pbiB1c2VyIGlmIGNyZWRlbnRpYWxzIHByb3ZpZGVkXG5pZiBbIC1uIFwiJHtTRVJWSUNFX1VTRVJfQURNSU59XCIgXSAmJiBbIC1uIFwiJHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfVwiIF07IHRoZW5cbiAgKFxuICAgIHdoaWxlICEgY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MDA4L2hlYWx0aCA+IC9kZXYvbnVsbCAyPiYxOyBkb1xuICAgICAgc2xlZXAgMlxuICAgIGRvbmVcbiAgICByZWdpc3Rlcl9uZXdfbWF0cml4X3VzZXIgLWEgXFxcbiAgICAgIC11IFwiJHtTRVJWSUNFX1VTRVJfQURNSU59XCIgXFxcbiAgICAgIC1wIFwiJHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfVwiIFxcXG4gICAgICAtYyAvZGF0YS9ob21lc2VydmVyLnlhbWwgXFxcbiAgICAgIGh0dHA6Ly9sb2NhbGhvc3Q6ODAwOCAyPi9kZXYvbnVsbCB8fCB0cnVlXG4gICkgJlxuZmlcblxuZXhlYyAvc3RhcnQucHkiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODAwOC9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMK", "tags": [ "chat", "slack", @@ -3349,21 +2884,6 @@ "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, - "mediawiki": { - "documentation": "https://www.mediawiki.org?utm_source=coolify.io", - "slogan": "MediaWiki is a collaboration and documentation platform brought to you by a vibrant community.", - "compose": "c2VydmljZXM6CiAgbWVkaWF3aWtpOgogICAgaW1hZ2U6ICdtZWRpYXdpa2k6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX01FRElBV0lLSV84MAogICAgdm9sdW1lczoKICAgICAgLSAnbWVkaWF3aWtpLWltYWdlczovdmFyL3d3dy9odG1sL2ltYWdlcycKICAgICAgLSAnbWVkaWF3aWtpLXNxbGl0ZTovdmFyL3d3dy9odG1sL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6ODAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "wiki", - "collaboration", - "documentation" - ], - "category": "cms", - "logo": "svgs/mediawiki.ico", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "meilisearch": { "documentation": "https://www.meilisearch.com?utm_source=coolify.io", "slogan": "MeiliSearch is a powerful, fast, easy to use and deploy search engine.", @@ -3494,45 +3014,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, - "mixpost": { - "documentation": "https://docs.mixpost.app/lite?utm_source=coolify.io", - "slogan": "Mixpost is a robust and versatile social media management software, designed to streamline social media operations and enhance content marketing strategies.", - "compose": "c2VydmljZXM6CiAgbWl4cG9zdDoKICAgIGltYWdlOiAnaW5vdmVjdG9yL21peHBvc3Q6bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX01JWFBPU1QKICAgICAgLSBBUFBfTkFNRT1NaXhwb3N0CiAgICAgIC0gJ0FQUF9LRVk9JHtTRVJWSUNFX0JBU0U2NF9NSVhQT1NUfScKICAgICAgLSBBUFBfREVCVUc9ZmFsc2UKICAgICAgLSAnQVBQX0RPTUFJTj0ke1NFUlZJQ0VfRlFETl9NSVhQT1NUfScKICAgICAgLSAnQVBQX1VSTD0ke1NFUlZJQ0VfRlFETl9NSVhQT1NUfScKICAgICAgLSAnREJfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotbWl4cG9zdF9kYn0nCiAgICAgIC0gJ0RCX1VTRVJOQU1FPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnU1NMX0VNQUlMPSR7U1NMX0VNQUlMOi11c2VyQGV4YW1wbGUuY29tfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ21peHBvc3Qtc3RvcmFnZTovdmFyL3d3dy9odG1sL3N0b3JhZ2UvYXBwJwogICAgICAtICdtaXhwb3N0LWxvZ3M6L3Zhci93d3cvaHRtbC9zdG9yYWdlL2xvZ3MnCiAgICBkZXBlbmRzX29uOgogICAgICAtIG15c3FsCiAgICAgIC0gcmVkaXMKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWwvbXlzcWwtc2VydmVyOjguMCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1taXhwb3N0X2RifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdtaXhwb3N0LW15c3FsOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICAgIC0gJy11cm9vdCcKICAgICAgICAtICctcCR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtICctLXJhdycKICAgICAgICAtIGluY3IKICAgICAgICAtIHBpbmcK", - "tags": [ - "social media", - "marketing", - "content", - "management", - "automation", - "self-hosted" - ], - "category": "analytics", - "logo": "svgs/mixpost.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "moodle": { - "documentation": "https://moodle.org?utm_source=coolify.io", - "slogan": "Moodle is the world\u2019s most customisable and trusted eLearning solution that empowers educators to improve our world.", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS4xJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQUxMT1dfRU1QVFlfUEFTU1dPUkQ9bm8KICAgICAgLSBNWVNRTF9ST09UX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1JPT1QKICAgICAgLSBNWVNRTF9EQVRBQkFTRT1iaXRuYW1pX21vb2RsZQogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9NQVJJQURCCiAgICAgIC0gTVlTUUxfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQgogICAgICAtIE1BUklBREJfQ0hBUkFDVEVSX1NFVD11dGY4bWI0CiAgICAgIC0gTUFSSUFEQl9DT0xMQVRFPXV0ZjhtYjRfdW5pY29kZV9jaQogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJiYXNoIC1jICc8L2Rldi90Y3AvbG9jYWxob3N0LzMzMDYnIgogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBtb29kbGU6CiAgICBpbWFnZTogJ2RvY2tlci5pby9iaXRuYW1pbGVnYWN5L21vb2RsZTo0LjMnCiAgICBkZXBlbmRzX29uOgogICAgICAtIG1hcmlhZGIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NT09ETEVfODA4MAogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9IT1NUPW1hcmlhZGIKICAgICAgLSBNT09ETEVfREFUQUJBU0VfUE9SVF9OVU1CRVI9MzMwNgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9VU0VSPSRTRVJWSUNFX1VTRVJfTUFSSUFEQgogICAgICAtIE1PT0RMRV9EQVRBQkFTRV9OQU1FPWJpdG5hbWlfbW9vZGxlCiAgICAgIC0gTU9PRExFX0RBVEFCQVNFX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX01BUklBREIKICAgICAgLSBBTExPV19FTVBUWV9QQVNTV09SRD1ubwogICAgICAtICdNT09ETEVfVVNFUk5BTUU9JHtNT09ETEVfVVNFUk5BTUU6LXVzZXJ9JwogICAgICAtIE1PT0RMRV9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NT09ETEUKICAgICAgLSBNT09ETEVfRU1BSUw9dXNlckBleGFtcGxlLmNvbQogICAgICAtICdNT09ETEVfU0lURV9OQU1FPSR7TU9PRExFX1NJVEVfTkFNRTotTmV3IFNpdGV9JwogICAgdm9sdW1lczoKICAgICAgLSAnbW9vZGxlLWRhdGE6L2JpdG5hbWkvbW9vZGxlJwogICAgICAtICdtb29kbGVkYXRhLWRhdGE6L2JpdG5hbWkvbW9vZGxlZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwaHAKICAgICAgICAtICctcicKICAgICAgICAtICJleGl0KGZpbGVfZXhpc3RzKCcvb3B0L2JpdG5hbWkvbW9vZGxlL2NvbmZpZy5waHAnKSA/IDAgOiAxKTsiCiAgICAgIGludGVydmFsOiAyMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDUK", - "tags": [ - "moodle", - "elearning", - "education", - "lms", - "cms", - "open", - "source", - "low", - "code" - ], - "category": "cms", - "logo": "svgs/moodle.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-10-09T09:49:33+05:30", - "port": "8080" - }, "mosquitto": { "documentation": "https://mosquitto.org/documentation/?utm_source=coolify.io", "slogan": "Mosquitto is lightweight and suitable for use on all devices, from low-power single-board computers to full servers.", @@ -3703,74 +3184,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "3000" }, - "nextcloud-with-mariadb": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTkVYVENMT1VEXzgwCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9QYXJpc30nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TUFSSUFEQl9EQVRBQkFTRTotbmV4dGNsb3VkfScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01BUklBREJ9JwogICAgICAtIE1ZU1FMX0hPU1Q9bmV4dGNsb3VkLWRiCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLWNvbmZpZzovY29uZmlnJwogICAgICAtICduZXh0Y2xvdWQtZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIG5leHRjbG91ZC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwL3N0YXR1cy5waHAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbmV4dGNsb3VkLWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLW1hcmlhZGItZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1uZXh0Y2xvdWR9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny40LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ25leHRjbG91ZC1yZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud-with-mysql": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTkVYVENMT1VEXzgwCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9QYXJpc30nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7TVlTUUxfREFUQUJBU0U6LW5leHRjbG91ZH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICAtIE1ZU1FMX0hPU1Q9bmV4dGNsb3VkLWRiCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLWNvbmZpZzovY29uZmlnJwogICAgICAtICduZXh0Y2xvdWQtZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIG5leHRjbG91ZC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwL3N0YXR1cy5waHAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbmV4dGNsb3VkLWRiOgogICAgaW1hZ2U6ICdteXNxbDo4LjQuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJ25leHRjbG91ZC1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1uZXh0Y2xvdWR9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo3LjQtYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLXJlZGlzLWRhdGE6L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSBQSU5HCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMjAK", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud-with-postgres": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTkVYVENMT1VEXzgwCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9QYXJpc30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LW5leHRjbG91ZH0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtIFBPU1RHUkVTX0hPU1Q9bmV4dGNsb3VkLWRiCiAgICAgIC0gUkVESVNfSE9TVD1yZWRpcwogICAgICAtIFJFRElTX1BPUlQ9NjM3OQogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLWNvbmZpZzovY29uZmlnJwogICAgICAtICduZXh0Y2xvdWQtZGF0YTovZGF0YScKICAgIGRlcGVuZHNfb246CiAgICAgIG5leHRjbG91ZC1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwL3N0YXR1cy5waHAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgbmV4dGNsb3VkLWRiOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICduZXh0Y2xvdWQtcG9zdGdyZXNxbC1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotbmV4dGNsb3VkfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6Ny40LWFscGluZScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ25leHRjbG91ZC1yZWRpcy1kYXRhOi9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gUElORwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDIwCg==", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, - "nextcloud": { - "documentation": "https://docs.nextcloud.com?utm_source=coolify.io", - "slogan": "NextCloud is a self-hosted, open-source platform that provides file storage, collaboration, and communication tools for seamless data management.", - "compose": "c2VydmljZXM6CiAgbmV4dGNsb3VkOgogICAgaW1hZ2U6ICdsc2NyLmlvL2xpbnV4c2VydmVyL25leHRjbG91ZDpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTkVYVENMT1VEXzgwCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gJ1RaPSR7VFo6LUV1cm9wZS9NYWRyaWR9JwogICAgdm9sdW1lczoKICAgICAgLSAnbmV4dGNsb3VkLWNvbmZpZzovY29uZmlnJwogICAgICAtICduZXh0Y2xvdWQtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MC9zdGF0dXMucGhwJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "cloud", - "collaboration", - "communication", - "filestorage", - "data" - ], - "category": "storage", - "logo": "svgs/nextcloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-07T23:32:57+05:30", - "port": "80" - }, "nexus-arm": { "documentation": "https://help.sonatype.com/en/sonatype-nexus-repository.html?utm_source=coolify.io", "slogan": "Open source Universal Repository Manager (ARM version, community edition), default credentials: admin/admin123", @@ -3914,24 +3327,10 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, - "observium": { - "documentation": "https://docs.observium.org/?utm_source=coolify.io", - "slogan": "Observium is a comprehensive network monitoring platform designed to deliver powerful monitoring capabilities, combined with an elegant and intuitive user interface.", - "compose": "c2VydmljZXM6CiAgb2JzZXJ2aXVtOgogICAgaW1hZ2U6ICdtYml4dGVjaC9vYnNlcnZpdW06bGF0ZXN0JwogICAgcGxhdGZvcm06IGxpbnV4L2FtZDY0CiAgICB2b2x1bWVzOgogICAgICAtICdvYnNlcnZpdW1fbG9nczovb3B0L29ic2Vydml1bS9sb2dzJwogICAgICAtICdvYnNlcnZpdW1fcnJkOi9vcHQvb2JzZXJ2aXVtL3JyZCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdTRVJWSUNFX0ZRRE5fT0JTRVJWSVVNPSR7U0VSVklDRV9GUUROX09CU0VSVklVTX0nCiAgICAgIC0gJ09CU0VSVklVTV9BRE1JTl9QQVNTPSR7U0VSVklDRV9QQVNTV09SRF9PQlNFUlZJVU1BRE1JTn0nCiAgICAgIC0gJ09CU0VSVklVTV9BRE1JTl9VU0VSPSR7U0VSVklDRV9VU0VSX09CU0VSVklVTUFETUlOfScKICAgICAgLSBPQlNFUlZJVU1fREJfSE9TVD1tYXJpYWRiCiAgICAgIC0gJ09CU0VSVklVTV9EQl9OQU1FPSR7T0JTRVJWSVVNX0RCX05BTUU6LW9ic2Vydml1bS1kYn0nCiAgICAgIC0gJ09CU0VSVklVTV9EQl9QQVNTPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnT0JTRVJWSVVNX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QvJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogNXMKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOmx0cycKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29ic2Vydml1bV9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCUk9PVH0nCiAgICAgIC0gJ01BUklBREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NQVJJQURCfScKICAgICAgLSAnTUFSSUFEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNQVJJQURCX0RBVEFCQVNFOi1vYnNlcnZpdW0tZGJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "networking", - "monitoring" - ], - "category": "monitoring", - "logo": "svgs/observium.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "obsidian-livesync": { "documentation": "https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/setup_own_server.md?utm_source=coolify.io", "slogan": "Self-hosted sync backend for Obsidian LiveSync plugin using CouchDB", - "compose": "c2VydmljZXM6CiAgY291Y2hkYjoKICAgIGltYWdlOiAnY291Y2hkYjozLjQuMicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DT1VDSERCXzU5ODQKICAgICAgLSAnQ09VQ0hEQl9VU0VSPSR7U0VSVklDRV9VU0VSX0NPVUNIREJ9JwogICAgICAtICdDT1VDSERCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9DT1VDSERCfScKICAgICAgLSAnTUFYX0RPQ1VNRU5UX1NJWkU9JHtNQVhfRE9DVU1FTlRfU0laRTotNTI0Mjg4MDB9JwogICAgICAtICdNQVhfSFRUUF9SRVFVRVNUX1NJWkU9JHtNQVhfSFRUUF9SRVFVRVNUX1NJWkU6LTY3MTA4ODY0fScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xvY2FsLmluaQogICAgICAgIHRhcmdldDogL29wdC9jb3VjaGRiL2V0Yy9sb2NhbC5pbmkKICAgICAgICBjb250ZW50OiAiW2NvdWNoZGJdXG5zaW5nbGVfbm9kZT10cnVlXG5tYXhfZG9jdW1lbnRfc2l6ZSA9ICR7TUFYX0RPQ1VNRU5UX1NJWkV9XG5cbltjaHR0cGRdXG5yZXF1aXJlX3ZhbGlkX3VzZXIgPSB0cnVlXG5tYXhfaHR0cF9yZXF1ZXN0X3NpemUgPSAke01BWF9IVFRQX1JFUVVFU1RfU0laRX1cblxuW2NodHRwZF9hdXRoXVxucmVxdWlyZV92YWxpZF91c2VyID0gdHJ1ZVxuYXV0aGVudGljYXRpb25fcmVkaXJlY3QgPSAvX3V0aWxzL3Nlc3Npb24uaHRtbFxuXG5baHR0cGRdXG5XV1ctQXV0aGVudGljYXRlID0gQmFzaWMgcmVhbG09XCJjb3VjaGRiXCJcbmVuYWJsZV9jb3JzID0gdHJ1ZVxuXG5bY29yc11cbm9yaWdpbnMgPSBhcHA6Ly9vYnNpZGlhbi5tZCxjYXBhY2l0b3I6Ly9sb2NhbGhvc3QsaHR0cDovL2xvY2FsaG9zdFxuY3JlZGVudGlhbHMgPSB0cnVlXG5oZWFkZXJzID0gYWNjZXB0LCBhdXRob3JpemF0aW9uLCBjb250ZW50LXR5cGUsIG9yaWdpbiwgcmVmZXJlclxubWV0aG9kcyA9IEdFVCwgUFVULCBQT1NULCBIRUFELCBERUxFVEVcbm1heF9hZ2UgPSAzNjAwIgogICAgICAtICdjb3VjaGRiLWRhdGE6L29wdC9jb3VjaGRiL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLWYgLXUgIiQke0NPVUNIREJfVVNFUn06JCR7Q09VQ0hEQl9QQVNTV09SRH0iIGh0dHA6Ly9sb2NhbGhvc3Q6NTk4NC9fdXAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHN0YXJ0X3BlcmlvZDogNDBzCiAgICAgIHJldHJpZXM6IDMKdm9sdW1lczoKICBjb3VjaGRiLWRhdGE6IG51bGwK", + "compose": "c2VydmljZXM6CiAgY291Y2hkYjoKICAgIGltYWdlOiAnY291Y2hkYjozLjQuMicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9DT1VDSERCXzU5ODQKICAgICAgLSAnQ09VQ0hEQl9VU0VSPSR7U0VSVklDRV9VU0VSX0NPVUNIREJ9JwogICAgICAtICdDT1VDSERCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF82NF9DT1VDSERCfScKICAgICAgLSAnTUFYX0RPQ1VNRU5UX1NJWkU9JHtNQVhfRE9DVU1FTlRfU0laRTotNTI0Mjg4MDB9JwogICAgICAtICdNQVhfSFRUUF9SRVFVRVNUX1NJWkU9JHtNQVhfSFRUUF9SRVFVRVNUX1NJWkU6LTY3MTA4ODY0fScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2xvY2FsLmluaQogICAgICAgIHRhcmdldDogL29wdC9jb3VjaGRiL2V0Yy9sb2NhbC5pbmkKICAgICAgICBjb250ZW50OiAiW2NvdWNoZGJdXG5zaW5nbGVfbm9kZT10cnVlXG5tYXhfZG9jdW1lbnRfc2l6ZSA9ICR7TUFYX0RPQ1VNRU5UX1NJWkV9XG5cbltjaHR0cGRdXG5yZXF1aXJlX3ZhbGlkX3VzZXIgPSB0cnVlXG5tYXhfaHR0cF9yZXF1ZXN0X3NpemUgPSAke01BWF9IVFRQX1JFUVVFU1RfU0laRX1cblxuW2NodHRwZF9hdXRoXVxucmVxdWlyZV92YWxpZF91c2VyID0gdHJ1ZVxuYXV0aGVudGljYXRpb25fcmVkaXJlY3QgPSAvX3V0aWxzL3Nlc3Npb24uaHRtbFxuXG5baHR0cGRdXG5XV1ctQXV0aGVudGljYXRlID0gQmFzaWMgcmVhbG09XCJjb3VjaGRiXCJcbmVuYWJsZV9jb3JzID0gdHJ1ZVxuXG5bY29yc11cbm9yaWdpbnMgPSBhcHA6Ly9vYnNpZGlhbi5tZCxjYXBhY2l0b3I6Ly9sb2NhbGhvc3QsaHR0cDovL2xvY2FsaG9zdFxuY3JlZGVudGlhbHMgPSB0cnVlXG5oZWFkZXJzID0gYWNjZXB0LCBhdXRob3JpemF0aW9uLCBjb250ZW50LXR5cGUsIG9yaWdpbiwgcmVmZXJlclxubWV0aG9kcyA9IEdFVCwgUFVULCBQT1NULCBIRUFELCBERUxFVEVcbm1heF9hZ2UgPSAzNjAwXG4iCiAgICAgIC0gJ2NvdWNoZGItZGF0YTovb3B0L2NvdWNoZGIvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiAtdSAiJCR7Q09VQ0hEQl9VU0VSfTokJHtDT1VDSERCX1BBU1NXT1JEfSIgaHR0cDovL2xvY2FsaG9zdDo1OTg0L191cCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMKICAgICAgcmV0cmllczogMwp2b2x1bWVzOgogIGNvdWNoZGItZGF0YTogbnVsbAo=", "tags": [ "obsidian", "couchdb", @@ -3981,7 +3380,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", + "template_last_updated_at": "2026-10-19T21:46:16+02:00", "port": "8069" }, "ollama-with-open-webui": { @@ -4136,7 +3535,7 @@ "openpanel": { "documentation": "https://openpanel.dev/docs?utm_source=coolify.io", "slogan": "Open source alternative to Mixpanel and Plausible for product analytics", - "compose": "c2VydmljZXM6CiAgb3BlbnBhbmVsLWRhc2hib2FyZDoKICAgIGltYWdlOiAnbGluZGVzdmFyZC9vcGVucGFuZWwtZGFzaGJvYXJkOjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfRlFETl9PUERBU0hCT0FSRF8zMDAwCiAgICAgIC0gJ0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fT1BBUEl9JwogICAgICAtICdEQVNIQk9BUkRfVVJMPSR7U0VSVklDRV9GUUROX09QREFTSEJPQVJEfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9wZW5wYW5lbC13b3JrZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mIGh0dHA6Ly9sb2NhbGhvc3Q6MzAwMC9hcGkvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMKICBvcGVucGFuZWwtYXBpOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC1hcGk6MicKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIlxuICBlY2hvICdSdW5uaW5nIG1pZ3JhdGlvbnMuLi4nXG4gIENJPXRydWUgcG5wbSAtciBydW4gbWlncmF0ZTpkZXBsb3lcblxuICBwbnBtIHN0YXJ0XG5cIlxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BBUEkKICAgICAgLSAnQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9PUEFQSX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VUkw9JHtTRVJWSUNFX0ZRRE5fT1BEQVNIQk9BUkR9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ0RBVEFCQVNFX1VSTF9ESVJFQ1Q9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzL29wZW5wYW5lbCcKICAgICAgLSAnQ09PS0lFX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NPT0tJRVNFQ1JFVH0nCiAgICAgIC0gJ0FMTE9XX1JFR0lTVFJBVElPTj0ke09QRU5QQU5FTF9BTExPV19SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnQUxMT1dfSU5WSVRBVElPTj0ke09QRU5QQU5FTF9BTExPV19JTlZJVEFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VNQUlMX1NFTkRFUj0ke09QRU5QQU5FTF9FTUFJTF9TRU5ERVJ9JwogICAgICAtICdSRVNFTkRfQVBJX0tFWT0ke1JFU0VORF9BUElfS0VZfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBvcGVucGFuZWwtd29ya2VyOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC13b3JrZXI6MicKICAgIGVudmlyb25tZW50OgogICAgICAtICdESVNBQkxFX0JVTExCT0FSRD0ke0RJU0FCTEVfQlVMTEJPQVJEOi0xfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfRlFETl9PUEJVTExCT0FSRAogICAgICAtICdBUElfVVJMPSR7U0VSVklDRV9GUUROX09QQVBJfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdEQVRBQkFTRV9VUkxfRElSRUNUPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdvcGVucGFuZWxfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuNC1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdvcGVucGFuZWxfcmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30gLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNS4xMC4yLjY1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdvcGVucGFuZWxfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLWNvbmZpZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvb3AtY29uZmlnLnhtbAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8bG9nZ2VyPlxuICAgICAgICA8bGV2ZWw+d2FybmluZzwvbGV2ZWw+XG4gICAgICAgIDxjb25zb2xlPnRydWU8L2NvbnNvbGU+XG4gICAgPC9sb2dnZXI+XG4gICAgPGtlZXBfYWxpdmVfdGltZW91dD4xMDwva2VlcF9hbGl2ZV90aW1lb3V0PlxuICAgIDwhLS0gU3RvcCBhbGwgdGhlIHVubmVjZXNzYXJ5IGxvZ2dpbmcgLS0+XG4gICAgPHF1ZXJ5X3RocmVhZF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxxdWVyeV9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDx0ZXh0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRyYWNlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPG1ldHJpY19sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxhc3luY2hyb25vdXNfbWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHNlc3Npb25fbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cGFydF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxsaXN0ZW5faG9zdD4wLjAuMC4wPC9saXN0ZW5faG9zdD5cbiAgICA8aW50ZXJzZXJ2ZXJfbGlzdGVuX2hvc3Q+MC4wLjAuMDwvaW50ZXJzZXJ2ZXJfbGlzdGVuX2hvc3Q+XG4gICAgPGludGVyc2VydmVyX2h0dHBfaG9zdD5vcGNoPC9pbnRlcnNlcnZlcl9odHRwX2hvc3Q+XG4gICAgPCEtLSBEaXNhYmxlIGNncm91cCBtZW1vcnkgb2JzZXJ2ZXIgLS0+XG4gICAgPGNncm91cHNfbWVtb3J5X3VzYWdlX29ic2VydmVyX3dhaXRfdGltZT4wPC9jZ3JvdXBzX21lbW9yeV91c2FnZV9vYnNlcnZlcl93YWl0X3RpbWU+XG4gICAgPCEtLSBOb3QgdXNlZCBhbnltb3JlLCBidXQga2VwdCBmb3IgYmFja3dhcmRzIGNvbXBhdGliaWxpdHkgLS0+XG4gICAgPG1hY3Jvcz5cbiAgICAgICAgPHNoYXJkPjE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMTwvcmVwbGljYT5cbiAgICAgICAgPGNsdXN0ZXI+b3BlbnBhbmVsX2NsdXN0ZXI8L2NsdXN0ZXI+XG4gICAgPC9tYWNyb3M+XG48L2NsaWNraG91c2U+IgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLXVzZXItY29uZmlnLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci91c2Vycy5kL29wLXVzZXItY29uZmlnLnhtbAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8cHJvZmlsZXM+XG4gICAgICAgIDxkZWZhdWx0PlxuICAgICAgICAgICAgPGxvZ19xdWVyaWVzPjA8L2xvZ19xdWVyaWVzPlxuICAgICAgICAgICAgPGxvZ19xdWVyeV90aHJlYWRzPjA8L2xvZ19xdWVyeV90aHJlYWRzPlxuICAgICAgICA8L2RlZmF1bHQ+XG4gICAgPC9wcm9maWxlcz5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vaW5pdC1kYi5zaAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtZGIuc2gKICAgICAgICBjb250ZW50OiAiIyEvYmluL3NoXG5zZXQgLWVcblxuY2xpY2tob3VzZSBjbGllbnQgLW4gPDwtRU9TUUxcbiAgQ1JFQVRFIERBVEFCQVNFIElGIE5PVCBFWElTVFMgb3BlbnBhbmVsO1xuRU9TUUwiCiAgICB1bGltaXRzOgogICAgICBub2ZpbGU6CiAgICAgICAgc29mdDogMjYyMTQ0CiAgICAgICAgaGFyZDogMjYyMTQ0CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2NsaWNraG91c2UtY2xpZW50IC0tcXVlcnkgIlNFTEVDVCAxIicKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1Cg==", + "compose": "c2VydmljZXM6CiAgb3BlbnBhbmVsLWRhc2hib2FyZDoKICAgIGltYWdlOiAnbGluZGVzdmFyZC9vcGVucGFuZWwtZGFzaGJvYXJkOjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfRlFETl9PUERBU0hCT0FSRF8zMDAwCiAgICAgIC0gJ0FQSV9VUkw9JHtTRVJWSUNFX0ZRRE5fT1BBUEl9JwogICAgICAtICdEQVNIQk9BUkRfVVJMPSR7U0VSVklDRV9GUUROX09QREFTSEJPQVJEfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG9wZW5wYW5lbC13b3JrZXI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjdXJsIC1mIGh0dHA6Ly9sb2NhbGhvc3Q6MzAwMC9hcGkvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAxNXMKICBvcGVucGFuZWwtYXBpOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC1hcGk6MicKICAgIGNvbW1hbmQ6ICJzaCAtYyBcIlxuICBlY2hvICdSdW5uaW5nIG1pZ3JhdGlvbnMuLi4nXG4gIENJPXRydWUgcG5wbSAtciBydW4gbWlncmF0ZTpkZXBsb3lcblxuICBwbnBtIHN0YXJ0XG5cIlxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFNFTEZfSE9TVEVEPXRydWUKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1BBUEkKICAgICAgLSAnQVBJX1VSTD0ke1NFUlZJQ0VfRlFETl9PUEFQSX0nCiAgICAgIC0gJ0RBU0hCT0FSRF9VUkw9JHtTRVJWSUNFX0ZRRE5fT1BEQVNIQk9BUkR9JwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ0RBVEFCQVNFX1VSTF9ESVJFQ1Q9cG9zdGdyZXM6Ly8ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU306JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUBwb3N0Z3Jlczo1NDMyLyR7T1BFTlBBTkVMX1BPU1RHUkVTX0RCOi1vcGVucGFuZWwtZGJ9P3NjaGVtYT1wdWJsaWMnCiAgICAgIC0gJ1JFRElTX1VSTD1yZWRpczovL2RlZmF1bHQ6JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfUByZWRpczo2Mzc5JwogICAgICAtICdDTElDS0hPVVNFX1VSTD1odHRwOi8vY2xpY2tob3VzZTo4MTIzL29wZW5wYW5lbCcKICAgICAgLSAnQ09PS0lFX1NFQ1JFVD0ke1NFUlZJQ0VfQkFTRTY0X0NPT0tJRVNFQ1JFVH0nCiAgICAgIC0gJ0FMTE9XX1JFR0lTVFJBVElPTj0ke09QRU5QQU5FTF9BTExPV19SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnQUxMT1dfSU5WSVRBVElPTj0ke09QRU5QQU5FTF9BTExPV19JTlZJVEFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VNQUlMX1NFTkRFUj0ke09QRU5QQU5FTF9FTUFJTF9TRU5ERVJ9JwogICAgICAtICdSRVNFTkRfQVBJX0tFWT0ke1JFU0VORF9BUElfS0VZfScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBvcGVucGFuZWwtd29ya2VyOgogICAgaW1hZ2U6ICdsaW5kZXN2YXJkL29wZW5wYW5lbC13b3JrZXI6MicKICAgIGVudmlyb25tZW50OgogICAgICAtICdESVNBQkxFX0JVTExCT0FSRD0ke0RJU0FCTEVfQlVMTEJPQVJEOi0xfScKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gU0VMRl9IT1NURUQ9dHJ1ZQogICAgICAtIFNFUlZJQ0VfRlFETl9PUEJVTExCT0FSRAogICAgICAtICdBUElfVVJMPSR7U0VSVklDRV9GUUROX09QQVBJfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdEQVRBQkFTRV9VUkxfRElSRUNUPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXM6NTQzMi8ke09QRU5QQU5FTF9QT1NUR1JFU19EQjotb3BlbnBhbmVsLWRifT9zY2hlbWE9cHVibGljJwogICAgICAtICdSRURJU19VUkw9cmVkaXM6Ly9kZWZhdWx0OiR7U0VSVklDRV9QQVNTV09SRF9SRURJU31AcmVkaXM6NjM3OScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovL2NsaWNraG91c2U6ODEyMy9vcGVucGFuZWwnCiAgICBkZXBlbmRzX29uOgogICAgICBvcGVucGFuZWwtYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtZiBodHRwOi8vbG9jYWxob3N0OjMwMDAvaGVhbHRoY2hlY2sgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiA1cwogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdvcGVucGFuZWxfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtPUEVOUEFORUxfUE9TVEdSRVNfREI6LW9wZW5wYW5lbC1kYn0nCiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjcuNC1hbHBpbmUnCiAgICB2b2x1bWVzOgogICAgICAtICdvcGVucGFuZWxfcmVkaXNfZGF0YTovZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30gLS1tYXhtZW1vcnktcG9saWN5IG5vZXZpY3Rpb24nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgY2xpY2tob3VzZToKICAgIGltYWdlOiAnY2xpY2tob3VzZS9jbGlja2hvdXNlLXNlcnZlcjoyNS4xMC4yLjY1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLSAnb3BlbnBhbmVsX2NsaWNraG91c2VfZGF0YTovdmFyL2xpYi9jbGlja2hvdXNlJwogICAgICAtICdvcGVucGFuZWxfY2xpY2tob3VzZV9sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlLWNvbmZpZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvb3AtY29uZmlnLnhtbAogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8bG9nZ2VyPlxuICAgICAgICA8bGV2ZWw+d2FybmluZzwvbGV2ZWw+XG4gICAgICAgIDxjb25zb2xlPnRydWU8L2NvbnNvbGU+XG4gICAgPC9sb2dnZXI+XG4gICAgPGtlZXBfYWxpdmVfdGltZW91dD4xMDwva2VlcF9hbGl2ZV90aW1lb3V0PlxuICAgIDwhLS0gU3RvcCBhbGwgdGhlIHVubmVjZXNzYXJ5IGxvZ2dpbmcgLS0+XG4gICAgPHF1ZXJ5X3RocmVhZF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxxdWVyeV9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDx0ZXh0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRyYWNlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPG1ldHJpY19sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxhc3luY2hyb25vdXNfbWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHNlc3Npb25fbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cGFydF9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxsaXN0ZW5faG9zdD4wLjAuMC4wPC9saXN0ZW5faG9zdD5cbiAgICA8aW50ZXJzZXJ2ZXJfbGlzdGVuX2hvc3Q+MC4wLjAuMDwvaW50ZXJzZXJ2ZXJfbGlzdGVuX2hvc3Q+XG4gICAgPGludGVyc2VydmVyX2h0dHBfaG9zdD5vcGNoPC9pbnRlcnNlcnZlcl9odHRwX2hvc3Q+XG4gICAgPCEtLSBEaXNhYmxlIGNncm91cCBtZW1vcnkgb2JzZXJ2ZXIgLS0+XG4gICAgPGNncm91cHNfbWVtb3J5X3VzYWdlX29ic2VydmVyX3dhaXRfdGltZT4wPC9jZ3JvdXBzX21lbW9yeV91c2FnZV9vYnNlcnZlcl93YWl0X3RpbWU+XG4gICAgPCEtLSBOb3QgdXNlZCBhbnltb3JlLCBidXQga2VwdCBmb3IgYmFja3dhcmRzIGNvbXBhdGliaWxpdHkgLS0+XG4gICAgPG1hY3Jvcz5cbiAgICAgICAgPHNoYXJkPjE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMTwvcmVwbGljYT5cbiAgICAgICAgPGNsdXN0ZXI+b3BlbnBhbmVsX2NsdXN0ZXI8L2NsdXN0ZXI+XG4gICAgPC9tYWNyb3M+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2UtdXNlci1jb25maWcueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL3VzZXJzLmQvb3AtdXNlci1jb25maWcueG1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxwcm9maWxlcz5cbiAgICAgICAgPGRlZmF1bHQ+XG4gICAgICAgICAgICA8bG9nX3F1ZXJpZXM+MDwvbG9nX3F1ZXJpZXM+XG4gICAgICAgICAgICA8bG9nX3F1ZXJ5X3RocmVhZHM+MDwvbG9nX3F1ZXJ5X3RocmVhZHM+XG4gICAgICAgIDwvZGVmYXVsdD5cbiAgICA8L3Byb2ZpbGVzPlxuPC9jbGlja2hvdXNlPlxuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9pbml0LWRiLnNoCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvaW5pdC1kYi5zaAogICAgICAgIGNvbnRlbnQ6ICIjIS9iaW4vc2hcbnNldCAtZVxuXG5jbGlja2hvdXNlIGNsaWVudCAtbiA8PC1FT1NRTFxuICBDUkVBVEUgREFUQUJBU0UgSUYgTk9UIEVYSVNUUyBvcGVucGFuZWw7XG5FT1NRTFxuIgogICAgdWxpbWl0czoKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdjbGlja2hvdXNlLWNsaWVudCAtLXF1ZXJ5ICJTRUxFQ1QgMSInCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQo=", "tags": [ "analytics", "insights", @@ -4192,58 +3591,6 @@ "template_last_updated_at": "2026-09-28T00:20:58+02:00", "port": "2785" }, - "opnform": { - "documentation": "https://docs.opnform.com/introduction?utm_source=coolify.io", - "slogan": "OpnForm is an open-source form builder that lets you create beautiful forms and share them anywhere. It's super fast, you don't need to know how to code", - "compose": "eC1zaGFyZWQtZW52OgogIEFQUF9OQU1FOiBPcG5Gb3JtCiAgQVBQX0VOVjogcHJvZHVjdGlvbgogIEFQUF9LRVk6ICcke1NFUlZJQ0VfQkFTRTY0X0FQSUtFWX0nCiAgQVBQX0RFQlVHOiAnJHtBUFBfREVCVUc6LWZhbHNlfScKICBBUFBfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fTkdJTlh9JwogIExPR19DSEFOTkVMOiBlcnJvcmxvZwogIExPR19MRVZFTDogJyR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgRklMRVNZU1RFTV9EUklWRVI6ICcke0ZJTEVTWVNURU1fRFJJVkVSOi1sb2NhbH0nCiAgTE9DQUxfRklMRVNZU1RFTV9WSVNJQklMSVRZOiBwdWJsaWMKICBDQUNIRV9EUklWRVI6IHJlZGlzCiAgUVVFVUVfQ09OTkVDVElPTjogcmVkaXMKICBTRVNTSU9OX0RSSVZFUjogcmVkaXMKICBTRVNTSU9OX0xJRkVUSU1FOiAxMjAKICBNQUlMX01BSUxFUjogJyR7TUFJTF9NQUlMRVI6LWxvZ30nCiAgTUFJTF9IT1NUOiAnJHtNQUlMX0hPU1R9JwogIE1BSUxfUE9SVDogJyR7TUFJTF9QT1JUfScKICBNQUlMX1VTRVJOQU1FOiAnJHtNQUlMX1VTRVJOQU1FOi15b3VyQGVtYWlsLmNvbX0nCiAgTUFJTF9QQVNTV09SRDogJyR7TUFJTF9QQVNTV09SRH0nCiAgTUFJTF9FTkNSWVBUSU9OOiAnJHtNQUlMX0VOQ1JZUFRJT059JwogIE1BSUxfRlJPTV9BRERSRVNTOiAnJHtNQUlMX0ZST01fQUREUkVTUzoteW91ckBlbWFpbC5jb219JwogIE1BSUxfRlJPTV9OQU1FOiAnJHtNQUlMX0ZST01fTkFNRTotT3BuRm9ybX0nCiAgQVdTX0FDQ0VTU19LRVlfSUQ6ICcke0FXU19BQ0NFU1NfS0VZX0lEfScKICBBV1NfU0VDUkVUX0FDQ0VTU19LRVk6ICcke0FXU19TRUNSRVRfQUNDRVNTX0tFWX0nCiAgQVdTX0RFRkFVTFRfUkVHSU9OOiAnJHtBV1NfREVGQVVMVF9SRUdJT046LXVzLWVhc3QtMX0nCiAgQVdTX0JVQ0tFVDogJyR7QVdTX0JVQ0tFVH0nCiAgT1BFTl9BSV9BUElfS0VZOiAnJHtPUEVOX0FJX0FQSV9LRVl9JwogIFRFTEVHUkFNX0JPVF9JRDogJyR7VEVMRUdSQU1fQk9UX0lEfScKICBURUxFR1JBTV9CT1RfVE9LRU46ICcke1RFTEVHUkFNX0JPVF9UT0tFTn0nCiAgUkVESVNfSE9TVDogcmVkaXMKICBSRURJU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgREJfSE9TVDogcG9zdGdyZXNxbAogIERCX0RBVEFCQVNFOiAnJHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1vcG5mb3JtfScKICBEQl9VU0VSTkFNRTogJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogIERCX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogIERCX0NPTk5FQ1RJT046IHBnc3FsCiAgUEhQX01FTU9SWV9MSU1JVDogMUcKICBQSFBfTUFYX0VYRUNVVElPTl9USU1FOiAnNjAwJwogIFBIUF9VUExPQURfTUFYX0ZJTEVTSVpFOiA2NE0KICBQSFBfUE9TVF9NQVhfU0laRTogNjRNCiAgRlJPTlRfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fTkdJTlh9JwogIEZST05UX0FQSV9TRUNSRVQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfRlJPTlRBUElTRUNSRVR9JwogIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX01JTlVURTogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOi0zMH0nCiAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjotMzAwfScKc2VydmljZXM6CiAgb3BuZm9ybS1hcGk6CiAgICBpbWFnZTogJ2podW1hbmovb3BuZm9ybS1hcGk6Mi4yLjQnCiAgICB2b2x1bWVzOgogICAgICAtICdhcGktc3RvcmFnZTovdXNyL3NoYXJlL25naW54L2h0bWwvc3RvcmFnZScKICAgIGVudmlyb25tZW50OgogICAgICBBUFBfTkFNRTogT3BuRm9ybQogICAgICBBUFBfRU5WOiBwcm9kdWN0aW9uCiAgICAgIEFQUF9LRVk6ICcke1NFUlZJQ0VfQkFTRTY0X0FQSUtFWX0nCiAgICAgIEFQUF9ERUJVRzogJyR7QVBQX0RFQlVHOi1mYWxzZX0nCiAgICAgIEFQUF9VUkw6ICcke1NFUlZJQ0VfRlFETl9OR0lOWH0nCiAgICAgIExPR19DSEFOTkVMOiBlcnJvcmxvZwogICAgICBMT0dfTEVWRUw6ICcke0xPR19MRVZFTDotZGVidWd9JwogICAgICBGSUxFU1lTVEVNX0RSSVZFUjogJyR7RklMRVNZU1RFTV9EUklWRVI6LWxvY2FsfScKICAgICAgTE9DQUxfRklMRVNZU1RFTV9WSVNJQklMSVRZOiBwdWJsaWMKICAgICAgQ0FDSEVfRFJJVkVSOiByZWRpcwogICAgICBRVUVVRV9DT05ORUNUSU9OOiByZWRpcwogICAgICBTRVNTSU9OX0RSSVZFUjogcmVkaXMKICAgICAgU0VTU0lPTl9MSUZFVElNRTogMTIwCiAgICAgIE1BSUxfTUFJTEVSOiAnJHtNQUlMX01BSUxFUjotbG9nfScKICAgICAgTUFJTF9IT1NUOiAnJHtNQUlMX0hPU1R9JwogICAgICBNQUlMX1BPUlQ6ICcke01BSUxfUE9SVH0nCiAgICAgIE1BSUxfVVNFUk5BTUU6ICcke01BSUxfVVNFUk5BTUU6LXlvdXJAZW1haWwuY29tfScKICAgICAgTUFJTF9QQVNTV09SRDogJyR7TUFJTF9QQVNTV09SRH0nCiAgICAgIE1BSUxfRU5DUllQVElPTjogJyR7TUFJTF9FTkNSWVBUSU9OfScKICAgICAgTUFJTF9GUk9NX0FERFJFU1M6ICcke01BSUxfRlJPTV9BRERSRVNTOi15b3VyQGVtYWlsLmNvbX0nCiAgICAgIE1BSUxfRlJPTV9OQU1FOiAnJHtNQUlMX0ZST01fTkFNRTotT3BuRm9ybX0nCiAgICAgIEFXU19BQ0NFU1NfS0VZX0lEOiAnJHtBV1NfQUNDRVNTX0tFWV9JRH0nCiAgICAgIEFXU19TRUNSRVRfQUNDRVNTX0tFWTogJyR7QVdTX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgQVdTX0RFRkFVTFRfUkVHSU9OOiAnJHtBV1NfREVGQVVMVF9SRUdJT046LXVzLWVhc3QtMX0nCiAgICAgIEFXU19CVUNLRVQ6ICcke0FXU19CVUNLRVR9JwogICAgICBPUEVOX0FJX0FQSV9LRVk6ICcke09QRU5fQUlfQVBJX0tFWX0nCiAgICAgIFRFTEVHUkFNX0JPVF9JRDogJyR7VEVMRUdSQU1fQk9UX0lEfScKICAgICAgVEVMRUdSQU1fQk9UX1RPS0VOOiAnJHtURUxFR1JBTV9CT1RfVE9LRU59JwogICAgICBSRURJU19IT1NUOiByZWRpcwogICAgICBSRURJU19QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICAgIERCX0hPU1Q6IHBvc3RncmVzcWwKICAgICAgREJfREFUQUJBU0U6ICcke1BPU1RHUkVTUUxfREFUQUJBU0U6LW9wbmZvcm19JwogICAgICBEQl9VU0VSTkFNRTogJyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTUUx9JwogICAgICBEQl9QQVNTV09SRDogJyR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgREJfQ09OTkVDVElPTjogcGdzcWwKICAgICAgUEhQX01FTU9SWV9MSU1JVDogMUcKICAgICAgUEhQX01BWF9FWEVDVVRJT05fVElNRTogJzYwMCcKICAgICAgUEhQX1VQTE9BRF9NQVhfRklMRVNJWkU6IDY0TQogICAgICBQSFBfUE9TVF9NQVhfU0laRTogNjRNCiAgICAgIEZST05UX1VSTDogJyR7U0VSVklDRV9GUUROX05HSU5YfScKICAgICAgRlJPTlRfQVBJX1NFQ1JFVDogJyR7U0VSVklDRV9QQVNTV09SRF9GUk9OVEFQSVNFQ1JFVH0nCiAgICAgIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX01JTlVURTogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOi0zMH0nCiAgICAgIFBVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6ICcke1BVQkxJQ19VUExPQURTX1JBVEVfTElNSVRfUEVSX0hPVVI6LTMwMH0nCiAgICAgIEpXVF9UVEw6ICcke0pXVF9UVEw6LTE0NDB9JwogICAgICBKV1RfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0pXVFNFQ1JFVH0nCiAgICAgIEpXVF9TS0lQX0lQX1VBX1ZBTElEQVRJT046ICcke0pXVF9TS0lQX0lQX1VBX1ZBTElEQVRJT046LWZhbHNlfScKICAgICAgSF9DQVBUQ0hBX1NJVEVfS0VZOiAnJHtIX0NBUFRDSEFfU0lURV9LRVl9JwogICAgICBIX0NBUFRDSEFfU0VDUkVUX0tFWTogJyR7SF9DQVBUQ0hBX1NFQ1JFVF9LRVl9JwogICAgICBSRV9DQVBUQ0hBX1NJVEVfS0VZOiAnJHtSRV9DQVBUQ0hBX1NJVEVfS0VZfScKICAgICAgUkVfQ0FQVENIQV9TRUNSRVRfS0VZOiAnJHtSRV9DQVBUQ0hBX1NFQ1JFVF9LRVl9JwogICAgICBTSE9XX09GRklDSUFMX1RFTVBMQVRFUzogJyR7U0hPV19PRkZJQ0lBTF9URU1QTEFURVM6LXRydWV9JwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwaHAgL3Vzci9zaGFyZS9uZ2lueC9odG1sL2FydGlzYW4gYWJvdXQgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDE1cwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogNjBzCiAgYXBpLXdvcmtlcjoKICAgIGltYWdlOiAnamh1bWFuai9vcG5mb3JtLWFwaToyLjIuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwaS1zdG9yYWdlOi91c3Ivc2hhcmUvbmdpbngvaHRtbC9zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9OQU1FOiBPcG5Gb3JtCiAgICAgIEFQUF9FTlY6IHByb2R1Y3Rpb24KICAgICAgQVBQX0tFWTogJyR7U0VSVklDRV9CQVNFNjRfQVBJS0VZfScKICAgICAgQVBQX0RFQlVHOiAnJHtBUFBfREVCVUc6LWZhbHNlfScKICAgICAgQVBQX1VSTDogJyR7U0VSVklDRV9GUUROX05HSU5YfScKICAgICAgTE9HX0NIQU5ORUw6IGVycm9ybG9nCiAgICAgIExPR19MRVZFTDogJyR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIEZJTEVTWVNURU1fRFJJVkVSOiAnJHtGSUxFU1lTVEVNX0RSSVZFUjotbG9jYWx9JwogICAgICBMT0NBTF9GSUxFU1lTVEVNX1ZJU0lCSUxJVFk6IHB1YmxpYwogICAgICBDQUNIRV9EUklWRVI6IHJlZGlzCiAgICAgIFFVRVVFX0NPTk5FQ1RJT046IHJlZGlzCiAgICAgIFNFU1NJT05fRFJJVkVSOiByZWRpcwogICAgICBTRVNTSU9OX0xJRkVUSU1FOiAxMjAKICAgICAgTUFJTF9NQUlMRVI6ICcke01BSUxfTUFJTEVSOi1sb2d9JwogICAgICBNQUlMX0hPU1Q6ICcke01BSUxfSE9TVH0nCiAgICAgIE1BSUxfUE9SVDogJyR7TUFJTF9QT1JUfScKICAgICAgTUFJTF9VU0VSTkFNRTogJyR7TUFJTF9VU0VSTkFNRToteW91ckBlbWFpbC5jb219JwogICAgICBNQUlMX1BBU1NXT1JEOiAnJHtNQUlMX1BBU1NXT1JEfScKICAgICAgTUFJTF9FTkNSWVBUSU9OOiAnJHtNQUlMX0VOQ1JZUFRJT059JwogICAgICBNQUlMX0ZST01fQUREUkVTUzogJyR7TUFJTF9GUk9NX0FERFJFU1M6LXlvdXJAZW1haWwuY29tfScKICAgICAgTUFJTF9GUk9NX05BTUU6ICcke01BSUxfRlJPTV9OQU1FOi1PcG5Gb3JtfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICcke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAnJHtBV1NfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICBBV1NfREVGQVVMVF9SRUdJT046ICcke0FXU19ERUZBVUxUX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgQVdTX0JVQ0tFVDogJyR7QVdTX0JVQ0tFVH0nCiAgICAgIE9QRU5fQUlfQVBJX0tFWTogJyR7T1BFTl9BSV9BUElfS0VZfScKICAgICAgVEVMRUdSQU1fQk9UX0lEOiAnJHtURUxFR1JBTV9CT1RfSUR9JwogICAgICBURUxFR1JBTV9CT1RfVE9LRU46ICcke1RFTEVHUkFNX0JPVF9UT0tFTn0nCiAgICAgIFJFRElTX0hPU1Q6IHJlZGlzCiAgICAgIFJFRElTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgREJfSE9TVDogcG9zdGdyZXNxbAogICAgICBEQl9EQVRBQkFTRTogJyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotb3BuZm9ybX0nCiAgICAgIERCX1VTRVJOQU1FOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIERCX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICBEQl9DT05ORUNUSU9OOiBwZ3NxbAogICAgICBQSFBfTUVNT1JZX0xJTUlUOiAxRwogICAgICBQSFBfTUFYX0VYRUNVVElPTl9USU1FOiAnNjAwJwogICAgICBQSFBfVVBMT0FEX01BWF9GSUxFU0laRTogNjRNCiAgICAgIFBIUF9QT1NUX01BWF9TSVpFOiA2NE0KICAgICAgRlJPTlRfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fTkdJTlh9JwogICAgICBGUk9OVF9BUElfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOiAnJHtQVUJMSUNfVVBMT0FEU19SQVRFX0xJTUlUX1BFUl9NSU5VVEU6LTMwfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjotMzAwfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcGhwCiAgICAgIC0gYXJ0aXNhbgogICAgICAtICdxdWV1ZTp3b3JrJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICJwZ3JlcCAtZiAncGhwIGFydGlzYW4gcXVldWU6d29yaycgPiAvZGV2L251bGwgfHwgZXhpdCAxIgogICAgICBpbnRlcnZhbDogNjBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgYXBpLXNjaGVkdWxlcjoKICAgIGltYWdlOiAnamh1bWFuai9vcG5mb3JtLWFwaToyLjIuNCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ2FwaS1zdG9yYWdlOi91c3Ivc2hhcmUvbmdpbngvaHRtbC9zdG9yYWdlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIEFQUF9OQU1FOiBPcG5Gb3JtCiAgICAgIEFQUF9FTlY6IHByb2R1Y3Rpb24KICAgICAgQVBQX0tFWTogJyR7U0VSVklDRV9CQVNFNjRfQVBJS0VZfScKICAgICAgQVBQX0RFQlVHOiAnJHtBUFBfREVCVUc6LWZhbHNlfScKICAgICAgQVBQX1VSTDogJyR7U0VSVklDRV9GUUROX05HSU5YfScKICAgICAgTE9HX0NIQU5ORUw6IGVycm9ybG9nCiAgICAgIExPR19MRVZFTDogJyR7TE9HX0xFVkVMOi1kZWJ1Z30nCiAgICAgIEZJTEVTWVNURU1fRFJJVkVSOiAnJHtGSUxFU1lTVEVNX0RSSVZFUjotbG9jYWx9JwogICAgICBMT0NBTF9GSUxFU1lTVEVNX1ZJU0lCSUxJVFk6IHB1YmxpYwogICAgICBDQUNIRV9EUklWRVI6IHJlZGlzCiAgICAgIFFVRVVFX0NPTk5FQ1RJT046IHJlZGlzCiAgICAgIFNFU1NJT05fRFJJVkVSOiByZWRpcwogICAgICBTRVNTSU9OX0xJRkVUSU1FOiAxMjAKICAgICAgTUFJTF9NQUlMRVI6ICcke01BSUxfTUFJTEVSOi1sb2d9JwogICAgICBNQUlMX0hPU1Q6ICcke01BSUxfSE9TVH0nCiAgICAgIE1BSUxfUE9SVDogJyR7TUFJTF9QT1JUfScKICAgICAgTUFJTF9VU0VSTkFNRTogJyR7TUFJTF9VU0VSTkFNRToteW91ckBlbWFpbC5jb219JwogICAgICBNQUlMX1BBU1NXT1JEOiAnJHtNQUlMX1BBU1NXT1JEfScKICAgICAgTUFJTF9FTkNSWVBUSU9OOiAnJHtNQUlMX0VOQ1JZUFRJT059JwogICAgICBNQUlMX0ZST01fQUREUkVTUzogJyR7TUFJTF9GUk9NX0FERFJFU1M6LXlvdXJAZW1haWwuY29tfScKICAgICAgTUFJTF9GUk9NX05BTUU6ICcke01BSUxfRlJPTV9OQU1FOi1PcG5Gb3JtfScKICAgICAgQVdTX0FDQ0VTU19LRVlfSUQ6ICcke0FXU19BQ0NFU1NfS0VZX0lEfScKICAgICAgQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZOiAnJHtBV1NfU0VDUkVUX0FDQ0VTU19LRVl9JwogICAgICBBV1NfREVGQVVMVF9SRUdJT046ICcke0FXU19ERUZBVUxUX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgQVdTX0JVQ0tFVDogJyR7QVdTX0JVQ0tFVH0nCiAgICAgIE9QRU5fQUlfQVBJX0tFWTogJyR7T1BFTl9BSV9BUElfS0VZfScKICAgICAgVEVMRUdSQU1fQk9UX0lEOiAnJHtURUxFR1JBTV9CT1RfSUR9JwogICAgICBURUxFR1JBTV9CT1RfVE9LRU46ICcke1RFTEVHUkFNX0JPVF9UT0tFTn0nCiAgICAgIFJFRElTX0hPU1Q6IHJlZGlzCiAgICAgIFJFRElTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgREJfSE9TVDogcG9zdGdyZXNxbAogICAgICBEQl9EQVRBQkFTRTogJyR7UE9TVEdSRVNRTF9EQVRBQkFTRTotb3BuZm9ybX0nCiAgICAgIERCX1VTRVJOQU1FOiAnJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIERCX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTUUx9JwogICAgICBEQl9DT05ORUNUSU9OOiBwZ3NxbAogICAgICBQSFBfTUVNT1JZX0xJTUlUOiAxRwogICAgICBQSFBfTUFYX0VYRUNVVElPTl9USU1FOiAnNjAwJwogICAgICBQSFBfVVBMT0FEX01BWF9GSUxFU0laRTogNjRNCiAgICAgIFBIUF9QT1NUX01BWF9TSVpFOiA2NE0KICAgICAgRlJPTlRfVVJMOiAnJHtTRVJWSUNFX0ZRRE5fTkdJTlh9JwogICAgICBGUk9OVF9BUElfU0VDUkVUOiAnJHtTRVJWSUNFX1BBU1NXT1JEX0ZST05UQVBJU0VDUkVUfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfTUlOVVRFOiAnJHtQVUJMSUNfVVBMT0FEU19SQVRFX0xJTUlUX1BFUl9NSU5VVEU6LTMwfScKICAgICAgUFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjogJyR7UFVCTElDX1VQTE9BRFNfUkFURV9MSU1JVF9QRVJfSE9VUjotMzAwfScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcGhwCiAgICAgIC0gYXJ0aXNhbgogICAgICAtICdzY2hlZHVsZTp3b3JrJwogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwaHAgL3Vzci9zaGFyZS9uZ2lueC9odG1sL2FydGlzYW4gYXBwOnNjaGVkdWxlci1zdGF0dXMgLS1tb2RlPWNoZWNrIC0tbWF4LW1pbnV0ZXM9MyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiA2MHMKICAgICAgdGltZW91dDogMzBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA3MHMKICBvcG5mb3JtLXVpOgogICAgaW1hZ2U6ICdqaHVtYW5qL29wbmZvcm0tY2xpZW50OjIuMi40JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTlVYVF9QVUJMSUNfQVBQX1VSTD0vCiAgICAgIC0gTlVYVF9QVUJMSUNfQVBJX0JBU0U9L2FwaQogICAgICAtICdOVVhUX1BSSVZBVEVfQVBJX0JBU0U9aHR0cDovL25naW54L2FwaScKICAgICAgLSBOVVhUX1BVQkxJQ19FTlY9cHJvZHVjdGlvbgogICAgICAtICdOVVhUX1BVQkxJQ19IX0NBUFRDSEFfU0lURV9LRVk9JHtIX0NBUFRDSEFfU0lURV9LRVl9JwogICAgICAtICdOVVhUX1BVQkxJQ19SRV9DQVBUQ0hBX1NJVEVfS0VZPSR7UkVfQ0FQVENIQV9TSVRFX0tFWX0nCiAgICAgIC0gJ05VWFRfQVBJX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfRlJPTlRBUElTRUNSRVR9JwogICAgICAtICdOVVhUX1BVQkxJQ19MSUNFTlNFX0FQSV9FTkRQT0lOVD0ke05VWFRfUFVCTElDX0xJQ0VOU0VfQVBJX0VORFBPSU5UOi1odHRwczovL2FwaS5vcG5mb3JtLmNvbX0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLS1zcGlkZXIgLXEgaHR0cDovL29wbmZvcm0tdWk6MzAwMC9sb2dpbiB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA0NXMKICAgIGRlcGVuZHNfb246CiAgICAgIG9wbmZvcm0tYXBpOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgcG9zdGdyZXNxbDoKICAgIGltYWdlOiAncG9zdGdyZXM6MTgnCiAgICB2b2x1bWVzOgogICAgICAtICdvcG5mb3JtLXBvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVNRTH0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU1FMfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU1FMX0RBVEFCQVNFOi1vcG5mb3JtfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncGdfaXNyZWFkeSAtVSAkJHtQT1NUR1JFU19VU0VSfSAtZCAkJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NycKICAgIGVudmlyb25tZW50OgogICAgICAtICdSRURJU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVESVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6CiAgICAgIC0gcmVkaXMtc2VydmVyCiAgICAgIC0gJy0tcmVxdWlyZXBhc3MnCiAgICAgIC0gJyR7U0VSVklDRV9QQVNTV09SRF82NF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gcmVkaXMtY2xpCiAgICAgICAgLSAnLWEnCiAgICAgICAgLSAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgICAtIFBJTkcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiAzMHMKICAgICAgcmV0cmllczogMwogIG5naW54OgogICAgaW1hZ2U6ICduZ2lueDoxLjMxLjMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fTkdJTlgKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL25naW54L25naW54LmNvbmYKICAgICAgICB0YXJnZXQ6IC9ldGMvbmdpbngvY29uZi5kL2RlZmF1bHQuY29uZgogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAgIGNvbnRlbnQ6ICJtYXAgJG9yaWdpbmFsX3VyaSAkYXBpX3VyaSB7XG4gICAgfl4vYXBpKC8uKiQpICQxO1xuICAgIGRlZmF1bHQgJG9yaWdpbmFsX3VyaTtcbn1cblxuc2VydmVyIHtcbiAgICBsaXN0ZW4gODAgZGVmYXVsdF9zZXJ2ZXI7XG4gICAgcm9vdCAvdXNyL3NoYXJlL25naW54L2h0bWwvcHVibGljO1xuXG4gICAgYWNjZXNzX2xvZyAvZGV2L3N0ZG91dDtcbiAgICBlcnJvcl9sb2cgL2Rldi9zdGRlcnIgZXJyb3I7XG5cbiAgICBpbmRleCBpbmRleC5odG1sIGluZGV4Lmh0bSBpbmRleC5waHA7XG5cbiAgICBsb2NhdGlvbiAvIHtcbiAgICAgICAgcHJveHlfaHR0cF92ZXJzaW9uIDEuMTtcbiAgICAgICAgcHJveHlfcGFzcyBodHRwOi8vb3BuZm9ybS11aTozMDAwO1xuICAgICAgICBwcm94eV9zZXRfaGVhZGVyIFgtUmVhbC1JUCAkcmVtb3RlX2FkZHI7XG4gICAgICAgIHByb3h5X3NldF9oZWFkZXIgWC1Gb3J3YXJkZWQtSG9zdCAkaG9zdDtcbiAgICAgICAgcHJveHlfc2V0X2hlYWRlciBYLUZvcndhcmRlZC1Qb3J0ICRzZXJ2ZXJfcG9ydDtcbiAgICAgICAgcHJveHlfc2V0X2hlYWRlciBVcGdyYWRlICRodHRwX3VwZ3JhZGU7XG4gICAgICAgIHByb3h5X3NldF9oZWFkZXIgQ29ubmVjdGlvbiBcIlVwZ3JhZGVcIjtcbiAgICB9XG5cbiAgICBsb2NhdGlvbiB+LyhhcGl8b3Blbnxsb2NhbFxcL3RlbXB8Zm9ybXNcXC9hc3NldHMpLyB7XG4gICAgICAgIHNldCAkb3JpZ2luYWxfdXJpICR1cmk7XG4gICAgICAgIHRyeV9maWxlcyAkdXJpICR1cmkvIC9pbmRleC5waHAkaXNfYXJncyRhcmdzO1xuICAgIH1cblxuICAgIGxvY2F0aW9uIH4gXFwucGhwJCB7XG4gICAgICAgIGZhc3RjZ2lfc3BsaXRfcGF0aF9pbmZvIF4oLitcXC5waHApKC8uKykkO1xuICAgICAgICBmYXN0Y2dpX3Bhc3Mgb3BuZm9ybS1hcGk6OTAwMDtcbiAgICAgICAgZmFzdGNnaV9pbmRleCBpbmRleC5waHA7XG4gICAgICAgIGluY2x1ZGUgZmFzdGNnaV9wYXJhbXM7XG4gICAgICAgIGZhc3RjZ2lfcGFyYW0gU0NSSVBUX0ZJTEVOQU1FIC91c3Ivc2hhcmUvbmdpbngvaHRtbC9wdWJsaWMvaW5kZXgucGhwO1xuICAgICAgICBmYXN0Y2dpX3BhcmFtIFJFUVVFU1RfVVJJICRhcGlfdXJpO1xuICAgIH1cbn0iCiAgICBkZXBlbmRzX29uOgogICAgICAtIG9wbmZvcm0tYXBpCiAgICAgIC0gb3BuZm9ybS11aQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG5naW54CiAgICAgICAgLSAnLXQnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiA0MHMK", - "tags": [ - "opnform", - "form", - "survey", - "cloud", - "open-source", - "self-hosted", - "docker", - "no-code", - "embeddable" - ], - "category": "productivity", - "logo": "svg/opnform.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T12:49:29+02:00", - "port": "80" - }, - "orangehrm": { - "documentation": "https://starterhelp.orangehrm.com/hc/en-us?utm_source=coolify.io", - "slogan": "OrangeHRM open source HR management software.", - "compose": "c2VydmljZXM6CiAgb3JhbmdlaHJtOgogICAgaW1hZ2U6ICdvcmFuZ2Vocm0vb3JhbmdlaHJtOmxhdGVzdCcKICAgIHBsYXRmb3JtOiBsaW51eC9hbWQ2NAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX09SQU5HRUhSTV84MAogICAgICAtIE9SQU5HRUhSTV9EQVRBQkFTRV9IT1NUPW1hcmlhZGIKICAgICAgLSAnT1JBTkdFSFJNX0RBVEFCQVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfTVlTUUx9JwogICAgICAtICdPUkFOR0VIUk1fREFUQUJBU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgICAgLSAnT1JBTkdFSFJNX0RBVEFCQVNFX05BTUU9JHtNWVNRTF9EQVRBQkFTRTotb3JhbmdlaHJtLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ29yYW5nZWhybS1kYXRhOi9vcmFuZ2Vocm0nCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnb3JhbmdlaHJtX21hcmlhZGJfZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdNWVNRTF9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTFJPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1vcmFuZ2Vocm0tZGJ9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgLSAnTVlTUUxfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX01ZU1FMfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "hr", - "hris", - "hrms", - "human resource management", - "orangehrm", - "hr management" - ], - "category": "productivity", - "logo": "svgs/orangehrm.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, - "organizr": { - "documentation": "https://docs.organizr.app/?utm_source=coolify.io", - "slogan": "Homelab Services Organizer", - "compose": "c2VydmljZXM6CiAgb3JnYW5penI6CiAgICBpbWFnZTogJ29yZ2FuaXpyL29yZ2FuaXpyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9PUkdBTklaUl84MAogICAgICAtICdicmFuY2g9JHticmFuY2g6LXYyLW1hc3Rlcn0nCiAgICB2b2x1bWVzOgogICAgICAtICdvcmdhbml6ci1kYXRhOi9jb25maWcnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXNmIGh0dHA6Ly9sb2NhbGhvc3Q6ODAgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMK", - "tags": [ - "tool" - ], - "category": "productivity", - "logo": "svgs/organizr.png", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "overseerr": { "documentation": "https://docs.overseerr.dev/getting-started/installation#docker?utm_source=coolify.io", "slogan": "Overseerr is a request management and media discovery tool built to work with your existing Plex ecosystem.", @@ -4261,23 +3608,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5055" }, - "owncloud": { - "documentation": "https://owncloud.com/docs-guides/?utm_source=coolify.io", - "slogan": "OwnCloud with Open Web UI integrates file management with a powerful, user-friendly interface.", - "compose": "c2VydmljZXM6CiAgb3duY2xvdWQ6CiAgICBpbWFnZTogJ293bmNsb3VkL3NlcnZlcjpsYXRlc3QnCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fT1dOQ0xPVURfODA4MAogICAgICAtICdPV05DTE9VRF9ET01BSU49JHtTRVJWSUNFX0ZRRE5fT1dOQ0xPVUR9JwogICAgICAtICdPV05DTE9VRF9UUlVTVEVEX0RPTUFJTlM9JHtTRVJWSUNFX0ZRRE5fT1dOQ0xPVUR9JwogICAgICAtIE9XTkNMT1VEX0RCX1RZUEU9bXlzcWwKICAgICAgLSBPV05DTE9VRF9EQl9IT1NUPW1hcmlhZGIKICAgICAgLSAnT1dOQ0xPVURfREJfTkFNRT0ke0RCX05BTUU6LW93bmNsb3VkfScKICAgICAgLSAnT1dOQ0xPVURfREJfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ09XTkNMT1VEX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnT1dOQ0xPVURfQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfT1dOQ0xPVUR9JwogICAgICAtICdPV05DTE9VRF9BRE1JTl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfT1dOQ0xPVUR9JwogICAgICAtICdPV05DTE9VRF9NWVNRTF9VVEY4TUI0PSR7TVlTUUxfVVRGOE1CNDotdHJ1ZX0nCiAgICAgIC0gJ09XTkNMT1VEX1JFRElTX0VOQUJMRUQ9JHtSRURJU19FTkFCTEVEOi10cnVlfScKICAgICAgLSBPV05DTE9VRF9SRURJU19IT1NUPXJlZGlzCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gL3Vzci9iaW4vaGVhbHRoY2hlY2sKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogNQogICAgdm9sdW1lczoKICAgICAgLSAnb3duY2xvdWQtZGF0YTovbW50L2RhdGEnCiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQlJPT1R9JwogICAgICAtICdNWVNRTF9VU0VSPSR7U0VSVklDRV9VU0VSX01BUklBREJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX0RBVEFCQVNFPSR7REJfTkFNRTotb3duY2xvdWR9JwogICAgICAtIFRaPWF1dG8KICAgIGNvbW1hbmQ6CiAgICAgIC0gJy0tY2hhcmFjdGVyLXNldC1zZXJ2ZXI9dXRmOG1iNCcKICAgICAgLSAnLS1jb2xsYXRpb24tc2VydmVyPXV0ZjhtYjRfYmluJwogICAgICAtICctLW1heC1hbGxvd2VkLXBhY2tldD0xMjhNJwogICAgICAtICctLWlubm9kYi1sb2ctZmlsZS1zaXplPTY0TScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBoZWFsdGhjaGVjay5zaAogICAgICAgIC0gJy0tY29ubmVjdCcKICAgICAgICAtICctLWlubm9kYl9pbml0aWFsaXplZCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnb3duY2xvdWQtbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6NicKICAgIGNvbW1hbmQ6CiAgICAgIC0gJy0tZGF0YWJhc2VzJwogICAgICAtICcxJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHJlZGlzLWNsaQogICAgICAgIC0gcGluZwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", - "tags": [ - "owncloud", - "file-management", - "open-web-ui", - "integration", - "cloud" - ], - "category": "storage", - "logo": "svgs/owncloud.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-06-02T12:12:42+03:00", - "port": "8080" - }, "pairdrop": { "documentation": "https://pairdrop.net/?utm_source=coolify.io", "slogan": "Pairdrop is a self-hosted file sharing and collaboration platform, offering secure file sharing and collaboration capabilities for efficient teamwork.", @@ -4315,37 +3645,6 @@ "template_last_updated_at": "2025-12-02T12:07:14+01:00", "port": "8000" }, - "passbolt": { - "documentation": "https://www.passbolt.com/ce/docker?utm_source=coolify.io", - "slogan": "Passbolt Community Edition (CE) API. The JSON API for the open source password manager for teams!", - "compose": "c2VydmljZXM6CiAgcGFzc2JvbHQ6CiAgICBpbWFnZTogJ3Bhc3Nib2x0L3Bhc3Nib2x0OmxhdGVzdC1jZScKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QQVNTQk9MVAogICAgICAtICdBUFBfRlVMTF9CQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9QQVNTQk9MVH0nCiAgICAgIC0gREFUQVNPVVJDRVNfREVGQVVMVF9IT1NUPW1hcmlhZGIKICAgICAgLSAnREFUQVNPVVJDRVNfREVGQVVMVF9VU0VSTkFNRT0ke1NFUlZJQ0VfVVNFUl9QQVNTQk9MVH0nCiAgICAgIC0gJ0RBVEFTT1VSQ0VTX0RFRkFVTFRfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BBU1NCT0xUfScKICAgICAgLSAnREFUQVNPVVJDRVNfREVGQVVMVF9EQVRBQkFTRT0ke01ZU1FMX0RCOi1wYXNzYm9sdC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdncGdfdm9sdW1lOi9ldGMvcGFzc2JvbHQvZ3BnJwogICAgICAtICdqd3Rfdm9sdW1lOi9ldGMvcGFzc2JvbHQvand0JwogICAgY29tbWFuZDoKICAgICAgLSAvdXNyL2Jpbi93YWl0LWZvci5zaAogICAgICAtICctdCcKICAgICAgLSAnMCcKICAgICAgLSAnbWFyaWFkYjozMzA2JwogICAgICAtICctLScKICAgICAgLSAvZG9ja2VyLWVudHJ5cG9pbnQuc2gKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUFSSUFEQl9ST09UX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9ST09UTUFSSUFEQn0nCiAgICAgIC0gJ01BUklBREJfREFUQUJBU0U9JHtNWVNRTF9EQjotcGFzc2JvbHQtZGJ9JwogICAgICAtICdNQVJJQURCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUEFTU0JPTFR9JwogICAgICAtICdNQVJJQURCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QQVNTQk9MVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdwYXNzYm9sdF9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "security", - "credentials", - "password-manager", - "open source" - ], - "category": "auth", - "logo": "svgs/passbolt.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "paymenter": { - "documentation": "https://paymenter.org/docs/guides/docker?utm_source=coolify.io", - "slogan": "Open-Source Billing, Built for Hosting", - "compose": "c2VydmljZXM6CiAgcGF5bWVudGVyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3BheW1lbnRlci9wYXltZW50ZXI6djEuNC41JwogICAgdm9sdW1lczoKICAgICAgLSAnYXBwX2xvZ3M6L2FwcC9zdG9yYWdlL2xvZ3MnCiAgICAgIC0gJ2V4dGVuc3Rpb25zOi9hcHAvZXh0ZW5zaW9ucycKICAgICAgLSAndGhlbWVzOi9hcHAvdGhlbWVzJwogICAgICAtICdhcHBfc3RvcmFnZTovYXBwL3N0b3JhZ2UvYXBwJwogICAgICAtICdhcHBfcHVibGljX3N0b3JhZ2U6L2FwcC9zdG9yYWdlL2FwcC9wdWJsaWMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgU0VSVklDRV9GUUROX1BBWU1FTlRFUjogJyR7U0VSVklDRV9GUUROX1BBWU1FTlRFUl84MH0nCiAgICAgIERCX0RBVEFCQVNFOiAnJHtNWVNRTF9EQVRBQkFTRTotcGF5bWVudGVyLWRifScKICAgICAgREJfUEFTU1dPUkQ6ICcke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUx9JwogICAgICBEQl9VU0VSTkFNRTogJyR7U0VSVklDRV9VU0VSX01ZU1FMfScKICAgICAgQVBQX0VOVjogcHJvZHVjdGlvbgogICAgICBDQUNIRV9TVE9SRTogcmVkaXMKICAgICAgU0VTU0lPTl9EUklWRVI6IHJlZGlzCiAgICAgIFFVRVVFX0NPTk5FQ1RJT046IHJlZGlzCiAgICAgIFJFRElTX0hPU1Q6IHJlZGlzCiAgICAgIFJFRElTX1VTRVJOQU1FOiBkZWZhdWx0CiAgICAgIFJFRElTX1BBU1NXT1JEOiAnJHtTRVJWSUNFX1BBU1NXT1JEXzY0X1JFRElTfScKICAgICAgREJfQ09OTkVDVElPTjogbWFyaWFkYgogICAgICBEQl9IT1NUOiBtYXJpYWRiCiAgICAgIERCX1BPUlQ6IDMzMDYKICAgICAgQVBQX0tFWTogJyR7U0VSVklDRV9CQVNFNjRfS0VZfScKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX3N0YXJ0ZWQKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogIG1hcmlhZGI6CiAgICBpbWFnZTogJ21hcmlhZGI6MTEuOCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3BheW1lbnRlcl9tYXJpYWRiX2RhdGE6L3Zhci9saWIvbXlzcWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UfScKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotcGF5bWVudGVyLWRifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9NWVNRTH0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NWVNRTH0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICByZWRpczoKICAgIGltYWdlOiAncmVkaXM6YWxwaW5lJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdyZWRpcy1jbGkgcGluZyB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwo=", - "tags": [ - "automation", - "billing", - "open source" - ], - "category": "finance", - "logo": "svgs/paymenter.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "80" - }, "peertube": { "documentation": "https://docs.joinpeertube.org/install/docker?utm_source=coolify.io", "slogan": "Free, open-source and decentralized video platform.", @@ -4379,7 +3678,7 @@ "category": "productivity", "logo": "svgs/penpot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-29T15:56:03+05:30", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "penpot": { @@ -4428,19 +3727,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "8085" }, - "phpmyadmin": { - "documentation": "https://phpmyadmin.net?utm_source=coolify.io", - "slogan": "phpMyAdmin is a web-based database management tool for administering your MySQL and MariaDB databases through a user-friendly interface.", - "compose": "c2VydmljZXM6CiAgcGhwbXlhZG1pbjoKICAgIGltYWdlOiAnbHNjci5pby9saW51eHNlcnZlci9waHBteWFkbWluOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QSFBNWUFETUlOCiAgICAgIC0gUFVJRD0xMDAwCiAgICAgIC0gUEdJRD0xMDAwCiAgICAgIC0gVFo9RXVyb3BlL01hZHJpZAogICAgICAtIFBNQV9BUkJJVFJBUlk9MQogICAgICAtIFBNQV9BQlNPTFVURV9VUkk9JFNFUlZJQ0VfRlFETl9QSFBNWUFETUlOCiAgICB2b2x1bWVzOgogICAgICAtICdwaHBteWFkbWluLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1Cg==", - "tags": [ - "database management" - ], - "category": "database", - "logo": "svgs/phpmyadmin.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "pi-hole": { "documentation": "https://pi-hole.net/?utm_source=coolify.io", "slogan": "Network-wide Ad Blocking", @@ -4478,7 +3764,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-10-15T22:51:15+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "plex": { @@ -4638,20 +3924,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "4200" }, - "privatebin": { - "documentation": "https://github.com/PrivateBin/PrivateBin/blob/master/doc/README.md?utm_source=coolify.io", - "slogan": "PrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of pasted data.", - "compose": "c2VydmljZXM6CiAgcHJpdmF0ZWJpbjoKICAgIGltYWdlOiBwcml2YXRlYmluL25naW54LWZwbS1hbHBpbmUKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9QUklWQVRFQklOXzgwODAKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3ByaXZhdGViaW5fZGF0YTovc3J2L2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3dnZXQgLXFPLSBodHRwOi8vMTI3LjAuMC4xOjgwODAvJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "text", - "sharing" - ], - "category": "security", - "logo": "svgs/privatebin.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8080" - }, "prowlarr": { "documentation": "https://hub.docker.com/r/linuxserver/prowlarr?utm_source=coolify.io", "slogan": "Prowlarr\u2060 is a indexer manager/proxy built on the popular arr .net/reactjs base stack to integrate with your various PVR apps.", @@ -4687,40 +3959,6 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9159" }, - "pterodactyl-panel": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4zJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04K", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T18:06:35+02:00", - "port": "80" - }, - "pterodactyl-with-wings": { - "documentation": "https://pterodactyl.io/?utm_source=coolify.io", - "slogan": "Pterodactyl is a free, open-source game server management panel", - "compose": "c2VydmljZXM6CiAgbWFyaWFkYjoKICAgIGltYWdlOiAnbWFyaWFkYjoxMS44JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdoZWFsdGhjaGVjay5zaCAtLWNvbm5lY3QgLS1pbm5vZGJfaW5pdGlhbGl6ZWQgfHwgZXhpdCAxJwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfTVlTUUxST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9cHRlcm9kYWN0eWwtZGIKICAgICAgLSBNWVNRTF9VU0VSPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgdm9sdW1lczoKICAgICAgLSAncHRlcm9kYWN0eWwtZGI6L3Zhci9saWIvbXlzcWwnCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOmFscGluZScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAncmVkaXMtY2xpIHBpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDFzCiAgICAgIHJldHJpZXM6IDMKICBwdGVyb2RhY3R5bDoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC9wYW5lbDp2MS4xMi4zJwogICAgdm9sdW1lczoKICAgICAgLSAncGFuZWwtdmFyOi9hcHAvdmFyLycKICAgICAgLSAncGFuZWwtbmdpbng6L2V0Yy9uZ2lueC9odHRwLmQvJwogICAgICAtICdwYW5lbC1jZXJ0czovZXRjL2xldHNlbmNyeXB0LycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vZXRjL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgbW9kZTogJzA3NTUnCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuc2V0IC1lXG5cbiBlY2hvIFwiU2V0dGluZyBsb2dzIHBlcm1pc3Npb25zLi4uXCJcbiBjaG93biAtUiBuZ2lueDogL2FwcC9zdG9yYWdlL2xvZ3MvXG5cbiBVU0VSX0VYSVNUUz0kKHBocCBhcnRpc2FuIHRpbmtlciAtLW5vLWFuc2kgLS1leGVjdXRlPSdlY2hvIFxcUHRlcm9kYWN0eWxcXE1vZGVsc1xcVXNlcjo6d2hlcmUoXCJlbWFpbFwiLCBcIidcIiRBRE1JTl9FTUFJTFwiJ1wiKS0+ZXhpc3RzKCkgPyBcIjFcIiA6IFwiMFwiOycpXG5cbiBpZiBbIFwiJFVTRVJfRVhJU1RTXCIgPSBcIjBcIiBdOyB0aGVuXG4gICBlY2hvIFwiQWRtaW4gVXNlciBkb2VzIG5vdCBleGlzdCwgY3JlYXRpbmcgdXNlciBub3cuXCJcbiAgIHBocCBhcnRpc2FuIHA6dXNlcjptYWtlIC0tbm8taW50ZXJhY3Rpb24gXFxcbiAgICAgLS1hZG1pbj0xIFxcXG4gICAgIC0tZW1haWw9XCIkQURNSU5fRU1BSUxcIiBcXFxuICAgICAtLXVzZXJuYW1lPVwiJEFETUlOX1VTRVJOQU1FXCIgXFxcbiAgICAgLS1uYW1lLWZpcnN0PVwiJEFETUlOX0ZJUlNUTkFNRVwiIFxcXG4gICAgIC0tbmFtZS1sYXN0PVwiJEFETUlOX0xBU1ROQU1FXCIgXFxcbiAgICAgLS1wYXNzd29yZD1cIiRBRE1JTl9QQVNTV09SRFwiXG4gICBlY2hvIFwiQWRtaW4gdXNlciBjcmVhdGVkIHN1Y2Nlc3NmdWxseSFcIlxuIGVsc2VcbiAgIGVjaG8gXCJBZG1pbiBVc2VyIGFscmVhZHkgZXhpc3RzLCBza2lwcGluZyBjcmVhdGlvbi5cIlxuIGZpXG5cbiBleGVjIHN1cGVydmlzb3JkIC0tbm9kYWVtb25cbiIKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtc2YgaHR0cDovL2xvY2FsaG9zdDo4MCB8fCBleGl0IDEnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gSEFTSElEU19TQUxUPSRTRVJWSUNFX1BBU1NXT1JEX0hBU0hJRFMKICAgICAgLSBIQVNISURTX0xFTkdUSD04CiAgICAgIC0gU0VSVklDRV9GUUROX1BURVJPREFDVFlMXzgwCiAgICAgIC0gJ0FETUlOX0VNQUlMPSR7QURNSU5fRU1BSUw6LWFkbWluQGV4YW1wbGUuY29tfScKICAgICAgLSAnQURNSU5fVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdBRE1JTl9GSVJTVE5BTUU9JHtBRE1JTl9GSVJTVE5BTUU6LUFkbWlufScKICAgICAgLSAnQURNSU5fTEFTVE5BTUU9JHtBRE1JTl9MQVNUTkFNRTotVXNlcn0nCiAgICAgIC0gJ0FETUlOX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9BRE1JTn0nCiAgICAgIC0gJ1BURVJPREFDVFlMX0hUVFBTPSR7UFRFUk9EQUNUWUxfSFRUUFM6LWZhbHNlfScKICAgICAgLSBBUFBfRU5WPXByb2R1Y3Rpb24KICAgICAgLSBBUFBfRU5WSVJPTk1FTlRfT05MWT1mYWxzZQogICAgICAtIEFQUF9VUkw9JFNFUlZJQ0VfRlFETl9QVEVST0RBQ1RZTAogICAgICAtICdBUFBfVElNRVpPTkU9JHtUSU1FWk9ORTotVVRDfScKICAgICAgLSAnQVBQX1NFUlZJQ0VfQVVUSE9SPSR7QVBQX1NFUlZJQ0VfQVVUSE9SOi1hdXRob3JAZXhhbXBsZS5jb219JwogICAgICAtICdMT0dfTEVWRUw9JHtMT0dfTEVWRUw6LWRlYnVnfScKICAgICAgLSBDQUNIRV9EUklWRVI9cmVkaXMKICAgICAgLSBTRVNTSU9OX0RSSVZFUj1yZWRpcwogICAgICAtIFFVRVVFX0RSSVZFUj1yZWRpcwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBEQl9EQVRBQkFTRT1wdGVyb2RhY3R5bC1kYgogICAgICAtIERCX1VTRVJOQU1FPSRTRVJWSUNFX1VTRVJfTVlTUUwKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSBEQl9QT1JUPTMzMDYKICAgICAgLSBEQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9NWVNRTAogICAgICAtIE1BSUxfRlJPTT0kTUFJTF9GUk9NCiAgICAgIC0gTUFJTF9EUklWRVI9JE1BSUxfRFJJVkVSCiAgICAgIC0gTUFJTF9IT1NUPSRNQUlMX0hPU1QKICAgICAgLSBNQUlMX1BPUlQ9JE1BSUxfUE9SVAogICAgICAtIE1BSUxfVVNFUk5BTUU9JE1BSUxfVVNFUk5BTUUKICAgICAgLSBNQUlMX1BBU1NXT1JEPSRNQUlMX1BBU1NXT1JECiAgICAgIC0gTUFJTF9FTkNSWVBUSU9OPSRNQUlMX0VOQ1JZUFRJT04KICB3aW5nczoKICAgIGltYWdlOiAnZ2hjci5pby9wdGVyb2RhY3R5bC93aW5nczp2MS4xMi4xJwogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIHBvcnRzOgogICAgICAtICcyMDIyOjIwMjInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0lOR1NfODQ0MwogICAgICAtICdUWj0ke1RJTUVaT05FOi1VVEN9JwogICAgICAtIFdJTkdTX1VTRVJOQU1FPXB0ZXJvZGFjdHlsCiAgICB2b2x1bWVzOgogICAgICAtICcvdmFyL3J1bi9kb2NrZXIuc29jazovdmFyL3J1bi9kb2NrZXIuc29jaycKICAgICAgLSAnL3Zhci9saWIvZG9ja2VyL2NvbnRhaW5lcnMvOi92YXIvbGliL2RvY2tlci9jb250YWluZXJzLycKICAgICAgLSAnL3Zhci9saWIvcHRlcm9kYWN0eWwvOi92YXIvbGliL3B0ZXJvZGFjdHlsLycKICAgICAgLSAnL3RtcC9wdGVyb2RhY3R5bC86L3RtcC9wdGVyb2RhY3R5bC8nCiAgICAgIC0gJ3dpbmdzLWxvZ3M6L3Zhci9sb2cvcHRlcm9kYWN0eWwvJwogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9ldGMvY29uZmlnLnltbAogICAgICAgIHRhcmdldDogL2V0Yy9wdGVyb2RhY3R5bC9jb25maWcueW1sCiAgICAgICAgY29udGVudDogImRlYnVnOiBmYWxzZVxudXVpZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjOWFiYzgtYWJjNy1hYmM2LWFiYzUtYWJjNGFiYzNhYmMyXG50b2tlbl9pZDogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogYWJjMWFiYzJhYmMzYWJjNFxudG9rZW46IFJFUExBQ0UgRlJPTSBDT05GSUcgICNleGFtcGxlOiBhYmMxYWJjMmFiYzNhYmM0YWJjNWFiYzZhYmM3YWJjOGFiYzlhYmMxMGFiYzExYWJjMTJhYmMxM2FiYzE0YWJjMTVhYmMxNlxuYXBpOlxuICBob3N0OiAwLjAuMC4wXG4gIHBvcnQ6IDg0NDMgIyB1c2UgcG9ydCA0NDMgSU4gVEhFIFBBTkVMIGR1cmluZyBub2RlIHNldHVwXG4gIHNzbDpcbiAgICBlbmFibGVkOiBmYWxzZVxuICAgIGNlcnQ6IFJFUExBQ0UgRlJPTSBDT05GSUcgI2V4YW1wbGU6IC9ldGMvbGV0c2VuY3J5cHQvbGl2ZS93aW5ncy1hYmNhYmNhYmNhYmNhYmMuZXhhbXBsZS5jb20vZnVsbGNoYWluLnBlbVxuICAgIGtleTogUkVQTEFDRSBGUk9NIENPTkZJRyAjZXhhbXBsZTogL2V0Yy9sZXRzZW5jcnlwdC9saXZlL3dpbmdzLWFiY2FiY2FiY2FiY2FiYy5leGFtcGxlLmNvbS9wcml2a2V5LnBlbVxuICBkaXNhYmxlX3JlbW90ZV9kb3dubG9hZDogZmFsc2VcbiAgdXBsb2FkX2xpbWl0OiAxMDBcbiAgdHJ1c3RlZF9wcm94aWVzOiBbXVxuc3lzdGVtOlxuICByb290X2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWxcbiAgbG9nX2RpcmVjdG9yeTogL3Zhci9sb2cvcHRlcm9kYWN0eWxcbiAgZGF0YTogL3Zhci9saWIvcHRlcm9kYWN0eWwvdm9sdW1lc1xuICBhcmNoaXZlX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYXJjaGl2ZXNcbiAgYmFja3VwX2RpcmVjdG9yeTogL3Zhci9saWIvcHRlcm9kYWN0eWwvYmFja3Vwc1xuICB0bXBfZGlyZWN0b3J5OiAvdG1wL3B0ZXJvZGFjdHlsXG4gIHVzZXJuYW1lOiBwdGVyb2RhY3R5bFxuICB0aW1lem9uZTogVVRDXG4gIHVzZXI6XG4gICAgcm9vdGxlc3M6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICAgICAgY29udGFpbmVyX3VpZDogMFxuICAgICAgY29udGFpbmVyX2dpZDogMFxuICAgIHVpZDogOTg4XG4gICAgZ2lkOiA5ODhcbiAgZGlza19jaGVja19pbnRlcnZhbDogMTUwXG4gIGFjdGl2aXR5X3NlbmRfaW50ZXJ2YWw6IDYwXG4gIGFjdGl2aXR5X3NlbmRfY291bnQ6IDEwMFxuICBjaGVja19wZXJtaXNzaW9uc19vbl9ib290OiB0cnVlXG4gIGVuYWJsZV9sb2dfcm90YXRlOiB0cnVlXG4gIHdlYnNvY2tldF9sb2dfY291bnQ6IDE1MFxuICBzZnRwOlxuICAgIGJpbmRfYWRkcmVzczogMC4wLjAuMFxuICAgIGJpbmRfcG9ydDogMjAyMlxuICAgIHJlYWRfb25seTogZmFsc2VcbiAgY3Jhc2hfZGV0ZWN0aW9uOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkZXRlY3RfY2xlYW5fZXhpdF9hc19jcmFzaDogdHJ1ZVxuICAgIHRpbWVvdXQ6IDYwXG4gIGJhY2t1cHM6XG4gICAgd3JpdGVfbGltaXQ6IDBcbiAgICBjb21wcmVzc2lvbl9sZXZlbDogYmVzdF9zcGVlZFxuICB0cmFuc2ZlcnM6XG4gICAgZG93bmxvYWRfbGltaXQ6IDBcbiAgb3BlbmF0X21vZGU6IGF1dG9cbmRvY2tlcjpcbiAgbmV0d29yazpcbiAgICBpbnRlcmZhY2U6IDE3Mi4yOC4wLjFcbiAgICBkbnM6XG4gICAgICAtIDEuMS4xLjFcbiAgICAgIC0gMS4wLjAuMVxuICAgIG5hbWU6IHB0ZXJvZGFjdHlsX253XG4gICAgaXNwbjogZmFsc2VcbiAgICBkcml2ZXI6IGJyaWRnZVxuICAgIG5ldHdvcmtfbW9kZTogcHRlcm9kYWN0eWxfbndcbiAgICBpc19pbnRlcm5hbDogZmFsc2VcbiAgICBlbmFibGVfaWNjOiB0cnVlXG4gICAgbmV0d29ya19tdHU6IDE1MDBcbiAgICBpbnRlcmZhY2VzOlxuICAgICAgdjQ6XG4gICAgICAgIHN1Ym5ldDogMTcyLjI4LjAuMC8xNlxuICAgICAgICBnYXRld2F5OiAxNzIuMjguMC4xXG4gICAgICB2NjpcbiAgICAgICAgc3VibmV0OiBmZGJhOjE3Yzg6NmM5NDo6LzY0XG4gICAgICAgIGdhdGV3YXk6IGZkYmE6MTdjODo2Yzk0OjoxMDExXG4gIGRvbWFpbm5hbWU6IFwiXCJcbiAgcmVnaXN0cmllczoge31cbiAgdG1wZnNfc2l6ZTogMTAwXG4gIGNvbnRhaW5lcl9waWRfbGltaXQ6IDUxMlxuICBpbnN0YWxsZXJfbGltaXRzOlxuICAgIG1lbW9yeTogMTAyNFxuICAgIGNwdTogMTAwXG4gIG92ZXJoZWFkOlxuICAgIG92ZXJyaWRlOiBmYWxzZVxuICAgIGRlZmF1bHRfbXVsdGlwbGllcjogMS4wNVxuICAgIG11bHRpcGxpZXJzOiB7fVxuICB1c2VfcGVyZm9ybWFudF9pbnNwZWN0OiB0cnVlXG4gIHVzZXJuc19tb2RlOiBcIlwiXG4gIGxvZ19jb25maWc6XG4gICAgdHlwZTogbG9jYWxcbiAgICBjb25maWc6XG4gICAgICBjb21wcmVzczogXCJmYWxzZVwiXG4gICAgICBtYXgtZmlsZTogXCIxXCJcbiAgICAgIG1heC1zaXplOiA1bVxuICAgICAgbW9kZTogbm9uLWJsb2NraW5nXG50aHJvdHRsZXM6XG4gIGVuYWJsZWQ6IHRydWVcbiAgbGluZXM6IDIwMDBcbiAgbGluZV9yZXNldF9pbnRlcnZhbDogMTAwXG5yZW1vdGU6IGh0dHA6Ly9wdGVyb2RhY3R5bDo4MFxucmVtb3RlX3F1ZXJ5OlxuICB0aW1lb3V0OiAzMFxuICBib290X3NlcnZlcnNfcGVyX3BhZ2U6IDUwXG5hbGxvd2VkX21vdW50czogW11cbmFsbG93ZWRfb3JpZ2luczpcbiAgLSBodHRwOi8vcHRlcm9kYWN0eWw6ODBcbiAgLSBQQU5FTCBET01BSU4gIyBleGFtcGxlOiBodHRwczovL3B0ZXJvZGFjdHlsLWFiY2FiY2FiY2FiY2F2Yy5leGFtcGxlLmNvbVxuYWxsb3dfY29yc19wcml2YXRlX25ldHdvcms6IGZhbHNlXG5pZ25vcmVfcGFuZWxfY29uZmlnX3VwZGF0ZXM6IGZhbHNlIgo=", - "tags": [ - "game", - "game server", - "management", - "panel", - "minecraft" - ], - "category": "media", - "logo": "svgs/pterodactyl.png", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-10T18:06:35+02:00", - "port": "80, 8443" - }, "qbittorrent": { "documentation": "https://docs.linuxserver.io/images/docker-qbittorrent/?utm_source=coolify.io", "slogan": "The qBittorrent project aims to provide an open-source software alternative to \u03bcTorrent.", @@ -4772,7 +4010,7 @@ "category": "messaging", "logo": "svgs/rabbitmq.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "15672" }, "radarr": { @@ -4820,7 +4058,7 @@ "category": "productivity", "logo": "svgs/rxresume.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:30:50+01:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "3000" }, "readeck": { @@ -4924,7 +4162,7 @@ "rybbit": { "documentation": "https://rybbit.io/docs?utm_source=coolify.io", "slogan": "Open-source, privacy-first web analytics.", - "compose": "c2VydmljZXM6CiAgcnliYml0OgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtY2xpZW50OnYyLjIuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9SWUJCSVRfMzAwMgogICAgICAtIE5PREVfRU5WPXByb2R1Y3Rpb24KICAgICAgLSAnTkVYVF9QVUJMSUNfQkFDS0VORF9VUkw9JHtTRVJWSUNFX0ZRRE5fUllCQklUfScKICAgICAgLSAnTkVYVF9QVUJMSUNfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSByeWJiaXRfYmFja2VuZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiAxMjcuMC4wLjEgMzAwMicKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJ5YmJpdF9iYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtYmFja2VuZDp2Mi4yLjMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gVFJVU1RfUFJPWFk9dHJ1ZQogICAgICAtICdCQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9SWUJCSVR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQkVUVEVSX0FVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfQkFDS0VORH0nCiAgICAgIC0gJ0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9URUxFTUVUUlk9JHtESVNBQkxFX1RFTEVNRVRSWTotdHJ1ZX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfSE9TVD1odHRwOi8vcnliYml0X2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7Q0xJQ0tIT1VTRV9VU0VSOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWFuYWx5dGljc30nCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1yeWJiaXRfcG9zdGdyZXMKICAgICAgLSBQT1NUR1JFU19QT1JUPTU0MzIKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgZGVwZW5kc19vbjoKICAgICAgcnliYml0X2NsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnliYml0X3Bvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAxL2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcnliYml0X2NsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1hbmFseXRpY3N9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtDTElDS0hPVVNFX1VTRVI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgdm9sdW1lczoKICAgICAgLSAnY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2VuYWJsZV9qc29uLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9lbmFibGVfanNvbi54bWwKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPHNldHRpbmdzPlxuICAgICAgICA8ZW5hYmxlX2pzb25fdHlwZT4xPC9lbmFibGVfanNvbl90eXBlPlxuICAgIDwvc2V0dGluZ3M+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsb2dnZXI+XG4gICAgICAgIDxsZXZlbD53YXJuaW5nPC9sZXZlbD5cbiAgICAgICAgPGNvbnNvbGU+dHJ1ZTwvY29uc29sZT5cbiAgICA8L2xvZ2dlcj5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHF1ZXJ5X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRleHRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGFzeW5jaHJvbm91c19tZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8c2Vzc2lvbl9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxwYXJ0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGxhdGVuY3lfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cHJvY2Vzc29yc19wcm9maWxlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG48L2NsaWNraG91c2U+IgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlX2NvbmZpZy9uZXR3b3JrLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9uZXR3b3JrLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8bGlzdGVuX2hvc3Q+MC4wLjAuMDwvbGlzdGVuX2hvc3Q+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL3VzZXJfbG9nZ2luZy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvdXNlcl9sb2dnaW5nLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8Y2xpY2tob3VzZT5cbiAgICA8cHJvZmlsZXM+XG4gICAgICAgIDxkZWZhdWx0PlxuICAgICAgICAgICAgPGxvZ19xdWVyaWVzPjA8L2xvZ19xdWVyaWVzPlxuICAgICAgICAgICAgPGxvZ19xdWVyeV90aHJlYWRzPjA8L2xvZ19xdWVyeV90aHJlYWRzPlxuICAgICAgICAgICAgPGxvZ19wcm9jZXNzb3JzX3Byb2ZpbGVzPjA8L2xvZ19wcm9jZXNzb3JzX3Byb2ZpbGVzPlxuICAgICAgICA8L2RlZmF1bHQ+XG4gICAgPC9wcm9maWxlcz5cbjwvY2xpY2tob3VzZT5cbiIK", + "compose": "c2VydmljZXM6CiAgcnliYml0OgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtY2xpZW50OnYyLjIuMycKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9SWUJCSVRfMzAwMgogICAgICAtIE5PREVfRU5WPXByb2R1Y3Rpb24KICAgICAgLSAnTkVYVF9QVUJMSUNfQkFDS0VORF9VUkw9JHtTRVJWSUNFX0ZRRE5fUllCQklUfScKICAgICAgLSAnTkVYVF9QVUJMSUNfRElTQUJMRV9TSUdOVVA9JHtESVNBQkxFX1NJR05VUDotZmFsc2V9JwogICAgZGVwZW5kc19vbjoKICAgICAgLSByeWJiaXRfYmFja2VuZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICduYyAteiAxMjcuMC4wLjEgMzAwMicKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogIHJ5YmJpdF9iYWNrZW5kOgogICAgaW1hZ2U6ICdnaGNyLmlvL3J5YmJpdC1pby9yeWJiaXQtYmFja2VuZDp2Mi4yLjMnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBOT0RFX0VOVj1wcm9kdWN0aW9uCiAgICAgIC0gVFJVU1RfUFJPWFk9dHJ1ZQogICAgICAtICdCQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9SWUJCSVR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnQkVUVEVSX0FVVEhfU0VDUkVUPSR7U0VSVklDRV9CQVNFNjRfNjRfQkFDS0VORH0nCiAgICAgIC0gJ0RJU0FCTEVfU0lHTlVQPSR7RElTQUJMRV9TSUdOVVA6LWZhbHNlfScKICAgICAgLSAnRElTQUJMRV9URUxFTUVUUlk9JHtESVNBQkxFX1RFTEVNRVRSWTotdHJ1ZX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfSE9TVD1odHRwOi8vcnliYml0X2NsaWNraG91c2U6ODEyMycKICAgICAgLSAnQ0xJQ0tIT1VTRV9VU0VSPSR7Q0xJQ0tIT1VTRV9VU0VSOi1kZWZhdWx0fScKICAgICAgLSAnQ0xJQ0tIT1VTRV9EQj0ke0NMSUNLSE9VU0VfREI6LWFuYWx5dGljc30nCiAgICAgIC0gUE9TVEdSRVNfSE9TVD1yeWJiaXRfcG9zdGdyZXMKICAgICAgLSBQT1NUR1JFU19QT1JUPTU0MzIKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgZGVwZW5kc19vbjoKICAgICAgcnliYml0X2NsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcnliYml0X3Bvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAxL2FwaS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICByeWJiaXRfcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE3LjQnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotYW5hbHl0aWNzfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1BPU1RHUkVTX1VTRVI6LWZyb2d9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgcnliYml0X2NsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuNC4yJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0NMSUNLSE9VU0VfREI9JHtDTElDS0hPVVNFX0RCOi1hbmFseXRpY3N9JwogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtDTElDS0hPVVNFX1VTRVI6LWRlZmF1bHR9JwogICAgICAtICdDTElDS0hPVVNFX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9DTElDS0hPVVNFfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLS1uby12ZXJib3NlJwogICAgICAgIC0gJy0tdHJpZXM9MScKICAgICAgICAtICctLXNwaWRlcicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogICAgICBzdGFydF9wZXJpb2Q6IDEwcwogICAgdm9sdW1lczoKICAgICAgLSAnY2xpY2tob3VzZV9kYXRhOi92YXIvbGliL2NsaWNraG91c2UnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2VuYWJsZV9qc29uLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9lbmFibGVfanNvbi54bWwKICAgICAgICBjb250ZW50OiAiPGNsaWNraG91c2U+XG4gICAgPHNldHRpbmdzPlxuICAgICAgICA8ZW5hYmxlX2pzb25fdHlwZT4xPC9lbmFibGVfanNvbl90eXBlPlxuICAgIDwvc2V0dGluZ3M+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL2xvZ2dpbmdfcnVsZXMueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsb2dnZXI+XG4gICAgICAgIDxsZXZlbD53YXJuaW5nPC9sZXZlbD5cbiAgICAgICAgPGNvbnNvbGU+dHJ1ZTwvY29uc29sZT5cbiAgICA8L2xvZ2dlcj5cbiAgICA8cXVlcnlfdGhyZWFkX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHF1ZXJ5X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPHRleHRfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8dHJhY2VfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8bWV0cmljX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGFzeW5jaHJvbm91c19tZXRyaWNfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8c2Vzc2lvbl9sb2cgcmVtb3ZlPVwicmVtb3ZlXCIvPlxuICAgIDxwYXJ0X2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG4gICAgPGxhdGVuY3lfbG9nIHJlbW92ZT1cInJlbW92ZVwiLz5cbiAgICA8cHJvY2Vzc29yc19wcm9maWxlX2xvZyByZW1vdmU9XCJyZW1vdmVcIi8+XG48L2NsaWNraG91c2U+XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2NsaWNraG91c2VfY29uZmlnL25ldHdvcmsueG1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL2NsaWNraG91c2Utc2VydmVyL2NvbmZpZy5kL25ldHdvcmsueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxsaXN0ZW5faG9zdD4wLjAuMC4wPC9saXN0ZW5faG9zdD5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZV9jb25maWcvdXNlcl9sb2dnaW5nLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC91c2VyX2xvZ2dpbmcueG1sCiAgICAgICAgY29udGVudDogIjxjbGlja2hvdXNlPlxuICAgIDxwcm9maWxlcz5cbiAgICAgICAgPGRlZmF1bHQ+XG4gICAgICAgICAgICA8bG9nX3F1ZXJpZXM+MDwvbG9nX3F1ZXJpZXM+XG4gICAgICAgICAgICA8bG9nX3F1ZXJ5X3RocmVhZHM+MDwvbG9nX3F1ZXJ5X3RocmVhZHM+XG4gICAgICAgICAgICA8bG9nX3Byb2Nlc3NvcnNfcHJvZmlsZXM+MDwvbG9nX3Byb2Nlc3NvcnNfcHJvZmlsZXM+XG4gICAgICAgIDwvZGVmYXVsdD5cbiAgICA8L3Byb2ZpbGVzPlxuPC9jbGlja2hvdXNlPlxuIgo=", "tags": [ "analytics", "web", @@ -4987,7 +4225,7 @@ "category": "storage", "logo": "svgs/seafile.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-23T08:56:51+05:30", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "80" }, "searxng": { @@ -5077,29 +4315,10 @@ "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, - "shlink": { - "documentation": "https://shlink.io/?utm_source=coolify.io", - "slogan": "The definitive self-hosted URL shortener", - "compose": "c2VydmljZXM6CiAgc2hsaW5rOgogICAgaW1hZ2U6ICdzaGxpbmtpby9zaGxpbms6c3RhYmxlJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NITElOS184MDgwCiAgICAgIC0gJ0RFRkFVTFRfRE9NQUlOPSR7U0VSVklDRV9GUUROX1NITElOS30nCiAgICAgIC0gSVNfSFRUUFNfRU5BQkxFRD1mYWxzZQogICAgICAtICdJTklUSUFMX0FQSV9LRVk9JHtTRVJWSUNFX0JBU0U2NF9TSExJTktBUElLRVl9JwogICAgdm9sdW1lczoKICAgICAgLSAnc2hsaW5rLWRhdGE6L2V0Yy9zaGxpbmsvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3Jlc3QvdjMvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDE1CiAgc2hsaW5rLXdlYjoKICAgIGltYWdlOiBzaGxpbmtpby9zaGxpbmstd2ViLWNsaWVudAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NITElOS1dFQl84MDgwCiAgICAgIC0gJ1NITElOS19TRVJWRVJfQVBJX0tFWT0ke1NFUlZJQ0VfQkFTRTY0X1NITElOS0FQSUtFWX0nCiAgICAgIC0gJ1NITElOS19TRVJWRVJfVVJMPSR7U0VSVklDRV9GUUROX1NITElOS30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "links", - "shortener", - "sharing", - "url", - "short", - "link", - "sharing" - ], - "category": "productivity", - "logo": "svgs/default.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2025-10-02T19:40:32+02:00", - "port": "8080" - }, "signoz": { "documentation": "https://signoz.io/docs/introduction/?utm_source=coolify.io", "slogan": "An observability platform native to OpenTelemetry with logs, traces and metrics.", - "compose": "c2VydmljZXM6CiAgaW5pdC1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSBiYXNoCiAgICAgIC0gJy1jJwogICAgICAtICJ2ZXJzaW9uPVwidjAuMC4xXCJcbm5vZGVfb3M9JCQodW5hbWUgLXMgfCB0ciAnWzp1cHBlcjpdJyAnWzpsb3dlcjpdJylcbm5vZGVfYXJjaD0kJCh1bmFtZSAtbSB8IHNlZCBzL2FhcmNoNjQvYXJtNjQvIHwgc2VkIHMveDg2XzY0L2FtZDY0LylcbmVjaG8gXCJGZXRjaGluZyBoaXN0b2dyYW0tYmluYXJ5IGZvciAkJHtub2RlX29zfS8kJHtub2RlX2FyY2h9XCJcbmNkIC90bXBcbndnZXQgLU8gaGlzdG9ncmFtLXF1YW50aWxlLnRhci5neiBcImh0dHBzOi8vZ2l0aHViLmNvbS9TaWdOb3ovc2lnbm96L3JlbGVhc2VzL2Rvd25sb2FkL2hpc3RvZ3JhbS1xdWFudGlsZSUyRiQke3ZlcnNpb259L2hpc3RvZ3JhbS1xdWFudGlsZV8kJHtub2RlX29zfV8kJHtub2RlX2FyY2h9LnRhci5nelwiXG50YXIgLXh2emYgaGlzdG9ncmFtLXF1YW50aWxlLnRhci5nelxubXYgaGlzdG9ncmFtLXF1YW50aWxlIC92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzL2hpc3RvZ3JhbVF1YW50aWxlXG4iCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IHVzZXItc2NyaXB0cwogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS91c2VyX3NjcmlwdHMKICB6b29rZWVwZXI6CiAgICBpbWFnZTogJ3NpZ25vei96b29rZWVwZXI6My45LjMnCiAgICB1c2VyOiByb290CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgLW0gMiBodHRwOi8vbG9jYWxob3N0OjgwODAvY29tbWFuZHMvcnVvayB8IGdyZXAgZXJyb3IgfCBncmVwIG51bGwnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgdm9sdW1lczoKICAgICAgLSAnem9va2VlcGVyOi9iaXRuYW1pL3pvb2tlZXBlcicKICAgIGVudmlyb25tZW50OgogICAgICAtICdBTExPV19BTk9OWU1PVVNfTE9HSU49JHtaT09fQUxMT1dfQU5PTllNT1VTX0xPR0lOOi15ZXN9JwogICAgICAtICdaT09fQVVUT1BVUkdFX0lOVEVSVkFMPSR7Wk9PX0FVVE9QVVJHRV9JTlRFUlZBTDotMX0nCiAgICAgIC0gJ1pPT19FTkFCTEVfUFJPTUVUSEVVU19NRVRSSUNTPSR7Wk9PX0VOQUJMRV9QUk9NRVRIRVVTX01FVFJJQ1M6LXllc30nCiAgICAgIC0gJ1pPT19QUk9NRVRIRVVTX01FVFJJQ1NfUE9SVF9OVU1CRVI9JHtaT09fUFJPTUVUSEVVU19NRVRSSUNTX1BPUlRfTlVNQkVSOi05MTQxfScKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgdHR5OiB0cnVlCiAgICBkZXBlbmRzX29uOgogICAgICBpbml0LWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgem9va2VlcGVyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy1PLScKICAgICAgICAtICcwLjAuMC4wOjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICB1bGltaXRzOgogICAgICBucHJvYzogNjU1MzUKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogY2xpY2tob3VzZQogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS8KICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogdXNlci1zY3JpcHRzCiAgICAgICAgdGFyZ2V0OiAvdmFyL2xpYi9jbGlja2hvdXNlL3VzZXJfc2NyaXB0cwogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2N1c3RvbS1mdW5jdGlvbi54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY3VzdG9tLWZ1bmN0aW9uLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8ZnVuY3Rpb25zPlxuICAgIDxmdW5jdGlvbj5cbiAgICAgICAgPHR5cGU+ZXhlY3V0YWJsZTwvdHlwZT5cbiAgICAgICAgPG5hbWU+aGlzdG9ncmFtUXVhbnRpbGU8L25hbWU+XG4gICAgICAgIDxyZXR1cm5fdHlwZT5GbG9hdDY0PC9yZXR1cm5fdHlwZT5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+QXJyYXkoRmxvYXQ2NCk8L3R5cGU+XG4gICAgICAgICAgICA8bmFtZT5idWNrZXRzPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8YXJndW1lbnQ+XG4gICAgICAgICAgICA8dHlwZT5BcnJheShGbG9hdDY0KTwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPmNvdW50czwvbmFtZT5cbiAgICAgICAgPC9hcmd1bWVudD5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+RmxvYXQ2NDwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPnF1YW50aWxlPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8Zm9ybWF0PkNTVjwvZm9ybWF0PlxuICAgICAgICA8Y29tbWFuZD4uL2hpc3RvZ3JhbVF1YW50aWxlPC9jb21tYW5kPlxuICAgIDwvZnVuY3Rpb24+XG48L2Z1bmN0aW9ucz5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9jbHVzdGVyLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9jbHVzdGVyLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8P3htbCB2ZXJzaW9uPVwiMS4wXCI/PlxuPGNsaWNraG91c2U+XG4gICAgPHpvb2tlZXBlcj5cbiAgICAgICAgPG5vZGUgaW5kZXg9XCIxXCI+XG4gICAgICAgICAgICA8aG9zdD56b29rZWVwZXI8L2hvc3Q+XG4gICAgICAgICAgICA8cG9ydD4yMTgxPC9wb3J0PlxuICAgICAgICA8L25vZGU+XG4gICAgPC96b29rZWVwZXI+XG4gICAgPHJlbW90ZV9zZXJ2ZXJzPlxuICAgICAgICA8Y2x1c3Rlcj5cbiAgICAgICAgICAgIDxzaGFyZD5cbiAgICAgICAgICAgICAgICA8cmVwbGljYT5cbiAgICAgICAgICAgICAgICAgICAgPGhvc3Q+Y2xpY2tob3VzZTwvaG9zdD5cbiAgICAgICAgICAgICAgICAgICAgPHBvcnQ+OTAwMDwvcG9ydD5cbiAgICAgICAgICAgICAgICA8L3JlcGxpY2E+XG4gICAgICAgICAgICA8L3NoYXJkPlxuICAgICAgICA8L2NsdXN0ZXI+XG4gICAgPC9yZW1vdGVfc2VydmVycz5cbiAgICA8bWFjcm9zPlxuICAgICAgICA8c2hhcmQ+MDE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMDE8L3JlcGxpY2E+XG4gICAgPC9tYWNyb3M+XG4gICAgPHVzZXJfc2NyaXB0c19wYXRoPi92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzLzwvdXNlcl9zY3JpcHRzX3BhdGg+XG4gICAgPHVzZXJfZGVmaW5lZF9leGVjdXRhYmxlX2Z1bmN0aW9uc19jb25maWc+KmZ1bmN0aW9uLnhtbDwvdXNlcl9kZWZpbmVkX2V4ZWN1dGFibGVfZnVuY3Rpb25zX2NvbmZpZz5cbjwvY2xpY2tob3VzZT5cbiIKICBzaWdub3o6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3o6djAuMTM3LjEnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBzcWxpdGUKICAgICAgICB0YXJnZXQ6IC92YXIvbGliL3NpZ25vei8KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TSUdOT1pfODA4MAogICAgICAtICdTSUdOT1pfVE9LRU5JWkVSX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUU0VDUkVUfScKICAgICAgLSBTSUdOT1pfU1FMU1RPUkVfUFJPVklERVI9c3FsaXRlCiAgICAgIC0gU0lHTk9aX1NRTFNUT1JFX1NRTElURV9QQVRIPS92YXIvbGliL3NpZ25vei9zaWdub3ouZGIKICAgICAgLSBTSUdOT1pfVEVMRU1FVFJZU1RPUkVfUFJPVklERVI9Y2xpY2tob3VzZQogICAgICAtICdTSUdOT1pfVEVMRU1FVFJZU1RPUkVfQ0xJQ0tIT1VTRV9EU049dGNwOi8vY2xpY2tob3VzZTo5MDAwJwogICAgICAtIEdPREVCVUc9bmV0ZG5zPWdvCiAgICAgIC0gREVQTE9ZTUVOVF9UWVBFPWRvY2tlci1zdGFuZGFsb25lLWFtZAogICAgICAtICdTSUdOT1pfU1RBVFNSRVBPUlRFUl9FTkFCTEVEPSR7U0lHTk9aX1NUQVRTUkVQT1JURVJfRU5BQkxFRDotdHJ1ZX0nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19FTkFCTEVEPSR7U0lHTk9aX0VNQUlMSU5HX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQUREUkVTUz0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FERFJFU1N9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9GUk9NPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfRlJPTX0nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfVVNFUk5BTUU9JHtTSUdOT1pfRU1BSUxJTkdfU01UUF9BVVRIX1VTRVJOQU1FfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9QQVNTV09SRD0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfUEFTU1dPUkR9JwogICAgICAtIFNJR05PWl9BTEVSVE1BTkFHRVJfUFJPVklERVI9c2lnbm96CiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19QQVNTV09SRD0ke1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19QQVNTV09SRH0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19VU0VSTkFNRT0ke1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19VU0VSTkFNRX0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19GUk9NPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0ZST019JwogICAgICAtICdTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fU01BUlRIT1NUPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX1NNQVJUSE9TVH0nCiAgICAgIC0gRE9UX01FVFJJQ1NfRU5BQkxFRD10cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJy1xJwogICAgICAgIC0gJ2xvY2FsaG9zdDo4MDgwL2FwaS92MS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgaW1hZ2U6ICdzaWdub3ovc2lnbm96LW90ZWwtY29sbGVjdG9yOnYwLjE0NC44JwogICAgZGVwZW5kc19vbjoKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW50cnlwb2ludDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgY29tbWFuZDoKICAgICAgLSAiL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIGJvb3RzdHJhcCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMCAmJlxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIHN5bmMgdXAgLS1jbGlja2hvdXNlLWRzbiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAgJiZcbi9zaWdub3otb3RlbC1jb2xsZWN0b3IgbWlncmF0ZSBhc3luYyB1cCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMFxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1NJR05PWl9PVEVMX0NPTExFQ1RPUl9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfQ0xVU1RFUj1jbHVzdGVyCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfUkVQTElDQVRJT049dHJ1ZQogICAgICAtIFNJR05PWl9PVEVMX0NPTExFQ1RPUl9USU1FT1VUPTEwbQogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgb3RlbC1jb2xsZWN0b3I6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3otb3RlbC1jb2xsZWN0b3I6djAuMTQ0LjgnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICAgIHNpZ25vejoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW50cnlwb2ludDoKICAgICAgLSAvYmluL3NoCiAgICBjb21tYW5kOgogICAgICAtICctYycKICAgICAgLSAic2V0IC1lXG5jYXQgPiAvdG1wL290ZWwtY29uZmlnLnlhbWwgPDwnU0lHTk9aX0NGRydcbmNvbm5lY3RvcnM6XG4gIHNpZ25vem1ldGVyOlxuICAgIG1ldHJpY3NfZmx1c2hfaW50ZXJ2YWw6IDFoXG4gICAgZGltZW5zaW9uczpcbiAgICAgIC0gbmFtZTogc2VydmljZS5uYW1lXG4gICAgICAtIG5hbWU6IGRlcGxveW1lbnQuZW52aXJvbm1lbnRcbiAgICAgIC0gbmFtZTogaG9zdC5uYW1lXG5yZWNlaXZlcnM6XG4gIG90bHA6XG4gICAgcHJvdG9jb2xzOlxuICAgICAgZ3JwYzpcbiAgICAgICAgZW5kcG9pbnQ6IDAuMC4wLjA6NDMxN1xuICAgICAgaHR0cDpcbiAgICAgICAgZW5kcG9pbnQ6IDAuMC4wLjA6NDMxOFxuICAgICAgICBhdXRoOlxuICAgICAgICAgIGF1dGhlbnRpY2F0b3I6IGJhc2ljYXV0aC9zZXJ2ZXJcbnByb2Nlc3NvcnM6XG4gIGJhdGNoOlxuICAgIHNlbmRfYmF0Y2hfc2l6ZTogNTAwMDBcbiAgICBzZW5kX2JhdGNoX21heF9zaXplOiA1NTAwMFxuICAgIHRpbWVvdXQ6IDVzXG4gIGJhdGNoL21ldGVyOlxuICAgIHNlbmRfYmF0Y2hfc2l6ZTogMjAwMDBcbiAgICBzZW5kX2JhdGNoX21heF9zaXplOiAyNTAwMFxuICAgIHRpbWVvdXQ6IDVzXG4gIHNpZ25venNwYW5tZXRyaWNzL2RlbHRhOlxuICAgIG1ldHJpY3NfZXhwb3J0ZXI6IHNpZ25vemNsaWNraG91c2VtZXRyaWNzXG4gICAgbWV0cmljc19mbHVzaF9pbnRlcnZhbDogNjBzXG4gICAgbGF0ZW5jeV9oaXN0b2dyYW1fYnVja2V0czogWzEwMHVzLCAxbXMsIDJtcywgNm1zLCAxMG1zLCA1MG1zLCAxMDBtcywgMjUwbXMsIDUwMG1zLCAxMDAwbXMsIDE0MDBtcywgMjAwMG1zLCA1cywgMTBzLCAyMHMsIDQwcywgNjBzXVxuICAgIGRpbWVuc2lvbnNfY2FjaGVfc2l6ZTogMTAwMDAwXG4gICAgYWdncmVnYXRpb25fdGVtcG9yYWxpdHk6IEFHR1JFR0FUSU9OX1RFTVBPUkFMSVRZX0RFTFRBXG4gICAgZW5hYmxlX2V4cF9oaXN0b2dyYW06IHRydWVcbiAgICBkaW1lbnNpb25zOlxuICAgICAgLSBuYW1lOiBzZXJ2aWNlLm5hbWVzcGFjZVxuICAgICAgICBkZWZhdWx0OiBkZWZhdWx0XG4gICAgICAtIG5hbWU6IGRlcGxveW1lbnQuZW52aXJvbm1lbnRcbiAgICAgICAgZGVmYXVsdDogZGVmYXVsdFxuICAgICAgIyBUaGlzIGlzIGFkZGVkIHRvIGVuc3VyZSB0aGUgdW5pcXVlbmVzcyBvZiB0aGUgdGltZXNlcmllc1xuICAgICAgIyBPdGhlcndpc2UsIGlkZW50aWNhbCB0aW1lc2VyaWVzIHByb2R1Y2VkIGJ5IG11bHRpcGxlIHJlcGxpY2FzIG9mXG4gICAgICAjIGNvbGxlY3RvcnMgcmVzdWx0IGluIGluY29ycmVjdCBBUE0gbWV0cmljc1xuICAgICAgLSBuYW1lOiBzaWdub3ouY29sbGVjdG9yLmlkXG4gICAgICAtIG5hbWU6IHNlcnZpY2UudmVyc2lvblxuZXhwb3J0ZXJzOlxuICBjbGlja2hvdXNldHJhY2VzOlxuICAgIGRhdGFzb3VyY2U6IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfdHJhY2VzXG4gICAgbG93X2NhcmRpbmFsX2V4Y2VwdGlvbl9ncm91cGluZzogZmFsc2VcbiAgICB1c2VfbmV3X3NjaGVtYTogdHJ1ZVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBzaWdub3pjbGlja2hvdXNlbWV0cmljczpcbiAgICBkc246IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfbWV0cmljc1xuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBjbGlja2hvdXNlbG9nc2V4cG9ydGVyOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9sb2dzXG4gICAgdXNlX25ld19zY2hlbWE6IHRydWVcbiAgICB0aW1lb3V0OiA0NXNcbiAgICBzZW5kaW5nX3F1ZXVlOlxuICAgICAgZW5hYmxlZDogZmFsc2VcbiAgc2lnbm96Y2xpY2tob3VzZW1ldGVyOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRlclxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBtZXRhZGF0YWV4cG9ydGVyOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkc246IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfbWV0YWRhdGFcbiAgICB0aW1lb3V0OiA0NXNcbiAgICBjYWNoZTpcbiAgICAgIHByb3ZpZGVyOiBpbl9tZW1vcnlcbmV4dGVuc2lvbnM6XG4gIHNpZ25vel9oZWFsdGhfY2hlY2s6XG4gICAgZW5kcG9pbnQ6IDAuMC4wLjA6MTMxMzNcbiAgcHByb2Y6XG4gICAgZW5kcG9pbnQ6IDAuMC4wLjA6MTc3N1xuICBiYXNpY2F1dGgvc2VydmVyOlxuICAgIGh0cGFzc3dkOlxuICAgICAgaW5saW5lOiBcIiQke2VudjpPVEVMX0JBU0lDX0FVVEh9XFxuXCJcbnNlcnZpY2U6XG4gIHRlbGVtZXRyeTpcbiAgICBsb2dzOlxuICAgICAgZW5jb2Rpbmc6IGpzb25cbiAgZXh0ZW5zaW9uczpcbiAgICAtIHNpZ25vel9oZWFsdGhfY2hlY2tcbiAgICAtIHBwcm9mXG4gICAgLSBiYXNpY2F1dGgvc2VydmVyXG4gIHBpcGVsaW5lczpcbiAgICB0cmFjZXM6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW3NpZ25venNwYW5tZXRyaWNzL2RlbHRhLCBiYXRjaF1cbiAgICAgIGV4cG9ydGVyczogW2NsaWNraG91c2V0cmFjZXMsIHNpZ25vem1ldGVyLCBtZXRhZGF0YWV4cG9ydGVyXVxuICAgIG1ldHJpY3M6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbc2lnbm96Y2xpY2tob3VzZW1ldHJpY3MsIHNpZ25vem1ldGVyLCBtZXRhZGF0YWV4cG9ydGVyXVxuICAgIGxvZ3M6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbY2xpY2tob3VzZWxvZ3NleHBvcnRlciwgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbWV0cmljcy9tZXRlcjpcbiAgICAgIHJlY2VpdmVyczogW3NpZ25vem1ldGVyXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoL21ldGVyXVxuICAgICAgZXhwb3J0ZXJzOiBbc2lnbm96Y2xpY2tob3VzZW1ldGVyXVxuU0lHTk9aX0NGR1xuY2F0ID4gL3RtcC9vcGFtcC1jb25maWcueWFtbCA8PCdTSUdOT1pfT1BBTVAnXG5zZXJ2ZXJfZW5kcG9pbnQ6IHdzOi8vc2lnbm96OjQzMjAvdjEvb3BhbXBcblNJR05PWl9PUEFNUFxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIHN5bmMgY2hlY2tcbmV4ZWMgL3NpZ25vei1vdGVsLWNvbGxlY3RvciAtLWNvbmZpZz0vdG1wL290ZWwtY29uZmlnLnlhbWwgLS1tYW5hZ2VyLWNvbmZpZz0vdG1wL29wYW1wLWNvbmZpZy55YW1sIC0tY29weS1wYXRoPS92YXIvdG1wL2NvbGxlY3Rvci1jb25maWcueWFtbFxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX09URUxDT0xMRUNUT1JIVFRQXzQzMTgKICAgICAgLSAnT1RFTF9SRVNPVVJDRV9BVFRSSUJVVEVTPWhvc3QubmFtZT1zaWdub3otaG9zdCxvcy50eXBlPWxpbnV4JwogICAgICAtIExPV19DQVJESU5BTF9FWENFUFRJT05fR1JPVVBJTkc9ZmFsc2UKICAgICAgLSAnU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfRFNOPXRjcDovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9DTFVTVEVSPWNsdXN0ZXIKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9SRVBMSUNBVElPTj10cnVlCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX1RJTUVPVVQ9MTBtCiAgICAgIC0gJ09URUxfQkFTSUNfQVVUSD1zaWdub3o6JHtTRVJWSUNFX1BBU1NXT1JEXzY0X09URUxBVVRIfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnYmFzaCAtYyAiZXhlYyA2PD4gL2Rldi90Y3AvbG9jYWxob3N0LzEzMTMzIicKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", + "compose": "c2VydmljZXM6CiAgaW5pdC1jbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgY29tbWFuZDoKICAgICAgLSBiYXNoCiAgICAgIC0gJy1jJwogICAgICAtICJ2ZXJzaW9uPVwidjAuMC4xXCJcbm5vZGVfb3M9JCQodW5hbWUgLXMgfCB0ciAnWzp1cHBlcjpdJyAnWzpsb3dlcjpdJylcbm5vZGVfYXJjaD0kJCh1bmFtZSAtbSB8IHNlZCBzL2FhcmNoNjQvYXJtNjQvIHwgc2VkIHMveDg2XzY0L2FtZDY0LylcbmVjaG8gXCJGZXRjaGluZyBoaXN0b2dyYW0tYmluYXJ5IGZvciAkJHtub2RlX29zfS8kJHtub2RlX2FyY2h9XCJcbmNkIC90bXBcbndnZXQgLU8gaGlzdG9ncmFtLXF1YW50aWxlLnRhci5neiBcImh0dHBzOi8vZ2l0aHViLmNvbS9TaWdOb3ovc2lnbm96L3JlbGVhc2VzL2Rvd25sb2FkL2hpc3RvZ3JhbS1xdWFudGlsZSUyRiQke3ZlcnNpb259L2hpc3RvZ3JhbS1xdWFudGlsZV8kJHtub2RlX29zfV8kJHtub2RlX2FyY2h9LnRhci5nelwiXG50YXIgLXh2emYgaGlzdG9ncmFtLXF1YW50aWxlLnRhci5nelxubXYgaGlzdG9ncmFtLXF1YW50aWxlIC92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzL2hpc3RvZ3JhbVF1YW50aWxlXG4iCiAgICByZXN0YXJ0OiBvbi1mYWlsdXJlCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIGxvZ2dpbmc6CiAgICAgIG9wdGlvbnM6CiAgICAgICAgbWF4LXNpemU6IDUwbQogICAgICAgIG1heC1maWxlOiAnMycKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiB2b2x1bWUKICAgICAgICBzb3VyY2U6IHVzZXItc2NyaXB0cwogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS91c2VyX3NjcmlwdHMKICB6b29rZWVwZXI6CiAgICBpbWFnZTogJ3NpZ25vei96b29rZWVwZXI6My45LjMnCiAgICB1c2VyOiByb290CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2N1cmwgLXMgLW0gMiBodHRwOi8vbG9jYWxob3N0OjgwODAvY29tbWFuZHMvcnVvayB8IGdyZXAgZXJyb3IgfCBncmVwIG51bGwnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgdm9sdW1lczoKICAgICAgLSAnem9va2VlcGVyOi9iaXRuYW1pL3pvb2tlZXBlcicKICAgIGVudmlyb25tZW50OgogICAgICAtICdBTExPV19BTk9OWU1PVVNfTE9HSU49JHtaT09fQUxMT1dfQU5PTllNT1VTX0xPR0lOOi15ZXN9JwogICAgICAtICdaT09fQVVUT1BVUkdFX0lOVEVSVkFMPSR7Wk9PX0FVVE9QVVJHRV9JTlRFUlZBTDotMX0nCiAgICAgIC0gJ1pPT19FTkFCTEVfUFJPTUVUSEVVU19NRVRSSUNTPSR7Wk9PX0VOQUJMRV9QUk9NRVRIRVVTX01FVFJJQ1M6LXllc30nCiAgICAgIC0gJ1pPT19QUk9NRVRIRVVTX01FVFJJQ1NfUE9SVF9OVU1CRVI9JHtaT09fUFJPTUVUSEVVU19NRVRSSUNTX1BPUlRfTlVNQkVSOi05MTQxfScKICBjbGlja2hvdXNlOgogICAgaW1hZ2U6ICdjbGlja2hvdXNlL2NsaWNraG91c2Utc2VydmVyOjI1LjEyLjUtYWxwaW5lJwogICAgdHR5OiB0cnVlCiAgICBkZXBlbmRzX29uOgogICAgICBpbml0LWNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgICAgem9va2VlcGVyOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xJwogICAgICAgIC0gJy1PLScKICAgICAgICAtICcwLjAuMC4wOjgxMjMvcGluZycKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCiAgICB1bGltaXRzOgogICAgICBucHJvYzogNjU1MzUKICAgICAgbm9maWxlOgogICAgICAgIHNvZnQ6IDI2MjE0NAogICAgICAgIGhhcmQ6IDI2MjE0NAogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gQ0xJQ0tIT1VTRV9TS0lQX1VTRVJfU0VUVVA9MQogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogY2xpY2tob3VzZQogICAgICAgIHRhcmdldDogL3Zhci9saWIvY2xpY2tob3VzZS8KICAgICAgLQogICAgICAgIHR5cGU6IHZvbHVtZQogICAgICAgIHNvdXJjZTogdXNlci1zY3JpcHRzCiAgICAgICAgdGFyZ2V0OiAvdmFyL2xpYi9jbGlja2hvdXNlL3VzZXJfc2NyaXB0cwogICAgICAgIHJlYWRfb25seTogdHJ1ZQogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi9jbGlja2hvdXNlL2N1c3RvbS1mdW5jdGlvbi54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY3VzdG9tLWZ1bmN0aW9uLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8ZnVuY3Rpb25zPlxuICAgIDxmdW5jdGlvbj5cbiAgICAgICAgPHR5cGU+ZXhlY3V0YWJsZTwvdHlwZT5cbiAgICAgICAgPG5hbWU+aGlzdG9ncmFtUXVhbnRpbGU8L25hbWU+XG4gICAgICAgIDxyZXR1cm5fdHlwZT5GbG9hdDY0PC9yZXR1cm5fdHlwZT5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+QXJyYXkoRmxvYXQ2NCk8L3R5cGU+XG4gICAgICAgICAgICA8bmFtZT5idWNrZXRzPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8YXJndW1lbnQ+XG4gICAgICAgICAgICA8dHlwZT5BcnJheShGbG9hdDY0KTwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPmNvdW50czwvbmFtZT5cbiAgICAgICAgPC9hcmd1bWVudD5cbiAgICAgICAgPGFyZ3VtZW50PlxuICAgICAgICAgICAgPHR5cGU+RmxvYXQ2NDwvdHlwZT5cbiAgICAgICAgICAgIDxuYW1lPnF1YW50aWxlPC9uYW1lPlxuICAgICAgICA8L2FyZ3VtZW50PlxuICAgICAgICA8Zm9ybWF0PkNTVjwvZm9ybWF0PlxuICAgICAgICA8Y29tbWFuZD4uL2hpc3RvZ3JhbVF1YW50aWxlPC9jb21tYW5kPlxuICAgIDwvZnVuY3Rpb24+XG48L2Z1bmN0aW9ucz5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9jbHVzdGVyLnhtbAogICAgICAgIHRhcmdldDogL2V0Yy9jbGlja2hvdXNlLXNlcnZlci9jb25maWcuZC9jbHVzdGVyLnhtbAogICAgICAgIGNvbnRlbnQ6ICI8P3htbCB2ZXJzaW9uPVwiMS4wXCI/PlxuPGNsaWNraG91c2U+XG4gICAgPHpvb2tlZXBlcj5cbiAgICAgICAgPG5vZGUgaW5kZXg9XCIxXCI+XG4gICAgICAgICAgICA8aG9zdD56b29rZWVwZXI8L2hvc3Q+XG4gICAgICAgICAgICA8cG9ydD4yMTgxPC9wb3J0PlxuICAgICAgICA8L25vZGU+XG4gICAgPC96b29rZWVwZXI+XG4gICAgPHJlbW90ZV9zZXJ2ZXJzPlxuICAgICAgICA8Y2x1c3Rlcj5cbiAgICAgICAgICAgIDxzaGFyZD5cbiAgICAgICAgICAgICAgICA8cmVwbGljYT5cbiAgICAgICAgICAgICAgICAgICAgPGhvc3Q+Y2xpY2tob3VzZTwvaG9zdD5cbiAgICAgICAgICAgICAgICAgICAgPHBvcnQ+OTAwMDwvcG9ydD5cbiAgICAgICAgICAgICAgICA8L3JlcGxpY2E+XG4gICAgICAgICAgICA8L3NoYXJkPlxuICAgICAgICA8L2NsdXN0ZXI+XG4gICAgPC9yZW1vdGVfc2VydmVycz5cbiAgICA8bWFjcm9zPlxuICAgICAgICA8c2hhcmQ+MDE8L3NoYXJkPlxuICAgICAgICA8cmVwbGljYT5yZXBsaWNhMDE8L3JlcGxpY2E+XG4gICAgPC9tYWNyb3M+XG4gICAgPHVzZXJfc2NyaXB0c19wYXRoPi92YXIvbGliL2NsaWNraG91c2UvdXNlcl9zY3JpcHRzLzwvdXNlcl9zY3JpcHRzX3BhdGg+XG4gICAgPHVzZXJfZGVmaW5lZF9leGVjdXRhYmxlX2Z1bmN0aW9uc19jb25maWc+KmZ1bmN0aW9uLnhtbDwvdXNlcl9kZWZpbmVkX2V4ZWN1dGFibGVfZnVuY3Rpb25zX2NvbmZpZz5cbjwvY2xpY2tob3VzZT5cbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vY2xpY2tob3VzZS9zeXN0ZW0tbG9ncy54bWwKICAgICAgICB0YXJnZXQ6IC9ldGMvY2xpY2tob3VzZS1zZXJ2ZXIvY29uZmlnLmQvc3lzdGVtLWxvZ3MueG1sCiAgICAgICAgY29udGVudDogIjw/eG1sIHZlcnNpb249XCIxLjBcIj8+XG48Y2xpY2tob3VzZT5cbiAgICA8cXVlcnlfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvcXVlcnlfbG9nPlxuICAgIDxxdWVyeV90aHJlYWRfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICA8L3F1ZXJ5X3RocmVhZF9sb2c+XG4gICAgPHF1ZXJ5X21ldHJpY19sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvcXVlcnlfbWV0cmljX2xvZz5cbiAgICA8cXVlcnlfdmlld3NfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICA8L3F1ZXJ5X3ZpZXdzX2xvZz5cbiAgICA8cGFydF9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvcGFydF9sb2c+XG4gICAgPG1ldHJpY19sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgICAgICA8Zmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPjMwMDAwPC9mbHVzaF9pbnRlcnZhbF9taWxsaXNlY29uZHM+XG4gICAgPC9tZXRyaWNfbG9nPlxuICAgIDxhc3luY2hyb25vdXNfbWV0cmljX2xvZz5cbiAgICAgICAgPHR0bD5ldmVudF9kYXRlICsgSU5URVJWQUwgMSBEQVkgREVMRVRFPC90dGw+XG4gICAgPC9hc3luY2hyb25vdXNfbWV0cmljX2xvZz5cbiAgICA8dHJhY2VfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvdHJhY2VfbG9nPlxuICAgIDxlcnJvcl9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgIDwvZXJyb3JfbG9nPlxuICAgIDxwcm9jZXNzb3JzX3Byb2ZpbGVfbG9nPlxuICAgICAgICA8dHRsPmV2ZW50X2RhdGUgKyBJTlRFUlZBTCAxIERBWSBERUxFVEU8L3R0bD5cbiAgICAgICAgPGZsdXNoX2ludGVydmFsX21pbGxpc2Vjb25kcz4zMDAwMDwvZmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPlxuICAgIDwvcHJvY2Vzc29yc19wcm9maWxlX2xvZz5cbiAgICA8dGV4dF9sb2c+XG4gICAgICAgIDx0dGw+ZXZlbnRfZGF0ZSArIElOVEVSVkFMIDEgREFZIERFTEVURTwvdHRsPlxuICAgICAgICA8Zmx1c2hfaW50ZXJ2YWxfbWlsbGlzZWNvbmRzPjMwMDAwPC9mbHVzaF9pbnRlcnZhbF9taWxsaXNlY29uZHM+XG4gICAgPC90ZXh0X2xvZz5cbjwvY2xpY2tob3VzZT5cbiIKICBzaWdub3o6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3o6djAuMTM3LjEnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogdm9sdW1lCiAgICAgICAgc291cmNlOiBzcWxpdGUKICAgICAgICB0YXJnZXQ6IC92YXIvbGliL3NpZ25vei8KICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9TSUdOT1pfODA4MAogICAgICAtICdTSUdOT1pfVE9LRU5JWkVSX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1JFQUxCQVNFNjRfSldUU0VDUkVUfScKICAgICAgLSBTSUdOT1pfU1FMU1RPUkVfUFJPVklERVI9c3FsaXRlCiAgICAgIC0gU0lHTk9aX1NRTFNUT1JFX1NRTElURV9QQVRIPS92YXIvbGliL3NpZ25vei9zaWdub3ouZGIKICAgICAgLSBTSUdOT1pfVEVMRU1FVFJZU1RPUkVfUFJPVklERVI9Y2xpY2tob3VzZQogICAgICAtICdTSUdOT1pfVEVMRU1FVFJZU1RPUkVfQ0xJQ0tIT1VTRV9EU049dGNwOi8vY2xpY2tob3VzZTo5MDAwJwogICAgICAtIEdPREVCVUc9bmV0ZG5zPWdvCiAgICAgIC0gREVQTE9ZTUVOVF9UWVBFPWRvY2tlci1zdGFuZGFsb25lLWFtZAogICAgICAtICdTSUdOT1pfU1RBVFNSRVBPUlRFUl9FTkFCTEVEPSR7U0lHTk9aX1NUQVRTUkVQT1JURVJfRU5BQkxFRDotdHJ1ZX0nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19FTkFCTEVEPSR7U0lHTk9aX0VNQUlMSU5HX0VOQUJMRUQ6LWZhbHNlfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQUREUkVTUz0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FERFJFU1N9JwogICAgICAtICdTSUdOT1pfRU1BSUxJTkdfU01UUF9GUk9NPSR7U0lHTk9aX0VNQUlMSU5HX1NNVFBfRlJPTX0nCiAgICAgIC0gJ1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfVVNFUk5BTUU9JHtTSUdOT1pfRU1BSUxJTkdfU01UUF9BVVRIX1VTRVJOQU1FfScKICAgICAgLSAnU0lHTk9aX0VNQUlMSU5HX1NNVFBfQVVUSF9QQVNTV09SRD0ke1NJR05PWl9FTUFJTElOR19TTVRQX0FVVEhfUEFTU1dPUkR9JwogICAgICAtIFNJR05PWl9BTEVSVE1BTkFHRVJfUFJPVklERVI9c2lnbm96CiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19QQVNTV09SRD0ke1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19QQVNTV09SRH0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19VU0VSTkFNRT0ke1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19BVVRIX19VU0VSTkFNRX0nCiAgICAgIC0gJ1NJR05PWl9BTEVSVE1BTkFHRVJfU0lHTk9aX0dMT0JBTF9TTVRQX19GUk9NPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX0ZST019JwogICAgICAtICdTSUdOT1pfQUxFUlRNQU5BR0VSX1NJR05PWl9HTE9CQUxfU01UUF9fU01BUlRIT1NUPSR7U0lHTk9aX0FMRVJUTUFOQUdFUl9TSUdOT1pfR0xPQkFMX1NNVFBfX1NNQVJUSE9TVH0nCiAgICAgIC0gRE9UX01FVFJJQ1NfRU5BQkxFRD10cnVlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tc3BpZGVyJwogICAgICAgIC0gJy1xJwogICAgICAgIC0gJ2xvY2FsaG9zdDo4MDgwL2FwaS92MS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMwogIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgaW1hZ2U6ICdzaWdub3ovc2lnbm96LW90ZWwtY29sbGVjdG9yOnYwLjE0NC44JwogICAgZGVwZW5kc19vbjoKICAgICAgY2xpY2tob3VzZToKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZW50cnlwb2ludDoKICAgICAgLSAvYmluL3NoCiAgICAgIC0gJy1jJwogICAgY29tbWFuZDoKICAgICAgLSAiL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIGJvb3RzdHJhcCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMCAmJlxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIHN5bmMgdXAgLS1jbGlja2hvdXNlLWRzbiB0Y3A6Ly9jbGlja2hvdXNlOjkwMDAgJiZcbi9zaWdub3otb3RlbC1jb2xsZWN0b3IgbWlncmF0ZSBhc3luYyB1cCAtLWNsaWNraG91c2UtZHNuIHRjcDovL2NsaWNraG91c2U6OTAwMFxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1NJR05PWl9PVEVMX0NPTExFQ1RPUl9DTElDS0hPVVNFX0RTTj10Y3A6Ly9jbGlja2hvdXNlOjkwMDAnCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfQ0xVU1RFUj1jbHVzdGVyCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfUkVQTElDQVRJT049dHJ1ZQogICAgICAtIFNJR05PWl9PVEVMX0NPTExFQ1RPUl9USU1FT1VUPTEwbQogICAgcmVzdGFydDogb24tZmFpbHVyZQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBsb2dnaW5nOgogICAgICBvcHRpb25zOgogICAgICAgIG1heC1zaXplOiA1MG0KICAgICAgICBtYXgtZmlsZTogJzMnCiAgb3RlbC1jb2xsZWN0b3I6CiAgICBpbWFnZTogJ3NpZ25vei9zaWdub3otb3RlbC1jb2xsZWN0b3I6djAuMTQ0LjgnCiAgICBkZXBlbmRzX29uOgogICAgICBjbGlja2hvdXNlOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHRlbGVtZXRyeXN0b3JlLW1pZ3JhdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9jb21wbGV0ZWRfc3VjY2Vzc2Z1bGx5CiAgICAgIHNpZ25vejoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgbG9nZ2luZzoKICAgICAgb3B0aW9uczoKICAgICAgICBtYXgtc2l6ZTogNTBtCiAgICAgICAgbWF4LWZpbGU6ICczJwogICAgZW50cnlwb2ludDoKICAgICAgLSAvYmluL3NoCiAgICBjb21tYW5kOgogICAgICAtICctYycKICAgICAgLSAic2V0IC1lXG5jYXQgPiAvdG1wL290ZWwtY29uZmlnLnlhbWwgPDwnU0lHTk9aX0NGRydcbmNvbm5lY3RvcnM6XG4gIHNpZ25vem1ldGVyOlxuICAgIG1ldHJpY3NfZmx1c2hfaW50ZXJ2YWw6IDFoXG4gICAgZGltZW5zaW9uczpcbiAgICAgIC0gbmFtZTogc2VydmljZS5uYW1lXG4gICAgICAtIG5hbWU6IGRlcGxveW1lbnQuZW52aXJvbm1lbnRcbiAgICAgIC0gbmFtZTogaG9zdC5uYW1lXG5yZWNlaXZlcnM6XG4gIG90bHA6XG4gICAgcHJvdG9jb2xzOlxuICAgICAgZ3JwYzpcbiAgICAgICAgZW5kcG9pbnQ6IDAuMC4wLjA6NDMxN1xuICAgICAgaHR0cDpcbiAgICAgICAgZW5kcG9pbnQ6IDAuMC4wLjA6NDMxOFxuICAgICAgICBhdXRoOlxuICAgICAgICAgIGF1dGhlbnRpY2F0b3I6IGJhc2ljYXV0aC9zZXJ2ZXJcbnByb2Nlc3NvcnM6XG4gIGJhdGNoOlxuICAgIHNlbmRfYmF0Y2hfc2l6ZTogNTAwMDBcbiAgICBzZW5kX2JhdGNoX21heF9zaXplOiA1NTAwMFxuICAgIHRpbWVvdXQ6IDVzXG4gIGJhdGNoL21ldGVyOlxuICAgIHNlbmRfYmF0Y2hfc2l6ZTogMjAwMDBcbiAgICBzZW5kX2JhdGNoX21heF9zaXplOiAyNTAwMFxuICAgIHRpbWVvdXQ6IDVzXG4gIHNpZ25venNwYW5tZXRyaWNzL2RlbHRhOlxuICAgIG1ldHJpY3NfZXhwb3J0ZXI6IHNpZ25vemNsaWNraG91c2VtZXRyaWNzXG4gICAgbWV0cmljc19mbHVzaF9pbnRlcnZhbDogNjBzXG4gICAgbGF0ZW5jeV9oaXN0b2dyYW1fYnVja2V0czogWzEwMHVzLCAxbXMsIDJtcywgNm1zLCAxMG1zLCA1MG1zLCAxMDBtcywgMjUwbXMsIDUwMG1zLCAxMDAwbXMsIDE0MDBtcywgMjAwMG1zLCA1cywgMTBzLCAyMHMsIDQwcywgNjBzXVxuICAgIGRpbWVuc2lvbnNfY2FjaGVfc2l6ZTogMTAwMDAwXG4gICAgYWdncmVnYXRpb25fdGVtcG9yYWxpdHk6IEFHR1JFR0FUSU9OX1RFTVBPUkFMSVRZX0RFTFRBXG4gICAgZW5hYmxlX2V4cF9oaXN0b2dyYW06IHRydWVcbiAgICBkaW1lbnNpb25zOlxuICAgICAgLSBuYW1lOiBzZXJ2aWNlLm5hbWVzcGFjZVxuICAgICAgICBkZWZhdWx0OiBkZWZhdWx0XG4gICAgICAtIG5hbWU6IGRlcGxveW1lbnQuZW52aXJvbm1lbnRcbiAgICAgICAgZGVmYXVsdDogZGVmYXVsdFxuICAgICAgIyBUaGlzIGlzIGFkZGVkIHRvIGVuc3VyZSB0aGUgdW5pcXVlbmVzcyBvZiB0aGUgdGltZXNlcmllc1xuICAgICAgIyBPdGhlcndpc2UsIGlkZW50aWNhbCB0aW1lc2VyaWVzIHByb2R1Y2VkIGJ5IG11bHRpcGxlIHJlcGxpY2FzIG9mXG4gICAgICAjIGNvbGxlY3RvcnMgcmVzdWx0IGluIGluY29ycmVjdCBBUE0gbWV0cmljc1xuICAgICAgLSBuYW1lOiBzaWdub3ouY29sbGVjdG9yLmlkXG4gICAgICAtIG5hbWU6IHNlcnZpY2UudmVyc2lvblxuZXhwb3J0ZXJzOlxuICBjbGlja2hvdXNldHJhY2VzOlxuICAgIGRhdGFzb3VyY2U6IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfdHJhY2VzXG4gICAgbG93X2NhcmRpbmFsX2V4Y2VwdGlvbl9ncm91cGluZzogZmFsc2VcbiAgICB1c2VfbmV3X3NjaGVtYTogdHJ1ZVxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBzaWdub3pjbGlja2hvdXNlbWV0cmljczpcbiAgICBkc246IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfbWV0cmljc1xuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBjbGlja2hvdXNlbG9nc2V4cG9ydGVyOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9sb2dzXG4gICAgdXNlX25ld19zY2hlbWE6IHRydWVcbiAgICB0aW1lb3V0OiA0NXNcbiAgICBzZW5kaW5nX3F1ZXVlOlxuICAgICAgZW5hYmxlZDogZmFsc2VcbiAgc2lnbm96Y2xpY2tob3VzZW1ldGVyOlxuICAgIGRzbjogdGNwOi8vY2xpY2tob3VzZTo5MDAwL3NpZ25vel9tZXRlclxuICAgIHRpbWVvdXQ6IDQ1c1xuICAgIHNlbmRpbmdfcXVldWU6XG4gICAgICBlbmFibGVkOiBmYWxzZVxuICBtZXRhZGF0YWV4cG9ydGVyOlxuICAgIGVuYWJsZWQ6IHRydWVcbiAgICBkc246IHRjcDovL2NsaWNraG91c2U6OTAwMC9zaWdub3pfbWV0YWRhdGFcbiAgICB0aW1lb3V0OiA0NXNcbiAgICBjYWNoZTpcbiAgICAgIHByb3ZpZGVyOiBpbl9tZW1vcnlcbmV4dGVuc2lvbnM6XG4gIHNpZ25vel9oZWFsdGhfY2hlY2s6XG4gICAgZW5kcG9pbnQ6IDAuMC4wLjA6MTMxMzNcbiAgcHByb2Y6XG4gICAgZW5kcG9pbnQ6IDAuMC4wLjA6MTc3N1xuICBiYXNpY2F1dGgvc2VydmVyOlxuICAgIGh0cGFzc3dkOlxuICAgICAgaW5saW5lOiBcIiQke2VudjpPVEVMX0JBU0lDX0FVVEh9XFxuXCJcbnNlcnZpY2U6XG4gIHRlbGVtZXRyeTpcbiAgICBsb2dzOlxuICAgICAgZW5jb2Rpbmc6IGpzb25cbiAgZXh0ZW5zaW9uczpcbiAgICAtIHNpZ25vel9oZWFsdGhfY2hlY2tcbiAgICAtIHBwcm9mXG4gICAgLSBiYXNpY2F1dGgvc2VydmVyXG4gIHBpcGVsaW5lczpcbiAgICB0cmFjZXM6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW3NpZ25venNwYW5tZXRyaWNzL2RlbHRhLCBiYXRjaF1cbiAgICAgIGV4cG9ydGVyczogW2NsaWNraG91c2V0cmFjZXMsIHNpZ25vem1ldGVyLCBtZXRhZGF0YWV4cG9ydGVyXVxuICAgIG1ldHJpY3M6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbc2lnbm96Y2xpY2tob3VzZW1ldHJpY3MsIHNpZ25vem1ldGVyLCBtZXRhZGF0YWV4cG9ydGVyXVxuICAgIGxvZ3M6XG4gICAgICByZWNlaXZlcnM6IFtvdGxwXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoXVxuICAgICAgZXhwb3J0ZXJzOiBbY2xpY2tob3VzZWxvZ3NleHBvcnRlciwgc2lnbm96bWV0ZXIsIG1ldGFkYXRhZXhwb3J0ZXJdXG4gICAgbWV0cmljcy9tZXRlcjpcbiAgICAgIHJlY2VpdmVyczogW3NpZ25vem1ldGVyXVxuICAgICAgcHJvY2Vzc29yczogW2JhdGNoL21ldGVyXVxuICAgICAgZXhwb3J0ZXJzOiBbc2lnbm96Y2xpY2tob3VzZW1ldGVyXVxuU0lHTk9aX0NGR1xuY2F0ID4gL3RtcC9vcGFtcC1jb25maWcueWFtbCA8PCdTSUdOT1pfT1BBTVAnXG5zZXJ2ZXJfZW5kcG9pbnQ6IHdzOi8vc2lnbm96OjQzMjAvdjEvb3BhbXBcblNJR05PWl9PUEFNUFxuL3NpZ25vei1vdGVsLWNvbGxlY3RvciBtaWdyYXRlIHN5bmMgY2hlY2tcbmV4ZWMgL3NpZ25vei1vdGVsLWNvbGxlY3RvciAtLWNvbmZpZz0vdG1wL290ZWwtY29uZmlnLnlhbWwgLS1tYW5hZ2VyLWNvbmZpZz0vdG1wL29wYW1wLWNvbmZpZy55YW1sIC0tY29weS1wYXRoPS92YXIvdG1wL2NvbGxlY3Rvci1jb25maWcueWFtbFxuIgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX09URUxDT0xMRUNUT1JIVFRQXzQzMTgKICAgICAgLSAnT1RFTF9SRVNPVVJDRV9BVFRSSUJVVEVTPWhvc3QubmFtZT1zaWdub3otaG9zdCxvcy50eXBlPWxpbnV4JwogICAgICAtIExPV19DQVJESU5BTF9FWENFUFRJT05fR1JPVVBJTkc9ZmFsc2UKICAgICAgLSAnU0lHTk9aX09URUxfQ09MTEVDVE9SX0NMSUNLSE9VU0VfRFNOPXRjcDovL2NsaWNraG91c2U6OTAwMCcKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9DTFVTVEVSPWNsdXN0ZXIKICAgICAgLSBTSUdOT1pfT1RFTF9DT0xMRUNUT1JfQ0xJQ0tIT1VTRV9SRVBMSUNBVElPTj10cnVlCiAgICAgIC0gU0lHTk9aX09URUxfQ09MTEVDVE9SX1RJTUVPVVQ9MTBtCiAgICAgIC0gJ09URUxfQkFTSUNfQVVUSD1zaWdub3o6JHtTRVJWSUNFX1BBU1NXT1JEXzY0X09URUxBVVRIfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAnYmFzaCAtYyAiZXhlYyA2PD4gL2Rldi90Y3AvbG9jYWxob3N0LzEzMTMzIicKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAzCg==", "tags": [ "telemetry", "server", @@ -5113,7 +4332,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-08T19:22:39+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8080" }, "silverbullet": { @@ -5165,38 +4384,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "5231" }, - "snapdrop": { - "documentation": "https://github.com/RobinLinus/snapdrop?utm_source=coolify.io", - "slogan": "A self-hosted file-sharing service for secure and convenient file transfers, whether on a local network or the internet.", - "compose": "c2VydmljZXM6CiAgc25hcGRyb3A6CiAgICBpbWFnZTogJ2xpbnV4c2VydmVyL3NuYXBkcm9wOnZlcnNpb24tYjhiNzhjYzInCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU05BUERST1AKICAgICAgLSBQVUlEPTEwMDAKICAgICAgLSBQR0lEPTEwMDAKICAgICAgLSBUWj1FdXJvcGUvTWFkcmlkCiAgICB2b2x1bWVzOgogICAgICAtICdzbmFwZHJvcC1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MCcKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxNQo=", - "tags": [ - "file", - "sharing", - "transfer", - "local", - "network", - "internet" - ], - "category": "productivity", - "logo": "svgs/default.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "snipe-it": { - "documentation": "https://snipe-it.readme.io/docs/introduction?utm_source=coolify.io", - "slogan": "Snipe-IT is an open-source asset management platform built for IT departments.", - "compose": "c2VydmljZXM6CiAgYXBwOgogICAgaW1hZ2U6ICdzbmlwZS9zbmlwZS1pdDp2OC43LjEnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU05JUEVJVF84MAogICAgICAtIEFQUF9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9ERUJVRz1mYWxzZQogICAgICAtICdBUFBfS0VZPWJhc2U2NDoke1NFUlZJQ0VfUkVBTEJBU0U2NF8zMl9BUFBLRVl9JwogICAgICAtICdBUFBfVVJMPSR7U0VSVklDRV9GUUROX1NOSVBFSVR9JwogICAgICAtICdBUFBfVElNRVpPTkU9JHtBUFBfVElNRVpPTkU6LVVUQ30nCiAgICAgIC0gJ0FQUF9MT0NBTEU9JHtBUFBfTE9DQUxFOi1lbi1VU30nCiAgICAgIC0gREJfQ09OTkVDVElPTj1teXNxbAogICAgICAtIERCX0hPU1Q9bWFyaWFkYgogICAgICAtIERCX1BPUlQ9MzMwNgogICAgICAtICdEQl9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi1zbmlwZWl0fScKICAgICAgLSAnREJfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTUFJTF9NQUlMRVI9JHtNQUlMX01BSUxFUjotc210cH0nCiAgICAgIC0gJ01BSUxfSE9TVD0ke01BSUxfSE9TVDotbnVsbH0nCiAgICAgIC0gJ01BSUxfUE9SVD0ke01BSUxfUE9SVDotMTAyNX0nCiAgICAgIC0gJ01BSUxfVVNFUk5BTUU9JHtNQUlMX1VTRVJOQU1FOi1udWxsfScKICAgICAgLSAnTUFJTF9QQVNTV09SRD0ke01BSUxfUEFTU1dPUkQ6LW51bGx9JwogICAgICAtICdNQUlMX0ZST01fQUREUj0ke01BSUxfRlJPTV9BRERSOi15b3VAZXhhbXBsZS5jb219JwogICAgICAtICdNQUlMX0ZST01fTkFNRT0ke01BSUxfRlJPTV9OQU1FOi1TbmlwZS1JVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdG9yYWdlOi92YXIvbGliL3NuaXBlaXQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3QvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICAgICAgc3RhcnRfcGVyaW9kOiAzMHMKICAgIGRlcGVuZHNfb246CiAgICAgIG1hcmlhZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExLjgnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTVlTUUxfREFUQUJBU0U9JHtNWVNRTF9EQVRBQkFTRTotc25pcGVpdH0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfTUFSSUFEQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9NQVJJQURCfScKICAgICAgLSAnTVlTUUxfUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUk9PVH0nCiAgICB2b2x1bWVzOgogICAgICAtICdkYl9kYXRhOi92YXIvbGliL215c3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMXMKICAgICAgcmV0cmllczogNQo=", - "tags": [ - "inventory", - "open-source" - ], - "category": "productivity", - "logo": "svgs/snipe-it.webp", - "minversion": "0.0.0", - "template_last_updated_at": "2026-08-17T14:08:10+02:00", - "port": "80" - }, "soju": { "documentation": "https://soju.im/?utm_source=coolify.io", "slogan": "A user-friendly IRC bouncer with a modern web interface", @@ -5214,22 +4401,6 @@ "template_last_updated_at": "2025-12-09T00:35:53+03:00", "port": "80" }, - "soketi-app-manager": { - "documentation": "https://github.com/rahulhaque/soketi-app-manager-filament?utm_source=coolify.io", - "slogan": "Manage soketi websocket server and apps with ease.", - "compose": "c2VydmljZXM6CiAgc29rZXRpLWFwcC1tYW5hZ2VyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3JhaHVsaGFxdWUvc29rZXRpLWFwcC1tYW5hZ2VyLWZpbGFtZW50LWFscGluZTpsYXRlc3QnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU09LRVRJQVBQTUFOQUdFUl84MDgwCiAgICAgIC0gQVVUT1JVTl9FTkFCTEVEPXRydWUKICAgICAgLSAnQVVUT1JVTl9MQVJBVkVMX01JR1JBVElPTj0ke0FVVE9SVU5fTEFSQVZFTF9NSUdSQVRJT046LXRydWV9JwogICAgICAtICdBUFBfREVCVUc9JHtBUFBfREVCVUc6LWZhbHNlfScKICAgICAgLSBBUFBfVVJMPSRTRVJWSUNFX0ZRRE5fU09LRVRJQVBQTUFOQUdFUgogICAgICAtIEFQUF9LRVk9JEFQUF9LRVkKICAgICAgLSBEQl9DT05ORUNUSU9OPSREQl9DT05ORUNUSU9OCiAgICAgIC0gREJfSE9TVD0kREJfSE9TVAogICAgICAtIERCX1BPUlQ9JERCX1BPUlQKICAgICAgLSBEQl9EQVRBQkFTRT0kREJfREFUQUJBU0UKICAgICAgLSBEQl9VU0VSTkFNRT0kREJfVVNFUk5BTUUKICAgICAgLSBEQl9QQVNTV09SRD0kREJfUEFTU1dPUkQKICAgICAgLSBQVVNIRVJfSE9TVD0kUFVTSEVSX0hPU1QKICAgICAgLSBQVVNIRVJfUE9SVD0kUFVTSEVSX1BPUlQKICAgICAgLSBQVVNIRVJfU0NIRU1FPSRQVVNIRVJfU0NIRU1FCiAgICAgIC0gUFVTSEVSX0FQUF9DTFVTVEVSPSRQVVNIRVJfQVBQX0NMVVNURVIKICAgICAgLSBTT0tFVElfREJfUkVESVNfVVNFUk5BTUU9JFNPS0VUSV9EQl9SRURJU19VU0VSTkFNRQogICAgICAtIFNPS0VUSV9EQl9SRURJU19QQVNTV09SRD0kU09LRVRJX0RCX1JFRElTX1BBU1NXT1JECiAgICAgIC0gTUVUUklDU19IT1NUPSRNRVRSSUNTX0hPU1QKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBwaHAtZnBtLWhlYWx0aGNoZWNrCiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCg==", - "tags": [ - "soketi", - "websockets", - "app-manager", - "dashboard" - ], - "category": "devtools", - "logo": "svgs/soketi-app-manager.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-04-06T11:35:16-05:00", - "port": "8080" - }, "soketi": { "documentation": "https://docs.soketi.app?utm_source=coolify.io", "slogan": "Soketi is your simple, fast, and resilient open-source WebSockets server.", @@ -5377,7 +4548,7 @@ "supabase": { "documentation": "https://supabase.io?utm_source=coolify.io", "slogan": "The open source Firebase alternative.", - "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZy9rb25nOjMuOS4xJwogICAgZW50cnlwb2ludDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGtvbmcKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L3Vzci9sb2NhbC9rb25nL2tvbmcueW1sCiAgICAgIC0gJ0tPTkdfRE5TX09SREVSPUxBU1QsQSxDTkFNRScKICAgICAgLSBLT05HX0ROU19OT1RfRk9VTkRfVFRMPTEKICAgICAgLSAnS09OR19QTFVHSU5TPXJlcXVlc3QtdHJhbnNmb3JtZXIsY29ycyxrZXktYXV0aCxhY2wsYmFzaWMtYXV0aCxyZXF1ZXN0LXRlcm1pbmF0aW9uLGlwLXJlc3RyaWN0aW9uLHBvc3QtZnVuY3Rpb24nCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdLT05HX1BST1hZX0FDQ0VTU19MT0c9L2Rldi9zdGRvdXQgY29tYmluZWQnCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVk9JHtTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVk6LX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFQ1JFVF9LRVk9JHtTVVBBQkFTRV9TRUNSRVRfS0VZOi19JwogICAgICAtICdBTk9OX0tFWV9BU1lNTUVUUklDPSR7QU5PTl9LRVlfQVNZTU1FVFJJQzotfScKICAgICAgLSAnU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDPSR7U0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDOi19JwogICAgICAtICdEQVNIQk9BUkRfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdEQVNIQk9BUkRfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnS09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9DT05ORUNUX1RJTUVPVVQ6LTYwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1dSSVRFX1RJTUVPVVQ9JHtLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVDotMzYwMH0nCiAgICAgIC0gJ0tPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVQ9JHtLT05HX1NUT1JBR0VfUkVBRF9USU1FT1VUOi0zNjAwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HPSR7S09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HOi1mYWxzZX0nCiAgICAgIC0gJ0tPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkc9JHtLT05HX1NUT1JBR0VfUkVTUE9OU0VfQlVGRkVSSU5HOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLWVudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcva29uZy1lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9iYXNoXG4jIEN1c3RvbSBlbnRyeXBvaW50IGZvciBLb25nIHRoYXQgYnVpbGRzIEx1YSBleHByZXNzaW9ucyBmb3IgcmVxdWVzdC10cmFuc2Zvcm1lclxuIyBhbmQgcGVyZm9ybXMgZW52aXJvbm1lbnQgdmFyaWFibGUgc3Vic3RpdHV0aW9uIGluIHRoZSBkZWNsYXJhdGl2ZSBjb25maWcuXG5cbmlmIFsgLW4gXCIkU1VQQUJBU0VfU0VDUkVUX0tFWVwiIF0gJiYgWyAtbiBcIiRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVlcIiBdOyB0aGVuXG4gICAgZXhwb3J0IExVQV9BVVRIX0VYUFI9XCJcXCQoKGhlYWRlcnMuYXV0aG9yaXphdGlvbiB+PSBuaWwgYW5kIGhlYWRlcnMuYXV0aG9yaXphdGlvbjpzdWIoMSwgMTApIH49ICdCZWFyZXIgc2JfJyBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uKSBvciAoaGVhZGVycy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9TRUNSRVRfS0VZJyBhbmQgJ0JlYXJlciAkU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDJykgb3IgKGhlYWRlcnMuYXBpa2V5ID09ICckU1VQQUJBU0VfUFVCTElTSEFCTEVfS0VZJyBhbmQgJ0JlYXJlciAkQU5PTl9LRVlfQVNZTU1FVFJJQycpIG9yIGhlYWRlcnMuYXBpa2V5KVwiXG4gICAgZXhwb3J0IExVQV9SVF9XU19FWFBSPVwiXFwkKChxdWVyeV9wYXJhbXMuYXBpa2V5ID09ICckU1VQQUJBU0VfU0VDUkVUX0tFWScgYW5kICckU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDJykgb3IgKHF1ZXJ5X3BhcmFtcy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVknIGFuZCAnJEFOT05fS0VZX0FTWU1NRVRSSUMnKSBvciBxdWVyeV9wYXJhbXMuYXBpa2V5KVwiXG5lbHNlXG4gICAgZXhwb3J0IExVQV9BVVRIX0VYUFI9XCJcXCQoKGhlYWRlcnMuYXV0aG9yaXphdGlvbiB+PSBuaWwgYW5kIGhlYWRlcnMuYXV0aG9yaXphdGlvbjpzdWIoMSwgMTApIH49ICdCZWFyZXIgc2JfJyBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uKSBvciBoZWFkZXJzLmFwaWtleSlcIlxuICAgIGV4cG9ydCBMVUFfUlRfV1NfRVhQUj1cIlxcJChxdWVyeV9wYXJhbXMuYXBpa2V5KVwiXG5maVxuXG5hd2sgJ3tcbiAgcmVzdWx0ID0gXCJcIlxuICByZXN0ID0gJDBcbiAgd2hpbGUgKG1hdGNoKHJlc3QsIC9cXCRbQS1aYS16X11bQS1aYS16XzAtOV0qLykpIHtcbiAgICB2YXJuYW1lID0gc3Vic3RyKHJlc3QsIFJTVEFSVCArIDEsIFJMRU5HVEggLSAxKVxuICAgIGlmICh2YXJuYW1lIGluIEVOVklST04pIHtcbiAgICAgIHJlc3VsdCA9IHJlc3VsdCBzdWJzdHIocmVzdCwgMSwgUlNUQVJUIC0gMSkgRU5WSVJPTlt2YXJuYW1lXVxuICAgIH0gZWxzZSB7XG4gICAgICByZXN1bHQgPSByZXN1bHQgc3Vic3RyKHJlc3QsIDEsIFJTVEFSVCArIFJMRU5HVEggLSAxKVxuICAgIH1cbiAgICByZXN0ID0gc3Vic3RyKHJlc3QsIFJTVEFSVCArIFJMRU5HVEgpXG4gIH1cbiAgcHJpbnQgcmVzdWx0IHJlc3Rcbn0nIC9ob21lL2tvbmcvdGVtcC55bWwgPiBcIiRLT05HX0RFQ0xBUkFUSVZFX0NPTkZJR1wiXG5cbnNlZCAtaSAnL15bWzpzcGFjZTpdXSotIGtleTpbWzpzcGFjZTpdXSokL2QnIFwiJEtPTkdfREVDTEFSQVRJVkVfQ09ORklHXCJcblxuZXhlYyAvZW50cnlwb2ludC5zaCBrb25nIGRvY2tlci1zdGFydFxuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VDUkVUX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJyREQVNIQk9BUkRfVVNFUk5BTUUnXG4gIHBhc3N3b3JkOiAnJERBU0hCT0FSRF9QQVNTV09SRCdcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tandrc1xuICAgIF9jb21tZW50OiAnQXV0aDogL2F1dGgvdjEvLndlbGwta25vd24vandrcy5qc29uIC0+IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvLndlbGwta25vd24vandrcy5qc29uJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9qd2tzLmpzb25cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1qd2tzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS8ud2VsbC1rbm93bi9qd2tzLmpzb25cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLWFjc1xuICAgIHVybDogXCJodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L3Nzby9zYW1sL2Fjc1wiXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLWFjc1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAtIC9zc28vc2FtbC9hY3NcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLW1ldGFkYXRhXG4gICAgdXJsOiBcImh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvc3NvL3NhbWwvbWV0YWRhdGFcIlxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLXNzby1tZXRhZGF0YVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAtIC9zc28vc2FtbC9tZXRhZGF0YVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgICAgICByZXBsYWNlOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBHcmFwaFFMIHJvdXRlc1xuICAtIG5hbWU6IGdyYXBocWwtdjFcbiAgICBfY29tbWVudDogJ1Bvc3RnUkVTVDogL2dyYXBocWwvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZ3JhcGhxbC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9ncmFwaHFsL3YxXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkNvbnRlbnQtUHJvZmlsZTogZ3JhcGhxbF9wdWJsaWNcIlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQ29udGVudC1Qcm9maWxlOiBncmFwaHFsX3B1YmxpY1wiXG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIHF1ZXJ5c3RyaW5nOlxuICAgICAgICAgICAgICAtIFwiYXBpa2V5OiAkTFVBX1JUX1dTX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBxdWVyeXN0cmluZzpcbiAgICAgICAgICAgICAgLSBcImFwaWtleTogJExVQV9SVF9XU19FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvYXBpLyogLT4gaHR0cDovL3JlYWx0aW1lOjQwMDAvYXBpLyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSBBUEkgZW5kcG9pbnRcbiAgIyMgTm8ga2V5LWF1dGggLSBTMyBwcm90b2NvbCByZXF1ZXN0cyBkb24ndCBjYXJyeSBhbiBhcGlrZXkgaGVhZGVyLlxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIGNvbm5lY3RfdGltZW91dDogJEtPTkdfU1RPUkFHRV9DT05ORUNUX1RJTUVPVVRcbiAgICB3cml0ZV90aW1lb3V0OiAkS09OR19TVE9SQUdFX1dSSVRFX1RJTUVPVVRcbiAgICByZWFkX3RpbWVvdXQ6ICRLT05HX1NUT1JBR0VfUkVBRF9USU1FT1VUXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3RvcmFnZTo1MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogc3RvcmFnZS12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9zdG9yYWdlL3YxL1xuICAgICAgICByZXF1ZXN0X2J1ZmZlcmluZzogJEtPTkdfU1RPUkFHRV9SRVFVRVNUX0JVRkZFUklOR1xuICAgICAgICByZXNwb25zZV9idWZmZXJpbmc6ICRLT05HX1NUT1JBR0VfUkVTUE9OU0VfQlVGRkVSSU5HXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBwb3N0LWZ1bmN0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhY2Nlc3M6XG4gICAgICAgICAgICAtIHxcbiAgICAgICAgICAgICAgbG9jYWwgYXV0aCA9IGtvbmcucmVxdWVzdC5nZXRfaGVhZGVyKFwiYXV0aG9yaXphdGlvblwiKVxuICAgICAgICAgICAgICBpZiBhdXRoID09IG5pbCBvciBhdXRoID09IFwiXCIgb3IgYXV0aDpmaW5kKFwiXiVzKiRcIikgdGhlblxuICAgICAgICAgICAgICAgIGtvbmcuc2VydmljZS5yZXF1ZXN0LmNsZWFyX2hlYWRlcihcImF1dGhvcml6YXRpb25cIilcbiAgICAgICAgICAgICAgZW5kXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByZWFkX3RpbWVvdXQ6IDE1MDAwMFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZnVuY3Rpb25zLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2Z1bmN0aW9ucy92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgT0F1dGggMi4wIEF1dGhvcml6YXRpb24gU2VydmVyIE1ldGFkYXRhIChSRkMgODQxNClcbiAgLSBuYW1lOiB3ZWxsLWtub3duLW9hdXRoXG4gICAgX2NvbW1lbnQ6ICdBdXRoOiAvLndlbGwta25vd24vb2F1dGgtYXV0aG9yaXphdGlvbi1zZXJ2ZXIgLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlcidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvLndlbGwta25vd24vb2F1dGgtYXV0aG9yaXphdGlvbi1zZXJ2ZXJcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHdlbGwta25vd24tb2F1dGhcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlclxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gICMjIE5vdCB1c2VkIC0gU3R1ZGlvIGFuZCBWZWN0b3IgdGFsayBkaXJlY3RseSB0byBhbmFseXRpY3MgdmlhIERvY2tlciBuZXR3b3JraW5nLlxuICAjIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICMgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICMgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9cbiAgIyAgIHJvdXRlczpcbiAgIyAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICMgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAjICAgICAgIHBhdGhzOlxuICAjICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBCbG9jayBhY2Nlc3MgdG8gL2FwaS9tY3BcbiAgLSBuYW1lOiBtY3AtYmxvY2tlclxuICAgIF9jb21tZW50OiAnQmxvY2sgZGlyZWN0IGFjY2VzcyB0byAvYXBpL21jcCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9hcGkvbWNwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtY3AtYmxvY2tlci1yb3V0ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FwaS9tY3BcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRlcm1pbmF0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBzdGF0dXNfY29kZTogNDAzXG4gICAgICAgICAgbWVzc2FnZTogXCJBY2Nlc3MgaXMgZm9yYmlkZGVuLlwiXG5cbiAgIyMgTUNQIGVuZHBvaW50IC0gbG9jYWwgYWNjZXNzXG4gIC0gbmFtZTogbWNwXG4gICAgX2NvbW1lbnQ6ICdNQ1A6IC9tY3AgLT4gaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL2FwaS9tY3AgKGxvY2FsIGFjY2VzcyknXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvYXBpL21jcFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWNwXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvbWNwXG4gICAgcGx1Z2luczpcbiAgICAgICMgQmxvY2sgYWNjZXNzIHRvIC9tY3AgYnkgZGVmYXVsdFxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRlcm1pbmF0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBzdGF0dXNfY29kZTogNDAzXG4gICAgICAgICAgbWVzc2FnZTogXCJBY2Nlc3MgaXMgZm9yYmlkZGVuLlwiXG4gICAgICAjIEVuYWJsZSBsb2NhbCBhY2Nlc3MgKGRhbmdlciB6b25lISlcbiAgICAgICMgMS4gQ29tbWVudCBvdXQgdGhlICdyZXF1ZXN0LXRlcm1pbmF0aW9uJyBzZWN0aW9uIGFib3ZlXG4gICAgICAjIDIuIFVuY29tbWVudCB0aGUgZW50aXJlIHNlY3Rpb24gYmVsb3csIGluY2x1ZGluZyAnZGVueSdcbiAgICAgICMgMy4gQWRkIHlvdXIgbG9jYWwgSVBzIHRvIHRoZSAnYWxsb3cnIGxpc3RcbiAgICAgICMtIG5hbWU6IGNvcnNcbiAgICAgICMtIG5hbWU6IGlwLXJlc3RyaWN0aW9uXG4gICAgICAjICBjb25maWc6XG4gICAgICAjICAgIGFsbG93OlxuICAgICAgIyAgICAgIC0gMTI3LjAuMC4xXG4gICAgICAjICAgICAgLSA6OjFcbiAgICAgICMgICAgZGVueTogW11cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNi4wMy4xNi1zaGEtNTUyODgxNycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSAnUEdSU1RfREJfTUFYX1JPV1M9JHtQR1JTVF9EQl9NQVhfUk9XUzotMTAwMH0nCiAgICAgIC0gJ1BHUlNUX0RCX0VYVFJBX1NFQVJDSF9QQVRIPSR7UEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg6LXB1YmxpY30nCiAgICAgIC0gJ0RFRkFVTFRfT1JHQU5JWkFUSU9OX05BTUU9JHtTVFVESU9fREVGQVVMVF9PUkdBTklaQVRJT046LURlZmF1bHQgT3JnYW5pemF0aW9ufScKICAgICAgLSAnREVGQVVMVF9QUk9KRUNUX05BTUU9JHtTVFVESU9fREVGQVVMVF9QUk9KRUNUOi1EZWZhdWx0IFByb2plY3R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9aHR0cDovL3N1cGFiYXNlLWtvbmc6ODAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdQR19NRVRBX0NSWVBUT19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1BHTUVUQUNSWVBUT30nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtICdMT0dGTEFSRV9QUklWQVRFX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkVQUklWQVRFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnTkVYVF9QVUJMSUNfU1VQQUJBU0VfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSBORVhUX1BVQkxJQ19FTkFCTEVfTE9HUz10cnVlCiAgICAgIC0gTkVYVF9BTkFMWVRJQ1NfQkFDS0VORF9QUk9WSURFUj1wb3N0Z3JlcwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSBTTklQUEVUU19NQU5BR0VNRU5UX0ZPTERFUj0vYXBwL3NuaXBwZXRzCiAgICAgIC0gRURHRV9GVU5DVElPTlNfTUFOQUdFTUVOVF9GT0xERVI9L2FwcC9lZGdlLWZ1bmN0aW9ucwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3NuaXBwZXRzOi9hcHAvc25pcHBldHMnCiAgICAgIC0gJy4vdm9sdW1lcy9mdW5jdGlvbnM6L2FwcC9lZGdlLWZ1bmN0aW9ucycKICBzdXBhYmFzZS1kYjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXM6MTUuOC4xLjA4NScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncGdfaXNyZWFkeSAtVSBwb3N0Z3JlcyAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtdmVjdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBjb21tYW5kOgogICAgICAtIHBvc3RncmVzCiAgICAgIC0gJy1jJwogICAgICAtIGNvbmZpZ19maWxlPS9ldGMvcG9zdGdyZXNxbC9wb3N0Z3Jlc3FsLmNvbmYKICAgICAgLSAnLWMnCiAgICAgIC0gbG9nX21pbl9tZXNzYWdlcz1mYXRhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfSE9TVD0vdmFyL3J1bi9wb3N0Z3Jlc3FsCiAgICAgIC0gJ1BHUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUEdEQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwYWJhc2UtZGItZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcmVhbHRpbWUuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85OS1yZWFsdGltZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9yZWFsdGltZTtcbmFsdGVyIHNjaGVtYSBfcmVhbHRpbWUgb3duZXIgdG8gOnBndXNlcjtcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9fc3VwYWJhc2Uuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85Ny1fc3VwYWJhc2Uuc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cbkNSRUFURSBEQVRBQkFTRSBfc3VwYWJhc2UgV0lUSCBPV05FUiA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3Bvb2xlci5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXBvb2xlci5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfc3VwYXZpc29yO1xuYWx0ZXIgc2NoZW1hIF9zdXBhdmlzb3Igb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3dlYmhvb2tzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OC13ZWJob29rcy5zcWwKICAgICAgICBjb250ZW50OiAiQkVHSU47XG4tLSBDcmVhdGUgcGdfbmV0IGV4dGVuc2lvblxuQ1JFQVRFIEVYVEVOU0lPTiBJRiBOT1QgRVhJU1RTIHBnX25ldCBTQ0hFTUEgZXh0ZW5zaW9ucztcbi0tIENyZWF0ZSBzdXBhYmFzZV9mdW5jdGlvbnMgc2NoZW1hXG5DUkVBVEUgU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBBVVRIT1JJWkFUSU9OIHN1cGFiYXNlX2FkbWluO1xuR1JBTlQgVVNBR0UgT04gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFRBQkxFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIEZVTkNUSU9OUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFNFUVVFTkNFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zIChcbiAgdmVyc2lvbiB0ZXh0IFBSSU1BUlkgS0VZLFxuICBpbnNlcnRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpXG4pO1xuLS0gSW5pdGlhbCBzdXBhYmFzZV9mdW5jdGlvbnMgbWlncmF0aW9uXG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnaW5pdGlhbCcpO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIGRlZmluaXRpb25cbkNSRUFURSBUQUJMRSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgKFxuICBpZCBiaWdzZXJpYWwgUFJJTUFSWSBLRVksXG4gIGhvb2tfdGFibGVfaWQgaW50ZWdlciBOT1QgTlVMTCxcbiAgaG9va19uYW1lIHRleHQgTk9UIE5VTEwsXG4gIGNyZWF0ZWRfYXQgdGltZXN0YW1wdHogTk9UIE5VTEwgREVGQVVMVCBOT1coKSxcbiAgcmVxdWVzdF9pZCBiaWdpbnRcbik7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX3JlcXVlc3RfaWRfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAocmVxdWVzdF9pZCk7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX2hfdGFibGVfaWRfaF9uYW1lX2lkeCBPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgVVNJTkcgYnRyZWUgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSk7XG5DT01NRU5UIE9OIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBJUyAnU3VwYWJhc2UgRnVuY3Rpb25zIEhvb2tzOiBBdWRpdCB0cmFpbCBmb3IgdHJpZ2dlcmVkIGhvb2tzLic7XG5DUkVBVEUgRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpXG4gIFJFVFVSTlMgdHJpZ2dlclxuICBMQU5HVUFHRSBwbHBnc3FsXG4gIEFTICRmdW5jdGlvbiRcbiAgREVDTEFSRVxuICAgIHJlcXVlc3RfaWQgYmlnaW50O1xuICAgIHBheWxvYWQganNvbmI7XG4gICAgdXJsIHRleHQgOj0gVEdfQVJHVlswXTo6dGV4dDtcbiAgICBtZXRob2QgdGV4dCA6PSBUR19BUkdWWzFdOjp0ZXh0O1xuICAgIGhlYWRlcnMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICBwYXJhbXMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICB0aW1lb3V0X21zIGludGVnZXIgREVGQVVMVCAxMDAwO1xuICBCRUdJTlxuICAgIElGIHVybCBJUyBOVUxMIE9SIHVybCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ3VybCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBtZXRob2QgSVMgTlVMTCBPUiBtZXRob2QgPSAnbnVsbCcgVEhFTlxuICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgaXMgbWlzc2luZyc7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlsyXSBJUyBOVUxMIE9SIFRHX0FSR1ZbMl0gPSAnbnVsbCcgVEhFTlxuICAgICAgaGVhZGVycyA9ICd7XCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCJ9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgaGVhZGVycyA9IFRHX0FSR1ZbMl06Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbM10gSVMgTlVMTCBPUiBUR19BUkdWWzNdID0gJ251bGwnIFRIRU5cbiAgICAgIHBhcmFtcyA9ICd7fSc6Ompzb25iO1xuICAgIEVMU0VcbiAgICAgIHBhcmFtcyA9IFRHX0FSR1ZbM106Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbNF0gSVMgTlVMTCBPUiBUR19BUkdWWzRdID0gJ251bGwnIFRIRU5cbiAgICAgIHRpbWVvdXRfbXMgPSAxMDAwO1xuICAgIEVMU0VcbiAgICAgIHRpbWVvdXRfbXMgPSBUR19BUkdWWzRdOjppbnRlZ2VyO1xuICAgIEVORCBJRjtcblxuICAgIENBU0VcbiAgICAgIFdIRU4gbWV0aG9kID0gJ0dFVCcgVEhFTlxuICAgICAgICBTRUxFQ1QgaHR0cF9nZXQgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfZ2V0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBXSEVOIG1ldGhvZCA9ICdQT1NUJyBUSEVOXG4gICAgICAgIHBheWxvYWQgPSBqc29uYl9idWlsZF9vYmplY3QoXG4gICAgICAgICAgJ29sZF9yZWNvcmQnLCBPTEQsXG4gICAgICAgICAgJ3JlY29yZCcsIE5FVyxcbiAgICAgICAgICAndHlwZScsIFRHX09QLFxuICAgICAgICAgICd0YWJsZScsIFRHX1RBQkxFX05BTUUsXG4gICAgICAgICAgJ3NjaGVtYScsIFRHX1RBQkxFX1NDSEVNQVxuICAgICAgICApO1xuXG4gICAgICAgIFNFTEVDVCBodHRwX3Bvc3QgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfcG9zdChcbiAgICAgICAgICB1cmwsXG4gICAgICAgICAgcGF5bG9hZCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBFTFNFXG4gICAgICAgIFJBSVNFIEVYQ0VQVElPTiAnbWV0aG9kIGFyZ3VtZW50ICUgaXMgaW52YWxpZCcsIG1ldGhvZDtcbiAgICBFTkQgQ0FTRTtcblxuICAgIElOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rc1xuICAgICAgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSwgcmVxdWVzdF9pZClcbiAgICBWQUxVRVNcbiAgICAgIChUR19SRUxJRCwgVEdfTkFNRSwgcmVxdWVzdF9pZCk7XG5cbiAgICBSRVRVUk4gTkVXO1xuICBFTkRcbiRmdW5jdGlvbiQ7XG4tLSBTdXBhYmFzZSBzdXBlciBhZG1pblxuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfcm9sZXNcbiAgICBXSEVSRSByb2xuYW1lID0gJ3N1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbidcbiAgKVxuICBUSEVOXG4gICAgQ1JFQVRFIFVTRVIgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIE5PSU5IRVJJVCBDUkVBVEVST0xFIExPR0lOIE5PUkVQTElDQVRJT047XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBUQUJMRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBBTEwgU0VRVUVOQ0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gU0VUIHNlYXJjaF9wYXRoID0gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIubWlncmF0aW9ucyBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiB0YWJsZSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiLmhvb2tzIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIGZ1bmN0aW9uIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaHR0cF9yZXF1ZXN0KCkgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIFRPIHBvc3RncmVzO1xuLS0gUmVtb3ZlIHVudXNlZCBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gcm9sZVxuRE9cbiQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfcGdfbmV0X2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBSRUFTU0lHTiBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gVE8gc3VwYWJhc2VfYWRtaW47XG4gICAgRFJPUCBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW47XG4gICAgRFJPUCBST0xFIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIHBnX25ldCBncmFudHMgd2hlbiBleHRlbnNpb24gaXMgYWxyZWFkeSBlbmFibGVkXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V4dGVuc2lvblxuICAgIFdIRVJFIGV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG4tLSBFdmVudCB0cmlnZ2VyIGZvciBwZ19uZXRcbkNSRUFURSBPUiBSRVBMQUNFIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcygpXG5SRVRVUk5TIGV2ZW50X3RyaWdnZXJcbkxBTkdVQUdFIHBscGdzcWxcbkFTICQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19ldmVudF90cmlnZ2VyX2RkbF9jb21tYW5kcygpIEFTIGV2XG4gICAgSk9JTiBwZ19leHRlbnNpb24gQVMgZXh0XG4gICAgT04gZXYub2JqaWQgPSBleHQub2lkXG4gICAgV0hFUkUgZXh0LmV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORDtcbiQkO1xuQ09NTUVOVCBPTiBGVU5DVElPTiBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MgSVMgJ0dyYW50cyBhY2Nlc3MgdG8gcGdfbmV0JztcbkRPXG4kJFxuQkVHSU5cbiAgSUYgTk9UIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJcbiAgICBXSEVSRSBldnRuYW1lID0gJ2lzc3VlX3BnX25ldF9hY2Nlc3MnXG4gICkgVEhFTlxuICAgIENSRUFURSBFVkVOVCBUUklHR0VSIGlzc3VlX3BnX25ldF9hY2Nlc3MgT04gZGRsX2NvbW1hbmRfZW5kIFdIRU4gVEFHIElOICgnQ1JFQVRFIEVYVEVOU0lPTicpXG4gICAgRVhFQ1VURSBQUk9DRURVUkUgZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKCk7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnMjAyMTA4MDkxODM0MjNfdXBkYXRlX2dyYW50cycpO1xuQUxURVIgZnVuY3Rpb24gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFNFQ1VSSVRZIERFRklORVI7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VUIHNlYXJjaF9wYXRoID0gc3VwYWJhc2VfZnVuY3Rpb25zO1xuUkVWT0tFIEFMTCBPTiBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgRlJPTSBQVUJMSUM7XG5HUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQ09NTUlUO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3JvbGVzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1yb2xlcy5zcWwKICAgICAgICBjb250ZW50OiAiLS0gTk9URTogY2hhbmdlIHRvIHlvdXIgb3duIHBhc3N3b3JkcyBmb3IgcHJvZHVjdGlvbiBlbnZpcm9ubWVudHNcbiBcXHNldCBwZ3Bhc3MgYGVjaG8gXCIkUE9TVEdSRVNfUEFTU1dPUkRcImBcblxuIEFMVEVSIFVTRVIgYXV0aGVudGljYXRvciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHBnYm91bmNlciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2F1dGhfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9zdG9yYWdlX2FkbWluIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL2p3dC5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9pbml0LXNjcmlwdHMvOTktand0LnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBqd3Rfc2VjcmV0IGBlY2hvIFwiJEpXVF9TRUNSRVRcImBcblxcc2V0IGp3dF9leHAgYGVjaG8gXCIkSldUX0VYUFwiYFxuXFxzZXQgZGJfbmFtZSBgZWNobyBcIiR7UE9TVEdSRVNfREI6LXBvc3RncmVzfVwiYFxuXG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X3NlY3JldFwiIFRPIDonand0X3NlY3JldCc7XG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X2V4cFwiIFRPIDonand0X2V4cCc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvbG9ncy5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LWxvZ3Muc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cXGMgX3N1cGFiYXNlXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX2FuYWx5dGljcztcbmFsdGVyIHNjaGVtYSBfYW5hbHl0aWNzIG93bmVyIHRvIDpwZ3VzZXI7XG5cXGMgcG9zdGdyZXNcbiIKICAgICAgLSAnc3VwYWJhc2UtZGItY29uZmlnOi9ldGMvcG9zdGdyZXNxbC1jdXN0b20nCiAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9sb2dmbGFyZToxLjMxLjInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIExPR0ZMQVJFX05PREVfSE9TVD0xMjcuMC4wLjEKICAgICAgLSBEQl9VU0VSTkFNRT1zdXBhYmFzZV9hZG1pbgogICAgICAtIERCX0RBVEFCQVNFPV9zdXBhYmFzZQogICAgICAtICdEQl9IT1NUTkFNRT0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSBEQl9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtICdMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BSSVZBVEVfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRVBSSVZBVEV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NVUEFCQVNFX01PREU9dHJ1ZQogICAgICAtICdQT1NUR1JFU19CQUNLRU5EX1VSTD1wb3N0Z3Jlc3FsOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gUE9TVEdSRVNfQkFDS0VORF9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtIExPR0ZMQVJFX0ZFQVRVUkVfRkxBR19PVkVSUklERT1tdWx0aWJhY2tlbmQ9dHJ1ZQogIHN1cGFiYXNlLXZlY3RvcjoKICAgIGltYWdlOiAndGltYmVyaW8vdmVjdG9yOjAuNTMuMC1hbHBpbmUnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovL3N1cGFiYXNlLXZlY3Rvcjo5MDAxL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvbG9ncy92ZWN0b3IueW1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL3ZlY3Rvci92ZWN0b3IueW1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogImFwaTpcbiAgZW5hYmxlZDogdHJ1ZVxuICBhZGRyZXNzOiAwLjAuMC4wOjkwMDFcblxuc291cmNlczpcbiAgZG9ja2VyX2hvc3Q6XG4gICAgdHlwZTogZG9ja2VyX2xvZ3NcbiAgICBleGNsdWRlX2NvbnRhaW5lcnM6XG4gICAgICAtIHN1cGFiYXNlLXZlY3RvclxuXG50cmFuc2Zvcm1zOlxuICBwcm9qZWN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIGRvY2tlcl9ob3N0XG4gICAgc291cmNlOiB8LVxuICAgICAgLnByb2plY3QgPSBcImRlZmF1bHRcIlxuICAgICAgLmV2ZW50X21lc3NhZ2UgPSBkZWwoLm1lc3NhZ2UpXG4gICAgICAuYXBwbmFtZSA9IGRlbCguY29udGFpbmVyX25hbWUpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9jcmVhdGVkX2F0KVxuICAgICAgZGVsKC5jb250YWluZXJfaWQpXG4gICAgICBkZWwoLnNvdXJjZV90eXBlKVxuICAgICAgZGVsKC5zdHJlYW0pXG4gICAgICBkZWwoLmxhYmVsKVxuICAgICAgZGVsKC5pbWFnZSlcbiAgICAgIGRlbCguaG9zdClcbiAgICAgIGRlbCguc3RyZWFtKVxuICByb3V0ZXI6XG4gICAgdHlwZTogcm91dGVcbiAgICBpbnB1dHM6XG4gICAgICAtIHByb2plY3RfbG9nc1xuICAgIHJvdXRlOlxuICAgICAga29uZzogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWtvbmdcIiknXG4gICAgICBhdXRoOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtYXV0aFwiKSdcbiAgICAgIHJlc3Q6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1yZXN0XCIpJ1xuICAgICAgcmVhbHRpbWU6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJyZWFsdGltZS1kZXZcIiknXG4gICAgICBzdG9yYWdlOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Utc3RvcmFnZVwiKSdcbiAgICAgIGZ1bmN0aW9uczogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgICAgICB1cmwsIHNwbGl0X2VyciA9IHNwbGl0KHJlcS5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBzcGxpdF9lcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIElnbm9yZXMgbm9uIG5naW54IGVycm9ycyBzaW5jZSB0aGV5IGFyZSByZWxhdGVkIHdpdGgga29uZyBib290aW5nIHVwXG4gIGtvbmdfZXJyOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIua29uZ1xuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IFwiR0VUXCJcbiAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IDIwMFxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiZXJyb3JcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcGFyc2VkLnRpbWVzdGFtcFxuICAgICAgICAgIC5zZXZlcml0eSA9IHBhcnNlZC5zZXZlcml0eVxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lmhvc3QgPSBwYXJzZWQuaG9zdFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMuY2ZfY29ubmVjdGluZ19pcCA9IHBhcnNlZC5jbGllbnRcbiAgICAgICAgICB1cmwsIGVyciA9IHNwbGl0KHBhcnNlZC5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIEdvdHJ1ZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIGpzb24gc3RyaW5ncyB3aGljaCBmcm9udGVuZCBwYXJzZXMgZGlyZWN0bHkuIEJ1dCB3ZSBrZWVwIG1ldGFkYXRhIGZvciBjb25zaXN0ZW5jeS5cbiAgYXV0aF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuYXV0aFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEgPSBtZXJnZSEoLm1ldGFkYXRhLCBwYXJzZWQpXG4gICAgICB9XG4gICMgUG9zdGdSRVNUIGxvZ3MgYXJlIHN0cnVjdHVyZWQgc28gd2Ugc2VwYXJhdGUgdGltZXN0YW1wIGZyb20gbWVzc2FnZSB1c2luZyByZWdleFxuICByZXN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZXN0XG4gICAgc291cmNlOiB8LVxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcideKD9QPHRpbWU+LiopOiAoP1A8bXNnPi4qKSQnKVxuICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgIC5ldmVudF9tZXNzYWdlID0gcGFyc2VkLm1zZ1xuICAgICAgICAgIC50aW1lc3RhbXAgPSBwYXJzZV90aW1lc3RhbXAhKHZhbHVlOiBwYXJzZWQudGltZSwgZm9ybWF0OiBcIiVkLyViLyVZOiVIOiVNOiVTICV6XCIpXG4gICAgICAgICAgLm1ldGFkYXRhLmhvc3QgPSAucHJvamVjdFxuICAgICAgfVxuICAjIEZpbHRlciBvdXQgaGVhbHRoY2hlY2sgbG9ncyBmcm9tIFJlYWx0aW1lXG4gIHJlYWx0aW1lX2xvZ3NfZmlsdGVyZWQ6XG4gICAgdHlwZTogZmlsdGVyXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVhbHRpbWVcbiAgICBjb25kaXRpb246ICchY29udGFpbnMoc3RyaW5nISguZXZlbnRfbWVzc2FnZSksIFwiL2hlYWx0aFwiKSdcbiAgIyBSZWFsdGltZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHBhcnNlIHRoZSBzZXZlcml0eSBsZXZlbCB1c2luZyByZWdleCAoaWdub3JlIHRpbWUgYmVjYXVzZSBpdCBoYXMgbm8gZGF0ZSlcbiAgcmVhbHRpbWVfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc19maWx0ZXJlZFxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIEZ1bmN0aW9uIGxvZ3MgYXJlIHVuc3RydWN0dXJlZCBtZXNzYWdlcyBvbiBzdGRlcnJcbiAgZnVuY3Rpb25zX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5mdW5jdGlvbnNcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucHJvamVjdF9yZWYgPSBkZWwoLnByb2plY3QpXG4gICMgUG9zdGdyZXMgbG9ncyBzb21lIG1lc3NhZ2VzIHRvIHN0ZGVyciB3aGljaCB3ZSBtYXAgdG8gd2FybmluZyBzZXZlcml0eSBsZXZlbFxuICBkYl9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZGJcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEuaG9zdCA9IFwiZGItZGVmYXVsdFwiXG4gICAgICAubWV0YWRhdGEucGFyc2VkLnRpbWVzdGFtcCA9IC50aW1lc3RhbXBcblxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcicuKig/UDxsZXZlbD5JTkZPfE5PVElDRXxXQVJOSU5HfEVSUk9SfExPR3xGQVRBTHxQQU5JQz8pOi4qJywgbnVtZXJpY19ncm91cHM6IHRydWUpXG5cbiAgICAgIGlmIGVyciAhPSBudWxsIHx8IHBhcnNlZCA9PSBudWxsIHtcbiAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwiaW5mb1wiXG4gICAgICB9XG4gICAgICBpZiBwYXJzZWQubGV2ZWwgIT0gbnVsbCB7XG4gICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1nb3RydWUubG9ncy5wcm9kJ1xuICBsb2dmbGFyZV9yZWFsdGltZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlYWx0aW1lX2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMzBcbiAgICAgIHJldHJ5X2luaXRpYWxfYmFja29mZl9zZWNzOiAxXG4gICAgICBoZWFkZXJzOlxuICAgICAgICB4LWFwaS1rZXk6ICcke0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4/TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTiBpcyByZXF1aXJlZH0nXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPXJlYWx0aW1lLmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfcmVzdDpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlc3RfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJ1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBmdW5jdGlvbnNfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9ZGVuby1yZWxheS1sb2dzJ1xuICBsb2dmbGFyZV9zdG9yYWdlOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gc3RvcmFnZV9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJ1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1jbG91ZGZsYXJlLmxvZ3MucHJvZCdcbiIKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jb25maWcnCiAgICAgIC0gL2V0Yy92ZWN0b3IvdmVjdG9yLnltbAogIHN1cGFiYXNlLXJlc3Q6CiAgICBpbWFnZTogJ3Bvc3RncmVzdC9wb3N0Z3Jlc3Q6djE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQR1JTVF9EQl9VUkk9cG9zdGdyZXM6Ly9hdXRoZW50aWNhdG9yOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE4Ni4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke0dPVFJVRV9TSVRFX1VSTDotJHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfX0nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjc2LjUnCiAgICBjb250YWluZXJfbmFtZTogcmVhbHRpbWUtZGV2LnN1cGFiYXNlLXJlYWx0aW1lCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLS1oZWFkJwogICAgICAgIC0gJy1vJwogICAgICAgIC0gL2Rldi9udWxsCiAgICAgICAgLSAnLUgnCiAgICAgICAgLSAnQXV0aG9yaXphdGlvbjogQmVhcmVyICR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL3RlbmFudHMvcmVhbHRpbWUtZGV2L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdEQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnREJfUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtIERCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0RCX0FGVEVSX0NPTk5FQ1RfUVVFUlk9U0VUIHNlYXJjaF9wYXRoIFRPIF9yZWFsdGltZScKICAgICAgLSBEQl9FTkNfS0VZPXN1cGFiYXNlcmVhbHRpbWUKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVBTFRJTUV9JwogICAgICAtICdNRVRSSUNTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgICAgLSBESVNBQkxFX0hFQUxUSENIRUNLX0xPR0dJTkc9dHJ1ZQogICAgY29tbWFuZDogInNoIC1jIFwiL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9yZWFsdGltZSBldmFsICdSZWFsdGltZS5SZWxlYXNlLnNlZWRzKFJlYWx0aW1lLlJlcG8pJyAmJiAvYXBwL2Jpbi9zZXJ2ZXJcIlxuIgogIHN1cGFiYXNlLW1pbmlvOgogICAgaW1hZ2U6ICdwZ3N0eS9zaWxvOlJFTEVBU0UuMjAyNi0xMC0xNVQwMC0wMC0wMFonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6ICdwZ3N0eS9zaWxvOlJFTEVBU0UuMjAyNi0xMC0xNVQwMC0wMC0wMFonCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjQ0LjInCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9L3VwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICAgIC0gJ0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdQT1NUR1JFU1RfVVJMPWh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAnCiAgICAgIC0gJ1BHUlNUX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NUT1JBR0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdURU5BTlRfSUQ9JHtTVE9SQUdFX1RFTkFOVF9JRDotc3RvcmFnZS1zaW5nbGUtdGVuYW50fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjMwLjEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0lNR1BST1hZX0JJTkQ9OjgwODAnCiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0FVVE9fV0VCUD0ke0lNR1BST1hZX0FVVE9fV0VCUDotdHJ1ZX0nCiAgICAgIC0gSU1HUFJPWFlfTUFYX1NSQ19SRVNPTFVUSU9OPTE2LjgKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC45NS4yJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdDUllQVE9fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9QR01FVEFDUllQVE99JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNzEuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLSAnZGVuby1jYWNoZTovcm9vdC8uY2FjaGUvZGVubycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBTVVBBQkFTRV9VUkwgPSBEZW5vLmVudi5nZXQoJ1NVUEFCQVNFX1VSTCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG4vLyBDcmVhdGUgSldLUyBmb3IgRVMyNTYvUlMyNTYgdG9rZW5zIChuZXdlciB0b2tlbnMpXG5sZXQgU1VQQUJBU0VfSldUX0tFWVM6IFJldHVyblR5cGU8dHlwZW9mIGpvc2UuY3JlYXRlUmVtb3RlSldLU2V0PiB8IG51bGwgPSBudWxsXG5pZiAoU1VQQUJBU0VfVVJMKSB7XG4gIHRyeSB7XG4gICAgU1VQQUJBU0VfSldUX0tFWVMgPSBqb3NlLmNyZWF0ZVJlbW90ZUpXS1NldChcbiAgICAgIG5ldyBVUkwoJy9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvbicsIFNVUEFCQVNFX1VSTClcbiAgICApXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gZmV0Y2ggSldLUyBmcm9tIFNVUEFCQVNFX1VSTDonLCBlKVxuICB9XG59XG5cbmZ1bmN0aW9uIGdldEF1dGhUb2tlbihyZXE6IFJlcXVlc3QpIHtcbiAgY29uc3QgYXV0aEhlYWRlciA9IHJlcS5oZWFkZXJzLmdldCgnYXV0aG9yaXphdGlvbicpXG4gIGlmICghYXV0aEhlYWRlcikge1xuICAgIHRocm93IG5ldyBFcnJvcignTWlzc2luZyBhdXRob3JpemF0aW9uIGhlYWRlcicpXG4gIH1cbiAgY29uc3QgW2JlYXJlciwgdG9rZW5dID0gYXV0aEhlYWRlci5zcGxpdCgnICcpXG4gIGlmIChiZWFyZXIgIT09ICdCZWFyZXInKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKGBBdXRoIGhlYWRlciBpcyBub3QgJ0JlYXJlciB7dG9rZW59J2ApXG4gIH1cbiAgcmV0dXJuIHRva2VuXG59XG5cbmFzeW5jIGZ1bmN0aW9uIGlzVmFsaWRMZWdhY3lKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgaWYgKCFKV1RfU0VDUkVUKSB7XG4gICAgY29uc29sZS5lcnJvcignSldUX1NFQ1JFVCBub3QgYXZhaWxhYmxlIGZvciBIUzI1NiB0b2tlbiB2ZXJpZmljYXRpb24nKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG5cbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpO1xuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc29sZS5lcnJvcignU3ltbWV0cmljIExlZ2FjeSBKV1QgdmVyaWZpY2F0aW9uIGVycm9yJywgZSk7XG4gICAgcmV0dXJuIGZhbHNlO1xuICB9XG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGlmICghU1VQQUJBU0VfSldUX0tFWVMpIHtcbiAgICBjb25zb2xlLmVycm9yKCdKV0tTIG5vdCBhdmFpbGFibGUgZm9yIEVTMjU2L1JTMjU2IHRva2VuIHZlcmlmaWNhdGlvbicpXG4gICAgcmV0dXJuIGZhbHNlXG4gIH1cblxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgU1VQQUJBU0VfSldUX0tFWVMpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdBc3ltbWV0cmljIEpXVCB2ZXJpZmljYXRpb24gZXJyb3InLCBlKTtcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSHlicmlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IHsgYWxnOiBqd3RBbGdvcml0aG0gfSA9IGpvc2UuZGVjb2RlUHJvdGVjdGVkSGVhZGVyKGp3dClcblxuICBpZiAoand0QWxnb3JpdGhtID09PSAnSFMyNTYnKSB7XG4gICAgY29uc29sZS5sb2coYExlZ2FjeSB0b2tlbiB0eXBlIGRldGVjdGVkLCBhdHRlbXB0aW5nICR7and0QWxnb3JpdGhtfSB2ZXJpZmljYXRpb24uYClcblxuICAgIHJldHVybiBhd2FpdCBpc1ZhbGlkTGVnYWN5SldUKGp3dClcbiAgfVxuXG4gIGlmIChqd3RBbGdvcml0aG0gPT09ICdFUzI1NicgfHwgand0QWxnb3JpdGhtID09PSAnUlMyNTYnKSB7XG4gICAgcmV0dXJuIGF3YWl0IGlzVmFsaWRKV1Qoand0KVxuICB9XG5cbiAgcmV0dXJuIGZhbHNlO1xufVxuXG5EZW5vLnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCBpc1ZhbGlkSHlicmlkSldUKHRva2VuKTtcblxuICAgICAgaWYgKCFpc1ZhbGlkSldUKSB7XG4gICAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6ICdJbnZhbGlkIEpXVCcgfSksIHtcbiAgICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgICAgfSlcbiAgICAgIH1cbiAgICB9IGNhdGNoIChlKSB7XG4gICAgICBjb25zb2xlLmVycm9yKGUpXG4gICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiBlLnRvU3RyaW5nKCkgfSksIHtcbiAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgfSlcbiAgICB9XG4gIH1cblxuICBjb25zdCB1cmwgPSBuZXcgVVJMKHJlcS51cmwpXG4gIGNvbnN0IHsgcGF0aG5hbWUgfSA9IHVybFxuICBjb25zdCBwYXRoX3BhcnRzID0gcGF0aG5hbWUuc3BsaXQoJy8nKVxuICBjb25zdCBzZXJ2aWNlX25hbWUgPSBwYXRoX3BhcnRzWzFdXG5cbiAgaWYgKCFzZXJ2aWNlX25hbWUgfHwgc2VydmljZV9uYW1lID09PSAnJykge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6ICdtaXNzaW5nIGZ1bmN0aW9uIG5hbWUgaW4gcmVxdWVzdCcgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDQwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cblxuICBjb25zdCBzZXJ2aWNlUGF0aCA9IGAvaG9tZS9kZW5vL2Z1bmN0aW9ucy8ke3NlcnZpY2VfbmFtZX1gXG4gIGNvbnNvbGUuZXJyb3IoYHNlcnZpbmcgdGhlIHJlcXVlc3Qgd2l0aCAke3NlcnZpY2VQYXRofWApXG5cbiAgY29uc3QgbWVtb3J5TGltaXRNYiA9IDE1MFxuICBjb25zdCB3b3JrZXJUaW1lb3V0TXMgPSAxICogNjAgKiAxMDAwXG4gIGNvbnN0IG5vTW9kdWxlQ2FjaGUgPSBmYWxzZVxuICBjb25zdCBpbXBvcnRNYXBQYXRoID0gbnVsbFxuICBjb25zdCBlbnZWYXJzT2JqID0gRGVuby5lbnYudG9PYmplY3QoKVxuICBjb25zdCBlbnZWYXJzID0gT2JqZWN0LmtleXMoZW52VmFyc09iaikubWFwKChrKSA9PiBbaywgZW52VmFyc09ialtrXV0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCB3b3JrZXIgPSBhd2FpdCBFZGdlUnVudGltZS51c2VyV29ya2Vycy5jcmVhdGUoe1xuICAgICAgc2VydmljZVBhdGgsXG4gICAgICBtZW1vcnlMaW1pdE1iLFxuICAgICAgd29ya2VyVGltZW91dE1zLFxuICAgICAgbm9Nb2R1bGVDYWNoZSxcbiAgICAgIGltcG9ydE1hcFBhdGgsXG4gICAgICBlbnZWYXJzLFxuICAgIH0pXG4gICAgcmV0dXJuIGF3YWl0IHdvcmtlci5mZXRjaChyZXEpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiBlLnRvU3RyaW5nKCkgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDUwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cbn0pIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvaGVsbG8vaW5kZXgudHMKICAgICAgICBjb250ZW50OiAiLy8gRm9sbG93IHRoaXMgc2V0dXAgZ3VpZGUgdG8gaW50ZWdyYXRlIHRoZSBEZW5vIGxhbmd1YWdlIHNlcnZlciB3aXRoIHlvdXIgZWRpdG9yOlxuLy8gaHR0cHM6Ly9kZW5vLmxhbmQvbWFudWFsL2dldHRpbmdfc3RhcnRlZC9zZXR1cF95b3VyX2Vudmlyb25tZW50XG4vLyBUaGlzIGVuYWJsZXMgYXV0b2NvbXBsZXRlLCBnbyB0byBkZWZpbml0aW9uLCBldGMuXG5cbkRlbm8uc2VydmUoYXN5bmMgKCkgPT4ge1xuICByZXR1cm4gbmV3IFJlc3BvbnNlKFxuICAgIGBcIkhlbGxvIGZyb20gRWRnZSBGdW5jdGlvbnMhXCJgLFxuICAgIHsgaGVhZGVyczogeyBcIkNvbnRlbnQtVHlwZVwiOiBcImFwcGxpY2F0aW9uL2pzb25cIiB9IH0sXG4gIClcbn0pXG5cbi8vIFRvIGludm9rZTpcbi8vIGN1cmwgJ2h0dHA6Ly9sb2NhbGhvc3Q6PEtPTkdfSFRUUF9QT1JUPi9mdW5jdGlvbnMvdjEvaGVsbG8nIFxcXG4vLyAgIC0taGVhZGVyICdBdXRob3JpemF0aW9uOiBCZWFyZXIgPGFub24vc2VydmljZV9yb2xlIEFQSSBrZXk+J1xuIgogICAgY29tbWFuZDoKICAgICAgLSBzdGFydAogICAgICAtICctLW1haW4tc2VydmljZScKICAgICAgLSAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9tYWluCiAgc3VwYWJhc2Utc3VwYXZpc29yOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdXBhdmlzb3I6Mi43LjQnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1zU2ZMJwogICAgICAgIC0gJy1vJwogICAgICAgIC0gL2Rldi9udWxsCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo0MDAwL2FwaS9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgICBzdGFydF9wZXJpb2Q6IDMwcwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9PTEVSX1RFTkFOVF9JRD0ke1BPT0xFUl9URU5BTlRfSUQ6LWRldl90ZW5hbnR9JwogICAgICAtIFBPT0xFUl9QT09MX01PREU9dHJhbnNhY3Rpb24KICAgICAgLSAnUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFPSR7UE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFOi0yMH0nCiAgICAgIC0gJ1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk49JHtQT09MRVJfTUFYX0NMSUVOVF9DT05OOi0xMDB9JwogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0hPU1ROQU1FPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdEQVRBQkFTRV9VUkw9ZWN0bzovL3N1cGFiYXNlX2FkbWluOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vX3N1cGFiYXNlJwogICAgICAtIENMVVNURVJfUE9TVEdSRVM9dHJ1ZQogICAgICAtICdTRUNSRVRfS0VZX0JBU0U9JHtTRVJWSUNFX1BBU1NXT1JEX1NVUEFWSVNPUlNFQ1JFVH0nCiAgICAgIC0gJ1ZBVUxUX0VOQ19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1ZBVUxURU5DfScKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ01FVFJJQ1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSBSRUdJT049bG9jYWwKICAgICAgLSAnRVJMX0FGTEFHUz0tcHJvdG9fZGlzdCBpbmV0X3RjcCcKICAgICAgLSAnREJfUE9PTF9TSVpFPSR7UE9PTEVSX0RCX1BPT0xfU0laRTotNX0nCiAgICAgIC0gR0xPQkFMX0RPV05TVFJFQU1fQ0VSVF9QQVRIPS9ldGMvc3NsL3NlcnZlci5jcnQKICAgICAgLSBHTE9CQUxfRE9XTlNUUkVBTV9LRVlfUEFUSD0vZXRjL3NzbC9zZXJ2ZXIua2V5CiAgICBjb21tYW5kOgogICAgICAtIC9iaW4vc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImlmIFsgISAtZiAvZXRjL3NzbC9zZXJ2ZXIuY3J0IF07IHRoZW5cbiAgb3BlbnNzbCByZXEgLXg1MDkgLW5vZGVzIC1kYXlzIDM2NTAgLW5ld2tleSByc2E6MjA0OCBcXFxuICAgIC1rZXlvdXQgL2V0Yy9zc2wvc2VydmVyLmtleSAtb3V0IC9ldGMvc3NsL3NlcnZlci5jcnQgXFxcbiAgICAtc3ViaiBcIi9DTj1zdXBhYmFzZS1wb29sZXJcIlxuZmlcbi9hcHAvYmluL21pZ3JhdGUgJiYgL2FwcC9iaW4vc3VwYXZpc29yIGV2YWwgXCIkJChjYXQgL2V0Yy9wb29sZXIvcG9vbGVyLmV4cylcIiAmJiAvYXBwL2Jpbi9zZXJ2ZXJcbiIKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICB0YXJnZXQ6IC9ldGMvcG9vbGVyL3Bvb2xlci5leHMKICAgICAgICBjb250ZW50OiAiezpvaywgX30gPSBBcHBsaWNhdGlvbi5lbnN1cmVfYWxsX3N0YXJ0ZWQoOnN1cGF2aXNvcilcbns6b2ssIHZlcnNpb259ID1cbiAgICBjYXNlIFN1cGF2aXNvci5SZXBvLnF1ZXJ5IShcInNlbGVjdCB2ZXJzaW9uKClcIikgZG9cbiAgICAle3Jvd3M6IFtbdmVyXV19IC0+IFN1cGF2aXNvci5IZWxwZXJzLnBhcnNlX3BnX3ZlcnNpb24odmVyKVxuICAgIF8gLT4gbmlsXG4gICAgZW5kXG5wYXJhbXMgPSAle1xuICAgIFwiZXh0ZXJuYWxfaWRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9URU5BTlRfSURcIiksXG4gICAgXCJkYl9ob3N0XCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19IT1NUTkFNRVwiKSxcbiAgICBcImRiX3BvcnRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BPUlRcIikgfD4gU3RyaW5nLnRvX2ludGVnZXIoKSxcbiAgICBcImRiX2RhdGFiYXNlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT1NUR1JFU19EQlwiKSxcbiAgICBcInJlcXVpcmVfdXNlclwiID0+IGZhbHNlLFxuICAgIFwiYXV0aF9xdWVyeVwiID0+IFwiU0VMRUNUICogRlJPTSBwZ2JvdW5jZXIuZ2V0X2F1dGgoJDEpXCIsXG4gICAgXCJkZWZhdWx0X21heF9jbGllbnRzXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfTUFYX0NMSUVOVF9DT05OXCIpLFxuICAgIFwiZGVmYXVsdF9wb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImRlZmF1bHRfcGFyYW1ldGVyX3N0YXR1c1wiID0+ICV7XCJzZXJ2ZXJfdmVyc2lvblwiID0+IHZlcnNpb259LFxuICAgIFwidXNlcnNcIiA9PiBbJXtcbiAgICBcImRiX3VzZXJcIiA9PiBcInBnYm91bmNlclwiLFxuICAgIFwiZGJfcGFzc3dvcmRcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX1BBU1NXT1JEXCIpLFxuICAgIFwibW9kZV90eXBlXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfUE9PTF9NT0RFXCIpLFxuICAgIFwicG9vbF9zaXplXCIgPT4gU3lzdGVtLmdldF9lbnYoXCJQT09MRVJfREVGQVVMVF9QT09MX1NJWkVcIiksXG4gICAgXCJpc19tYW5hZ2VyXCIgPT4gdHJ1ZVxuICAgIH1dXG59XG5cbnRlbmFudCA9IFN1cGF2aXNvci5UZW5hbnRzLmdldF90ZW5hbnRfYnlfZXh0ZXJuYWxfaWQocGFyYW1zW1wiZXh0ZXJuYWxfaWRcIl0pXG5cbmlmIHRlbmFudCBkb1xuICB7Om9rLCBffSA9IFN1cGF2aXNvci5UZW5hbnRzLnVwZGF0ZV90ZW5hbnQodGVuYW50LCBwYXJhbXMpXG5lbHNlXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMuY3JlYXRlX3RlbmFudChwYXJhbXMpXG5lbmRcbiIK", + "compose": "c2VydmljZXM6CiAgc3VwYWJhc2Uta29uZzoKICAgIGltYWdlOiAna29uZy9rb25nOjMuOS4xJwogICAgZW50cnlwb2ludDogL2hvbWUva29uZy9rb25nLWVudHJ5cG9pbnQuc2gKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGtvbmcKICAgICAgICAtIGhlYWx0aAogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX1NVUEFCQVNFS09OR184MDAwCiAgICAgIC0gJ0tPTkdfUE9SVF9NQVBTPTQ0Mzo4MDAwJwogICAgICAtICdKV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIEtPTkdfREFUQUJBU0U9b2ZmCiAgICAgIC0gS09OR19ERUNMQVJBVElWRV9DT05GSUc9L3Vzci9sb2NhbC9rb25nL2tvbmcueW1sCiAgICAgIC0gJ0tPTkdfRE5TX09SREVSPUxBU1QsQSxDTkFNRScKICAgICAgLSBLT05HX0ROU19OT1RfRk9VTkRfVFRMPTEKICAgICAgLSAnS09OR19QTFVHSU5TPXJlcXVlc3QtdHJhbnNmb3JtZXIsY29ycyxrZXktYXV0aCxhY2wsYmFzaWMtYXV0aCxyZXF1ZXN0LXRlcm1pbmF0aW9uLGlwLXJlc3RyaWN0aW9uLHBvc3QtZnVuY3Rpb24nCiAgICAgIC0gS09OR19OR0lOWF9QUk9YWV9QUk9YWV9CVUZGRVJfU0laRT0xNjBrCiAgICAgIC0gJ0tPTkdfTkdJTlhfUFJPWFlfUFJPWFlfQlVGRkVSUz02NCAxNjBrJwogICAgICAtICdLT05HX1BST1hZX0FDQ0VTU19MT0c9L2Rldi9zdGRvdXQgY29tYmluZWQnCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVk9JHtTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVk6LX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFQ1JFVF9LRVk9JHtTVVBBQkFTRV9TRUNSRVRfS0VZOi19JwogICAgICAtICdBTk9OX0tFWV9BU1lNTUVUUklDPSR7QU5PTl9LRVlfQVNZTU1FVFJJQzotfScKICAgICAgLSAnU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDPSR7U0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDOi19JwogICAgICAtICdEQVNIQk9BUkRfVVNFUk5BTUU9JHtTRVJWSUNFX1VTRVJfQURNSU59JwogICAgICAtICdEQVNIQk9BUkRfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0FETUlOfScKICAgICAgLSAnS09OR19TVE9SQUdFX0NPTk5FQ1RfVElNRU9VVD0ke0tPTkdfU1RPUkFHRV9DT05ORUNUX1RJTUVPVVQ6LTYwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1dSSVRFX1RJTUVPVVQ9JHtLT05HX1NUT1JBR0VfV1JJVEVfVElNRU9VVDotMzYwMH0nCiAgICAgIC0gJ0tPTkdfU1RPUkFHRV9SRUFEX1RJTUVPVVQ9JHtLT05HX1NUT1JBR0VfUkVBRF9USU1FT1VUOi0zNjAwfScKICAgICAgLSAnS09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HPSR7S09OR19TVE9SQUdFX1JFUVVFU1RfQlVGRkVSSU5HOi1mYWxzZX0nCiAgICAgIC0gJ0tPTkdfU1RPUkFHRV9SRVNQT05TRV9CVUZGRVJJTkc9JHtLT05HX1NUT1JBR0VfUkVTUE9OU0VfQlVGRkVSSU5HOi1mYWxzZX0nCiAgICB2b2x1bWVzOgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLWVudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9ob21lL2tvbmcva29uZy1lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9iYXNoXG4jIEN1c3RvbSBlbnRyeXBvaW50IGZvciBLb25nIHRoYXQgYnVpbGRzIEx1YSBleHByZXNzaW9ucyBmb3IgcmVxdWVzdC10cmFuc2Zvcm1lclxuIyBhbmQgcGVyZm9ybXMgZW52aXJvbm1lbnQgdmFyaWFibGUgc3Vic3RpdHV0aW9uIGluIHRoZSBkZWNsYXJhdGl2ZSBjb25maWcuXG5cbmlmIFsgLW4gXCIkU1VQQUJBU0VfU0VDUkVUX0tFWVwiIF0gJiYgWyAtbiBcIiRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVlcIiBdOyB0aGVuXG4gICAgZXhwb3J0IExVQV9BVVRIX0VYUFI9XCJcXCQoKGhlYWRlcnMuYXV0aG9yaXphdGlvbiB+PSBuaWwgYW5kIGhlYWRlcnMuYXV0aG9yaXphdGlvbjpzdWIoMSwgMTApIH49ICdCZWFyZXIgc2JfJyBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uKSBvciAoaGVhZGVycy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9TRUNSRVRfS0VZJyBhbmQgJ0JlYXJlciAkU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDJykgb3IgKGhlYWRlcnMuYXBpa2V5ID09ICckU1VQQUJBU0VfUFVCTElTSEFCTEVfS0VZJyBhbmQgJ0JlYXJlciAkQU5PTl9LRVlfQVNZTU1FVFJJQycpIG9yIGhlYWRlcnMuYXBpa2V5KVwiXG4gICAgZXhwb3J0IExVQV9SVF9XU19FWFBSPVwiXFwkKChxdWVyeV9wYXJhbXMuYXBpa2V5ID09ICckU1VQQUJBU0VfU0VDUkVUX0tFWScgYW5kICckU0VSVklDRV9ST0xFX0tFWV9BU1lNTUVUUklDJykgb3IgKHF1ZXJ5X3BhcmFtcy5hcGlrZXkgPT0gJyRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVknIGFuZCAnJEFOT05fS0VZX0FTWU1NRVRSSUMnKSBvciBxdWVyeV9wYXJhbXMuYXBpa2V5KVwiXG5lbHNlXG4gICAgZXhwb3J0IExVQV9BVVRIX0VYUFI9XCJcXCQoKGhlYWRlcnMuYXV0aG9yaXphdGlvbiB+PSBuaWwgYW5kIGhlYWRlcnMuYXV0aG9yaXphdGlvbjpzdWIoMSwgMTApIH49ICdCZWFyZXIgc2JfJyBhbmQgaGVhZGVycy5hdXRob3JpemF0aW9uKSBvciBoZWFkZXJzLmFwaWtleSlcIlxuICAgIGV4cG9ydCBMVUFfUlRfV1NfRVhQUj1cIlxcJChxdWVyeV9wYXJhbXMuYXBpa2V5KVwiXG5maVxuXG5hd2sgJ3tcbiAgcmVzdWx0ID0gXCJcIlxuICByZXN0ID0gJDBcbiAgd2hpbGUgKG1hdGNoKHJlc3QsIC9cXCRbQS1aYS16X11bQS1aYS16XzAtOV0qLykpIHtcbiAgICB2YXJuYW1lID0gc3Vic3RyKHJlc3QsIFJTVEFSVCArIDEsIFJMRU5HVEggLSAxKVxuICAgIGlmICh2YXJuYW1lIGluIEVOVklST04pIHtcbiAgICAgIHJlc3VsdCA9IHJlc3VsdCBzdWJzdHIocmVzdCwgMSwgUlNUQVJUIC0gMSkgRU5WSVJPTlt2YXJuYW1lXVxuICAgIH0gZWxzZSB7XG4gICAgICByZXN1bHQgPSByZXN1bHQgc3Vic3RyKHJlc3QsIDEsIFJTVEFSVCArIFJMRU5HVEggLSAxKVxuICAgIH1cbiAgICByZXN0ID0gc3Vic3RyKHJlc3QsIFJTVEFSVCArIFJMRU5HVEgpXG4gIH1cbiAgcHJpbnQgcmVzdWx0IHJlc3Rcbn0nIC9ob21lL2tvbmcvdGVtcC55bWwgPiBcIiRLT05HX0RFQ0xBUkFUSVZFX0NPTkZJR1wiXG5cbnNlZCAtaSAnL15bWzpzcGFjZTpdXSotIGtleTpbWzpzcGFjZTpdXSokL2QnIFwiJEtPTkdfREVDTEFSQVRJVkVfQ09ORklHXCJcblxuZXhlYyAvZW50cnlwb2ludC5zaCBrb25nIGRvY2tlci1zdGFydFxuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2FwaS9rb25nLnltbAogICAgICAgIHRhcmdldDogL2hvbWUva29uZy90ZW1wLnltbAogICAgICAgIGNvbnRlbnQ6ICJfZm9ybWF0X3ZlcnNpb246ICcyLjEnXG5fdHJhbnNmb3JtOiB0cnVlXG5cbiMjI1xuIyMjIENvbnN1bWVycyAvIFVzZXJzXG4jIyNcbmNvbnN1bWVyczpcbiAgLSB1c2VybmFtZTogREFTSEJPQVJEXG4gIC0gdXNlcm5hbWU6IGFub25cbiAgICBrZXlhdXRoX2NyZWRlbnRpYWxzOlxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9BTk9OX0tFWVxuICAgICAgLSBrZXk6ICRTVVBBQkFTRV9QVUJMSVNIQUJMRV9LRVlcbiAgLSB1c2VybmFtZTogc2VydmljZV9yb2xlXG4gICAga2V5YXV0aF9jcmVkZW50aWFsczpcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VSVklDRV9LRVlcbiAgICAgIC0ga2V5OiAkU1VQQUJBU0VfU0VDUkVUX0tFWVxuXG4jIyNcbiMjIyBBY2Nlc3MgQ29udHJvbCBMaXN0XG4jIyNcbmFjbHM6XG4gIC0gY29uc3VtZXI6IGFub25cbiAgICBncm91cDogYW5vblxuICAtIGNvbnN1bWVyOiBzZXJ2aWNlX3JvbGVcbiAgICBncm91cDogYWRtaW5cblxuIyMjXG4jIyMgRGFzaGJvYXJkIGNyZWRlbnRpYWxzXG4jIyNcbmJhc2ljYXV0aF9jcmVkZW50aWFsczpcbi0gY29uc3VtZXI6IERBU0hCT0FSRFxuICB1c2VybmFtZTogJyREQVNIQk9BUkRfVVNFUk5BTUUnXG4gIHBhc3N3b3JkOiAnJERBU0hCT0FSRF9QQVNTV09SRCdcblxuXG4jIyNcbiMjIyBBUEkgUm91dGVzXG4jIyNcbnNlcnZpY2VzOlxuXG4gICMjIE9wZW4gQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvdmVyaWZ5XG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW5cbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL3ZlcmlmeVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tY2FsbGJhY2tcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvY2FsbGJhY2tcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1jYWxsYmFja1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2F1dGgvdjEvY2FsbGJhY2tcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gIC0gbmFtZTogYXV0aC12MS1vcGVuLWF1dGhvcml6ZVxuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9hdXRob3JpemVcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1hdXRob3JpemVcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9hdXRoL3YxL2F1dGhvcml6ZVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tandrc1xuICAgIF9jb21tZW50OiAnQXV0aDogL2F1dGgvdjEvLndlbGwta25vd24vandrcy5qc29uIC0+IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvLndlbGwta25vd24vandrcy5qc29uJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9qd2tzLmpzb25cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtb3Blbi1qd2tzXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS8ud2VsbC1rbm93bi9qd2tzLmpzb25cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLWFjc1xuICAgIHVybDogXCJodHRwOi8vc3VwYWJhc2UtYXV0aDo5OTk5L3Nzby9zYW1sL2Fjc1wiXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLWFjc1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAtIC9zc28vc2FtbC9hY3NcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgLSBuYW1lOiBhdXRoLXYxLW9wZW4tc3NvLW1ldGFkYXRhXG4gICAgdXJsOiBcImh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvc3NvL3NhbWwvbWV0YWRhdGFcIlxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogYXV0aC12MS1vcGVuLXNzby1tZXRhZGF0YVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAtIC9zc28vc2FtbC9tZXRhZGF0YVxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBTZWN1cmUgQXV0aCByb3V0ZXNcbiAgLSBuYW1lOiBhdXRoLXYxXG4gICAgX2NvbW1lbnQ6ICdHb1RydWU6IC9hdXRoL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8qJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS9cbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IGF1dGgtdjEtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvYXV0aC92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU2VjdXJlIFJFU1Qgcm91dGVzXG4gIC0gbmFtZTogcmVzdC12MVxuICAgIF9jb21tZW50OiAnUG9zdGdSRVNUOiAvcmVzdC92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvKidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZXN0LXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3Jlc3QvdjEvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgICAgICByZXBsYWNlOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkF1dGhvcml6YXRpb246ICRMVUFfQVVUSF9FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuXG4gICMjIFNlY3VyZSBHcmFwaFFMIHJvdXRlc1xuICAtIG5hbWU6IGdyYXBocWwtdjFcbiAgICBfY29tbWVudDogJ1Bvc3RnUkVTVDogL2dyYXBocWwvdjEvKiAtPiBodHRwOi8vc3VwYWJhc2UtcmVzdDozMDAwL3JwYy9ncmFwaHFsJ1xuICAgIHVybDogaHR0cDovL3N1cGFiYXNlLXJlc3Q6MzAwMC9ycGMvZ3JhcGhxbFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZ3JhcGhxbC12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9ncmFwaHFsL3YxXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogcmVxdWVzdC10cmFuc2Zvcm1lclxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgYWRkOlxuICAgICAgICAgICAgaGVhZGVyczpcbiAgICAgICAgICAgICAgLSBcIkNvbnRlbnQtUHJvZmlsZTogZ3JhcGhxbF9wdWJsaWNcIlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQ29udGVudC1Qcm9maWxlOiBncmFwaHFsX3B1YmxpY1wiXG4gICAgICAgICAgICAgIC0gXCJBdXRob3JpemF0aW9uOiAkTFVBX0FVVEhfRVhQUlwiXG4gICAgICAtIG5hbWU6IGFjbFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9ncm91cHNfaGVhZGVyOiB0cnVlXG4gICAgICAgICAgYWxsb3c6XG4gICAgICAgICAgICAtIGFkbWluXG4gICAgICAgICAgICAtIGFub25cblxuICAjIyBTZWN1cmUgUmVhbHRpbWUgcm91dGVzXG4gIC0gbmFtZTogcmVhbHRpbWUtdjEtd3NcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvKiAtPiB3czovL3JlYWx0aW1lOjQwMDAvc29ja2V0LyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvc29ja2V0XG4gICAgcHJvdG9jb2w6IHdzXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiByZWFsdGltZS12MS13c1xuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL3JlYWx0aW1lL3YxL1xuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcbiAgICAgIC0gbmFtZToga2V5LWF1dGhcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfY3JlZGVudGlhbHM6IGZhbHNlXG4gICAgICAtIG5hbWU6IHJlcXVlc3QtdHJhbnNmb3JtZXJcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGFkZDpcbiAgICAgICAgICAgIHF1ZXJ5c3RyaW5nOlxuICAgICAgICAgICAgICAtIFwiYXBpa2V5OiAkTFVBX1JUX1dTX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBxdWVyeXN0cmluZzpcbiAgICAgICAgICAgICAgLSBcImFwaWtleTogJExVQV9SVF9XU19FWFBSXCJcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cbiAgICAgICAgICAgIC0gYW5vblxuICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICBfY29tbWVudDogJ1JlYWx0aW1lOiAvcmVhbHRpbWUvdjEvYXBpLyogLT4gaHR0cDovL3JlYWx0aW1lOjQwMDAvYXBpLyonXG4gICAgdXJsOiBodHRwOi8vcmVhbHRpbWUtZGV2OjQwMDAvYXBpXG4gICAgcHJvdG9jb2w6IGh0dHBcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHJlYWx0aW1lLXYxLXJlc3RcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9yZWFsdGltZS92MS9hcGlcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG4gICAgICAtIG5hbWU6IGtleS1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiBmYWxzZVxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRyYW5zZm9ybWVyXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhZGQ6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgICAgIHJlcGxhY2U6XG4gICAgICAgICAgICBoZWFkZXJzOlxuICAgICAgICAgICAgICAtIFwiQXV0aG9yaXphdGlvbjogJExVQV9BVVRIX0VYUFJcIlxuICAgICAgLSBuYW1lOiBhY2xcbiAgICAgICAgY29uZmlnOlxuICAgICAgICAgIGhpZGVfZ3JvdXBzX2hlYWRlcjogdHJ1ZVxuICAgICAgICAgIGFsbG93OlxuICAgICAgICAgICAgLSBhZG1pblxuICAgICAgICAgICAgLSBhbm9uXG5cbiAgIyMgU3RvcmFnZSBBUEkgZW5kcG9pbnRcbiAgIyMgTm8ga2V5LWF1dGggLSBTMyBwcm90b2NvbCByZXF1ZXN0cyBkb24ndCBjYXJyeSBhbiBhcGlrZXkgaGVhZGVyLlxuICAtIG5hbWU6IHN0b3JhZ2UtdjFcbiAgICBfY29tbWVudDogJ1N0b3JhZ2U6IC9zdG9yYWdlL3YxLyogLT4gaHR0cDovL3N1cGFiYXNlLXN0b3JhZ2U6NTAwMC8qJ1xuICAgIGNvbm5lY3RfdGltZW91dDogJEtPTkdfU1RPUkFHRV9DT05ORUNUX1RJTUVPVVRcbiAgICB3cml0ZV90aW1lb3V0OiAkS09OR19TVE9SQUdFX1dSSVRFX1RJTUVPVVRcbiAgICByZWFkX3RpbWVvdXQ6ICRLT05HX1NUT1JBR0VfUkVBRF9USU1FT1VUXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3RvcmFnZTo1MDAwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogc3RvcmFnZS12MS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9zdG9yYWdlL3YxL1xuICAgICAgICByZXF1ZXN0X2J1ZmZlcmluZzogJEtPTkdfU1RPUkFHRV9SRVFVRVNUX0JVRkZFUklOR1xuICAgICAgICByZXNwb25zZV9idWZmZXJpbmc6ICRLT05HX1NUT1JBR0VfUkVTUE9OU0VfQlVGRkVSSU5HXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBwb3N0LWZ1bmN0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBhY2Nlc3M6XG4gICAgICAgICAgICAtIHxcbiAgICAgICAgICAgICAgbG9jYWwgYXV0aCA9IGtvbmcucmVxdWVzdC5nZXRfaGVhZGVyKFwiYXV0aG9yaXphdGlvblwiKVxuICAgICAgICAgICAgICBpZiBhdXRoID09IG5pbCBvciBhdXRoID09IFwiXCIgb3IgYXV0aDpmaW5kKFwiXiVzKiRcIikgdGhlblxuICAgICAgICAgICAgICAgIGtvbmcuc2VydmljZS5yZXF1ZXN0LmNsZWFyX2hlYWRlcihcImF1dGhvcml6YXRpb25cIilcbiAgICAgICAgICAgICAgZW5kXG5cbiAgIyMgRWRnZSBGdW5jdGlvbnMgcm91dGVzXG4gIC0gbmFtZTogZnVuY3Rpb25zLXYxXG4gICAgX2NvbW1lbnQ6ICdFZGdlIEZ1bmN0aW9uczogL2Z1bmN0aW9ucy92MS8qIC0+IGh0dHA6Ly9zdXBhYmFzZS1lZGdlLWZ1bmN0aW9uczo5MDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtZWRnZS1mdW5jdGlvbnM6OTAwMC9cbiAgICByZWFkX3RpbWVvdXQ6IDE1MDAwMFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogZnVuY3Rpb25zLXYxLWFsbFxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2Z1bmN0aW9ucy92MS9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBjb3JzXG5cbiAgIyMgT0F1dGggMi4wIEF1dGhvcml6YXRpb24gU2VydmVyIE1ldGFkYXRhIChSRkMgODQxNClcbiAgLSBuYW1lOiB3ZWxsLWtub3duLW9hdXRoXG4gICAgX2NvbW1lbnQ6ICdBdXRoOiAvLndlbGwta25vd24vb2F1dGgtYXV0aG9yaXphdGlvbi1zZXJ2ZXIgLT4gaHR0cDovL3N1cGFiYXNlLWF1dGg6OTk5OS8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlcidcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hdXRoOjk5OTkvLndlbGwta25vd24vb2F1dGgtYXV0aG9yaXphdGlvbi1zZXJ2ZXJcbiAgICByb3V0ZXM6XG4gICAgICAtIG5hbWU6IHdlbGwta25vd24tb2F1dGhcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC8ud2VsbC1rbm93bi9vYXV0aC1hdXRob3JpemF0aW9uLXNlcnZlclxuICAgIHBsdWdpbnM6XG4gICAgICAtIG5hbWU6IGNvcnNcblxuICAjIyBBbmFseXRpY3Mgcm91dGVzXG4gICMjIE5vdCB1c2VkIC0gU3R1ZGlvIGFuZCBWZWN0b3IgdGFsayBkaXJlY3RseSB0byBhbmFseXRpY3MgdmlhIERvY2tlciBuZXR3b3JraW5nLlxuICAjIC0gbmFtZTogYW5hbHl0aWNzLXYxXG4gICMgICBfY29tbWVudDogJ0FuYWx5dGljczogL2FuYWx5dGljcy92MS8qIC0+IGh0dHA6Ly9sb2dmbGFyZTo0MDAwLyonXG4gICMgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9cbiAgIyAgIHJvdXRlczpcbiAgIyAgICAgLSBuYW1lOiBhbmFseXRpY3MtdjEtYWxsXG4gICMgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAjICAgICAgIHBhdGhzOlxuICAjICAgICAgICAgLSAvYW5hbHl0aWNzL3YxL1xuXG4gICMjIFNlY3VyZSBEYXRhYmFzZSByb3V0ZXNcbiAgLSBuYW1lOiBtZXRhXG4gICAgX2NvbW1lbnQ6ICdwZy1tZXRhOiAvcGcvKiAtPiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwL1xuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWV0YS1hbGxcbiAgICAgICAgc3RyaXBfcGF0aDogdHJ1ZVxuICAgICAgICBwYXRoczpcbiAgICAgICAgICAtIC9wZy9cbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiBrZXktYXV0aFxuICAgICAgICBjb25maWc6XG4gICAgICAgICAgaGlkZV9jcmVkZW50aWFsczogZmFsc2VcbiAgICAgIC0gbmFtZTogYWNsXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2dyb3Vwc19oZWFkZXI6IHRydWVcbiAgICAgICAgICBhbGxvdzpcbiAgICAgICAgICAgIC0gYWRtaW5cblxuICAjIyBCbG9jayBhY2Nlc3MgdG8gL2FwaS9tY3BcbiAgLSBuYW1lOiBtY3AtYmxvY2tlclxuICAgIF9jb21tZW50OiAnQmxvY2sgZGlyZWN0IGFjY2VzcyB0byAvYXBpL21jcCdcbiAgICB1cmw6IGh0dHA6Ly9zdXBhYmFzZS1zdHVkaW86MzAwMC9hcGkvbWNwXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBtY3AtYmxvY2tlci1yb3V0ZVxuICAgICAgICBzdHJpcF9wYXRoOiB0cnVlXG4gICAgICAgIHBhdGhzOlxuICAgICAgICAgIC0gL2FwaS9tY3BcbiAgICBwbHVnaW5zOlxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRlcm1pbmF0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBzdGF0dXNfY29kZTogNDAzXG4gICAgICAgICAgbWVzc2FnZTogXCJBY2Nlc3MgaXMgZm9yYmlkZGVuLlwiXG5cbiAgIyMgTUNQIGVuZHBvaW50IC0gbG9jYWwgYWNjZXNzXG4gIC0gbmFtZTogbWNwXG4gICAgX2NvbW1lbnQ6ICdNQ1A6IC9tY3AgLT4gaHR0cDovL3N1cGFiYXNlLXN0dWRpbzozMDAwL2FwaS9tY3AgKGxvY2FsIGFjY2VzcyknXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvYXBpL21jcFxuICAgIHJvdXRlczpcbiAgICAgIC0gbmFtZTogbWNwXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvbWNwXG4gICAgcGx1Z2luczpcbiAgICAgICMgQmxvY2sgYWNjZXNzIHRvIC9tY3AgYnkgZGVmYXVsdFxuICAgICAgLSBuYW1lOiByZXF1ZXN0LXRlcm1pbmF0aW9uXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBzdGF0dXNfY29kZTogNDAzXG4gICAgICAgICAgbWVzc2FnZTogXCJBY2Nlc3MgaXMgZm9yYmlkZGVuLlwiXG4gICAgICAjIEVuYWJsZSBsb2NhbCBhY2Nlc3MgKGRhbmdlciB6b25lISlcbiAgICAgICMgMS4gQ29tbWVudCBvdXQgdGhlICdyZXF1ZXN0LXRlcm1pbmF0aW9uJyBzZWN0aW9uIGFib3ZlXG4gICAgICAjIDIuIFVuY29tbWVudCB0aGUgZW50aXJlIHNlY3Rpb24gYmVsb3csIGluY2x1ZGluZyAnZGVueSdcbiAgICAgICMgMy4gQWRkIHlvdXIgbG9jYWwgSVBzIHRvIHRoZSAnYWxsb3cnIGxpc3RcbiAgICAgICMtIG5hbWU6IGNvcnNcbiAgICAgICMtIG5hbWU6IGlwLXJlc3RyaWN0aW9uXG4gICAgICAjICBjb25maWc6XG4gICAgICAjICAgIGFsbG93OlxuICAgICAgIyAgICAgIC0gMTI3LjAuMC4xXG4gICAgICAjICAgICAgLSA6OjFcbiAgICAgICMgICAgZGVueTogW11cblxuICAjIyBQcm90ZWN0ZWQgRGFzaGJvYXJkIC0gY2F0Y2ggYWxsIHJlbWFpbmluZyByb3V0ZXNcbiAgLSBuYW1lOiBkYXNoYm9hcmRcbiAgICBfY29tbWVudDogJ1N0dWRpbzogLyogLT4gaHR0cDovL3N0dWRpbzozMDAwLyonXG4gICAgdXJsOiBodHRwOi8vc3VwYWJhc2Utc3R1ZGlvOjMwMDAvXG4gICAgcm91dGVzOlxuICAgICAgLSBuYW1lOiBkYXNoYm9hcmQtYWxsXG4gICAgICAgIHN0cmlwX3BhdGg6IHRydWVcbiAgICAgICAgcGF0aHM6XG4gICAgICAgICAgLSAvXG4gICAgcGx1Z2luczpcbiAgICAgIC0gbmFtZTogY29yc1xuICAgICAgLSBuYW1lOiBiYXNpYy1hdXRoXG4gICAgICAgIGNvbmZpZzpcbiAgICAgICAgICBoaWRlX2NyZWRlbnRpYWxzOiB0cnVlXG4iCiAgc3VwYWJhc2Utc3R1ZGlvOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9zdHVkaW86MjAyNi4wMy4xNi1zaGEtNTUyODgxNycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAiZmV0Y2goJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMC9hcGkvcGxhdGZvcm0vcHJvZmlsZScpLnRoZW4oKHIpID0+IHtpZiAoci5zdGF0dXMgIT09IDIwMCkgdGhyb3cgbmV3IEVycm9yKHIuc3RhdHVzKX0pIgogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBIT1NUTkFNRT0wLjAuMC4wCiAgICAgIC0gJ1NUVURJT19QR19NRVRBX1VSTD1odHRwOi8vc3VwYWJhc2UtbWV0YTo4MDgwJwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19IT1NUPSR7UE9TVEdSRVNfSE9TVDotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnUEdSU1RfREJfU0NIRU1BUz0ke1BHUlNUX0RCX1NDSEVNQVM6LXB1YmxpYyxzdG9yYWdlLGdyYXBocWxfcHVibGljfScKICAgICAgLSAnUEdSU1RfREJfTUFYX1JPV1M9JHtQR1JTVF9EQl9NQVhfUk9XUzotMTAwMH0nCiAgICAgIC0gJ1BHUlNUX0RCX0VYVFJBX1NFQVJDSF9QQVRIPSR7UEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg6LXB1YmxpY30nCiAgICAgIC0gJ0RFRkFVTFRfT1JHQU5JWkFUSU9OX05BTUU9JHtTVFVESU9fREVGQVVMVF9PUkdBTklaQVRJT046LURlZmF1bHQgT3JnYW5pemF0aW9ufScKICAgICAgLSAnREVGQVVMVF9QUk9KRUNUX05BTUU9JHtTVFVESU9fREVGQVVMVF9QUk9KRUNUOi1EZWZhdWx0IFByb2plY3R9JwogICAgICAtICdTVVBBQkFTRV9VUkw9aHR0cDovL3N1cGFiYXNlLWtvbmc6ODAwMCcKICAgICAgLSAnU1VQQUJBU0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdTVVBBQkFTRV9BTk9OX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VBTk9OX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX1NFUlZJQ0VfS0VZPSR7U0VSVklDRV9TVVBBQkFTRVNFUlZJQ0VfS0VZfScKICAgICAgLSAnQVVUSF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtICdQR19NRVRBX0NSWVBUT19LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1BHTUVUQUNSWVBUT30nCiAgICAgIC0gJ0xPR0ZMQVJFX0FQSV9LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX0xPR0ZMQVJFfScKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgICAtICdMT0dGTEFSRV9QUklWQVRFX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkVQUklWQVRFfScKICAgICAgLSAnTE9HRkxBUkVfVVJMPWh0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMCcKICAgICAgLSAnTkVYVF9QVUJMSUNfU1VQQUJBU0VfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ05FWFRfUFVCTElDX1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSBORVhUX1BVQkxJQ19FTkFCTEVfTE9HUz10cnVlCiAgICAgIC0gTkVYVF9BTkFMWVRJQ1NfQkFDS0VORF9QUk9WSURFUj1wb3N0Z3JlcwogICAgICAtICdPUEVOQUlfQVBJX0tFWT0ke09QRU5BSV9BUElfS0VZfScKICAgICAgLSBTTklQUEVUU19NQU5BR0VNRU5UX0ZPTERFUj0vYXBwL3NuaXBwZXRzCiAgICAgIC0gRURHRV9GVU5DVElPTlNfTUFOQUdFTUVOVF9GT0xERVI9L2FwcC9lZGdlLWZ1bmN0aW9ucwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3NuaXBwZXRzOi9hcHAvc25pcHBldHMnCiAgICAgIC0gJy4vdm9sdW1lcy9mdW5jdGlvbnM6L2FwcC9lZGdlLWZ1bmN0aW9ucycKICBzdXBhYmFzZS1kYjoKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXM6MTUuOC4xLjA4NScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OiAncGdfaXNyZWFkeSAtVSBwb3N0Z3JlcyAtaCAxMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtdmVjdG9yOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBjb21tYW5kOgogICAgICAtIHBvc3RncmVzCiAgICAgIC0gJy1jJwogICAgICAtIGNvbmZpZ19maWxlPS9ldGMvcG9zdGdyZXNxbC9wb3N0Z3Jlc3FsLmNvbmYKICAgICAgLSAnLWMnCiAgICAgIC0gbG9nX21pbl9tZXNzYWdlcz1mYXRhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gUE9TVEdSRVNfSE9TVD0vdmFyL3J1bi9wb3N0Z3Jlc3FsCiAgICAgIC0gJ1BHUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtICdQT1NUR1JFU19QT1JUPSR7UE9TVEdSRVNfUE9SVDotNTQzMn0nCiAgICAgIC0gJ1BHUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUEdEQVRBQkFTRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSAnSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwYWJhc2UtZGItZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvcmVhbHRpbWUuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85OS1yZWFsdGltZS5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxuY3JlYXRlIHNjaGVtYSBpZiBub3QgZXhpc3RzIF9yZWFsdGltZTtcbmFsdGVyIHNjaGVtYSBfcmVhbHRpbWUgb3duZXIgdG8gOnBndXNlcjtcbiIKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9kYi9fc3VwYWJhc2Uuc3FsCiAgICAgICAgdGFyZ2V0OiAvZG9ja2VyLWVudHJ5cG9pbnQtaW5pdGRiLmQvbWlncmF0aW9ucy85Ny1fc3VwYWJhc2Uuc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cbkNSRUFURSBEQVRBQkFTRSBfc3VwYWJhc2UgV0lUSCBPV05FUiA6cGd1c2VyO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3Bvb2xlci5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LXBvb2xlci5zcWwKICAgICAgICBjb250ZW50OiAiXFxzZXQgcGd1c2VyIGBlY2hvIFwiJFBPU1RHUkVTX1VTRVJcImBcblxcYyBfc3VwYWJhc2VcbmNyZWF0ZSBzY2hlbWEgaWYgbm90IGV4aXN0cyBfc3VwYXZpc29yO1xuYWx0ZXIgc2NoZW1hIF9zdXBhdmlzb3Igb3duZXIgdG8gOnBndXNlcjtcblxcYyBwb3N0Z3Jlc1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3dlYmhvb2tzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OC13ZWJob29rcy5zcWwKICAgICAgICBjb250ZW50OiAiQkVHSU47XG4tLSBDcmVhdGUgcGdfbmV0IGV4dGVuc2lvblxuQ1JFQVRFIEVYVEVOU0lPTiBJRiBOT1QgRVhJU1RTIHBnX25ldCBTQ0hFTUEgZXh0ZW5zaW9ucztcbi0tIENyZWF0ZSBzdXBhYmFzZV9mdW5jdGlvbnMgc2NoZW1hXG5DUkVBVEUgU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBBVVRIT1JJWkFUSU9OIHN1cGFiYXNlX2FkbWluO1xuR1JBTlQgVVNBR0UgT04gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFRBQkxFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIEZVTkNUSU9OUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQUxURVIgREVGQVVMVCBQUklWSUxFR0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgR1JBTlQgQUxMIE9OIFNFUVVFTkNFUyBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLm1pZ3JhdGlvbnMgZGVmaW5pdGlvblxuQ1JFQVRFIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5taWdyYXRpb25zIChcbiAgdmVyc2lvbiB0ZXh0IFBSSU1BUlkgS0VZLFxuICBpbnNlcnRlZF9hdCB0aW1lc3RhbXB0eiBOT1QgTlVMTCBERUZBVUxUIE5PVygpXG4pO1xuLS0gSW5pdGlhbCBzdXBhYmFzZV9mdW5jdGlvbnMgbWlncmF0aW9uXG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnaW5pdGlhbCcpO1xuLS0gc3VwYWJhc2VfZnVuY3Rpb25zLmhvb2tzIGRlZmluaXRpb25cbkNSRUFURSBUQUJMRSBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgKFxuICBpZCBiaWdzZXJpYWwgUFJJTUFSWSBLRVksXG4gIGhvb2tfdGFibGVfaWQgaW50ZWdlciBOT1QgTlVMTCxcbiAgaG9va19uYW1lIHRleHQgTk9UIE5VTEwsXG4gIGNyZWF0ZWRfYXQgdGltZXN0YW1wdHogTk9UIE5VTEwgREVGQVVMVCBOT1coKSxcbiAgcmVxdWVzdF9pZCBiaWdpbnRcbik7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX3JlcXVlc3RfaWRfaWR4IE9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBVU0lORyBidHJlZSAocmVxdWVzdF9pZCk7XG5DUkVBVEUgSU5ERVggc3VwYWJhc2VfZnVuY3Rpb25zX2hvb2tzX2hfdGFibGVfaWRfaF9uYW1lX2lkeCBPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaG9va3MgVVNJTkcgYnRyZWUgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSk7XG5DT01NRU5UIE9OIFRBQkxFIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rcyBJUyAnU3VwYWJhc2UgRnVuY3Rpb25zIEhvb2tzOiBBdWRpdCB0cmFpbCBmb3IgdHJpZ2dlcmVkIGhvb2tzLic7XG5DUkVBVEUgRlVOQ1RJT04gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpXG4gIFJFVFVSTlMgdHJpZ2dlclxuICBMQU5HVUFHRSBwbHBnc3FsXG4gIEFTICRmdW5jdGlvbiRcbiAgREVDTEFSRVxuICAgIHJlcXVlc3RfaWQgYmlnaW50O1xuICAgIHBheWxvYWQganNvbmI7XG4gICAgdXJsIHRleHQgOj0gVEdfQVJHVlswXTo6dGV4dDtcbiAgICBtZXRob2QgdGV4dCA6PSBUR19BUkdWWzFdOjp0ZXh0O1xuICAgIGhlYWRlcnMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICBwYXJhbXMganNvbmIgREVGQVVMVCAne30nOjpqc29uYjtcbiAgICB0aW1lb3V0X21zIGludGVnZXIgREVGQVVMVCAxMDAwO1xuICBCRUdJTlxuICAgIElGIHVybCBJUyBOVUxMIE9SIHVybCA9ICdudWxsJyBUSEVOXG4gICAgICBSQUlTRSBFWENFUFRJT04gJ3VybCBhcmd1bWVudCBpcyBtaXNzaW5nJztcbiAgICBFTkQgSUY7XG5cbiAgICBJRiBtZXRob2QgSVMgTlVMTCBPUiBtZXRob2QgPSAnbnVsbCcgVEhFTlxuICAgICAgUkFJU0UgRVhDRVBUSU9OICdtZXRob2QgYXJndW1lbnQgaXMgbWlzc2luZyc7XG4gICAgRU5EIElGO1xuXG4gICAgSUYgVEdfQVJHVlsyXSBJUyBOVUxMIE9SIFRHX0FSR1ZbMl0gPSAnbnVsbCcgVEhFTlxuICAgICAgaGVhZGVycyA9ICd7XCJDb250ZW50LVR5cGVcIjogXCJhcHBsaWNhdGlvbi9qc29uXCJ9Jzo6anNvbmI7XG4gICAgRUxTRVxuICAgICAgaGVhZGVycyA9IFRHX0FSR1ZbMl06Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbM10gSVMgTlVMTCBPUiBUR19BUkdWWzNdID0gJ251bGwnIFRIRU5cbiAgICAgIHBhcmFtcyA9ICd7fSc6Ompzb25iO1xuICAgIEVMU0VcbiAgICAgIHBhcmFtcyA9IFRHX0FSR1ZbM106Ompzb25iO1xuICAgIEVORCBJRjtcblxuICAgIElGIFRHX0FSR1ZbNF0gSVMgTlVMTCBPUiBUR19BUkdWWzRdID0gJ251bGwnIFRIRU5cbiAgICAgIHRpbWVvdXRfbXMgPSAxMDAwO1xuICAgIEVMU0VcbiAgICAgIHRpbWVvdXRfbXMgPSBUR19BUkdWWzRdOjppbnRlZ2VyO1xuICAgIEVORCBJRjtcblxuICAgIENBU0VcbiAgICAgIFdIRU4gbWV0aG9kID0gJ0dFVCcgVEhFTlxuICAgICAgICBTRUxFQ1QgaHR0cF9nZXQgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfZ2V0KFxuICAgICAgICAgIHVybCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBXSEVOIG1ldGhvZCA9ICdQT1NUJyBUSEVOXG4gICAgICAgIHBheWxvYWQgPSBqc29uYl9idWlsZF9vYmplY3QoXG4gICAgICAgICAgJ29sZF9yZWNvcmQnLCBPTEQsXG4gICAgICAgICAgJ3JlY29yZCcsIE5FVyxcbiAgICAgICAgICAndHlwZScsIFRHX09QLFxuICAgICAgICAgICd0YWJsZScsIFRHX1RBQkxFX05BTUUsXG4gICAgICAgICAgJ3NjaGVtYScsIFRHX1RBQkxFX1NDSEVNQVxuICAgICAgICApO1xuXG4gICAgICAgIFNFTEVDVCBodHRwX3Bvc3QgSU5UTyByZXF1ZXN0X2lkIEZST00gbmV0Lmh0dHBfcG9zdChcbiAgICAgICAgICB1cmwsXG4gICAgICAgICAgcGF5bG9hZCxcbiAgICAgICAgICBwYXJhbXMsXG4gICAgICAgICAgaGVhZGVycyxcbiAgICAgICAgICB0aW1lb3V0X21zXG4gICAgICAgICk7XG4gICAgICBFTFNFXG4gICAgICAgIFJBSVNFIEVYQ0VQVElPTiAnbWV0aG9kIGFyZ3VtZW50ICUgaXMgaW52YWxpZCcsIG1ldGhvZDtcbiAgICBFTkQgQ0FTRTtcblxuICAgIElOU0VSVCBJTlRPIHN1cGFiYXNlX2Z1bmN0aW9ucy5ob29rc1xuICAgICAgKGhvb2tfdGFibGVfaWQsIGhvb2tfbmFtZSwgcmVxdWVzdF9pZClcbiAgICBWQUxVRVNcbiAgICAgIChUR19SRUxJRCwgVEdfTkFNRSwgcmVxdWVzdF9pZCk7XG5cbiAgICBSRVRVUk4gTkVXO1xuICBFTkRcbiRmdW5jdGlvbiQ7XG4tLSBTdXBhYmFzZSBzdXBlciBhZG1pblxuRE9cbiQkXG5CRUdJTlxuICBJRiBOT1QgRVhJU1RTIChcbiAgICBTRUxFQ1QgMVxuICAgIEZST00gcGdfcm9sZXNcbiAgICBXSEVSRSByb2xuYW1lID0gJ3N1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbidcbiAgKVxuICBUSEVOXG4gICAgQ1JFQVRFIFVTRVIgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIE5PSU5IRVJJVCBDUkVBVEVST0xFIExPR0lOIE5PUkVQTElDQVRJT047XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBTQ0hFTUEgc3VwYWJhc2VfZnVuY3Rpb25zIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkdSQU5UIEFMTCBQUklWSUxFR0VTIE9OIEFMTCBUQUJMRVMgSU4gU0NIRU1BIHN1cGFiYXNlX2Z1bmN0aW9ucyBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5HUkFOVCBBTEwgUFJJVklMRUdFUyBPTiBBTEwgU0VRVUVOQ0VTIElOIFNDSEVNQSBzdXBhYmFzZV9mdW5jdGlvbnMgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gU0VUIHNlYXJjaF9wYXRoID0gXCJzdXBhYmFzZV9mdW5jdGlvbnNcIjtcbkFMVEVSIHRhYmxlIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIubWlncmF0aW9ucyBPV05FUiBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW47XG5BTFRFUiB0YWJsZSBcInN1cGFiYXNlX2Z1bmN0aW9uc1wiLmhvb2tzIE9XTkVSIFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbjtcbkFMVEVSIGZ1bmN0aW9uIFwic3VwYWJhc2VfZnVuY3Rpb25zXCIuaHR0cF9yZXF1ZXN0KCkgT1dORVIgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluO1xuR1JBTlQgc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluIFRPIHBvc3RncmVzO1xuLS0gUmVtb3ZlIHVudXNlZCBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gcm9sZVxuRE9cbiQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19yb2xlc1xuICAgIFdIRVJFIHJvbG5hbWUgPSAnc3VwYWJhc2VfcGdfbmV0X2FkbWluJ1xuICApXG4gIFRIRU5cbiAgICBSRUFTU0lHTiBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW4gVE8gc3VwYWJhc2VfYWRtaW47XG4gICAgRFJPUCBPV05FRCBCWSBzdXBhYmFzZV9wZ19uZXRfYWRtaW47XG4gICAgRFJPUCBST0xFIHN1cGFiYXNlX3BnX25ldF9hZG1pbjtcbiAgRU5EIElGO1xuRU5EXG4kJDtcbi0tIHBnX25ldCBncmFudHMgd2hlbiBleHRlbnNpb24gaXMgYWxyZWFkeSBlbmFibGVkXG5ET1xuJCRcbkJFR0lOXG4gIElGIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V4dGVuc2lvblxuICAgIFdIRVJFIGV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG4tLSBFdmVudCB0cmlnZ2VyIGZvciBwZ19uZXRcbkNSRUFURSBPUiBSRVBMQUNFIEZVTkNUSU9OIGV4dGVuc2lvbnMuZ3JhbnRfcGdfbmV0X2FjY2VzcygpXG5SRVRVUk5TIGV2ZW50X3RyaWdnZXJcbkxBTkdVQUdFIHBscGdzcWxcbkFTICQkXG5CRUdJTlxuICBJRiBFWElTVFMgKFxuICAgIFNFTEVDVCAxXG4gICAgRlJPTSBwZ19ldmVudF90cmlnZ2VyX2RkbF9jb21tYW5kcygpIEFTIGV2XG4gICAgSk9JTiBwZ19leHRlbnNpb24gQVMgZXh0XG4gICAgT04gZXYub2JqaWQgPSBleHQub2lkXG4gICAgV0hFUkUgZXh0LmV4dG5hbWUgPSAncGdfbmV0J1xuICApXG4gIFRIRU5cbiAgICBHUkFOVCBVU0FHRSBPTiBTQ0hFTUEgbmV0IFRPIHN1cGFiYXNlX2Z1bmN0aW9uc19hZG1pbiwgcG9zdGdyZXMsIGFub24sIGF1dGhlbnRpY2F0ZWQsIHNlcnZpY2Vfcm9sZTtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgU0VDVVJJVFkgREVGSU5FUjtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFQ1VSSVRZIERFRklORVI7XG4gICAgQUxURVIgZnVuY3Rpb24gbmV0Lmh0dHBfZ2V0KHVybCB0ZXh0LCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBBTFRFUiBmdW5jdGlvbiBuZXQuaHR0cF9wb3N0KHVybCB0ZXh0LCBib2R5IGpzb25iLCBwYXJhbXMganNvbmIsIGhlYWRlcnMganNvbmIsIHRpbWVvdXRfbWlsbGlzZWNvbmRzIGludGVnZXIpIFNFVCBzZWFyY2hfcGF0aCA9IG5ldDtcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX2dldCh1cmwgdGV4dCwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBGUk9NIFBVQkxJQztcbiAgICBSRVZPS0UgQUxMIE9OIEZVTkNUSU9OIG5ldC5odHRwX3Bvc3QodXJsIHRleHQsIGJvZHkganNvbmIsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgRlJPTSBQVUJMSUM7XG4gICAgR1JBTlQgRVhFQ1VURSBPTiBGVU5DVElPTiBuZXQuaHR0cF9nZXQodXJsIHRleHQsIHBhcmFtcyBqc29uYiwgaGVhZGVycyBqc29uYiwgdGltZW91dF9taWxsaXNlY29uZHMgaW50ZWdlcikgVE8gc3VwYWJhc2VfZnVuY3Rpb25zX2FkbWluLCBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuICAgIEdSQU5UIEVYRUNVVEUgT04gRlVOQ1RJT04gbmV0Lmh0dHBfcG9zdCh1cmwgdGV4dCwgYm9keSBqc29uYiwgcGFyYW1zIGpzb25iLCBoZWFkZXJzIGpzb25iLCB0aW1lb3V0X21pbGxpc2Vjb25kcyBpbnRlZ2VyKSBUTyBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4sIHBvc3RncmVzLCBhbm9uLCBhdXRoZW50aWNhdGVkLCBzZXJ2aWNlX3JvbGU7XG4gIEVORCBJRjtcbkVORDtcbiQkO1xuQ09NTUVOVCBPTiBGVU5DVElPTiBleHRlbnNpb25zLmdyYW50X3BnX25ldF9hY2Nlc3MgSVMgJ0dyYW50cyBhY2Nlc3MgdG8gcGdfbmV0JztcbkRPXG4kJFxuQkVHSU5cbiAgSUYgTk9UIEVYSVNUUyAoXG4gICAgU0VMRUNUIDFcbiAgICBGUk9NIHBnX2V2ZW50X3RyaWdnZXJcbiAgICBXSEVSRSBldnRuYW1lID0gJ2lzc3VlX3BnX25ldF9hY2Nlc3MnXG4gICkgVEhFTlxuICAgIENSRUFURSBFVkVOVCBUUklHR0VSIGlzc3VlX3BnX25ldF9hY2Nlc3MgT04gZGRsX2NvbW1hbmRfZW5kIFdIRU4gVEFHIElOICgnQ1JFQVRFIEVYVEVOU0lPTicpXG4gICAgRVhFQ1VURSBQUk9DRURVUkUgZXh0ZW5zaW9ucy5ncmFudF9wZ19uZXRfYWNjZXNzKCk7XG4gIEVORCBJRjtcbkVORFxuJCQ7XG5JTlNFUlQgSU5UTyBzdXBhYmFzZV9mdW5jdGlvbnMubWlncmF0aW9ucyAodmVyc2lvbikgVkFMVUVTICgnMjAyMTA4MDkxODM0MjNfdXBkYXRlX2dyYW50cycpO1xuQUxURVIgZnVuY3Rpb24gc3VwYWJhc2VfZnVuY3Rpb25zLmh0dHBfcmVxdWVzdCgpIFNFQ1VSSVRZIERFRklORVI7XG5BTFRFUiBmdW5jdGlvbiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgU0VUIHNlYXJjaF9wYXRoID0gc3VwYWJhc2VfZnVuY3Rpb25zO1xuUkVWT0tFIEFMTCBPTiBGVU5DVElPTiBzdXBhYmFzZV9mdW5jdGlvbnMuaHR0cF9yZXF1ZXN0KCkgRlJPTSBQVUJMSUM7XG5HUkFOVCBFWEVDVVRFIE9OIEZVTkNUSU9OIHN1cGFiYXNlX2Z1bmN0aW9ucy5odHRwX3JlcXVlc3QoKSBUTyBwb3N0Z3JlcywgYW5vbiwgYXV0aGVudGljYXRlZCwgc2VydmljZV9yb2xlO1xuQ09NTUlUO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL3JvbGVzLnNxbAogICAgICAgIHRhcmdldDogL2RvY2tlci1lbnRyeXBvaW50LWluaXRkYi5kL2luaXQtc2NyaXB0cy85OS1yb2xlcy5zcWwKICAgICAgICBjb250ZW50OiAiLS0gTk9URTogY2hhbmdlIHRvIHlvdXIgb3duIHBhc3N3b3JkcyBmb3IgcHJvZHVjdGlvbiBlbnZpcm9ubWVudHNcbiBcXHNldCBwZ3Bhc3MgYGVjaG8gXCIkUE9TVEdSRVNfUEFTU1dPUkRcImBcblxuIEFMVEVSIFVTRVIgYXV0aGVudGljYXRvciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHBnYm91bmNlciBXSVRIIFBBU1NXT1JEIDoncGdwYXNzJztcbiBBTFRFUiBVU0VSIHN1cGFiYXNlX2F1dGhfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9mdW5jdGlvbnNfYWRtaW4gV0lUSCBQQVNTV09SRCA6J3BncGFzcyc7XG4gQUxURVIgVVNFUiBzdXBhYmFzZV9zdG9yYWdlX2FkbWluIFdJVEggUEFTU1dPUkQgOidwZ3Bhc3MnO1xuIgogICAgICAtCiAgICAgICAgdHlwZTogYmluZAogICAgICAgIHNvdXJjZTogLi92b2x1bWVzL2RiL2p3dC5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9pbml0LXNjcmlwdHMvOTktand0LnNxbAogICAgICAgIGNvbnRlbnQ6ICJcXHNldCBqd3Rfc2VjcmV0IGBlY2hvIFwiJEpXVF9TRUNSRVRcImBcblxcc2V0IGp3dF9leHAgYGVjaG8gXCIkSldUX0VYUFwiYFxuXFxzZXQgZGJfbmFtZSBgZWNobyBcIiR7UE9TVEdSRVNfREI6LXBvc3RncmVzfVwiYFxuXG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X3NlY3JldFwiIFRPIDonand0X3NlY3JldCc7XG5BTFRFUiBEQVRBQkFTRSA6ZGJfbmFtZSBTRVQgXCJhcHAuc2V0dGluZ3Muand0X2V4cFwiIFRPIDonand0X2V4cCc7XG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZGIvbG9ncy5zcWwKICAgICAgICB0YXJnZXQ6IC9kb2NrZXItZW50cnlwb2ludC1pbml0ZGIuZC9taWdyYXRpb25zLzk5LWxvZ3Muc3FsCiAgICAgICAgY29udGVudDogIlxcc2V0IHBndXNlciBgZWNobyBcIiRQT1NUR1JFU19VU0VSXCJgXG5cXGMgX3N1cGFiYXNlXG5jcmVhdGUgc2NoZW1hIGlmIG5vdCBleGlzdHMgX2FuYWx5dGljcztcbmFsdGVyIHNjaGVtYSBfYW5hbHl0aWNzIG93bmVyIHRvIDpwZ3VzZXI7XG5cXGMgcG9zdGdyZXNcbiIKICAgICAgLSAnc3VwYWJhc2UtZGItY29uZmlnOi9ldGMvcG9zdGdyZXNxbC1jdXN0b20nCiAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9sb2dmbGFyZToxLjMxLjInCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6NDAwMC9oZWFsdGgnCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIGludGVydmFsOiA1cwogICAgICByZXRyaWVzOiAxMAogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtIExPR0ZMQVJFX05PREVfSE9TVD0xMjcuMC4wLjEKICAgICAgLSBEQl9VU0VSTkFNRT1zdXBhYmFzZV9hZG1pbgogICAgICAtIERCX0RBVEFCQVNFPV9zdXBhYmFzZQogICAgICAtICdEQl9IT1NUTkFNRT0ke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn0nCiAgICAgIC0gJ0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSBEQl9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtICdMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRX0nCiAgICAgIC0gJ0xPR0ZMQVJFX1BSSVZBVEVfQUNDRVNTX1RPS0VOPSR7U0VSVklDRV9QQVNTV09SRF9MT0dGTEFSRVBSSVZBVEV9JwogICAgICAtIExPR0ZMQVJFX1NJTkdMRV9URU5BTlQ9dHJ1ZQogICAgICAtIExPR0ZMQVJFX1NVUEFCQVNFX01PREU9dHJ1ZQogICAgICAtICdQT1NUR1JFU19CQUNLRU5EX1VSTD1wb3N0Z3Jlc3FsOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gUE9TVEdSRVNfQkFDS0VORF9TQ0hFTUE9X2FuYWx5dGljcwogICAgICAtIExPR0ZMQVJFX0ZFQVRVUkVfRkxBR19PVkVSUklERT1tdWx0aWJhY2tlbmQ9dHJ1ZQogIHN1cGFiYXNlLXZlY3RvcjoKICAgIGltYWdlOiAndGltYmVyaW8vdmVjdG9yOjAuNTMuMC1hbHBpbmUnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovL3N1cGFiYXNlLXZlY3Rvcjo5MDAxL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvbG9ncy92ZWN0b3IueW1sCiAgICAgICAgdGFyZ2V0OiAvZXRjL3ZlY3Rvci92ZWN0b3IueW1sCiAgICAgICAgcmVhZF9vbmx5OiB0cnVlCiAgICAgICAgY29udGVudDogImFwaTpcbiAgZW5hYmxlZDogdHJ1ZVxuICBhZGRyZXNzOiAwLjAuMC4wOjkwMDFcblxuc291cmNlczpcbiAgZG9ja2VyX2hvc3Q6XG4gICAgdHlwZTogZG9ja2VyX2xvZ3NcbiAgICBleGNsdWRlX2NvbnRhaW5lcnM6XG4gICAgICAtIHN1cGFiYXNlLXZlY3RvclxuXG50cmFuc2Zvcm1zOlxuICBwcm9qZWN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIGRvY2tlcl9ob3N0XG4gICAgc291cmNlOiB8LVxuICAgICAgLnByb2plY3QgPSBcImRlZmF1bHRcIlxuICAgICAgLmV2ZW50X21lc3NhZ2UgPSBkZWwoLm1lc3NhZ2UpXG4gICAgICAuYXBwbmFtZSA9IGRlbCguY29udGFpbmVyX25hbWUpXG4gICAgICBkZWwoLmNvbnRhaW5lcl9jcmVhdGVkX2F0KVxuICAgICAgZGVsKC5jb250YWluZXJfaWQpXG4gICAgICBkZWwoLnNvdXJjZV90eXBlKVxuICAgICAgZGVsKC5zdHJlYW0pXG4gICAgICBkZWwoLmxhYmVsKVxuICAgICAgZGVsKC5pbWFnZSlcbiAgICAgIGRlbCguaG9zdClcbiAgICAgIGRlbCguc3RyZWFtKVxuICByb3V0ZXI6XG4gICAgdHlwZTogcm91dGVcbiAgICBpbnB1dHM6XG4gICAgICAtIHByb2plY3RfbG9nc1xuICAgIHJvdXRlOlxuICAgICAga29uZzogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWtvbmdcIiknXG4gICAgICBhdXRoOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2UtYXV0aFwiKSdcbiAgICAgIHJlc3Q6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1yZXN0XCIpJ1xuICAgICAgcmVhbHRpbWU6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJyZWFsdGltZS1kZXZcIiknXG4gICAgICBzdG9yYWdlOiAnY29udGFpbnMoc3RyaW5nISguYXBwbmFtZSksIFwic3VwYWJhc2Utc3RvcmFnZVwiKSdcbiAgICAgIGZ1bmN0aW9uczogJ2NvbnRhaW5zKHN0cmluZyEoLmFwcG5hbWUpLCBcInN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zXCIpJ1xuICAgICAgZGI6ICdjb250YWlucyhzdHJpbmchKC5hcHBuYW1lKSwgXCJzdXBhYmFzZS1kYlwiKSdcbiAgIyBJZ25vcmVzIG5vbiBuZ2lueCBlcnJvcnMgc2luY2UgdGhleSBhcmUgcmVsYXRlZCB3aXRoIGtvbmcgYm9vdGluZyB1cFxuICBrb25nX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5rb25nXG4gICAgc291cmNlOiB8LVxuICAgICAgcmVxLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiY29tYmluZWRcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcmVxLnRpbWVzdGFtcFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMucmVmZXJlciA9IHJlcS5yZWZlcmVyXG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy51c2VyX2FnZW50ID0gcmVxLmFnZW50XG4gICAgICAgICAgLm1ldGFkYXRhLnJlcXVlc3QuaGVhZGVycy5jZl9jb25uZWN0aW5nX2lwID0gcmVxLmNsaWVudFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IHJlcS5zdGF0dXNcbiAgICAgICAgICB1cmwsIHNwbGl0X2VyciA9IHNwbGl0KHJlcS5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBzcGxpdF9lcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIElnbm9yZXMgbm9uIG5naW54IGVycm9ycyBzaW5jZSB0aGV5IGFyZSByZWxhdGVkIHdpdGgga29uZyBib290aW5nIHVwXG4gIGtvbmdfZXJyOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIua29uZ1xuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IFwiR0VUXCJcbiAgICAgIC5tZXRhZGF0YS5yZXNwb25zZS5zdGF0dXNfY29kZSA9IDIwMFxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9uZ2lueF9sb2coLmV2ZW50X21lc3NhZ2UsIFwiZXJyb3JcIilcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAudGltZXN0YW1wID0gcGFyc2VkLnRpbWVzdGFtcFxuICAgICAgICAgIC5zZXZlcml0eSA9IHBhcnNlZC5zZXZlcml0eVxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lmhvc3QgPSBwYXJzZWQuaG9zdFxuICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LmhlYWRlcnMuY2ZfY29ubmVjdGluZ19pcCA9IHBhcnNlZC5jbGllbnRcbiAgICAgICAgICB1cmwsIGVyciA9IHNwbGl0KHBhcnNlZC5yZXF1ZXN0LCBcIiBcIilcbiAgICAgICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0Lm1ldGhvZCA9IHVybFswXVxuICAgICAgICAgICAgICAubWV0YWRhdGEucmVxdWVzdC5wYXRoID0gdXJsWzFdXG4gICAgICAgICAgICAgIC5tZXRhZGF0YS5yZXF1ZXN0LnByb3RvY29sID0gdXJsWzJdXG4gICAgICAgICAgfVxuICAgICAgfVxuICAgICAgaWYgZXJyICE9IG51bGwge1xuICAgICAgICBhYm9ydFxuICAgICAgfVxuICAjIEdvdHJ1ZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIGpzb24gc3RyaW5ncyB3aGljaCBmcm9udGVuZCBwYXJzZXMgZGlyZWN0bHkuIEJ1dCB3ZSBrZWVwIG1ldGFkYXRhIGZvciBjb25zaXN0ZW5jeS5cbiAgYXV0aF9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuYXV0aFxuICAgIHNvdXJjZTogfC1cbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEgPSBtZXJnZSEoLm1ldGFkYXRhLCBwYXJzZWQpXG4gICAgICB9XG4gICMgUG9zdGdSRVNUIGxvZ3MgYXJlIHN0cnVjdHVyZWQgc28gd2Ugc2VwYXJhdGUgdGltZXN0YW1wIGZyb20gbWVzc2FnZSB1c2luZyByZWdleFxuICByZXN0X2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5yZXN0XG4gICAgc291cmNlOiB8LVxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcideKD9QPHRpbWU+LiopOiAoP1A8bXNnPi4qKSQnKVxuICAgICAgaWYgZXJyID09IG51bGwge1xuICAgICAgICAgIC5ldmVudF9tZXNzYWdlID0gcGFyc2VkLm1zZ1xuICAgICAgICAgIC50aW1lc3RhbXAgPSBwYXJzZV90aW1lc3RhbXAhKHZhbHVlOiBwYXJzZWQudGltZSwgZm9ybWF0OiBcIiVkLyViLyVZOiVIOiVNOiVTICV6XCIpXG4gICAgICAgICAgLm1ldGFkYXRhLmhvc3QgPSAucHJvamVjdFxuICAgICAgfVxuICAjIEZpbHRlciBvdXQgaGVhbHRoY2hlY2sgbG9ncyBmcm9tIFJlYWx0aW1lXG4gIHJlYWx0aW1lX2xvZ3NfZmlsdGVyZWQ6XG4gICAgdHlwZTogZmlsdGVyXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIucmVhbHRpbWVcbiAgICBjb25kaXRpb246ICchY29udGFpbnMoc3RyaW5nISguZXZlbnRfbWVzc2FnZSksIFwiL2hlYWx0aFwiKSdcbiAgIyBSZWFsdGltZSBsb2dzIGFyZSBzdHJ1Y3R1cmVkIHNvIHdlIHBhcnNlIHRoZSBzZXZlcml0eSBsZXZlbCB1c2luZyByZWdleCAoaWdub3JlIHRpbWUgYmVjYXVzZSBpdCBoYXMgbm8gZGF0ZSlcbiAgcmVhbHRpbWVfbG9nczpcbiAgICB0eXBlOiByZW1hcFxuICAgIGlucHV0czpcbiAgICAgIC0gcmVhbHRpbWVfbG9nc19maWx0ZXJlZFxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLmV4dGVybmFsX2lkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfcmVnZXgoLmV2ZW50X21lc3NhZ2UsIHInXig/UDx0aW1lPlxcZCs6XFxkKzpcXGQrXFwuXFxkKykgXFxbKD9QPGxldmVsPlxcdyspXFxdICg/UDxtc2c+LiopJCcpXG4gICAgICBpZiBlcnIgPT0gbnVsbCB7XG4gICAgICAgICAgLmV2ZW50X21lc3NhZ2UgPSBwYXJzZWQubXNnXG4gICAgICAgICAgLm1ldGFkYXRhLmxldmVsID0gcGFyc2VkLmxldmVsXG4gICAgICB9XG4gICMgU3RvcmFnZSBsb2dzIG1heSBjb250YWluIGpzb24gb2JqZWN0cyBzbyB3ZSBwYXJzZSB0aGVtIGZvciBjb21wbGV0ZW5lc3NcbiAgc3RvcmFnZV9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuc3RvcmFnZVxuICAgIHNvdXJjZTogfC1cbiAgICAgIC5tZXRhZGF0YS5wcm9qZWN0ID0gZGVsKC5wcm9qZWN0KVxuICAgICAgLm1ldGFkYXRhLnRlbmFudElkID0gLm1ldGFkYXRhLnByb2plY3RcbiAgICAgIHBhcnNlZCwgZXJyID0gcGFyc2VfanNvbiguZXZlbnRfbWVzc2FnZSlcbiAgICAgIGlmIGVyciA9PSBudWxsIHtcbiAgICAgICAgICAuZXZlbnRfbWVzc2FnZSA9IHBhcnNlZC5tc2dcbiAgICAgICAgICAubWV0YWRhdGEubGV2ZWwgPSBwYXJzZWQubGV2ZWxcbiAgICAgICAgICAubWV0YWRhdGEudGltZXN0YW1wID0gcGFyc2VkLnRpbWVcbiAgICAgICAgICAubWV0YWRhdGEuY29udGV4dFswXS5ob3N0ID0gcGFyc2VkLmhvc3RuYW1lXG4gICAgICAgICAgLm1ldGFkYXRhLmNvbnRleHRbMF0ucGlkID0gcGFyc2VkLnBpZFxuICAgICAgfVxuICAjIEZ1bmN0aW9uIGxvZ3MgYXJlIHVuc3RydWN0dXJlZCBtZXNzYWdlcyBvbiBzdGRlcnJcbiAgZnVuY3Rpb25zX2xvZ3M6XG4gICAgdHlwZTogcmVtYXBcbiAgICBpbnB1dHM6XG4gICAgICAtIHJvdXRlci5mdW5jdGlvbnNcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEucHJvamVjdF9yZWYgPSBkZWwoLnByb2plY3QpXG4gICMgUG9zdGdyZXMgbG9ncyBzb21lIG1lc3NhZ2VzIHRvIHN0ZGVyciB3aGljaCB3ZSBtYXAgdG8gd2FybmluZyBzZXZlcml0eSBsZXZlbFxuICBkYl9sb2dzOlxuICAgIHR5cGU6IHJlbWFwXG4gICAgaW5wdXRzOlxuICAgICAgLSByb3V0ZXIuZGJcbiAgICBzb3VyY2U6IHwtXG4gICAgICAubWV0YWRhdGEuaG9zdCA9IFwiZGItZGVmYXVsdFwiXG4gICAgICAubWV0YWRhdGEucGFyc2VkLnRpbWVzdGFtcCA9IC50aW1lc3RhbXBcblxuICAgICAgcGFyc2VkLCBlcnIgPSBwYXJzZV9yZWdleCguZXZlbnRfbWVzc2FnZSwgcicuKig/UDxsZXZlbD5JTkZPfE5PVElDRXxXQVJOSU5HfEVSUk9SfExPR3xGQVRBTHxQQU5JQz8pOi4qJywgbnVtZXJpY19ncm91cHM6IHRydWUpXG5cbiAgICAgIGlmIGVyciAhPSBudWxsIHx8IHBhcnNlZCA9PSBudWxsIHtcbiAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwiaW5mb1wiXG4gICAgICB9XG4gICAgICBpZiBwYXJzZWQubGV2ZWwgIT0gbnVsbCB7XG4gICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IHBhcnNlZC5sZXZlbFxuICAgICAgfVxuICAgICAgaWYgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9PSBcImluZm9cIiB7XG4gICAgICAgICAgLm1ldGFkYXRhLnBhcnNlZC5lcnJvcl9zZXZlcml0eSA9IFwibG9nXCJcbiAgICAgIH1cbiAgICAgIC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkgPSB1cGNhc2UhKC5tZXRhZGF0YS5wYXJzZWQuZXJyb3Jfc2V2ZXJpdHkpXG5cbnNpbmtzOlxuICBsb2dmbGFyZV9hdXRoOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gYXV0aF9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1nb3RydWUubG9ncy5wcm9kJ1xuICBsb2dmbGFyZV9yZWFsdGltZTpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlYWx0aW1lX2xvZ3NcbiAgICBlbmNvZGluZzpcbiAgICAgIGNvZGVjOiAnanNvbidcbiAgICBtZXRob2Q6ICdwb3N0J1xuICAgIHJlcXVlc3Q6XG4gICAgICByZXRyeV9tYXhfZHVyYXRpb25fc2VjczogMzBcbiAgICAgIHJldHJ5X2luaXRpYWxfYmFja29mZl9zZWNzOiAxXG4gICAgICBoZWFkZXJzOlxuICAgICAgICB4LWFwaS1rZXk6ICcke0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4/TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTiBpcyByZXF1aXJlZH0nXG4gICAgdXJpOiAnaHR0cDovL3N1cGFiYXNlLWFuYWx5dGljczo0MDAwL2FwaS9sb2dzP3NvdXJjZV9uYW1lPXJlYWx0aW1lLmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfcmVzdDpcbiAgICB0eXBlOiAnaHR0cCdcbiAgICBpbnB1dHM6XG4gICAgICAtIHJlc3RfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9cG9zdGdSRVNULmxvZ3MucHJvZCdcbiAgbG9nZmxhcmVfZGI6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBkYl9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1wb3N0Z3Jlcy5sb2dzJ1xuICBsb2dmbGFyZV9mdW5jdGlvbnM6XG4gICAgdHlwZTogJ2h0dHAnXG4gICAgaW5wdXRzOlxuICAgICAgLSBmdW5jdGlvbnNfbG9nc1xuICAgIGVuY29kaW5nOlxuICAgICAgY29kZWM6ICdqc29uJ1xuICAgIG1ldGhvZDogJ3Bvc3QnXG4gICAgcmVxdWVzdDpcbiAgICAgIHJldHJ5X21heF9kdXJhdGlvbl9zZWNzOiAzMFxuICAgICAgcmV0cnlfaW5pdGlhbF9iYWNrb2ZmX3NlY3M6IDFcbiAgICAgIGhlYWRlcnM6XG4gICAgICAgIHgtYXBpLWtleTogJyR7TE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj9MT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOIGlzIHJlcXVpcmVkfSdcbiAgICB1cmk6ICdodHRwOi8vc3VwYWJhc2UtYW5hbHl0aWNzOjQwMDAvYXBpL2xvZ3M/c291cmNlX25hbWU9ZGVuby1yZWxheS1sb2dzJ1xuICBsb2dmbGFyZV9zdG9yYWdlOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0gc3RvcmFnZV9sb2dzXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1zdG9yYWdlLmxvZ3MucHJvZC4yJ1xuICBsb2dmbGFyZV9rb25nOlxuICAgIHR5cGU6ICdodHRwJ1xuICAgIGlucHV0czpcbiAgICAgIC0ga29uZ19sb2dzXG4gICAgICAtIGtvbmdfZXJyXG4gICAgZW5jb2Rpbmc6XG4gICAgICBjb2RlYzogJ2pzb24nXG4gICAgbWV0aG9kOiAncG9zdCdcbiAgICByZXF1ZXN0OlxuICAgICAgcmV0cnlfbWF4X2R1cmF0aW9uX3NlY3M6IDMwXG4gICAgICByZXRyeV9pbml0aWFsX2JhY2tvZmZfc2VjczogMVxuICAgICAgaGVhZGVyczpcbiAgICAgICAgeC1hcGkta2V5OiAnJHtMT0dGTEFSRV9QVUJMSUNfQUNDRVNTX1RPS0VOP0xPR0ZMQVJFX1BVQkxJQ19BQ0NFU1NfVE9LRU4gaXMgcmVxdWlyZWR9J1xuICAgIHVyaTogJ2h0dHA6Ly9zdXBhYmFzZS1hbmFseXRpY3M6NDAwMC9hcGkvbG9ncz9zb3VyY2VfbmFtZT1jbG91ZGZsYXJlLmxvZ3MucHJvZCdcbiIKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2s6cm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTE9HRkxBUkVfUFVCTElDX0FDQ0VTU19UT0tFTj0ke1NFUlZJQ0VfUEFTU1dPUkRfTE9HRkxBUkV9JwogICAgY29tbWFuZDoKICAgICAgLSAnLS1jb25maWcnCiAgICAgIC0gL2V0Yy92ZWN0b3IvdmVjdG9yLnltbAogIHN1cGFiYXNlLXJlc3Q6CiAgICBpbWFnZTogJ3Bvc3RncmVzdC9wb3N0Z3Jlc3Q6djE0LjYnCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQR1JTVF9EQl9VUkk9cG9zdGdyZXM6Ly9hdXRoZW50aWNhdG9yOiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AJHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9OiR7UE9TVEdSRVNfUE9SVDotNTQzMn0vJHtQT1NUR1JFU19EQjotcG9zdGdyZXN9JwogICAgICAtICdQR1JTVF9EQl9TQ0hFTUFTPSR7UEdSU1RfREJfU0NIRU1BUzotcHVibGljLHN0b3JhZ2UsZ3JhcGhxbF9wdWJsaWN9JwogICAgICAtICdQR1JTVF9EQl9NQVhfUk9XUz0ke1BHUlNUX0RCX01BWF9ST1dTOi0xMDAwfScKICAgICAgLSAnUEdSU1RfREJfRVhUUkFfU0VBUkNIX1BBVEg9JHtQR1JTVF9EQl9FWFRSQV9TRUFSQ0hfUEFUSDotcHVibGljfScKICAgICAgLSBQR1JTVF9EQl9BTk9OX1JPTEU9YW5vbgogICAgICAtICdQR1JTVF9KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFBHUlNUX0RCX1VTRV9MRUdBQ1lfR1VDUz1mYWxzZQogICAgICAtICdQR1JTVF9BUFBfU0VUVElOR1NfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnUEdSU1RfQVBQX1NFVFRJTkdTX0pXVF9FWFA9JHtKV1RfRVhQSVJZOi0zNjAwfScKICAgIGNvbW1hbmQ6IHBvc3RncmVzdAogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgc3VwYWJhc2UtYXV0aDoKICAgIGltYWdlOiAnc3VwYWJhc2UvZ290cnVlOnYyLjE4Ni4wJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo5OTk5L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIEdPVFJVRV9BUElfSE9TVD0wLjAuMC4wCiAgICAgIC0gR09UUlVFX0FQSV9QT1JUPTk5OTkKICAgICAgLSAnQVBJX0VYVEVSTkFMX1VSTD0ke0FQSV9FWFRFUk5BTF9VUkw6LWh0dHA6Ly9zdXBhYmFzZS1rb25nOjgwMDB9JwogICAgICAtIEdPVFJVRV9EQl9EUklWRVI9cG9zdGdyZXMKICAgICAgLSAnR09UUlVFX0RCX0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovL3N1cGFiYXNlX2F1dGhfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0dPVFJVRV9TSVRFX1VSTD0ke0dPVFJVRV9TSVRFX1VSTDotJHtTRVJWSUNFX0ZRRE5fU1VQQUJBU0VLT05HfX0nCiAgICAgIC0gJ0dPVFJVRV9VUklfQUxMT1dfTElTVD0ke0FERElUSU9OQUxfUkVESVJFQ1RfVVJMU30nCiAgICAgIC0gJ0dPVFJVRV9ESVNBQkxFX1NJR05VUD0ke0RJU0FCTEVfU0lHTlVQOi1mYWxzZX0nCiAgICAgIC0gR09UUlVFX0pXVF9BRE1JTl9ST0xFUz1zZXJ2aWNlX3JvbGUKICAgICAgLSBHT1RSVUVfSldUX0FVRD1hdXRoZW50aWNhdGVkCiAgICAgIC0gR09UUlVFX0pXVF9ERUZBVUxUX0dST1VQX05BTUU9YXV0aGVudGljYXRlZAogICAgICAtICdHT1RSVUVfSldUX0VYUD0ke0pXVF9FWFBJUlk6LTM2MDB9JwogICAgICAtICdHT1RSVUVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0VNQUlMX0VOQUJMRUQ9JHtFTkFCTEVfRU1BSUxfU0lHTlVQOi10cnVlfScKICAgICAgLSAnR09UUlVFX0VYVEVSTkFMX0FOT05ZTU9VU19VU0VSU19FTkFCTEVEPSR7RU5BQkxFX0FOT05ZTU9VU19VU0VSUzotZmFsc2V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX0FVVE9DT05GSVJNPSR7RU5BQkxFX0VNQUlMX0FVVE9DT05GSVJNOi1mYWxzZX0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0FETUlOX0VNQUlMPSR7U01UUF9BRE1JTl9FTUFJTH0nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdHT1RSVUVfU01UUF9QT1JUPSR7U01UUF9QT1JUOi01ODd9JwogICAgICAtICdHT1RSVUVfU01UUF9VU0VSPSR7U01UUF9VU0VSfScKICAgICAgLSAnR09UUlVFX1NNVFBfUEFTUz0ke1NNVFBfUEFTU30nCiAgICAgIC0gJ0dPVFJVRV9TTVRQX1NFTkRFUl9OQU1FPSR7U01UUF9TRU5ERVJfTkFNRX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVVJMUEFUSFNfSU5WSVRFPSR7TUFJTEVSX1VSTFBBVEhTX0lOVklURTotL2F1dGgvdjEvdmVyaWZ5fScKICAgICAgLSAnR09UUlVFX01BSUxFUl9VUkxQQVRIU19DT05GSVJNQVRJT049JHtNQUlMRVJfVVJMUEFUSFNfQ09ORklSTUFUSU9OOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZPSR7TUFJTEVSX1VSTFBBVEhTX1JFQ09WRVJZOi0vYXV0aC92MS92ZXJpZnl9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1VSTFBBVEhTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9VUkxQQVRIU19FTUFJTF9DSEFOR0U6LS9hdXRoL3YxL3ZlcmlmeX0nCiAgICAgIC0gJ0dPVFJVRV9NQUlMRVJfVEVNUExBVEVTX0lOVklURT0ke01BSUxFUl9URU1QTEFURVNfSU5WSVRFfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfQ09ORklSTUFUSU9OPSR7TUFJTEVSX1RFTVBMQVRFU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19SRUNPVkVSWT0ke01BSUxFUl9URU1QTEFURVNfUkVDT1ZFUll9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LPSR7TUFJTEVSX1RFTVBMQVRFU19NQUdJQ19MSU5LfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9URU1QTEFURVNfRU1BSUxfQ0hBTkdFPSR7TUFJTEVSX1RFTVBMQVRFU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0NPTkZJUk1BVElPTj0ke01BSUxFUl9TVUJKRUNUU19DT05GSVJNQVRJT059JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZPSR7TUFJTEVSX1NVQkpFQ1RTX1JFQ09WRVJZfScKICAgICAgLSAnR09UUlVFX01BSUxFUl9TVUJKRUNUU19NQUdJQ19MSU5LPSR7TUFJTEVSX1NVQkpFQ1RTX01BR0lDX0xJTkt9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0VNQUlMX0NIQU5HRT0ke01BSUxFUl9TVUJKRUNUU19FTUFJTF9DSEFOR0V9JwogICAgICAtICdHT1RSVUVfTUFJTEVSX1NVQkpFQ1RTX0lOVklURT0ke01BSUxFUl9TVUJKRUNUU19JTlZJVEV9JwogICAgICAtICdHT1RSVUVfRVhURVJOQUxfUEhPTkVfRU5BQkxFRD0ke0VOQUJMRV9QSE9ORV9TSUdOVVA6LXRydWV9JwogICAgICAtICdHT1RSVUVfU01TX0FVVE9DT05GSVJNPSR7RU5BQkxFX1BIT05FX0FVVE9DT05GSVJNOi10cnVlfScKICByZWFsdGltZS1kZXY6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3JlYWx0aW1lOnYyLjc2LjUnCiAgICBjb250YWluZXJfbmFtZTogcmVhbHRpbWUtZGV2LnN1cGFiYXNlLXJlYWx0aW1lCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLS1oZWFkJwogICAgICAgIC0gJy1vJwogICAgICAgIC0gL2Rldi9udWxsCiAgICAgICAgLSAnLUgnCiAgICAgICAgLSAnQXV0aG9yaXphdGlvbjogQmVhcmVyICR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL3RlbmFudHMvcmVhbHRpbWUtZGV2L2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAxMHMKICAgIGVudmlyb25tZW50OgogICAgICAtIFBPUlQ9NDAwMAogICAgICAtICdEQl9IT1NUPSR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifScKICAgICAgLSAnREJfUE9SVD0ke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9JwogICAgICAtIERCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnREJfTkFNRT0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ0RCX0FGVEVSX0NPTk5FQ1RfUVVFUlk9U0VUIHNlYXJjaF9wYXRoIFRPIF9yZWFsdGltZScKICAgICAgLSBEQl9FTkNfS0VZPXN1cGFiYXNlcmVhbHRpbWUKICAgICAgLSAnQVBJX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfUkVBTFRJTUV9JwogICAgICAtICdNRVRSSUNTX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ0VSTF9BRkxBR1M9LXByb3RvX2Rpc3QgaW5ldF90Y3AnCiAgICAgIC0gIkROU19OT0RFUz0nJyIKICAgICAgLSBSTElNSVRfTk9GSUxFPTEwMDAwCiAgICAgIC0gQVBQX05BTUU9cmVhbHRpbWUKICAgICAgLSBTRUVEX1NFTEZfSE9TVD10cnVlCiAgICAgIC0gTE9HX0xFVkVMPWVycm9yCiAgICAgIC0gUlVOX0pBTklUT1I9dHJ1ZQogICAgICAtIEpBTklUT1JfSU5URVJWQUw9NjAwMDAKICAgICAgLSBESVNBQkxFX0hFQUxUSENIRUNLX0xPR0dJTkc9dHJ1ZQogICAgY29tbWFuZDogInNoIC1jIFwiL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9yZWFsdGltZSBldmFsICdSZWFsdGltZS5SZWxlYXNlLnNlZWRzKFJlYWx0aW1lLlJlcG8pJyAmJiAvYXBwL2Jpbi9zZXJ2ZXJcIlxuIgogIHN1cGFiYXNlLW1pbmlvOgogICAgaW1hZ2U6ICdwZ3N0eS9zaWxvOlJFTEVBU0UuMjAyNi0xMC0xNVQwMC0wMC0wMFonCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiIC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG1jCiAgICAgICAgLSByZWFkeQogICAgICAgIC0gbG9jYWwKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL3N0b3JhZ2U6L2RhdGEnCiAgbWluaW8tY3JlYXRlYnVja2V0OgogICAgaW1hZ2U6ICdwZ3N0eS9zaWxvOlJFTEVBU0UuMjAyNi0xMC0xNVQwMC0wMC0wMFonCiAgICByZXN0YXJ0OiAnbm8nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtbWluaW86CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gL2VudHJ5cG9pbnQuc2gKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL2VudHJ5cG9pbnQuc2gKICAgICAgICB0YXJnZXQ6IC9lbnRyeXBvaW50LnNoCiAgICAgICAgY29udGVudDogIiMhL2Jpbi9zaFxuL3Vzci9iaW4vbWMgYWxpYXMgc2V0IHN1cGFiYXNlLW1pbmlvIGh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwICR7TUlOSU9fUk9PVF9VU0VSfSAke01JTklPX1JPT1RfUEFTU1dPUkR9O1xuL3Vzci9iaW4vbWMgbWIgLS1pZ25vcmUtZXhpc3Rpbmcgc3VwYWJhc2UtbWluaW8vc3R1YjtcbmV4aXQgMFxuIgogIHN1cGFiYXNlLXN0b3JhZ2U6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N0b3JhZ2UtYXBpOnYxLjQ0LjInCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1yZXN0OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICAgIGltZ3Byb3h5OgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy0tbm8tdmVyYm9zZScKICAgICAgICAtICctLXRyaWVzPTEnCiAgICAgICAgLSAnLS1zcGlkZXInCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo1MDAwL3N0YXR1cycKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDMKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZFUl9QT1JUPTUwMDAKICAgICAgLSBTRVJWRVJfUkVHSU9OPWxvY2FsCiAgICAgIC0gTVVMVElfVEVOQU5UPWZhbHNlCiAgICAgIC0gJ0FVVEhfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vc3VwYWJhc2Vfc3RvcmFnZV9hZG1pbjoke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QCR7UE9TVEdSRVNfSE9TVE5BTUU6LXN1cGFiYXNlLWRifToke1BPU1RHUkVTX1BPUlQ6LTU0MzJ9LyR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBEQl9JTlNUQUxMX1JPTEVTPWZhbHNlCiAgICAgIC0gU1RPUkFHRV9CQUNLRU5EPXMzCiAgICAgIC0gU1RPUkFHRV9TM19CVUNLRVQ9c3R1YgogICAgICAtICdTVE9SQUdFX1MzX0VORFBPSU5UPWh0dHA6Ly9zdXBhYmFzZS1taW5pbzo5MDAwJwogICAgICAtIFNUT1JBR0VfUzNfRk9SQ0VfUEFUSF9TVFlMRT10cnVlCiAgICAgIC0gU1RPUkFHRV9TM19SRUdJT049dXMtZWFzdC0xCiAgICAgIC0gJ0FXU19BQ0NFU1NfS0VZX0lEPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnQVdTX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gVVBMT0FEX0ZJTEVfU0laRV9MSU1JVD01MjQyODgwMDAKICAgICAgLSBVUExPQURfRklMRV9TSVpFX0xJTUlUX1NUQU5EQVJEPTUyNDI4ODAwMAogICAgICAtIFVQTE9BRF9TSUdORURfVVJMX0VYUElSQVRJT05fVElNRT0xMjAKICAgICAgLSBUVVNfVVJMX1BBVEg9L3VwbG9hZC9yZXN1bWFibGUKICAgICAgLSBUVVNfTUFYX1NJWkU9MzYwMDAwMAogICAgICAtIEVOQUJMRV9JTUFHRV9UUkFOU0ZPUk1BVElPTj10cnVlCiAgICAgIC0gJ0lNR1BST1hZX1VSTD1odHRwOi8vaW1ncHJveHk6ODA4MCcKICAgICAgLSBJTUdQUk9YWV9SRVFVRVNUX1RJTUVPVVQ9MTUKICAgICAgLSBEQVRBQkFTRV9TRUFSQ0hfUEFUSD1zdG9yYWdlCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIFJFUVVFU1RfQUxMT1dfWF9GT1JXQVJERURfUEFUSD10cnVlCiAgICAgIC0gJ0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU0VSVklDRV9LRVk9JHtTRVJWSUNFX1NVUEFCQVNFU0VSVklDRV9LRVl9JwogICAgICAtICdQT1NUR1JFU1RfVVJMPWh0dHA6Ly9zdXBhYmFzZS1yZXN0OjMwMDAnCiAgICAgIC0gJ1BHUlNUX0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NUT1JBR0VfUFVCTElDX1VSTD0ke1NFUlZJQ0VfRlFETl9TVVBBQkFTRUtPTkd9JwogICAgICAtICdURU5BTlRfSUQ9JHtTVE9SQUdFX1RFTkFOVF9JRDotc3RvcmFnZS1zaW5nbGUtdGVuYW50fScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgaW1ncHJveHk6CiAgICBpbWFnZTogJ2RhcnRoc2ltL2ltZ3Byb3h5OnYzLjMwLjEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaW1ncHJveHkKICAgICAgICAtIGhlYWx0aAogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0lNR1BST1hZX0JJTkQ9OjgwODAnCiAgICAgIC0gSU1HUFJPWFlfTE9DQUxfRklMRVNZU1RFTV9ST09UPS8KICAgICAgLSBJTUdQUk9YWV9VU0VfRVRBRz10cnVlCiAgICAgIC0gJ0lNR1BST1hZX0FVVE9fV0VCUD0ke0lNR1BST1hZX0FVVE9fV0VCUDotdHJ1ZX0nCiAgICAgIC0gSU1HUFJPWFlfTUFYX1NSQ19SRVNPTFVUSU9OPTE2LjgKICAgIHZvbHVtZXM6CiAgICAgIC0gJy4vdm9sdW1lcy9zdG9yYWdlOi92YXIvbGliL3N0b3JhZ2UnCiAgc3VwYWJhc2UtbWV0YToKICAgIGltYWdlOiAnc3VwYWJhc2UvcG9zdGdyZXMtbWV0YTp2MC45NS4yJwogICAgZGVwZW5kc19vbjoKICAgICAgc3VwYWJhc2UtZGI6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgc3VwYWJhc2UtYW5hbHl0aWNzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBQR19NRVRBX1BPUlQ9ODA4MAogICAgICAtICdQR19NRVRBX0RCX0hPU1Q9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQR19NRVRBX0RCX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUEdfTUVUQV9EQl9OQU1FPSR7UE9TVEdSRVNfREI6LXBvc3RncmVzfScKICAgICAgLSBQR19NRVRBX0RCX1VTRVI9c3VwYWJhc2VfYWRtaW4KICAgICAgLSAnUEdfTUVUQV9EQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdDUllQVE9fS0VZPSR7U0VSVklDRV9QQVNTV09SRF9QR01FVEFDUllQVE99JwogIHN1cGFiYXNlLWVkZ2UtZnVuY3Rpb25zOgogICAgaW1hZ2U6ICdzdXBhYmFzZS9lZGdlLXJ1bnRpbWU6djEuNzEuMicKICAgIGRlcGVuZHNfb246CiAgICAgIHN1cGFiYXNlLWFuYWx5dGljczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGVjaG8KICAgICAgICAtICdFZGdlIEZ1bmN0aW9ucyBpcyBoZWFsdGh5JwogICAgICB0aW1lb3V0OiA1cwogICAgICBpbnRlcnZhbDogNXMKICAgICAgcmV0cmllczogMwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0pXVF9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEX0pXVH0nCiAgICAgIC0gJ1NVUEFCQVNFX1VSTD1odHRwOi8vc3VwYWJhc2Uta29uZzo4MDAwJwogICAgICAtICdTVVBBQkFTRV9QVUJMSUNfVVJMPSR7U0VSVklDRV9GUUROX1NVUEFCQVNFS09OR30nCiAgICAgIC0gJ1NVUEFCQVNFX0FOT05fS0VZPSR7U0VSVklDRV9TVVBBQkFTRUFOT05fS0VZfScKICAgICAgLSAnU1VQQUJBU0VfU0VSVklDRV9ST0xFX0tFWT0ke1NFUlZJQ0VfU1VQQUJBU0VTRVJWSUNFX0tFWX0nCiAgICAgIC0gJ1NVUEFCQVNFX0RCX1VSTD1wb3N0Z3Jlc3FsOi8vcG9zdGdyZXM6JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS8ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1ZFUklGWV9KV1Q9JHtGVU5DVElPTlNfVkVSSUZZX0pXVDotZmFsc2V9JwogICAgdm9sdW1lczoKICAgICAgLSAnLi92b2x1bWVzL2Z1bmN0aW9uczovaG9tZS9kZW5vL2Z1bmN0aW9ucycKICAgICAgLSAnZGVuby1jYWNoZTovcm9vdC8uY2FjaGUvZGVubycKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIHRhcmdldDogL2hvbWUvZGVuby9mdW5jdGlvbnMvbWFpbi9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICJpbXBvcnQgKiBhcyBqb3NlIGZyb20gJ2h0dHBzOi8vZGVuby5sYW5kL3gvam9zZUB2NC4xNC40L2luZGV4LnRzJ1xuXG5jb25zb2xlLmxvZygnbWFpbiBmdW5jdGlvbiBzdGFydGVkJylcblxuY29uc3QgSldUX1NFQ1JFVCA9IERlbm8uZW52LmdldCgnSldUX1NFQ1JFVCcpXG5jb25zdCBTVVBBQkFTRV9VUkwgPSBEZW5vLmVudi5nZXQoJ1NVUEFCQVNFX1VSTCcpXG5jb25zdCBWRVJJRllfSldUID0gRGVuby5lbnYuZ2V0KCdWRVJJRllfSldUJykgPT09ICd0cnVlJ1xuXG4vLyBDcmVhdGUgSldLUyBmb3IgRVMyNTYvUlMyNTYgdG9rZW5zIChuZXdlciB0b2tlbnMpXG5sZXQgU1VQQUJBU0VfSldUX0tFWVM6IFJldHVyblR5cGU8dHlwZW9mIGpvc2UuY3JlYXRlUmVtb3RlSldLU2V0PiB8IG51bGwgPSBudWxsXG5pZiAoU1VQQUJBU0VfVVJMKSB7XG4gIHRyeSB7XG4gICAgU1VQQUJBU0VfSldUX0tFWVMgPSBqb3NlLmNyZWF0ZVJlbW90ZUpXS1NldChcbiAgICAgIG5ldyBVUkwoJy9hdXRoL3YxLy53ZWxsLWtub3duL2p3a3MuanNvbicsIFNVUEFCQVNFX1VSTClcbiAgICApXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdGYWlsZWQgdG8gZmV0Y2ggSldLUyBmcm9tIFNVUEFCQVNFX1VSTDonLCBlKVxuICB9XG59XG5cbmZ1bmN0aW9uIGdldEF1dGhUb2tlbihyZXE6IFJlcXVlc3QpIHtcbiAgY29uc3QgYXV0aEhlYWRlciA9IHJlcS5oZWFkZXJzLmdldCgnYXV0aG9yaXphdGlvbicpXG4gIGlmICghYXV0aEhlYWRlcikge1xuICAgIHRocm93IG5ldyBFcnJvcignTWlzc2luZyBhdXRob3JpemF0aW9uIGhlYWRlcicpXG4gIH1cbiAgY29uc3QgW2JlYXJlciwgdG9rZW5dID0gYXV0aEhlYWRlci5zcGxpdCgnICcpXG4gIGlmIChiZWFyZXIgIT09ICdCZWFyZXInKSB7XG4gICAgdGhyb3cgbmV3IEVycm9yKGBBdXRoIGhlYWRlciBpcyBub3QgJ0JlYXJlciB7dG9rZW59J2ApXG4gIH1cbiAgcmV0dXJuIHRva2VuXG59XG5cbmFzeW5jIGZ1bmN0aW9uIGlzVmFsaWRMZWdhY3lKV1Qoand0OiBzdHJpbmcpOiBQcm9taXNlPGJvb2xlYW4+IHtcbiAgaWYgKCFKV1RfU0VDUkVUKSB7XG4gICAgY29uc29sZS5lcnJvcignSldUX1NFQ1JFVCBub3QgYXZhaWxhYmxlIGZvciBIUzI1NiB0b2tlbiB2ZXJpZmljYXRpb24nKVxuICAgIHJldHVybiBmYWxzZVxuICB9XG5cbiAgY29uc3QgZW5jb2RlciA9IG5ldyBUZXh0RW5jb2RlcigpO1xuICBjb25zdCBzZWNyZXRLZXkgPSBlbmNvZGVyLmVuY29kZShKV1RfU0VDUkVUKVxuXG4gIHRyeSB7XG4gICAgYXdhaXQgam9zZS5qd3RWZXJpZnkoand0LCBzZWNyZXRLZXkpO1xuICB9IGNhdGNoIChlKSB7XG4gICAgY29uc29sZS5lcnJvcignU3ltbWV0cmljIExlZ2FjeSBKV1QgdmVyaWZpY2F0aW9uIGVycm9yJywgZSk7XG4gICAgcmV0dXJuIGZhbHNlO1xuICB9XG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGlmICghU1VQQUJBU0VfSldUX0tFWVMpIHtcbiAgICBjb25zb2xlLmVycm9yKCdKV0tTIG5vdCBhdmFpbGFibGUgZm9yIEVTMjU2L1JTMjU2IHRva2VuIHZlcmlmaWNhdGlvbicpXG4gICAgcmV0dXJuIGZhbHNlXG4gIH1cblxuICB0cnkge1xuICAgIGF3YWl0IGpvc2Uuand0VmVyaWZ5KGp3dCwgU1VQQUJBU0VfSldUX0tFWVMpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zb2xlLmVycm9yKCdBc3ltbWV0cmljIEpXVCB2ZXJpZmljYXRpb24gZXJyb3InLCBlKTtcbiAgICByZXR1cm4gZmFsc2VcbiAgfVxuXG4gIHJldHVybiB0cnVlO1xufVxuXG5hc3luYyBmdW5jdGlvbiBpc1ZhbGlkSHlicmlkSldUKGp3dDogc3RyaW5nKTogUHJvbWlzZTxib29sZWFuPiB7XG4gIGNvbnN0IHsgYWxnOiBqd3RBbGdvcml0aG0gfSA9IGpvc2UuZGVjb2RlUHJvdGVjdGVkSGVhZGVyKGp3dClcblxuICBpZiAoand0QWxnb3JpdGhtID09PSAnSFMyNTYnKSB7XG4gICAgY29uc29sZS5sb2coYExlZ2FjeSB0b2tlbiB0eXBlIGRldGVjdGVkLCBhdHRlbXB0aW5nICR7and0QWxnb3JpdGhtfSB2ZXJpZmljYXRpb24uYClcblxuICAgIHJldHVybiBhd2FpdCBpc1ZhbGlkTGVnYWN5SldUKGp3dClcbiAgfVxuXG4gIGlmIChqd3RBbGdvcml0aG0gPT09ICdFUzI1NicgfHwgand0QWxnb3JpdGhtID09PSAnUlMyNTYnKSB7XG4gICAgcmV0dXJuIGF3YWl0IGlzVmFsaWRKV1Qoand0KVxuICB9XG5cbiAgcmV0dXJuIGZhbHNlO1xufVxuXG5EZW5vLnNlcnZlKGFzeW5jIChyZXE6IFJlcXVlc3QpID0+IHtcbiAgaWYgKHJlcS5tZXRob2QgIT09ICdPUFRJT05TJyAmJiBWRVJJRllfSldUKSB7XG4gICAgdHJ5IHtcbiAgICAgIGNvbnN0IHRva2VuID0gZ2V0QXV0aFRva2VuKHJlcSlcbiAgICAgIGNvbnN0IGlzVmFsaWRKV1QgPSBhd2FpdCBpc1ZhbGlkSHlicmlkSldUKHRva2VuKTtcblxuICAgICAgaWYgKCFpc1ZhbGlkSldUKSB7XG4gICAgICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoeyBtc2c6ICdJbnZhbGlkIEpXVCcgfSksIHtcbiAgICAgICAgICBzdGF0dXM6IDQwMSxcbiAgICAgICAgICBoZWFkZXJzOiB7ICdDb250ZW50LVR5cGUnOiAnYXBwbGljYXRpb24vanNvbicgfSxcbiAgICAgICAgfSlcbiAgICAgIH1cbiAgICB9IGNhdGNoIChlKSB7XG4gICAgICBjb25zb2xlLmVycm9yKGUpXG4gICAgICByZXR1cm4gbmV3IFJlc3BvbnNlKEpTT04uc3RyaW5naWZ5KHsgbXNnOiBlLnRvU3RyaW5nKCkgfSksIHtcbiAgICAgICAgc3RhdHVzOiA0MDEsXG4gICAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgICAgfSlcbiAgICB9XG4gIH1cblxuICBjb25zdCB1cmwgPSBuZXcgVVJMKHJlcS51cmwpXG4gIGNvbnN0IHsgcGF0aG5hbWUgfSA9IHVybFxuICBjb25zdCBwYXRoX3BhcnRzID0gcGF0aG5hbWUuc3BsaXQoJy8nKVxuICBjb25zdCBzZXJ2aWNlX25hbWUgPSBwYXRoX3BhcnRzWzFdXG5cbiAgaWYgKCFzZXJ2aWNlX25hbWUgfHwgc2VydmljZV9uYW1lID09PSAnJykge1xuICAgIGNvbnN0IGVycm9yID0geyBtc2c6ICdtaXNzaW5nIGZ1bmN0aW9uIG5hbWUgaW4gcmVxdWVzdCcgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDQwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cblxuICBjb25zdCBzZXJ2aWNlUGF0aCA9IGAvaG9tZS9kZW5vL2Z1bmN0aW9ucy8ke3NlcnZpY2VfbmFtZX1gXG4gIGNvbnNvbGUuZXJyb3IoYHNlcnZpbmcgdGhlIHJlcXVlc3Qgd2l0aCAke3NlcnZpY2VQYXRofWApXG5cbiAgY29uc3QgbWVtb3J5TGltaXRNYiA9IDE1MFxuICBjb25zdCB3b3JrZXJUaW1lb3V0TXMgPSAxICogNjAgKiAxMDAwXG4gIGNvbnN0IG5vTW9kdWxlQ2FjaGUgPSBmYWxzZVxuICBjb25zdCBpbXBvcnRNYXBQYXRoID0gbnVsbFxuICBjb25zdCBlbnZWYXJzT2JqID0gRGVuby5lbnYudG9PYmplY3QoKVxuICBjb25zdCBlbnZWYXJzID0gT2JqZWN0LmtleXMoZW52VmFyc09iaikubWFwKChrKSA9PiBbaywgZW52VmFyc09ialtrXV0pXG5cbiAgdHJ5IHtcbiAgICBjb25zdCB3b3JrZXIgPSBhd2FpdCBFZGdlUnVudGltZS51c2VyV29ya2Vycy5jcmVhdGUoe1xuICAgICAgc2VydmljZVBhdGgsXG4gICAgICBtZW1vcnlMaW1pdE1iLFxuICAgICAgd29ya2VyVGltZW91dE1zLFxuICAgICAgbm9Nb2R1bGVDYWNoZSxcbiAgICAgIGltcG9ydE1hcFBhdGgsXG4gICAgICBlbnZWYXJzLFxuICAgIH0pXG4gICAgcmV0dXJuIGF3YWl0IHdvcmtlci5mZXRjaChyZXEpXG4gIH0gY2F0Y2ggKGUpIHtcbiAgICBjb25zdCBlcnJvciA9IHsgbXNnOiBlLnRvU3RyaW5nKCkgfVxuICAgIHJldHVybiBuZXcgUmVzcG9uc2UoSlNPTi5zdHJpbmdpZnkoZXJyb3IpLCB7XG4gICAgICBzdGF0dXM6IDUwMCxcbiAgICAgIGhlYWRlcnM6IHsgJ0NvbnRlbnQtVHlwZSc6ICdhcHBsaWNhdGlvbi9qc29uJyB9LFxuICAgIH0pXG4gIH1cbn0pXG4iCiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3ZvbHVtZXMvZnVuY3Rpb25zL2hlbGxvL2luZGV4LnRzCiAgICAgICAgdGFyZ2V0OiAvaG9tZS9kZW5vL2Z1bmN0aW9ucy9oZWxsby9pbmRleC50cwogICAgICAgIGNvbnRlbnQ6ICIvLyBGb2xsb3cgdGhpcyBzZXR1cCBndWlkZSB0byBpbnRlZ3JhdGUgdGhlIERlbm8gbGFuZ3VhZ2Ugc2VydmVyIHdpdGggeW91ciBlZGl0b3I6XG4vLyBodHRwczovL2Rlbm8ubGFuZC9tYW51YWwvZ2V0dGluZ19zdGFydGVkL3NldHVwX3lvdXJfZW52aXJvbm1lbnRcbi8vIFRoaXMgZW5hYmxlcyBhdXRvY29tcGxldGUsIGdvIHRvIGRlZmluaXRpb24sIGV0Yy5cblxuRGVuby5zZXJ2ZShhc3luYyAoKSA9PiB7XG4gIHJldHVybiBuZXcgUmVzcG9uc2UoXG4gICAgYFwiSGVsbG8gZnJvbSBFZGdlIEZ1bmN0aW9ucyFcImAsXG4gICAgeyBoZWFkZXJzOiB7IFwiQ29udGVudC1UeXBlXCI6IFwiYXBwbGljYXRpb24vanNvblwiIH0gfSxcbiAgKVxufSlcblxuLy8gVG8gaW52b2tlOlxuLy8gY3VybCAnaHR0cDovL2xvY2FsaG9zdDo8S09OR19IVFRQX1BPUlQ+L2Z1bmN0aW9ucy92MS9oZWxsbycgXFxcbi8vICAgLS1oZWFkZXIgJ0F1dGhvcml6YXRpb246IEJlYXJlciA8YW5vbi9zZXJ2aWNlX3JvbGUgQVBJIGtleT4nXG4iCiAgICBjb21tYW5kOgogICAgICAtIHN0YXJ0CiAgICAgIC0gJy0tbWFpbi1zZXJ2aWNlJwogICAgICAtIC9ob21lL2Rlbm8vZnVuY3Rpb25zL21haW4KICBzdXBhYmFzZS1zdXBhdmlzb3I6CiAgICBpbWFnZTogJ3N1cGFiYXNlL3N1cGF2aXNvcjoyLjcuNCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLXNTZkwnCiAgICAgICAgLSAnLW8nCiAgICAgICAgLSAvZGV2L251bGwKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjQwMDAvYXBpL2hlYWx0aCcKICAgICAgdGltZW91dDogNXMKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgICAgIHN0YXJ0X3BlcmlvZDogMzBzCiAgICBkZXBlbmRzX29uOgogICAgICBzdXBhYmFzZS1kYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBzdXBhYmFzZS1hbmFseXRpY3M6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT09MRVJfVEVOQU5UX0lEPSR7UE9PTEVSX1RFTkFOVF9JRDotZGV2X3RlbmFudH0nCiAgICAgIC0gUE9PTEVSX1BPT0xfTU9ERT10cmFuc2FjdGlvbgogICAgICAtICdQT09MRVJfREVGQVVMVF9QT09MX1NJWkU9JHtQT09MRVJfREVGQVVMVF9QT09MX1NJWkU6LTIwfScKICAgICAgLSAnUE9PTEVSX01BWF9DTElFTlRfQ09OTj0ke1BPT0xFUl9NQVhfQ0xJRU5UX0NPTk46LTEwMH0nCiAgICAgIC0gUE9SVD00MDAwCiAgICAgIC0gJ1BPU1RHUkVTX1BPUlQ9JHtQT1NUR1JFU19QT1JUOi01NDMyfScKICAgICAgLSAnUE9TVEdSRVNfSE9TVE5BTUU9JHtQT1NUR1JFU19IT1NUTkFNRTotc3VwYWJhc2UtZGJ9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1wb3N0Z3Jlc30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1lY3RvOi8vc3VwYWJhc2VfYWRtaW46JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfUAke1BPU1RHUkVTX0hPU1ROQU1FOi1zdXBhYmFzZS1kYn06JHtQT1NUR1JFU19QT1JUOi01NDMyfS9fc3VwYWJhc2UnCiAgICAgIC0gQ0xVU1RFUl9QT1NUR1JFUz10cnVlCiAgICAgIC0gJ1NFQ1JFVF9LRVlfQkFTRT0ke1NFUlZJQ0VfUEFTU1dPUkRfU1VQQVZJU09SU0VDUkVUfScKICAgICAgLSAnVkFVTFRfRU5DX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfVkFVTFRFTkN9JwogICAgICAtICdBUElfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfSldUfScKICAgICAgLSAnTUVUUklDU19KV1RfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9KV1R9JwogICAgICAtIFJFR0lPTj1sb2NhbAogICAgICAtICdFUkxfQUZMQUdTPS1wcm90b19kaXN0IGluZXRfdGNwJwogICAgICAtICdEQl9QT09MX1NJWkU9JHtQT09MRVJfREJfUE9PTF9TSVpFOi01fScKICAgICAgLSBHTE9CQUxfRE9XTlNUUkVBTV9DRVJUX1BBVEg9L2V0Yy9zc2wvc2VydmVyLmNydAogICAgICAtIEdMT0JBTF9ET1dOU1RSRUFNX0tFWV9QQVRIPS9ldGMvc3NsL3NlcnZlci5rZXkKICAgIGNvbW1hbmQ6CiAgICAgIC0gL2Jpbi9zaAogICAgICAtICctYycKICAgICAgLSAiaWYgWyAhIC1mIC9ldGMvc3NsL3NlcnZlci5jcnQgXTsgdGhlblxuICBvcGVuc3NsIHJlcSAteDUwOSAtbm9kZXMgLWRheXMgMzY1MCAtbmV3a2V5IHJzYToyMDQ4IFxcXG4gICAgLWtleW91dCAvZXRjL3NzbC9zZXJ2ZXIua2V5IC1vdXQgL2V0Yy9zc2wvc2VydmVyLmNydCBcXFxuICAgIC1zdWJqIFwiL0NOPXN1cGFiYXNlLXBvb2xlclwiXG5maVxuL2FwcC9iaW4vbWlncmF0ZSAmJiAvYXBwL2Jpbi9zdXBhdmlzb3IgZXZhbCBcIiQkKGNhdCAvZXRjL3Bvb2xlci9wb29sZXIuZXhzKVwiICYmIC9hcHAvYmluL3NlcnZlclxuIgogICAgdm9sdW1lczoKICAgICAgLQogICAgICAgIHR5cGU6IGJpbmQKICAgICAgICBzb3VyY2U6IC4vdm9sdW1lcy9wb29sZXIvcG9vbGVyLmV4cwogICAgICAgIHRhcmdldDogL2V0Yy9wb29sZXIvcG9vbGVyLmV4cwogICAgICAgIGNvbnRlbnQ6ICJ7Om9rLCBffSA9IEFwcGxpY2F0aW9uLmVuc3VyZV9hbGxfc3RhcnRlZCg6c3VwYXZpc29yKVxuezpvaywgdmVyc2lvbn0gPVxuICAgIGNhc2UgU3VwYXZpc29yLlJlcG8ucXVlcnkhKFwic2VsZWN0IHZlcnNpb24oKVwiKSBkb1xuICAgICV7cm93czogW1t2ZXJdXX0gLT4gU3VwYXZpc29yLkhlbHBlcnMucGFyc2VfcGdfdmVyc2lvbih2ZXIpXG4gICAgXyAtPiBuaWxcbiAgICBlbmRcbnBhcmFtcyA9ICV7XG4gICAgXCJleHRlcm5hbF9pZFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX1RFTkFOVF9JRFwiKSxcbiAgICBcImRiX2hvc3RcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX0hPU1ROQU1FXCIpLFxuICAgIFwiZGJfcG9ydFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfUE9SVFwiKSB8PiBTdHJpbmcudG9faW50ZWdlcigpLFxuICAgIFwiZGJfZGF0YWJhc2VcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPU1RHUkVTX0RCXCIpLFxuICAgIFwicmVxdWlyZV91c2VyXCIgPT4gZmFsc2UsXG4gICAgXCJhdXRoX3F1ZXJ5XCIgPT4gXCJTRUxFQ1QgKiBGUk9NIHBnYm91bmNlci5nZXRfYXV0aCgkMSlcIixcbiAgICBcImRlZmF1bHRfbWF4X2NsaWVudHNcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9NQVhfQ0xJRU5UX0NPTk5cIiksXG4gICAgXCJkZWZhdWx0X3Bvb2xfc2l6ZVwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9PTEVSX0RFRkFVTFRfUE9PTF9TSVpFXCIpLFxuICAgIFwiZGVmYXVsdF9wYXJhbWV0ZXJfc3RhdHVzXCIgPT4gJXtcInNlcnZlcl92ZXJzaW9uXCIgPT4gdmVyc2lvbn0sXG4gICAgXCJ1c2Vyc1wiID0+IFsle1xuICAgIFwiZGJfdXNlclwiID0+IFwicGdib3VuY2VyXCIsXG4gICAgXCJkYl9wYXNzd29yZFwiID0+IFN5c3RlbS5nZXRfZW52KFwiUE9TVEdSRVNfUEFTU1dPUkRcIiksXG4gICAgXCJtb2RlX3R5cGVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9QT09MX01PREVcIiksXG4gICAgXCJwb29sX3NpemVcIiA9PiBTeXN0ZW0uZ2V0X2VudihcIlBPT0xFUl9ERUZBVUxUX1BPT0xfU0laRVwiKSxcbiAgICBcImlzX21hbmFnZXJcIiA9PiB0cnVlXG4gICAgfV1cbn1cblxudGVuYW50ID0gU3VwYXZpc29yLlRlbmFudHMuZ2V0X3RlbmFudF9ieV9leHRlcm5hbF9pZChwYXJhbXNbXCJleHRlcm5hbF9pZFwiXSlcblxuaWYgdGVuYW50IGRvXG4gIHs6b2ssIF99ID0gU3VwYXZpc29yLlRlbmFudHMudXBkYXRlX3RlbmFudCh0ZW5hbnQsIHBhcmFtcylcbmVsc2VcbiAgezpvaywgX30gPSBTdXBhdmlzb3IuVGVuYW50cy5jcmVhdGVfdGVuYW50KHBhcmFtcylcbmVuZFxuIgo=", "tags": [ "firebase", "alternative", @@ -5386,13 +4557,13 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-10-15T23:15:19+02:00", + "template_last_updated_at": "2026-10-18T23:43:20+02:00", "port": "8000" }, "superset-with-postgresql": { "documentation": "https://github.com/amancevice/docker-superset?utm_source=coolify.io", "slogan": "Modern data exploration and visualization platform (unofficial community docker image)", - "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJIgogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgICAgcmVkaXM6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDg4L2hlYWx0aCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotc3VwZXJzZXQtZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcG9zdGdyZXNfZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAogIHJlZGlzOgogICAgaW1hZ2U6ICdyZWRpczo4JwogICAgdm9sdW1lczoKICAgICAgLSAnc3VwZXJzZXRfcmVkaXNfZGF0YTovZGF0YScKICAgIGNvbW1hbmQ6ICdyZWRpcy1zZXJ2ZXIgLS1yZXF1aXJlcGFzcyAke1NFUlZJQ0VfUEFTU1dPUkRfUkVESVN9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6ICdyZWRpcy1jbGkgcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", + "compose": "c2VydmljZXM6CiAgc3VwZXJzZXQ6CiAgICBpbWFnZTogJ2FtYW5jZXZpY2Uvc3VwZXJzZXQ6Ni4wLjAnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fU1VQRVJTRVRfODA4OAogICAgICAtICdTRUNSRVRfS0VZPSR7U0VSVklDRV9CQVNFNjRfNjRfU1VQRVJTRVRTRUNSRVRLRVl9JwogICAgICAtICdNQVBCT1hfQVBJX0tFWT0ke01BUEJPWF9BUElfS0VZfScKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LXN1cGVyc2V0LWRifScKICAgICAgLSAnUkVESVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JFRElTfScKICAgIHZvbHVtZXM6CiAgICAgIC0KICAgICAgICB0eXBlOiBiaW5kCiAgICAgICAgc291cmNlOiAuL3N1cGVyc2V0L3N1cGVyc2V0X2NvbmZpZy5weQogICAgICAgIHRhcmdldDogL2V0Yy9zdXBlcnNldC9zdXBlcnNldF9jb25maWcucHkKICAgICAgICBjb250ZW50OiAiXCJcIlwiXG5Gb3IgbW9yZSBjb25maWd1cmF0aW9uIG9wdGlvbnMsIHNlZTpcbi0gaHR0cHM6Ly9zdXBlcnNldC5hcGFjaGUub3JnL2RvY3MvY29uZmlndXJhdGlvbi9jb25maWd1cmluZy1zdXBlcnNldFxuXCJcIlwiXG5cbmltcG9ydCBvc1xuXG5TRUNSRVRfS0VZID0gb3MuZ2V0ZW52KFwiU0VDUkVUX0tFWVwiKVxuTUFQQk9YX0FQSV9LRVkgPSBvcy5nZXRlbnYoXCJNQVBCT1hfQVBJX0tFWVwiLCBcIlwiKVxuXG5DQUNIRV9DT05GSUcgPSB7XG4gIFwiQ0FDSEVfVFlQRVwiOiBcIlJlZGlzQ2FjaGVcIixcbiAgXCJDQUNIRV9ERUZBVUxUX1RJTUVPVVRcIjogMzAwLFxuICBcIkNBQ0hFX0tFWV9QUkVGSVhcIjogXCJzdXBlcnNldF9cIixcbiAgXCJDQUNIRV9SRURJU19IT1NUXCI6IFwicmVkaXNcIixcbiAgXCJDQUNIRV9SRURJU19QT1JUXCI6IDYzNzksXG4gIFwiQ0FDSEVfUkVESVNfREJcIjogMSxcbiAgXCJDQUNIRV9SRURJU19VUkxcIjogZlwicmVkaXM6Ly86e29zLmdldGVudignUkVESVNfUEFTU1dPUkQnKX1AcmVkaXM6NjM3OS8xXCIsXG59XG5cbkZJTFRFUl9TVEFURV9DQUNIRV9DT05GSUcgPSB7KipDQUNIRV9DT05GSUcsIFwiQ0FDSEVfS0VZX1BSRUZJWFwiOiBcInN1cGVyc2V0X2ZpbHRlcl9cIn1cbkVYUExPUkVfRk9STV9EQVRBX0NBQ0hFX0NPTkZJRyA9IHsqKkNBQ0hFX0NPTkZJRywgXCJDQUNIRV9LRVlfUFJFRklYXCI6IFwic3VwZXJzZXRfZXhwbG9yZV9mb3JtX1wifVxuXG5TUUxBTENIRU1ZX1RSQUNLX01PRElGSUNBVElPTlMgPSBUcnVlXG5TUUxBTENIRU1ZX0RBVEFCQVNFX1VSSSA9IGZcInBvc3RncmVzcWwrcHN5Y29wZzI6Ly97b3MuZ2V0ZW52KCdQT1NUR1JFU19VU0VSJyl9Ontvcy5nZXRlbnYoJ1BPU1RHUkVTX1BBU1NXT1JEJyl9QHBvc3RncmVzOjU0MzIve29zLmdldGVudignUE9TVEdSRVNfREInKX1cIlxuXG4jIFVuY29tbWVudCBpZiB5b3Ugd2FudCB0byBsb2FkIGV4YW1wbGUgZGF0YSAodXNpbmcgXCJzdXBlcnNldCBsb2FkX2V4YW1wbGVzXCIpIGF0IHRoZVxuIyBzYW1lIGxvY2F0aW9uIGFzIHlvdXIgbWV0YWRhdGEgcG9zdGdyZXNxbCBpbnN0YW5jZS4gT3RoZXJ3aXNlLCB0aGUgZGVmYXVsdCBzcWxpdGVcbiMgd2lsbCBiZSB1c2VkLCB3aGljaCB3aWxsIG5vdCBwZXJzaXN0IGluIHZvbHVtZSB3aGVuIHJlc3RhcnRpbmcgc3VwZXJzZXQgYnkgZGVmYXVsdC5cbiNTUUxBTENIRU1ZX0VYQU1QTEVTX1VSSSA9IFNRTEFMQ0hFTVlfREFUQUJBU0VfVVJJXG4iCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICByZWRpczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODgvaGVhbHRoJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE4JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1zdXBlcnNldC1kYn0nCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9wb3N0Z3Jlc19kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjgnCiAgICB2b2x1bWVzOgogICAgICAtICdzdXBlcnNldF9yZWRpc19kYXRhOi9kYXRhJwogICAgY29tbWFuZDogJ3JlZGlzLXNlcnZlciAtLXJlcXVpcmVwYXNzICR7U0VSVklDRV9QQVNTV09SRF9SRURJU30nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDogJ3JlZGlzLWNsaSBwaW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", "tags": [ "analytics", "bi", @@ -5467,7 +4638,7 @@ "category": "finance", "logo": "svgs/sure.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-05T06:44:13+01:00", + "template_last_updated_at": "2026-10-19T21:30:29+02:00", "port": "3000" }, "swetrix": { @@ -5636,7 +4807,7 @@ "trigger": { "documentation": "https://trigger.dev/docs/self-hosting/overview?utm_source=coolify.io", "slogan": "The open source background jobs platform for developers", - "compose": "c2VydmljZXM6CiAgdHJpZ2dlcjoKICAgIGltYWdlOiAnZ2hjci5pby90cmlnZ2VyZG90ZGV2L3RyaWdnZXIuZGV2OnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC4vc2NyaXB0cy9lbnRyeXBvaW50LnNoXCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9UUklHR0VSXzMwMDAKICAgICAgLSAnQVBQX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9UUklHR0VSXzMwMDB9JwogICAgICAtICdMT0dJTl9PUklHSU49JHtTRVJWSUNFX0ZRRE5fVFJJR0dFUl8zMDAwfScKICAgICAgLSAnQVBJX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9UUklHR0VSXzMwMDB9JwogICAgICAtICdTRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfU0VTU0lPTn0nCiAgICAgIC0gJ01BR0lDX0xJTktfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9NQUdJQ0xJTkt9JwogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ01BTkFHRURfV09SS0VSX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfV09SS0VSU0VDUkVUfScKICAgICAgLSAnVFJJR0dFUl9XT1JLRVJfVE9LRU49JHtUUklHR0VSX1dPUktFUl9UT0tFTn0nCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9MT0dfTEVWRUw9aW5mbwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCOi10cmlnZ2VyLWRifT9zY2hlbWE9cHVibGljJnNzbG1vZGU9ZGlzYWJsZScKICAgICAgLSAnRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NjaGVtYT1wdWJsaWMmc3NsbW9kZT1kaXNhYmxlJwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19UTFNfRElTQUJMRUQ9dHJ1ZQogICAgICAtICdFTEVDVFJJQ19PUklHSU49aHR0cDovL2VsZWN0cmljOjMwMDAnCiAgICAgIC0gJ0RFVl9PVEVMX0VYUE9SVEVSX09UTFBfRU5EUE9JTlQ9JHtERVZfT1RFTF9FWFBPUlRFUl9PVExQX0VORFBPSU5UOi19JwogICAgICAtICdERVBMT1lfUkVHSVNUUllfSE9TVD0ke0RFUExPWV9SRUdJU1RSWV9IT1NUOi1naGNyLmlvfScKICAgICAgLSAnREVQTE9ZX1JFR0lTVFJZX1BST1RPQ09MPSR7REVQTE9ZX1JFR0lTVFJZX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U9JHtERVBMT1lfUkVHSVNUUllfTkFNRVNQQUNFOi10cmlnZ2VyfScKICAgICAgLSAnVjRfREVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRT0ke0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U6LXRyaWdnZXJ9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfQkFTRV9VUkw9JHtPQkpFQ1RfU1RPUkVfQkFTRV9VUkx9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfQUNDRVNTX0tFWV9JRD0ke09CSkVDVF9TVE9SRV9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnT0JKRUNUX1NUT1JFX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7T0JKRUNUX1NUT1JFX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovLyR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9OiR7U0VSVklDRV9QQVNTV09SRF82NF9DTElDS0hPVVNFfUBjbGlja2hvdXNlOjgxMjM/c2VjdXJlPWZhbHNlJwogICAgICAtIENMSUNLSE9VU0VfTE9HX0xFVkVMPWluZm8KICAgICAgLSBSVU5fUkVQTElDQVRJT05fRU5BQkxFRD0xCiAgICAgIC0gJ1JVTl9SRVBMSUNBVElPTl9DTElDS0hPVVNFX1VSTD1odHRwOi8vJHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX06JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9QGNsaWNraG91c2U6ODEyMycKICAgICAgLSBSVU5fUkVQTElDQVRJT05fTE9HX0xFVkVMPWluZm8KICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9FTkFCTEVEPTEKICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9XT1JLRVJfR1JPVVBfTkFNRT1ib290c3RyYXAKICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9XT1JLRVJfVE9LRU5fUEFUSD0vaG9tZS9ub2RlL3NoYXJlZC93b3JrZXJfdG9rZW4KICAgICAgLSAnREVGQVVMVF9PUkdfRVhFQ1VUSU9OX0NPTkNVUlJFTkNZX0xJTUlUPSR7REVGQVVMVF9PUkdfRVhFQ1VUSU9OX0NPTkNVUlJFTkNZX0xJTUlUOi0zMDB9JwogICAgICAtICdERUZBVUxUX0VOVl9FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ9JHtERUZBVUxUX0VOVl9FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ6LTEwMH0nCiAgICAgIC0gJ0FVVEhfR0lUSFVCX0NMSUVOVF9JRD0ke0FVVEhfR0lUSFVCX0NMSUVOVF9JRDotfScKICAgICAgLSAnQVVUSF9HSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0FVVEhfR0lUSFVCX0NMSUVOVF9TRUNSRVQ6LX0nCiAgICAgIC0gJ0VNQUlMX1RSQU5TUE9SVD0ke0VNQUlMX1RSQU5TUE9SVDotc210cH0nCiAgICAgIC0gJ1JFU0VORF9BUElfS0VZPSR7UkVTRU5EX0FQSV9LRVl9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdGUk9NX0VNQUlMPSR7RlJPTV9FTUFJTH0nCiAgICAgIC0gJ1JFUExZX1RPX0VNQUlMPSR7UkVQTFlfVE9fRU1BSUx9JwogICAgICAtICdXSElURUxJU1RFRF9FTUFJTFM9JHtXSElURUxJU1RFRF9FTUFJTFN9JwogICAgICAtICdBRE1JTl9FTUFJTFM9JHtBRE1JTl9FTUFJTFN9JwogICAgICAtIEdSQUNFRlVMX1NIVVRET1dOX1RJTUVPVVQ9MTAwMAogICAgICAtICdJTlRFUk5BTF9PVEVMX1RSQUNFX0xPR0dJTkdfRU5BQkxFRD0ke0lOVEVSTkFMX09URUxfVFJBQ0VfTE9HR0lOR19FTkFCTEVEOi0wfScKICAgICAgLSAnVFJJR0dFUl9URUxFTUVUUllfRElTQUJMRUQ9JHtUUklHR0VSX1RFTEVNRVRSWV9ESVNBQkxFRDotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdzaGFyZWQtZGF0YTovaG9tZS9ub2RlL3NoYXJlZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovLzEyNy4wLjAuMTozMDAwL2hlYWx0aGNoZWNrJywocik9PnByb2Nlc3MuZXhpdChyLnN0YXR1c0NvZGU9PT0yMDA/MDoxKSkub24oJ2Vycm9yJywoKT0+cHJvY2Vzcy5leGl0KDEpKSIKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGVsZWN0cmljOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBzdXBlcnZpc29yOgogICAgaW1hZ2U6ICdnaGNyLmlvL3RyaWdnZXJkb3RkZXYvc3VwZXJ2aXNvcjp2NC4zLjAnCiAgICB1c2VyOiByb290CiAgICBjb21tYW5kOiAic2ggLWMgXCJjaG93biAtUiBub2RlOm5vZGUgL2hvbWUvbm9kZS9zaGFyZWQgJiYgZXhlYyAvdXNyL2Jpbi9kdW1iLWluaXQgLS0gcG5wbSBydW4gLS1maWx0ZXIgc3VwZXJ2aXNvciBzdGFydFwiXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnVFJJR0dFUl9BUElfVVJMPWh0dHA6Ly90cmlnZ2VyOjMwMDAnCiAgICAgIC0gJ09URUxfRVhQT1JURVJfT1RMUF9FTkRQT0lOVD1odHRwOi8vdHJpZ2dlcjozMDAwL290ZWwnCiAgICAgIC0gJ1RSSUdHRVJfV09SS0VSX1RPS0VOPSR7VFJJR0dFUl9XT1JLRVJfVE9LRU46LWZpbGU6Ly8vaG9tZS9ub2RlL3NoYXJlZC93b3JrZXJfdG9rZW59JwogICAgICAtICdNQU5BR0VEX1dPUktFUl9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1dPUktFUlNFQ1JFVH0nCiAgICAgIC0gJ1RSSUdHRVJfV09SS0xPQURfQVBJX0RPTUFJTj0ke1RSSUdHRVJfV09SS0xPQURfQVBJX0RPTUFJTn0nCiAgICAgIC0gVFJJR0dFUl9XT1JLTE9BRF9BUElfUE9SVF9FWFRFUk5BTD04MDIwCiAgICAgIC0gJ0RPQ0tFUl9IT1NUPXRjcDovL2RvY2tlci1wcm94eToyMzc1JwogICAgICAtICdET0NLRVJfUlVOTkVSX05FVFdPUktTPSR7RE9DS0VSX1JVTk5FUl9ORVRXT1JLU30nCiAgICAgIC0gJ0RPQ0tFUl9BVVRPUkVNT1ZFX0VYSVRFRF9DT05UQUlORVJTPSR7RE9DS0VSX0FVVE9SRU1PVkVfRVhJVEVEX0NPTlRBSU5FUlM6LTF9JwogICAgICAtICdET0NLRVJfUkVHSVNUUllfVVJMPWh0dHBzOi8vJHtERVBMT1lfUkVHSVNUUllfSE9TVDotZ2hjci5pb30nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9VU0VSTkFNRT0ke0RFUExPWV9SRUdJU1RSWV9VU0VSTkFNRX0nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9QQVNTV09SRD0ke0RFUExPWV9SRUdJU1RSWV9QQVNTV09SRH0nCiAgICAgIC0gRU5GT1JDRV9NQUNISU5FX1BSRVNFVFM9MQogICAgICAtIFRSSUdHRVJfREVRVUVVRV9JTlRFUlZBTF9NUz0xMDAwCiAgICAgIC0gJ0RFQlVHPSR7U1VQRVJWSVNPUl9ERUJVRzotMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzaGFyZWQtZGF0YTovaG9tZS9ub2RlL3NoYXJlZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovLzEyNy4wLjAuMTo4MDIwL2hlYWx0aCcsKHIpPT5wcm9jZXNzLmV4aXQoci5zdGF0dXNDb2RlPT09MjAwPzA6MSkpLm9uKCdlcnJvcicsKCk9PnByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZGVwZW5kc19vbjoKICAgICAgdHJpZ2dlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBkb2NrZXItcHJveHk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBkb2NrZXItcHJveHk6CiAgICBpbWFnZTogJ3RlY25hdGl2YS9kb2NrZXItc29ja2V0LXByb3h5OnYwLjQuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTE9HX0xFVkVMPWluZm8KICAgICAgLSAnUE9TVD0ke1BPU1Q6LTF9JwogICAgICAtICdDT05UQUlORVJTPSR7Q09OVEFJTkVSUzotMX0nCiAgICAgIC0gJ0lNQUdFUz0ke0lNQUdFUzotMX0nCiAgICAgIC0gJ0lORk89JHtJTkZPOi0xfScKICAgICAgLSAnTkVUV09SS1M9JHtORVRXT1JLUzotMX0nCiAgICAgIC0gJ0FVVEg9JHtBVVRIOi0xfScKICAgICAgLSAnRElTVFJJQlVUSU9OPSR7RElTVFJJQlVUSU9OOi0xfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnbmMgLXogMTI3LjAuMC4xIDIzNzUgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBjb21tYW5kOgogICAgICAtICctYycKICAgICAgLSB3YWxfbGV2ZWw9bG9naWNhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10cmlnZ2VyLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgZWxlY3RyaWM6CiAgICBpbWFnZTogJ2VsZWN0cmljc3FsL2VsZWN0cmljOjEuMi45JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NzbG1vZGU9ZGlzYWJsZScKICAgICAgLSAnRUxFQ1RSSUNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9FTEVDVFJJQ30nCiAgICAgIC0gRUxFQ1RSSUNfVVNBR0VfUkVQT1JUSU5HPWZhbHNlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMC92MS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9JwogICAgICAtIENMSUNLSE9VU0VfREVGQVVMVF9BQ0NFU1NfTUFOQUdFTUVOVD0xCiAgICB2b2x1bWVzOgogICAgICAtICdjbGlja2hvdXNlLWRhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnY2xpY2tob3VzZS1sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", + "compose": "c2VydmljZXM6CiAgdHJpZ2dlcjoKICAgIGltYWdlOiAnZ2hjci5pby90cmlnZ2VyZG90ZGV2L3RyaWdnZXIuZGV2OnY0LjMuMCcKICAgIHVzZXI6IHJvb3QKICAgIGNvbW1hbmQ6ICJzaCAtYyBcImNob3duIC1SIG5vZGU6bm9kZSAvaG9tZS9ub2RlL3NoYXJlZCAmJiBleGVjIC4vc2NyaXB0cy9lbnRyeXBvaW50LnNoXCJcbiIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9UUklHR0VSXzMwMDAKICAgICAgLSAnQVBQX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9UUklHR0VSfScKICAgICAgLSAnTE9HSU5fT1JJR0lOPSR7U0VSVklDRV9GUUROX1RSSUdHRVJ9JwogICAgICAtICdBUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX1RSSUdHRVJ9JwogICAgICAtICdTRVNTSU9OX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfU0VTU0lPTn0nCiAgICAgIC0gJ01BR0lDX0xJTktfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9NQUdJQ0xJTkt9JwogICAgICAtICdFTkNSWVBUSU9OX0tFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfRU5DUllQVElPTktFWX0nCiAgICAgIC0gJ01BTkFHRURfV09SS0VSX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfNjRfV09SS0VSU0VDUkVUfScKICAgICAgLSAnVFJJR0dFUl9XT1JLRVJfVE9LRU49JHtUUklHR0VSX1dPUktFUl9UT0tFTn0nCiAgICAgIC0gTk9ERV9FTlY9cHJvZHVjdGlvbgogICAgICAtIEFQUF9MT0dfTEVWRUw9aW5mbwogICAgICAtICdEQVRBQkFTRV9VUkw9cG9zdGdyZXNxbDovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QHBvc3RncmVzcWw6NTQzMi8ke1BPU1RHUkVTX0RCOi10cmlnZ2VyLWRifT9zY2hlbWE9cHVibGljJnNzbG1vZGU9ZGlzYWJsZScKICAgICAgLSAnRElSRUNUX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NjaGVtYT1wdWJsaWMmc3NsbW9kZT1kaXNhYmxlJwogICAgICAtIFJFRElTX0hPU1Q9cmVkaXMKICAgICAgLSBSRURJU19QT1JUPTYzNzkKICAgICAgLSBSRURJU19UTFNfRElTQUJMRUQ9dHJ1ZQogICAgICAtICdFTEVDVFJJQ19PUklHSU49aHR0cDovL2VsZWN0cmljOjMwMDAnCiAgICAgIC0gJ0RFVl9PVEVMX0VYUE9SVEVSX09UTFBfRU5EUE9JTlQ9JHtERVZfT1RFTF9FWFBPUlRFUl9PVExQX0VORFBPSU5UOi19JwogICAgICAtICdERVBMT1lfUkVHSVNUUllfSE9TVD0ke0RFUExPWV9SRUdJU1RSWV9IT1NUOi1naGNyLmlvfScKICAgICAgLSAnREVQTE9ZX1JFR0lTVFJZX1BST1RPQ09MPSR7REVQTE9ZX1JFR0lTVFJZX1BST1RPQ09MOi1odHRwc30nCiAgICAgIC0gJ0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U9JHtERVBMT1lfUkVHSVNUUllfTkFNRVNQQUNFOi10cmlnZ2VyfScKICAgICAgLSAnVjRfREVQTE9ZX1JFR0lTVFJZX05BTUVTUEFDRT0ke0RFUExPWV9SRUdJU1RSWV9OQU1FU1BBQ0U6LXRyaWdnZXJ9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfQkFTRV9VUkw9JHtPQkpFQ1RfU1RPUkVfQkFTRV9VUkx9JwogICAgICAtICdPQkpFQ1RfU1RPUkVfQUNDRVNTX0tFWV9JRD0ke09CSkVDVF9TVE9SRV9BQ0NFU1NfS0VZX0lEfScKICAgICAgLSAnT0JKRUNUX1NUT1JFX1NFQ1JFVF9BQ0NFU1NfS0VZPSR7T0JKRUNUX1NUT1JFX1NFQ1JFVF9BQ0NFU1NfS0VZfScKICAgICAgLSAnQ0xJQ0tIT1VTRV9VUkw9aHR0cDovLyR7U0VSVklDRV9VU0VSX0NMSUNLSE9VU0V9OiR7U0VSVklDRV9QQVNTV09SRF82NF9DTElDS0hPVVNFfUBjbGlja2hvdXNlOjgxMjM/c2VjdXJlPWZhbHNlJwogICAgICAtIENMSUNLSE9VU0VfTE9HX0xFVkVMPWluZm8KICAgICAgLSBSVU5fUkVQTElDQVRJT05fRU5BQkxFRD0xCiAgICAgIC0gJ1JVTl9SRVBMSUNBVElPTl9DTElDS0hPVVNFX1VSTD1odHRwOi8vJHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX06JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9QGNsaWNraG91c2U6ODEyMycKICAgICAgLSBSVU5fUkVQTElDQVRJT05fTE9HX0xFVkVMPWluZm8KICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9FTkFCTEVEPTEKICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9XT1JLRVJfR1JPVVBfTkFNRT1ib290c3RyYXAKICAgICAgLSBUUklHR0VSX0JPT1RTVFJBUF9XT1JLRVJfVE9LRU5fUEFUSD0vaG9tZS9ub2RlL3NoYXJlZC93b3JrZXJfdG9rZW4KICAgICAgLSAnREVGQVVMVF9PUkdfRVhFQ1VUSU9OX0NPTkNVUlJFTkNZX0xJTUlUPSR7REVGQVVMVF9PUkdfRVhFQ1VUSU9OX0NPTkNVUlJFTkNZX0xJTUlUOi0zMDB9JwogICAgICAtICdERUZBVUxUX0VOVl9FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ9JHtERUZBVUxUX0VOVl9FWEVDVVRJT05fQ09OQ1VSUkVOQ1lfTElNSVQ6LTEwMH0nCiAgICAgIC0gJ0FVVEhfR0lUSFVCX0NMSUVOVF9JRD0ke0FVVEhfR0lUSFVCX0NMSUVOVF9JRDotfScKICAgICAgLSAnQVVUSF9HSVRIVUJfQ0xJRU5UX1NFQ1JFVD0ke0FVVEhfR0lUSFVCX0NMSUVOVF9TRUNSRVQ6LX0nCiAgICAgIC0gJ0VNQUlMX1RSQU5TUE9SVD0ke0VNQUlMX1RSQU5TUE9SVDotc210cH0nCiAgICAgIC0gJ1JFU0VORF9BUElfS0VZPSR7UkVTRU5EX0FQSV9LRVl9JwogICAgICAtICdTTVRQX0hPU1Q9JHtTTVRQX0hPU1R9JwogICAgICAtICdTTVRQX1BPUlQ9JHtTTVRQX1BPUlR9JwogICAgICAtICdTTVRQX1VTRVI9JHtTTVRQX1VTRVJ9JwogICAgICAtICdTTVRQX1BBU1NXT1JEPSR7U01UUF9QQVNTV09SRH0nCiAgICAgIC0gJ1NNVFBfU0VDVVJFPSR7U01UUF9TRUNVUkV9JwogICAgICAtICdGUk9NX0VNQUlMPSR7RlJPTV9FTUFJTH0nCiAgICAgIC0gJ1JFUExZX1RPX0VNQUlMPSR7UkVQTFlfVE9fRU1BSUx9JwogICAgICAtICdXSElURUxJU1RFRF9FTUFJTFM9JHtXSElURUxJU1RFRF9FTUFJTFN9JwogICAgICAtICdBRE1JTl9FTUFJTFM9JHtBRE1JTl9FTUFJTFN9JwogICAgICAtIEdSQUNFRlVMX1NIVVRET1dOX1RJTUVPVVQ9MTAwMAogICAgICAtICdJTlRFUk5BTF9PVEVMX1RSQUNFX0xPR0dJTkdfRU5BQkxFRD0ke0lOVEVSTkFMX09URUxfVFJBQ0VfTE9HR0lOR19FTkFCTEVEOi0wfScKICAgICAgLSAnVFJJR0dFUl9URUxFTUVUUllfRElTQUJMRUQ9JHtUUklHR0VSX1RFTEVNRVRSWV9ESVNBQkxFRDotdHJ1ZX0nCiAgICB2b2x1bWVzOgogICAgICAtICdzaGFyZWQtZGF0YTovaG9tZS9ub2RlL3NoYXJlZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovLzEyNy4wLjAuMTozMDAwL2hlYWx0aGNoZWNrJywocik9PnByb2Nlc3MuZXhpdChyLnN0YXR1c0NvZGU9PT0yMDA/MDoxKSkub24oJ2Vycm9yJywoKT0+cHJvY2Vzcy5leGl0KDEpKSIKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgICAgIHN0YXJ0X3BlcmlvZDogMTBzCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3Jlc3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIHJlZGlzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGVsZWN0cmljOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIGNsaWNraG91c2U6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBzdXBlcnZpc29yOgogICAgaW1hZ2U6ICdnaGNyLmlvL3RyaWdnZXJkb3RkZXYvc3VwZXJ2aXNvcjp2NC4zLjAnCiAgICB1c2VyOiByb290CiAgICBjb21tYW5kOiAic2ggLWMgXCJjaG93biAtUiBub2RlOm5vZGUgL2hvbWUvbm9kZS9zaGFyZWQgJiYgZXhlYyAvdXNyL2Jpbi9kdW1iLWluaXQgLS0gcG5wbSBydW4gLS1maWx0ZXIgc3VwZXJ2aXNvciBzdGFydFwiXG4iCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnVFJJR0dFUl9BUElfVVJMPWh0dHA6Ly90cmlnZ2VyOjMwMDAnCiAgICAgIC0gJ09URUxfRVhQT1JURVJfT1RMUF9FTkRQT0lOVD1odHRwOi8vdHJpZ2dlcjozMDAwL290ZWwnCiAgICAgIC0gJ1RSSUdHRVJfV09SS0VSX1RPS0VOPSR7VFJJR0dFUl9XT1JLRVJfVE9LRU46LWZpbGU6Ly8vaG9tZS9ub2RlL3NoYXJlZC93b3JrZXJfdG9rZW59JwogICAgICAtICdNQU5BR0VEX1dPUktFUl9TRUNSRVQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X1dPUktFUlNFQ1JFVH0nCiAgICAgIC0gJ1RSSUdHRVJfV09SS0xPQURfQVBJX0RPTUFJTj0ke1RSSUdHRVJfV09SS0xPQURfQVBJX0RPTUFJTn0nCiAgICAgIC0gVFJJR0dFUl9XT1JLTE9BRF9BUElfUE9SVF9FWFRFUk5BTD04MDIwCiAgICAgIC0gJ0RPQ0tFUl9IT1NUPXRjcDovL2RvY2tlci1wcm94eToyMzc1JwogICAgICAtICdET0NLRVJfUlVOTkVSX05FVFdPUktTPSR7RE9DS0VSX1JVTk5FUl9ORVRXT1JLU30nCiAgICAgIC0gJ0RPQ0tFUl9BVVRPUkVNT1ZFX0VYSVRFRF9DT05UQUlORVJTPSR7RE9DS0VSX0FVVE9SRU1PVkVfRVhJVEVEX0NPTlRBSU5FUlM6LTF9JwogICAgICAtICdET0NLRVJfUkVHSVNUUllfVVJMPWh0dHBzOi8vJHtERVBMT1lfUkVHSVNUUllfSE9TVDotZ2hjci5pb30nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9VU0VSTkFNRT0ke0RFUExPWV9SRUdJU1RSWV9VU0VSTkFNRX0nCiAgICAgIC0gJ0RPQ0tFUl9SRUdJU1RSWV9QQVNTV09SRD0ke0RFUExPWV9SRUdJU1RSWV9QQVNTV09SRH0nCiAgICAgIC0gRU5GT1JDRV9NQUNISU5FX1BSRVNFVFM9MQogICAgICAtIFRSSUdHRVJfREVRVUVVRV9JTlRFUlZBTF9NUz0xMDAwCiAgICAgIC0gJ0RFQlVHPSR7U1VQRVJWSVNPUl9ERUJVRzotMH0nCiAgICB2b2x1bWVzOgogICAgICAtICdzaGFyZWQtZGF0YTovaG9tZS9ub2RlL3NoYXJlZCcKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBub2RlCiAgICAgICAgLSAnLWUnCiAgICAgICAgLSAicmVxdWlyZSgnaHR0cCcpLmdldCgnaHR0cDovLzEyNy4wLjAuMTo4MDIwL2hlYWx0aCcsKHIpPT5wcm9jZXNzLmV4aXQoci5zdGF0dXNDb2RlPT09MjAwPzA6MSkpLm9uKCdlcnJvcicsKCk9PnByb2Nlc3MuZXhpdCgxKSkiCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZGVwZW5kc19vbjoKICAgICAgdHJpZ2dlcjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBkb2NrZXItcHJveHk6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2hlYWx0aHkKICBkb2NrZXItcHJveHk6CiAgICBpbWFnZTogJ3RlY25hdGl2YS9kb2NrZXItc29ja2V0LXByb3h5OnYwLjQuMicKICAgIHZvbHVtZXM6CiAgICAgIC0gJy92YXIvcnVuL2RvY2tlci5zb2NrOi92YXIvcnVuL2RvY2tlci5zb2NrOnJvJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTE9HX0xFVkVMPWluZm8KICAgICAgLSAnUE9TVD0ke1BPU1Q6LTF9JwogICAgICAtICdDT05UQUlORVJTPSR7Q09OVEFJTkVSUzotMX0nCiAgICAgIC0gJ0lNQUdFUz0ke0lNQUdFUzotMX0nCiAgICAgIC0gJ0lORk89JHtJTkZPOi0xfScKICAgICAgLSAnTkVUV09SS1M9JHtORVRXT1JLUzotMX0nCiAgICAgIC0gJ0FVVEg9JHtBVVRIOi0xfScKICAgICAgLSAnRElTVFJJQlVUSU9OPSR7RElTVFJJQlVUSU9OOi0xfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnbmMgLXogMTI3LjAuMC4xIDIzNzUgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUKICBwb3N0Z3Jlc3FsOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNi1hbHBpbmUnCiAgICBjb21tYW5kOgogICAgICAtICctYycKICAgICAgLSB3YWxfbGV2ZWw9bG9naWNhbAogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi10cmlnZ2VyLWRifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzcWwtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJCR7UE9TVEdSRVNfVVNFUn0gLWQgJCR7UE9TVEdSRVNfREJ9JwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCiAgcmVkaXM6CiAgICBpbWFnZTogJ3JlZGlzOjctYWxwaW5lJwogICAgdm9sdW1lczoKICAgICAgLSAncmVkaXMtZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSByZWRpcy1jbGkKICAgICAgICAtIHBpbmcKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgZWxlY3RyaWM6CiAgICBpbWFnZTogJ2VsZWN0cmljc3FsL2VsZWN0cmljOjEuMi45JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3Jlc3FsOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AcG9zdGdyZXNxbDo1NDMyLyR7UE9TVEdSRVNfREI6LXRyaWdnZXItZGJ9P3NzbG1vZGU9ZGlzYWJsZScKICAgICAgLSAnRUxFQ1RSSUNfU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF82NF9FTEVDVFJJQ30nCiAgICAgIC0gRUxFQ1RSSUNfVVNBR0VfUkVQT1JUSU5HPWZhbHNlCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly9sb2NhbGhvc3Q6MzAwMC92MS9oZWFsdGgnCiAgICAgIGludGVydmFsOiAxMHMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogNQogICAgZGVwZW5kc19vbjoKICAgICAgcG9zdGdyZXNxbDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogIGNsaWNraG91c2U6CiAgICBpbWFnZTogJ2NsaWNraG91c2UvY2xpY2tob3VzZS1zZXJ2ZXI6MjUuOCcKICAgIGVudmlyb25tZW50OgogICAgICAtICdDTElDS0hPVVNFX1VTRVI9JHtTRVJWSUNFX1VTRVJfQ0xJQ0tIT1VTRX0nCiAgICAgIC0gJ0NMSUNLSE9VU0VfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEXzY0X0NMSUNLSE9VU0V9JwogICAgICAtIENMSUNLSE9VU0VfREVGQVVMVF9BQ0NFU1NfTUFOQUdFTUVOVD0xCiAgICB2b2x1bWVzOgogICAgICAtICdjbGlja2hvdXNlLWRhdGE6L3Zhci9saWIvY2xpY2tob3VzZScKICAgICAgLSAnY2xpY2tob3VzZS1sb2dzOi92YXIvbG9nL2NsaWNraG91c2Utc2VydmVyJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICd3Z2V0IC0tbm8tdmVyYm9zZSAtLXRyaWVzPTEgLS1zcGlkZXIgaHR0cDovL2xvY2FsaG9zdDo4MTIzL3BpbmcgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDUK", "tags": [ "background-jobs", "typescript", @@ -5650,7 +4821,7 @@ "category": "automation", "logo": "svgs/trigger.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-07T19:01:32+01:00", + "template_last_updated_at": "2026-10-19T21:05:49+02:00", "port": "3000" }, "triliumnext": { @@ -5683,7 +4854,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-09-03T17:29:34+02:00", + "template_last_updated_at": "2026-10-19T21:18:52+02:00", "port": "3000" }, "typesense": { @@ -5926,72 +5097,6 @@ "template_last_updated_at": "2026-08-17T11:10:01+02:00", "port": "3456" }, - "vvveb-with-mariadb": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9WVlZFQl84MAogICAgICAtIERCX0VOR0lORT1teXNxbGkKICAgICAgLSBEQl9IT1NUPW1hcmlhZGIKICAgICAgLSAnREJfVVNFUj0ke1NFUlZJQ0VfVVNFUl9WVlZFQn0nCiAgICAgIC0gJ0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WVlZFQn0nCiAgICAgIC0gJ0RCX05BTUU9JHtNQVJJQURCX0RBVEFCQVNFOi12dnZlYn0nCiAgICBkZXBlbmRzX29uOgogICAgICBtYXJpYWRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAndnZ2ZWItbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01BUklBREJfREFUQUJBU0U6LXZ2dmVifScKICAgICAgLSAnTVlTUUxfVVNFUj0ke1NFUlZJQ0VfVVNFUl9WVlZFQn0nCiAgICAgIC0gJ01ZU1FMX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9WVlZFQn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gaGVhbHRoY2hlY2suc2gKICAgICAgICAtICctLWNvbm5lY3QnCiAgICAgICAgLSAnLS1pbm5vZGJfaW5pdGlhbGl6ZWQnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "vvveb-with-mysql": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9WVlZFQl84MAogICAgICAtIERCX0VOR0lORT1teXNxbGkKICAgICAgLSBEQl9IT1NUPW15c3FsCiAgICAgIC0gJ0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfVlZWRUJ9JwogICAgICAtICdEQl9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfVlZWRUJ9JwogICAgICAtICdEQl9OQU1FPSR7TVlTUUxfREFUQUJBU0U6LXZ2dmVifScKICAgIGRlcGVuZHNfb246CiAgICAgIG15c3FsOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBteXNxbDoKICAgIGltYWdlOiAnbXlzcWw6OC40LjInCiAgICB2b2x1bWVzOgogICAgICAtICd2dnZlYi1teXNxbC1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ01ZU1FMX1JPT1RfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1JPT1R9JwogICAgICAtICdNWVNRTF9EQVRBQkFTRT0ke01ZU1FMX0RBVEFCQVNFOi12dnZlYn0nCiAgICAgIC0gJ01ZU1FMX1VTRVI9JHtTRVJWSUNFX1VTRVJfVlZWRUJ9JwogICAgICAtICdNWVNRTF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfVlZWRUJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIG15c3FsYWRtaW4KICAgICAgICAtIHBpbmcKICAgICAgICAtICctaCcKICAgICAgICAtIDEyNy4wLjAuMQogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, - "vvveb": { - "documentation": "https://docs.vvveb.com?utm_source=coolify.io", - "slogan": "Powerful and easy to use cms to build websites, blogs or ecommerce stores.", - "compose": "c2VydmljZXM6CiAgdnZ2ZWI6CiAgICBpbWFnZTogJ3Z2dmViL3Z2dmViY21zOmxhdGVzdCcKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Z2dmViLWRhdGE6L3Zhci93d3cvaHRtbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9WVlZFQl84MAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xJwogICAgICBpbnRlcnZhbDogMnMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "ecommerce", - "page-builder", - "nocode", - "mysql", - "sqlite", - "pgsql" - ], - "category": "cms", - "logo": "svgs/vvveb.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "80" - }, "wakapi": { "documentation": "https://wakapi.dev/?utm_source=coolify.io", "slogan": "A minimalist, self-hosted WakaTime-compatible backend for coding statistics", @@ -6064,7 +5169,7 @@ "category": "productivity", "logo": "svgs/weblate.webp", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-31T17:36:41+02:00", + "template_last_updated_at": "2026-10-19T21:18:38+02:00", "port": "8080" }, "whoogle": { @@ -6096,23 +5201,6 @@ "template_last_updated_at": "2025-08-17T18:23:57+02:00", "port": "80" }, - "windmill": { - "documentation": "https://www.windmill.dev/docs/?utm_source=coolify.io", - "slogan": "Windmill is a developer platform to build production-grade multi-steps automations and internal apps.", - "compose": "c2VydmljZXM6CiAgZGI6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE2JwogICAgc2htX3NpemU6IDFnCiAgICB2b2x1bWVzOgogICAgICAtICdkYi1kYXRhOi92YXIvbGliL3Bvc3RncmVzcWwvZGF0YScKICAgIGVudmlyb25tZW50OgogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtQT1NUR1JFU19EQjotd2luZG1pbGwtZGJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICQke1BPU1RHUkVTX1VTRVJ9IC1kICQke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDEwcwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiA1CiAgd2luZG1pbGwtc2VydmVyOgogICAgaW1hZ2U6ICdnaGNyLmlvL3dpbmRtaWxsLWxhYnMvd2luZG1pbGw6bWFpbicKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XSU5ETUlMTF84MDAwCiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGRiLyR7UE9TVEdSRVNfREI6LXdpbmRtaWxsLWRifScKICAgICAgLSBNT0RFPXNlcnZlcgogICAgICAtICdCQVNFX1VSTD0ke1NFUlZJQ0VfRlFETl9XSU5ETUlMTH0nCiAgICBkZXBlbmRzX29uOgogICAgICBkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnd29ya2VyLWxvZ3M6L3RtcC93aW5kbWlsbC9sb2dzJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctZicKICAgICAgICAtICdodHRwOi8vbG9jYWxob3N0OjgwMDAvaGVhbHRoJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgd2luZG1pbGwtd29ya2VyLTE6CiAgICBpbWFnZTogJ2doY3IuaW8vd2luZG1pbGwtbGFicy93aW5kbWlsbDptYWluJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGRiLyR7UE9TVEdSRVNfREI6LXdpbmRtaWxsLWRifScKICAgICAgLSBNT0RFPXdvcmtlcgogICAgICAtIFdPUktFUl9HUk9VUD1kZWZhdWx0CiAgICBkZXBlbmRzX29uOgogICAgICBkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICAgIC0gJ3dvcmtlci1kZXBlbmRlbmN5LWNhY2hlOi90bXAvd2luZG1pbGwvY2FjaGUnCiAgICAgIC0gJ3dvcmtlci1sb2dzOi90bXAvd2luZG1pbGwvbG9ncycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnZXhpdCAwJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgd2luZG1pbGwtd29ya2VyLTI6CiAgICBpbWFnZTogJ2doY3IuaW8vd2luZG1pbGwtbGFicy93aW5kbWlsbDptYWluJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGRiLyR7UE9TVEdSRVNfREI6LXdpbmRtaWxsLWRifScKICAgICAgLSBNT0RFPXdvcmtlcgogICAgICAtIFdPUktFUl9HUk9VUD1kZWZhdWx0CiAgICBkZXBlbmRzX29uOgogICAgICBkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICAgIC0gJ3dvcmtlci1kZXBlbmRlbmN5LWNhY2hlOi90bXAvd2luZG1pbGwvY2FjaGUnCiAgICAgIC0gJ3dvcmtlci1sb2dzOi90bXAvd2luZG1pbGwvbG9ncycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnZXhpdCAwJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgd2luZG1pbGwtd29ya2VyLTM6CiAgICBpbWFnZTogJ2doY3IuaW8vd2luZG1pbGwtbGFicy93aW5kbWlsbDptYWluJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ0RBVEFCQVNFX1VSTD1wb3N0Z3JlczovLyR7U0VSVklDRV9VU0VSX1BPU1RHUkVTfToke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9QGRiLyR7UE9TVEdSRVNfREI6LXdpbmRtaWxsLWRifScKICAgICAgLSBNT0RFPXdvcmtlcgogICAgICAtIFdPUktFUl9HUk9VUD1kZWZhdWx0CiAgICBkZXBlbmRzX29uOgogICAgICBkYjoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnL3Zhci9ydW4vZG9ja2VyLnNvY2s6L3Zhci9ydW4vZG9ja2VyLnNvY2snCiAgICAgIC0gJ3dvcmtlci1kZXBlbmRlbmN5LWNhY2hlOi90bXAvd2luZG1pbGwvY2FjaGUnCiAgICAgIC0gJ3dvcmtlci1sb2dzOi90bXAvd2luZG1pbGwvbG9ncycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnZXhpdCAwJwogICAgICBpbnRlcnZhbDogMzBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAzCiAgd2luZG1pbGwtd29ya2VyLW5hdGl2ZToKICAgIGltYWdlOiAnZ2hjci5pby93aW5kbWlsbC1sYWJzL3dpbmRtaWxsOm1haW4nCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnREFUQUJBU0VfVVJMPXBvc3RncmVzOi8vJHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9OiR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU31AZGIvJHtQT1NUR1JFU19EQjotd2luZG1pbGwtZGJ9JwogICAgICAtIE1PREU9d29ya2VyCiAgICAgIC0gV09SS0VSX0dST1VQPW5hdGl2ZQogICAgICAtIE5VTV9XT1JLRVJTPTgKICAgICAgLSBTTEVFUF9RVUVVRT0yMDAKICAgIGRlcGVuZHNfb246CiAgICAgIGRiOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICd3b3JrZXItbG9nczovdG1wL3dpbmRtaWxsL2xvZ3MnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ2V4aXQgMCcKICAgICAgaW50ZXJ2YWw6IDMwcwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMwogIGxzcDoKICAgIGltYWdlOiAnZ2hjci5pby93aW5kbWlsbC1sYWJzL3dpbmRtaWxsLWxzcDpsYXRlc3QnCiAgICB2b2x1bWVzOgogICAgICAtICdsc3AtY2FjaGU6L3Jvb3QvLmNhY2hlJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdleGl0IDAnCiAgICAgIGludGVydmFsOiAzMHMKICAgICAgdGltZW91dDogMTBzCiAgICAgIHJldHJpZXM6IDMKICAgICAgc3RhcnRfcGVyaW9kOiAyMHMK", - "tags": [ - "windmill", - "workflow", - "automation", - "developer", - "platform" - ], - "category": "automation", - "logo": "svgs/windmill.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", - "port": "8000" - }, "wings": { "documentation": "https://pterodactyl.io/?utm_source=coolify.io", "slogan": "The server control plane for Pterodactyl Panel. Written from the ground-up with security, speed, and stability in mind.", @@ -6146,56 +5234,6 @@ "template_last_updated_at": "2026-01-05T15:54:25+01:00", "port": "8000" }, - "wordpress-with-mariadb": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bWFyaWFkYgogICAgICAtIFdPUkRQUkVTU19EQl9VU0VSPSRTRVJWSUNFX1VTRVJfV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9OQU1FPXdvcmRwcmVzcwogICAgZGVwZW5kc19vbjoKICAgICAgLSBtYXJpYWRiCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAKICBtYXJpYWRiOgogICAgaW1hZ2U6ICdtYXJpYWRiOjExJwogICAgdm9sdW1lczoKICAgICAgLSAnbWFyaWFkYi1kYXRhOi92YXIvbGliL215c3FsJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gTVlTUUxfUk9PVF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9ST09UCiAgICAgIC0gTVlTUUxfREFUQUJBU0U9d29yZHByZXNzCiAgICAgIC0gTVlTUUxfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIE1ZU1FMX1BBU1NXT1JEPSRTRVJWSUNFX1BBU1NXT1JEX1dPUkRQUkVTUwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGhlYWx0aGNoZWNrLnNoCiAgICAgICAgLSAnLS1jb25uZWN0JwogICAgICAgIC0gJy0taW5ub2RiX2luaXRpYWxpemVkJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogMjBzCiAgICAgIHJldHJpZXM6IDEwCg==", - "tags": [ - "cms", - "blog", - "content", - "management", - "mariadb" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "wordpress-with-mysql": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICAgIC0gV09SRFBSRVNTX0RCX0hPU1Q9bXlzcWwKICAgICAgLSBXT1JEUFJFU1NfREJfVVNFUj0kU0VSVklDRV9VU0VSX1dPUkRQUkVTUwogICAgICAtIFdPUkRQUkVTU19EQl9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgICAgLSBXT1JEUFJFU1NfREJfTkFNRT13b3JkcHJlc3MKICAgIGRlcGVuZHNfb246CiAgICAgIC0gbXlzcWwKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLWYnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMScKICAgICAgaW50ZXJ2YWw6IDJzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMAogIG15c3FsOgogICAgaW1hZ2U6ICdteXNxbDo4JwogICAgdm9sdW1lczoKICAgICAgLSAnbXlzcWwtZGF0YTovdmFyL2xpYi9teXNxbCcKICAgIGVudmlyb25tZW50OgogICAgICAtIE1ZU1FMX1JPT1RfUEFTU1dPUkQ9JFNFUlZJQ0VfUEFTU1dPUkRfUk9PVAogICAgICAtIE1ZU1FMX0RBVEFCQVNFPXdvcmRwcmVzcwogICAgICAtIE1ZU1FMX1VTRVI9JFNFUlZJQ0VfVVNFUl9XT1JEUFJFU1MKICAgICAgLSBNWVNRTF9QQVNTV09SRD0kU0VSVklDRV9QQVNTV09SRF9XT1JEUFJFU1MKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBteXNxbGFkbWluCiAgICAgICAgLSBwaW5nCiAgICAgICAgLSAnLWgnCiAgICAgICAgLSAxMjcuMC4wLjEKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDIwcwogICAgICByZXRyaWVzOiAxMAo=", - "tags": [ - "cms", - "blog", - "content", - "management", - "mysql" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, - "wordpress-without-database": { - "documentation": "https://wordpress.org?utm_source=coolify.io", - "slogan": "WordPress is open source software you can use to create a beautiful website, blog, or app.", - "compose": "c2VydmljZXM6CiAgd29yZHByZXNzOgogICAgaW1hZ2U6ICd3b3JkcHJlc3M6bGF0ZXN0JwogICAgdm9sdW1lczoKICAgICAgLSAnd29yZHByZXNzLWZpbGVzOi92YXIvd3d3L2h0bWwnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV09SRFBSRVNTCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy1mJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjEnCiAgICAgIGludGVydmFsOiAycwogICAgICB0aW1lb3V0OiAxMHMKICAgICAgcmV0cmllczogMTAK", - "tags": [ - "cms", - "blog", - "content", - "management" - ], - "category": "cms", - "logo": "svgs/wordpress.svg", - "minversion": "0.0.0", - "template_last_updated_at": "2026-09-02T19:39:19+02:00", - "port": "80" - }, "yamtrack-with-postgresql": { "documentation": "https://github.com/FuzzyGrim/Yamtrack/wiki?utm_source=coolify.io", "slogan": "Yamtrack is a self hosted media tracker for movies, tv shows, anime, manga, video games and books.", diff --git a/tests/Feature/AdvisorySecurityRegressionTest.php b/tests/Feature/AdvisorySecurityRegressionTest.php index 4c14ce379e..00e8098197 100644 --- a/tests/Feature/AdvisorySecurityRegressionTest.php +++ b/tests/Feature/AdvisorySecurityRegressionTest.php @@ -4,7 +4,6 @@ use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Http\Middleware\ApiAllowed; -use App\Http\Middleware\ApiIpAllowed; use App\Models\EnvironmentVariable; use App\Models\InstanceSettings; use App\Models\Service; @@ -59,9 +58,9 @@ it('applies only the IP allow-list, not the REST API switch, to MCP and MCP swit ->and($mcp->gatherMiddleware())->not->toContain(ApiAllowed::class) ->and($enable->gatherMiddleware())->not->toContain(ApiAllowed::class) ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class) - ->and($mcp->gatherMiddleware())->toContain(ApiIpAllowed::class) - ->and($enable->gatherMiddleware())->toContain(ApiIpAllowed::class) - ->and($disable->gatherMiddleware())->toContain(ApiIpAllowed::class); + ->and($mcp->gatherMiddleware())->toContain('api.ip.allowed') + ->and($enable->gatherMiddleware())->toContain('api.ip.allowed') + ->and($disable->gatherMiddleware())->toContain('api.ip.allowed'); }); it('throttles only failed authentication on manual webhook routes', function (string $provider) { diff --git a/tests/Feature/Api/ApplicationRollbackImagesApiTest.php b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php index 4e61000ba7..ac94113353 100644 --- a/tests/Feature/Api/ApplicationRollbackImagesApiTest.php +++ b/tests/Feature/Api/ApplicationRollbackImagesApiTest.php @@ -1,21 +1,13 @@ setAccessible(true); - - return $method->invoke(null, $imageReference); -} +use App\Actions\Application\GetRollbackImages; it('extracts the current rollback image tag when the registry includes a port', function () { - expect(currentRollbackImageTag('registry.example.com:5000/team/application:commit-sha')) + expect(GetRollbackImages::imageTag('registry.example.com:5000/team/application:commit-sha')) ->toBe('commit-sha'); }); it('does not treat a digest as the current rollback image tag', function () { - expect(currentRollbackImageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64))) + expect(GetRollbackImages::imageTag('registry.example.com:5000/team/application@sha256:'.str_repeat('a', 64))) ->toBeNull(); }); diff --git a/tests/Feature/Api/ApplicationSettingsApiTest.php b/tests/Feature/Api/ApplicationSettingsApiTest.php index 0b32f5bf07..6e46957222 100644 --- a/tests/Feature/Api/ApplicationSettingsApiTest.php +++ b/tests/Feature/Api/ApplicationSettingsApiTest.php @@ -295,6 +295,35 @@ test('deployment parsing keeps commas in raw custom label values', function () { ->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels)); }); +test('http basic auth hash settings are saved and applied to managed labels', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'is_http_basic_auth_enabled' => true, + 'http_basic_auth_username' => 'api-user', + 'http_basic_auth_password' => 'api-password', + ]); + + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['http_basic_auth_bcrypt_cost' => 4]) + ->assertOk(); + + expect($this->application->fresh()->parseContainerLabels())->toContain('.basicauth.users=api-user:$2y$04$'); +}); + +test('rejects http basic auth settings that are not supported', function (string $field, mixed $value) { + $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [$field => $value]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); +})->with([ + 'Argon2id on a proxy without it' => ['http_basic_auth_hash_algorithm', 'argon2id'], + 'unknown algorithm' => ['http_basic_auth_hash_algorithm', 'md5'], + 'bcrypt cost above 14' => ['http_basic_auth_bcrypt_cost', 15], + 'Argon2id memory above 256 MiB' => ['http_basic_auth_argon2id_memory_cost', 262145], + 'Argon2id iterations above 12' => ['http_basic_auth_argon2id_time_cost', 13], + 'password longer than 72 characters' => ['http_basic_auth_password', str_repeat('a', 73)], +]); + test('rejects invalid boolean application settings', function () { $this->withHeaders(applicationSettingsApiHeaders($this->bearerToken)) ->patchJson("/api/v1/applications/{$this->application->uuid}", [ diff --git a/tests/Feature/Api/BackupS3DestinationsApiTest.php b/tests/Feature/Api/BackupS3DestinationsApiTest.php new file mode 100644 index 0000000000..5428dd51b7 --- /dev/null +++ b/tests/Feature/Api/BackupS3DestinationsApiTest.php @@ -0,0 +1,292 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 's3-destinations-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->headers = ['Authorization' => 'Bearer '.$token->getKey().'|'.$plainTextToken]; + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->database = StandalonePostgresql::create([ + 'name' => 'db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'app', + 'image' => 'postgres:16', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->volume = LocalPersistentVolume::create([ + 'name' => 'data', + 'mount_path' => '/data', + 'resource_id' => $this->application->id, + 'resource_type' => $this->application->getMorphClass(), + ]); + + $this->firstS3 = createBackupDestinationS3($this->team, 'first'); + $this->secondS3 = createBackupDestinationS3($this->team, 'second'); + $this->otherTeamS3 = createBackupDestinationS3(Team::factory()->create(), 'foreign'); +}); + +function createBackupDestinationS3(Team $team, string $name): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => $name.'-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); +} + +/** + * @return list + */ +function backupDestinationUuids(ScheduledDatabaseBackup|ScheduledVolumeBackup $backup): array +{ + return $backup->refresh()->selectedS3Storages()->pluck('uuid')->all(); +} + +describe('database backups', function () { + test('creates a backup with several destinations and lists them primary first', function () { + $response = $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertCreated(); + + $backup = ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->sole(); + expect($backup->s3_storage_id)->toBe($this->secondS3->id) + ->and(backupDestinationUuids($backup))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]); + + $this->withHeaders($this->headers)->getJson("/api/v1/databases/{$this->database->uuid}/backups") + ->assertOk() + ->assertJsonPath('0.s3_storage_uuid', $this->secondS3->uuid) + ->assertJsonPath('0.s3_storage_uuids', [$this->secondS3->uuid, $this->firstS3->uuid]) + ->assertJsonMissingPath('0.s3_storages') + ->assertJsonMissingPath('0.s3'); + }); + + test('uses the first listed destination as primary when s3_storage_uuid is not sent', function () { + $response = $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->secondS3->uuid, $this->firstS3->uuid], + ])->assertCreated(); + + expect(ScheduledDatabaseBackup::where('uuid', $response->json('uuid'))->sole()->s3_storage_id)->toBe($this->secondS3->id); + }); + + test('a single s3_storage_uuid that is already a destination becomes primary and keeps the others', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]) + ->and($backup->s3_storage_id)->toBe($this->secondS3->id); + }); + + test('a single new s3_storage_uuid replaces every destination', function () { + $thirdS3 = createBackupDestinationS3($this->team, 'third'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id, $this->secondS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuid' => $thirdS3->uuid, + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$thirdS3->uuid]) + ->and($backup->s3_storage_id)->toBe($thirdS3->id); + }); + + test('updates the destination list', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + ])->assertOk(); + + expect(backupDestinationUuids($backup))->toBe([$this->firstS3->uuid, $this->secondS3->uuid]) + ->and((bool) $backup->save_s3)->toBeTrue(); + }); + + test('rejects an S3 storage of another team without changing the backup', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $this->firstS3->id, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + $backup->syncS3Storages([$this->firstS3->id]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuids' => [$this->firstS3->uuid, $this->otherTeamS3->uuid], + ])->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuids']) + ->assertDontSee($this->otherTeamS3->name); + + expect(backupDestinationUuids($backup))->toBe([$this->firstS3->uuid]); + }); + + test('rejects invalid destination combinations', function (array $payload, string $errorField) { + $payload = array_map(fn ($value) => is_array($value) + ? array_map(fn (string $name) => $this->{$name}->uuid, $value) + : (is_string($value) && property_exists($this, $value) ? $this->{$value}->uuid : $value), $payload); + + $this->withHeaders($this->headers)->postJson("/api/v1/databases/{$this->database->uuid}/backups", ['frequency' => 'daily', ...$payload]) + ->assertUnprocessable() + ->assertJsonValidationErrors([$errorField]); + + expect(ScheduledDatabaseBackup::count())->toBe(0); + })->with([ + 'primary outside the list' => [['s3_storage_uuids' => ['firstS3'], 's3_storage_uuid' => 'secondS3'], 's3_storage_uuid'], + 'save_s3 without destinations' => [['save_s3' => true, 's3_storage_uuids' => []], 's3_storage_uuid'], + 'foreign storage in the list' => [['save_s3' => true, 's3_storage_uuids' => ['firstS3', 'otherTeamS3']], 's3_storage_uuids'], + ]); + + test('rejects clearing the destinations of a backup that saves to S3', function () { + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_id' => $this->firstS3->id, + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + 'team_id' => $this->team->id, + ]); + + $this->withHeaders($this->headers)->patchJson("/api/v1/databases/{$this->database->uuid}/backups/{$backup->uuid}", [ + 's3_storage_uuids' => [], + ])->assertUnprocessable(); + + expect($backup->refresh()->s3_storage_id)->toBe($this->firstS3->id); + }); +}); + +describe('volume backups', function () { + test('sets several destinations and returns them primary first', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ]) + ->assertCreated() + ->assertJsonPath('s3_storage_uuid', $this->secondS3->uuid) + ->assertJsonPath('s3_storage_uuids', [$this->secondS3->uuid, $this->firstS3->uuid]); + + expect(backupDestinationUuids(ScheduledVolumeBackup::sole()))->toBe([$this->secondS3->uuid, $this->firstS3->uuid]); + }); + + test('a single s3_storage_uuid replaces every destination', function () { + $url = "/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups"; + $this->withHeaders($this->headers)->putJson($url, [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->secondS3->uuid], + ])->assertCreated(); + + $this->withHeaders($this->headers)->putJson($url, [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuid' => $this->secondS3->uuid, + ])->assertOk()->assertJsonPath('s3_storage_uuids', [$this->secondS3->uuid]); + + expect(backupDestinationUuids(ScheduledVolumeBackup::sole()))->toBe([$this->secondS3->uuid]); + }); + + test('rejects an S3 storage of another team', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid, $this->otherTeamS3->uuid], + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuids']) + ->assertDontSee($this->otherTeamS3->name); + + expect(ScheduledVolumeBackup::count())->toBe(0); + }); + + test('rejects a primary destination outside the list', function () { + $this->withHeaders($this->headers) + ->putJson("/api/v1/applications/{$this->application->uuid}/storages/{$this->volume->uuid}/backups", [ + 'frequency' => 'daily', + 'save_s3' => true, + 's3_storage_uuids' => [$this->firstS3->uuid], + 's3_storage_uuid' => $this->secondS3->uuid, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['s3_storage_uuid']); + }); +}); diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index d6bb96d15a..d5f4908ed8 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -192,6 +192,31 @@ describe('PATCH /api/v1/notifications/*', function () { ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('updates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/notifications/email') + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', false); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => true, + ]) + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', true); + + expect(EmailNotificationSettings::query()->where('team_id', $this->team->id)->value('deployment_commit_details_email_notifications'))->toBeTruthy(); + }); + + test('validates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('deployment_commit_details_email_notifications'); + }); + test('validates the smtp ehlo domain', function () { $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', [ diff --git a/tests/Feature/Api/PrivateKeyUpdateApiTest.php b/tests/Feature/Api/PrivateKeyUpdateApiTest.php new file mode 100644 index 0000000000..a525d872d4 --- /dev/null +++ b/tests/Feature/Api/PrivateKeyUpdateApiTest.php @@ -0,0 +1,64 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->keyMaterial = generateSSHKey('ed25519')['private']; + $this->key = PrivateKey::create(['name' => 'Old name', 'private_key' => $this->keyMaterial, 'team_id' => $this->team->id]); + + $token = $this->user->createToken('write-token', ['write']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + $this->bearerToken = $token->plainTextToken; +}); + +function patchPrivateKeyUpdateApi(object $test, string $uuid, string $bearerToken, array $payload): TestResponse +{ + return $test->withHeaders(['Authorization' => 'Bearer '.$bearerToken]) + ->patchJson('/api/v1/security/keys/'.$uuid, $payload); +} + +test('a private key can be renamed without sending the key again', function () { + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['name' => 'New name', 'description' => 'Renamed']) + ->assertCreated(); + + $fresh = $this->key->fresh(); + expect($fresh->name)->toBe('New name') + ->and($fresh->description)->toBe('Renamed') + ->and($fresh->private_key)->toBe($this->key->private_key) + ->and($fresh->fingerprint)->toBe($this->key->fingerprint); +}); + +test('a private key update still replaces and validates the key when it is sent', function () { + $newKey = generateSSHKey('ed25519')['private']; + + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['private_key' => $newKey])->assertCreated(); + expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($newKey)); + + patchPrivateKeyUpdateApi($this, $this->key->uuid, $this->bearerToken, ['private_key' => 'not a key'])->assertUnprocessable(); + expect($this->key->fresh()->fingerprint)->toBe(PrivateKey::generateFingerprint($newKey)); +}); + +test('a private key of another team cannot be renamed', function () { + $otherTeam = Team::factory()->create(); + $foreign = PrivateKey::create(['name' => 'Theirs', 'private_key' => generateSSHKey('ed25519')['private'], 'team_id' => $otherTeam->id]); + + patchPrivateKeyUpdateApi($this, $foreign->uuid, $this->bearerToken, ['name' => 'Hijacked'])->assertNotFound(); + expect($foreign->fresh()->name)->toBe('Theirs'); +}); diff --git a/tests/Feature/Api/ServerSubsystemsApiTest.php b/tests/Feature/Api/ServerSubsystemsApiTest.php index 631ae83241..a131bf22ab 100644 --- a/tests/Feature/Api/ServerSubsystemsApiTest.php +++ b/tests/Feature/Api/ServerSubsystemsApiTest.php @@ -240,6 +240,89 @@ describe('Log drains API', function () { expect($this->server->settings->fresh()->logdrain_custom_config)->toBe("[OUTPUT]\n Name stdout"); }); + test('PATCH enables the Amazon CloudWatch Logs drain', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => '/coolify/prod', + 'logdrain_cloudwatch_region' => 'us-east-1', + 'logdrain_cloudwatch_stream_prefix' => 'docker/', + ]) + ->assertOk() + ->assertJsonPath('is_logdrain_cloudwatch_enabled', true) + ->assertJsonPath('logdrain_cloudwatch_group', '/coolify/prod') + ->assertJsonPath('logdrain_cloudwatch_stream_prefix', 'docker/'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1'); + }); + + test('PATCH rejects enabling CloudWatch without a log group', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_group'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects enabling CloudWatch without a region', function () { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('logdrain_cloudwatch_region'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); + }); + + test('PATCH rejects CloudWatch values that could break the compose file', function (string $field, mixed $value) { + Queue::fake(); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + $field => $value, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors($field); + })->with([ + 'prefix with interpolation' => ['logdrain_cloudwatch_stream_prefix', 'docker/${HOME}'], + 'prefix with colon' => ['logdrain_cloudwatch_stream_prefix', 'docker:'], + 'prefix with template braces' => ['logdrain_cloudwatch_stream_prefix', '{{.ID}}'], + 'invalid region' => ['logdrain_cloudwatch_region', 'not a region'], + 'group with spaces' => ['logdrain_cloudwatch_group', 'my group'], + ]); + + test('PATCH rejects enabling CloudWatch together with another log drain', function () { + Queue::fake(); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + ]); + + $this->withHeaders(serverSubsystemsHeaders()) + ->patchJson("/api/v1/servers/{$this->server->uuid}/log-drains", [ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_logdrain_cloudwatch_enabled'); + }); + test('other-team log drains endpoints return 404', function () { $this->withHeaders(serverSubsystemsHeaders()) ->getJson("/api/v1/servers/{$this->otherServer->uuid}/log-drains") diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php index ad1f6d4387..c1d9aa536a 100644 --- a/tests/Feature/Api/ServerTransferApiTest.php +++ b/tests/Feature/Api/ServerTransferApiTest.php @@ -1,8 +1,13 @@ 'local']); - + // A claim writes the owner marker on the host over SSH. + Process::fake(); + // An import validates the claimed server over SSH; the sync test queue would run it. + Queue::fake([ValidateAndInstallServerJob::class]); Storage::fake('ssh-keys'); InstanceSettings::forceCreate([ @@ -38,6 +46,7 @@ beforeEach(function () { $this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken; $this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken; $this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken; + $this->importToken = $this->user->createToken('transfer-import', ['read', 'write', 'deploy'])->plainTextToken; $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); $this->server = Server::factory()->create([ @@ -78,19 +87,19 @@ beforeEach(function () { }); }); -test('server transfer API is unavailable outside development mode', function (string $method, string $uri) { +test('server transfer API is available in production', function () { config(['app.env' => 'production']); $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->json($method, str_replace('{uuid}', $this->server->uuid, $uri)) - ->assertNotFound(); -})->with([ - ['POST', '/api/v1/servers/import'], - ['GET', '/api/v1/servers/{uuid}/export'], - ['POST', '/api/v1/servers/{uuid}/export/mailbox'], - ['POST', '/api/v1/servers/{uuid}/transfer/complete'], - ['POST', '/api/v1/servers/{uuid}/migrate'], -]); + ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") + ->assertOk() + ->assertJsonPath('server_uuid', $this->server->uuid); +}); + +function transferExportForApiTest(Server $server): array +{ + return app(ServerTransferExporter::class)->export($server); +} function transferHeaders(string $token): array { @@ -101,53 +110,15 @@ function transferHeaders(string $token): array ]; } -describe('GET /api/v1/servers/{uuid}/export', function () { - test('exports bundle with sensitive token', function () { - $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export"); - - $response->assertOk() - ->assertJsonPath('schema_version', ServerTransferBundle::SCHEMA_VERSION) - ->assertJsonPath('server.uuid', $this->server->uuid) - ->assertJsonPath('server.ip', '10.66.0.20'); - - $envs = collect($response->json('projects.0.environments.0.applications.0.environment_variables')); - $tokenEnv = $envs->firstWhere('key', 'API_TOKEN'); - - expect($response->json('private_key.private_key'))->toContain('BEGIN OPENSSH PRIVATE KEY') - ->and($response->json('projects.0.environments.0.applications.0.uuid'))->toBe($this->application->uuid) - ->and($tokenEnv)->not->toBeNull() - ->and($tokenEnv['value'])->toBe('token-value-123'); - }); - - test('rejects token without read:sensitive', function () { - $this->withHeaders(transferHeaders($this->readToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->assertForbidden(); - }); - - test('returns 404 for unknown or other team server uuid', function () { - $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson('/api/v1/servers/not-a-real-server-uuid/export') - ->assertNotFound(); - }); - - test('can return passphrase-encrypted envelope', function () { - $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export?encrypt=1&passphrase=secret-pass"); - - $response->assertOk() - ->assertJsonPath('encrypted', true); - - expect($response->json('payload'))->toBeString()->not->toBeEmpty(); - }); -}); +test('the manual export endpoints no longer exist', function (string $path) { + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}{$path}", ['passphrase' => 'correct horse battery']) + ->assertNotFound(); +})->with(['/export', '/export/mailbox']); describe('POST /api/v1/servers/import', function () { test('dry run does not create resources', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); + $export = transferExportForApiTest($this->server); $before = Server::count(); @@ -164,9 +135,7 @@ describe('POST /api/v1/servers/import', function () { }); test('imports after source handoff and preserves application uuid', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); + $export = transferExportForApiTest($this->server); $appUuid = $this->application->uuid; $serverUuid = $this->server->uuid; @@ -195,51 +164,63 @@ describe('POST /api/v1/servers/import', function () { ->toBe('claimed'); }); - test('imports encrypted bundle with passphrase', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); - $encrypted = ServerTransferBundle::encryptWithPassphrase($export, 'mailbox-pass'); - - $this->application->forceDelete(); - $this->server->forceDelete(); - $this->privateKey->delete(); - - $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->postJson('/api/v1/servers/import', [ - 'bundle' => $encrypted, - 'passphrase' => 'mailbox-pass', - ]) - ->assertCreated() - ->assertJsonPath('server_uuid', $export['server']['uuid']); - }); - - test('rejects encrypted bundle without passphrase', function () { - $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->postJson('/api/v1/servers/import', [ - 'bundle' => ['encrypted' => true, 'payload' => 'abc', 'schema_version' => 1], - ]) - ->assertStatus(422); - }); - test('rejects import when ip still exists', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); + $export = transferExportForApiTest($this->server); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson('/api/v1/servers/import', ['bundle' => $export]) ->assertStatus(422) - ->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true); + ->assertJsonValidationErrors(['server.uuid']); + }); + + test('does not reveal the server of another team when the ip exists', function () { + $export = transferExportForApiTest($this->server); + $this->application->forceDelete(); + $this->server->forceDelete(); + $otherServer = Server::factory()->create([ + 'team_id' => Team::factory()->create()->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.66.0.20', + ]); + + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export]) + ->assertStatus(422); + + expect($response->getContent())->toContain('already exists') + ->not->toContain($otherServer->uuid); + }); + + test('rejects a bundle from a newer Coolify version', function () { + $export = transferExportForApiTest($this->server); + $export['coolify_version'] = '99.0.0'; + $this->application->forceDelete(); + $this->server->forceDelete(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export]) + ->assertStatus(422) + ->assertJsonPath('errors.bundle.0', fn ($message) => str_contains($message, 'Upgrade this instance')); + + expect(Server::where('uuid', $export['server']['uuid'])->exists())->toBeFalse(); + }); + + test('adopt mode requires the original uuids', function () { + $export = transferExportForApiTest($this->server); + $this->application->forceDelete(); + $this->server->forceDelete(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export, 'preserve_uuids' => false, 'adopt_mode' => true]) + ->assertStatus(422) + ->assertJsonValidationErrors(['preserve_uuids']); }); test('rejects import when the destination team is at its cloud server limit', function () { config()->set('constants.coolify.self_hosted', false); $this->team->update(['custom_server_limit' => 1]); - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); + $export = transferExportForApiTest($this->server); $this->server->forceDelete(); Server::factory()->create([ @@ -259,9 +240,7 @@ describe('POST /api/v1/servers/import', function () { describe('POST /api/v1/servers/import claim', function () { test('import takes management of the server and rebinds Sentinel', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->json(); + $export = transferExportForApiTest($this->server); $this->application->forceDelete(); $this->server->forceDelete(); @@ -315,10 +294,7 @@ describe('POST /api/v1/servers/{uuid}/transfer/complete', function () { describe('full transfer flow A to B on same process', function () { test('export complete import sequence', function () { - $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) - ->getJson("/api/v1/servers/{$this->server->uuid}/export") - ->assertOk() - ->json(); + $export = transferExportForApiTest($this->server); $this->withHeaders(transferHeaders($this->sensitiveToken)) ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [ @@ -358,15 +334,151 @@ describe('full transfer flow A to B on same process', function () { }); }); -describe('ServerTransferClaimer unit-ish via container', function () { - test('writeMailbox returns path even when remote fails in tests', function () { - $claimer = app(ServerTransferClaimer::class); - $exporter = app(ServerTransferExporter::class); - $bundle = $exporter->export($this->server); +describe('POST /api/v1/servers/{uuid}/migrate', function () { + test('starts the transfer in a queued job', function () { + Queue::fake(); + Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); - $result = $claimer->writeMailbox($this->server, $bundle, 'pass'); + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ + 'target_url' => 'https://8.8.8.8', + 'target_token' => 'target-token', + ]) + ->assertStatus(202) + ->assertJsonPath('migration.status', 'running'); - expect($result['path'])->toContain('/data/coolify/exports/server-transfer-') - ->and($result)->toHaveKey('written'); + Queue::assertPushed(MigrateServerToInstanceJob::class, fn ($job) => $job->server->is($this->server) && $job->targetToken === 'target-token'); + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/transfer") + ->assertOk() + ->assertJsonPath('migration.status', 'running') + ->assertJsonPath('migration.target_url', 'https://8.8.8.8'); + }); + + test('rejects a target without https', function () { + Queue::fake(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ + 'target_url' => 'http://8.8.8.8', + 'target_token' => 'target-token', + ]) + ->assertStatus(422) + ->assertJsonPath('message', fn ($message) => str_contains($message, 'https')); + + Queue::assertNothingPushed(); + }); + + test('rejects a transfer while a deployment runs on the server', function () { + Queue::fake(); + ApplicationDeploymentQueue::create([ + 'application_id' => (string) $this->application->id, + 'deployment_uuid' => new_public_id(), + 'server_id' => $this->server->id, + 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, + ]); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [ + 'target_url' => 'https://8.8.8.8', + 'target_token' => 'target-token', + ]) + ->assertStatus(422) + ->assertJsonPath('message', fn ($message) => str_contains($message, 'deployment')); + + Queue::assertNothingPushed(); }); }); + +describe('GET /api/v1/servers/import/check', function () { + test('accepts a server that this instance can import and reach', function () { + $listener = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); + + $this->withHeaders(transferHeaders($this->importToken)) + ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") + ->assertOk() + ->assertJsonPath('checks', fn ($checks) => collect($checks)->every(fn ($check) => $check['ok'] === true) + && collect($checks)->pluck('label')->contains("the target can reach the SSH port of the server (127.0.0.1:{$port})")); + + fclose($listener); + }); + + test('rejects a server whose SSH port this instance cannot reach', function () { + $listener = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); + fclose($listener); + + $this->withHeaders(transferHeaders($this->importToken)) + ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") + ->assertStatus(422) + ->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot reach the server at 127.0.0.1')); + }); + + test('rejects a target that is not an IP address or hostname before it opens a socket', function (string $ip) { + $this->withHeaders(transferHeaders($this->importToken)) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip)) + ->assertStatus(422) + ->assertJsonValidationErrors('ip') + ->assertJsonMissingPath('checks'); + })->with(['tcp://127.0.0.1', 'unix:///var/run/docker.sock', 'ssl://example.com']); + + test('rejects another transfer bundle version', function () { + $this->withHeaders(transferHeaders($this->importToken)) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&schema_version=999') + ->assertStatus(422) + ->assertJsonPath('message', fn ($message) => str_contains($message, 'Upgrade both instances')); + }); + + test('lists every check with its result when a server already exists on this instance', function () { + $listener = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); + $this->server->forceFill(['ip' => '127.0.0.1'])->save(); + + $checks = $this->withHeaders(transferHeaders($this->importToken)) + ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}") + ->assertStatus(422) + ->assertJsonPath('message', fn ($message) => str_contains($message, 'already exists on this instance')) + ->json('checks'); + + expect(collect($checks)->pluck('ok', 'label')->all())->toMatchArray([ + 'the transfer bundle version is supported' => true, + 'no server with the same UUID exists' => true, + 'no server with the same IP exists' => false, + "the target can reach the SSH port of the server (127.0.0.1:{$port})" => true, + ]); + fclose($listener); + }); + + test('rejects a token without the write permission', function () { + $this->withHeaders(transferHeaders($this->readToken)) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') + ->assertForbidden(); + }); + + test('rejects a token without the deploy permission that the import needs', function () { + $this->withHeaders(transferHeaders($this->writeToken)) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') + ->assertForbidden(); + }); + + test('rejects an invalid token', function () { + $this->withHeaders(transferHeaders('1|not-a-real-token')) + ->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99') + ->assertUnauthorized(); + }); +}); + +test('the import check lists the system-wide git sources that this instance does not have', function () { + $present = GithubApp::create(['name' => 'present', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true]); + $listener = stream_socket_server('tcp://127.0.0.1:0'); + $port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1); + + $this->withHeaders(transferHeaders($this->importToken)) + ->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}&system_github_apps[]={$present->uuid}&system_github_apps[]=missing-uuid&system_gitlab_apps[]=other-missing") + ->assertOk() + ->assertJsonPath('missing_system_sources.github', ['missing-uuid']) + ->assertJsonPath('missing_system_sources.gitlab', ['other-missing']); + + fclose($listener); +}); diff --git a/tests/Feature/Api/StateChangingRoutesUsePostTest.php b/tests/Feature/Api/StateChangingRoutesUsePostTest.php index f48722d8fd..a44eb7d7ec 100644 --- a/tests/Feature/Api/StateChangingRoutesUsePostTest.php +++ b/tests/Feature/Api/StateChangingRoutesUsePostTest.php @@ -14,7 +14,6 @@ it('uses POST for state changes and keeps GET as a non-mutating compatibility re ->and($getRoute)->not->toBeNull() ->and($getRoute->getActionName())->toBe(OtherController::class.'@post_required'); })->with([ - 'enable API' => 'api/v1/enable', 'disable API' => 'api/v1/disable', 'deploy' => 'api/v1/deploy', 'validate server' => 'api/v1/servers/{uuid}/validate', diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 7a57b91a2e..f17a59e5df 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -155,7 +155,7 @@ it('keeps deployment logging available if value formatting fails', function () { [$application, $server] = makeDeploymentControlVarFixture(); $variable = new class extends EnvironmentVariable { - public function logRedactionValues(): array + public function logRedactionValues(?Collection $sharedVariables = null): array { throw new RuntimeException('Harmless formatting failure'); } @@ -1546,7 +1546,7 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool expect($variables->get('APP_ENV'))->toBe('production'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); expect($variables->get('RAILPACK_INSTALL_CMD'))->toBe('pnpm install --frozen-lockfile'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('RUNTIME_ONLY'))->toBeFalse(); @@ -1631,7 +1631,7 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func expect($variables->get('PREVIEW_BUILD_FLAG'))->toBe('enabled'); expect($variables->get('RAILPACK_NODE_VERSION'))->toBe('20'); - expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('curl wget'); + expect($variables->get('RAILPACK_DEPLOY_APT_PACKAGES'))->toBe('... curl wget'); expect($variables->get('COOLIFY_RESOURCE_UUID'))->toBe($application->uuid); expect($variables->has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); diff --git a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php index d842d7c546..3d163f1a81 100644 --- a/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php +++ b/tests/Feature/ApplicationGeneralBuildpackSelectorTest.php @@ -122,6 +122,26 @@ test('switching from railpack to compose preserves the existing application doma ->toBe('https://example.com,https://www.example.com'); }); +test('Dockerfile location defaults to the Dockerfile only for the Dockerfile build pack', function (string $buildPack, ?string $expected) { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => $buildPack, + 'static_image' => 'nginx:alpine', + 'base_directory' => '/', + 'is_http_basic_auth_enabled' => false, + 'redirect' => 'no', + ]); + + Livewire::test(General::class, ['application' => $application]) + ->assertSuccessful() + ->assertSet('dockerfileLocation', $expected); +})->with([ + 'Dockerfile' => ['dockerfile', '/Dockerfile'], + 'Nixpacks' => ['nixpacks', null], +]); + test('networking section hints that internal ports can be set per domain', function () { $application = Application::factory()->create([ 'environment_id' => $this->environment->id, diff --git a/tests/Feature/ApplicationGpuSettingsTest.php b/tests/Feature/ApplicationGpuSettingsTest.php new file mode 100644 index 0000000000..0f32b3f8b2 --- /dev/null +++ b/tests/Feature/ApplicationGpuSettingsTest.php @@ -0,0 +1,175 @@ +create(); + $team->members()->attach(auth()->id(), ['role' => 'owner']); + session(['currentTeam' => $team]); + $server = Server::factory()->create(['team_id' => $team->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return Application::create([ + 'name' => 'gpu-settings-test-app', + 'git_repository' => 'https://github.com/coollabsio/coolify', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $server->standaloneDockers()->firstOrFail()->id, + 'destination_type' => $server->standaloneDockers()->firstOrFail()->getMorphClass(), + ]); +} + +it('builds a valid compose device request from the GPU settings', function (array $settings, array $expected) { + $setting = new ApplicationSetting(['gpu_driver' => 'nvidia', ...$settings]); + + expect($setting->gpuDeviceReservation())->toBe($expected); +})->with([ + 'all gpus' => [[], ['capabilities' => ['gpu'], 'driver' => 'nvidia']], + 'count' => [['gpu_count' => '2'], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'count' => 2]], + 'count all' => [['gpu_count' => 'all'], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'count' => 'all']], + 'device ids' => [['gpu_device_ids' => '0, 2,'], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'device_ids' => ['0', '2']]], + 'count wins over device ids' => [['gpu_count' => '1', 'gpu_device_ids' => '0'], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'count' => 1]], + 'blank driver' => [['gpu_driver' => ''], ['capabilities' => ['gpu']]], + 'key=value options' => [['gpu_options' => "virtualization=false\nfoo = bar"], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'options' => ['virtualization' => 'false', 'foo' => 'bar']]], + 'option value with commas' => [['gpu_options' => 'capabilities=compute,utility'], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'options' => ['capabilities' => 'compute,utility']]], + 'yaml options' => [['gpu_options' => "virtualization: false\nlevel: 2"], ['capabilities' => ['gpu'], 'driver' => 'nvidia', 'options' => ['virtualization' => 'false', 'level' => '2']]], + 'empty options' => [['gpu_options' => ''], ['capabilities' => ['gpu'], 'driver' => 'nvidia']], + 'invalid options are left out' => [['gpu_options' => 'not an option'], ['capabilities' => ['gpu'], 'driver' => 'nvidia']], +]); + +describe('advanced GPU form', function () { + beforeEach(function () { + $this->actingAs(User::factory()->create()); + }); + + it('saves valid GPU options', function () { + $application = createApplicationForGpuSettingsTest(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set('gpuCount', '2') + ->set('gpuOptions', 'virtualization=false') + ->call('submit') + ->assertDispatched('success'); + + $settings = $application->settings()->first(); + expect($settings->gpu_count)->toBe('2') + ->and($settings->gpu_options)->toBe('virtualization=false'); + }); + + it('rejects invalid GPU values', function (string $property, string $value) { + $application = createApplicationForGpuSettingsTest(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->set($property, $value) + ->call('submit') + ->assertDispatched('error') + ->assertNotDispatched('success'); + })->with([ + 'count' => ['gpuCount', 'two'], + 'options' => ['gpuOptions', 'not an option'], + ]); + + it('does not block other settings when a stored GPU value is invalid', function () { + $application = createApplicationForGpuSettingsTest(); + $application->settings->update(['gpu_options' => 'legacy value', 'gpu_count' => 'some']); + + Livewire::test(Advanced::class, ['application' => $application->fresh()]) + ->set('includeSourceCommitInBuild', true) + ->call('submit') + ->assertDispatched('success'); + + expect($application->settings()->first()->include_source_commit_in_build)->toBeTrue(); + }); + + it('keeps a stored GPU count and device IDs pair when other settings are saved', function () { + $application = createApplicationForGpuSettingsTest(); + $application->settings->update(['gpu_count' => '1', 'gpu_device_ids' => '0']); + + Livewire::test(Advanced::class, ['application' => $application->fresh()]) + ->set('includeSourceCommitInBuild', true) + ->call('submit') + ->assertDispatched('success'); + + $settings = $application->settings()->first(); + expect($settings->gpu_count)->toBe('1') + ->and($settings->gpu_device_ids)->toBe('0') + ->and($settings->include_source_commit_in_build)->toBeTrue(); + }); + + it('refreshes the GPUs of the application server', function () { + $application = createApplicationForGpuSettingsTest(); + $server = $application->destination->server; + $server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $server->team_id])->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + Process::fake(['*' => Process::result(output: "---GPUS---\n0, GPU-1a2b, 81559, NVIDIA H100\n---NVIDIA_TOOLKIT---\n/usr/bin/nvidia-ctk")]); + + Livewire::test(Advanced::class, ['application' => $application]) + ->call('refreshGpus') + ->assertDispatched('success') + ->assertSee('NVIDIA H100'); + + expect($server->fresh()->detectedGpus())->toBe([['index' => '0', 'uuid' => 'GPU-1a2b', 'memory_mb' => 81559, 'name' => 'NVIDIA H100']]); + }); + + it('does not refresh GPUs when no server is validated', function () { + $application = createApplicationForGpuSettingsTest(); + $application->destination->server->settings->update(['is_reachable' => false, 'is_usable' => false]); + Process::fake(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->call('refreshGpus') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + Process::assertNothingRan(); + }); + + it('does not let a team member refresh GPUs', function () { + $application = createApplicationForGpuSettingsTest(); + $server = $application->destination->server; + $server->settings->update(['is_reachable' => true, 'is_usable' => true]); + $member = User::factory()->create(); + $application->team()->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + Process::fake(); + + Livewire::test(Advanced::class, ['application' => $application]) + ->call('refreshGpus') + ->assertNotDispatched('success'); + + Process::assertNothingRan(); + expect($server->fresh()->detectedGpus())->toBeNull(); + }); + + it('rejects a change that sets both GPU count and device IDs without clearing stored values', function () { + $application = createApplicationForGpuSettingsTest(); + $application->settings->update(['gpu_device_ids' => '0']); + + Livewire::test(Advanced::class, ['application' => $application->fresh()]) + ->set('gpuCount', '2') + ->call('submit') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + $settings = $application->settings()->first(); + expect($settings->gpu_count)->toBeNull() + ->and($settings->gpu_device_ids)->toBe('0'); + }); +}); diff --git a/tests/Feature/ApplicationHttpBasicAuthUiTest.php b/tests/Feature/ApplicationHttpBasicAuthUiTest.php index 78d42bd481..666cddec44 100644 --- a/tests/Feature/ApplicationHttpBasicAuthUiTest.php +++ b/tests/Feature/ApplicationHttpBasicAuthUiTest.php @@ -1,5 +1,6 @@ team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); @@ -47,9 +49,12 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== ?? StandaloneDocker::factory()->create(['server_id' => $this->server->id, 'network' => 'coolify-test']); }); +afterEach(fn () => Server::flushIdentityMap()); + function createApplicationForHttpBasicAuth(array $overrides = []): Application { return Application::factory()->create(array_merge([ + 'name' => 'Basic Auth App', 'environment_id' => test()->environment->id, 'destination_id' => test()->destination->id, 'destination_type' => StandaloneDocker::class, @@ -112,3 +117,68 @@ test('disabling http basic auth is saved instantly', function () { expect((bool) $application->is_http_basic_auth_enabled)->toBeFalse(); }); + +function useCaddyProxyForHttpBasicAuth(string $image): void +{ + test()->server->forceFill(['proxy' => [ + 'type' => ProxyTypes::CADDY->value, + 'status' => 'running', + 'last_saved_settings' => 'applied', + 'last_applied_settings' => 'applied', + 'last_saved_proxy_configuration' => "services:\n caddy:\n image: '{$image}'\n", + ]])->save(); +} + +test('changing the bcrypt cost factor is saved instantly and applied to the proxy labels', function () { + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthBcryptCost', 4) + ->call('instantSave') + ->assertDispatched('success'); + + $application->refresh(); + + expect($application->http_basic_auth_bcrypt_cost)->toBe(4) + ->and(base64_decode($application->custom_labels))->toContain('.basicauth.users=admin:$2y$04$'); +}); + +test('selecting Argon2id is saved instantly and applied to the Caddy labels', function () { + useCaddyProxyForHttpBasicAuth('lucaslorentz/caddy-docker-proxy:2.13-alpine'); + $application = createApplicationForHttpBasicAuth(['fqdn' => 'https://app.example.com']); + + Livewire::test(General::class, ['application' => $application]) + ->set('httpBasicAuthHashAlgorithm', 'argon2id') + ->set('httpBasicAuthArgon2idMemoryCost', 8192) + ->call('instantSave') + ->assertDispatched('success') + ->assertSee('Iterations') + ->assertDontSee('Cost factor'); + + expect(base64_decode($application->refresh()->custom_labels)) + ->toContain('caddy_0.basic_auth=argon2id', 'caddy_0.basic_auth.admin="$argon2id$v=19$m=8192,t=4,p=1$'); +}); + +test('http basic auth form explains why Argon2id cannot be selected', function (?string $image, string $reason) { + if ($image !== null) { + useCaddyProxyForHttpBasicAuth($image); + } + + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->assertSee($reason); +})->with([ + 'Traefik' => [null, 'Only supported by the Caddy proxy'], + 'Caddy 2.10' => ['lucaslorentz/caddy-docker-proxy:2.10-alpine', 'Needs Caddy 2.11 or newer on this server'], +]); + +test('an unsupported http basic auth setting is rejected', function (string $property, mixed $value) { + Livewire::test(General::class, ['application' => createApplicationForHttpBasicAuth()]) + ->set($property, $value) + ->call('instantSave') + ->assertDispatched('error') + ->assertNotDispatched('success'); +})->with([ + 'Argon2id on a proxy without it' => ['httpBasicAuthHashAlgorithm', 'argon2id'], + 'bcrypt cost above 14' => ['httpBasicAuthBcryptCost', 15], + 'password longer than 72 characters' => ['httpBasicAuthPassword', str_repeat('a', 73)], +]); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index 5d6fd1c3dc..9dd7633d0f 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Application\Source; use App\Models\Application; use App\Models\Environment; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\InstanceSettings; use App\Models\PrivateKey; @@ -242,3 +243,47 @@ describe('after the user switches the session team', function () { expect($this->applicationA->refresh()->source_id)->toBe($this->systemWideGithubApp->id); }); }); + +function applicationSourceGiteaAppForTeam(int $teamId, string $name): GiteaApp +{ + // Http::GitSource() pins DNS before Http::fake() answers, so use an IP literal. + return GiteaApp::create([ + 'name' => $name, + 'team_id' => $teamId, + 'api_url' => 'https://1.1.1.1/api/v1', + 'html_url' => 'https://1.1.1.1', + 'access_token' => 'gitea-access-token', + 'refresh_token' => 'gitea-refresh-token', + 'expires_at' => time() + 3600, + 'is_public' => false, + ]); +} + +test('changeSource switches to an owned GiteaApp and stores the Gitea repository id', function () { + Http::fake([ + '1.1.1.1/api/v1/repos/acme/web' => Http::response(['id' => 314, 'full_name' => 'acme/web']), + ]); + $source = applicationSourceGiteaAppForTeam($this->teamA->id, 'own-gitea-app'); + $this->applicationA->update(['git_repository' => 'acme/web']); + + Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]) + ->call('changeSource', $source->id, GiteaApp::class) + ->assertDispatched('configurationChanged'); + + $this->applicationA->refresh(); + expect($this->applicationA->source_type)->toBe(GiteaApp::class) + ->and($this->applicationA->source_id)->toBe($source->id) + ->and($this->applicationA->repository_project_id)->toBe(314); +}); + +test('changeSource rejects a GiteaApp owned by another team', function () { + Http::fake(); + $victimSource = applicationSourceGiteaAppForTeam($this->teamB->id, 'victim-gitea-app'); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('changeSource', $victimSource->id, GiteaApp::class); + + $this->applicationA->refresh(); + expect($this->applicationA->source_type)->not->toBe(GiteaApp::class); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Auth/PasskeyAccessTest.php b/tests/Feature/Auth/PasskeyAccessTest.php new file mode 100644 index 0000000000..8d2a9d5fca --- /dev/null +++ b/tests/Feature/Auth/PasskeyAccessTest.php @@ -0,0 +1,98 @@ +withoutDefer(); + config()->set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); + Log::spy(); + + $this->user = User::factory()->create(['name' => 'Passkey User']); + $this->team = $this->user->teams()->sole(); +}); + +it('allows the authenticated passkey paths for unsubscribed and boarding accounts', function () { + expect(allowedPathsForBoardingAccounts())->toContain( + 'user/passkeys', + 'user/passkeys/options', + 'passkeys/confirm', + 'passkeys/confirm/options', + 'profile/passkeys/confirm', + ); +}); + +it('sets the session team after a login that did not set one', function () { + $this->actingAs($this->user)->get('/profile')->assertOk(); + + expect(session('currentTeam')->id)->toBe($this->team->id); +}); + +it('sends a multi team user without a stored team to the team selection', function () { + $other = Team::factory()->create(); + $other->members()->attach($this->user->id, ['role' => 'member']); + + $this->actingAs($this->user)->get('/profile')->assertRedirect(route('team.select')); +}); + +it('asks for the password before the add passkey form opens', function () { + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('password.confirm')); + + $this->actingAs($this->user) + ->withSession(['currentTeam' => $this->team, 'auth.password_confirmed_at' => time()]) + ->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); +}); + +it('lets restricted accounts confirm their password before adding a passkey', function (bool $selfHosted) { + config()->set('constants.coolify.self_hosted', $selfHosted); + $this->team->update(['show_boarding' => true]); + $this->actingAs($this->user)->withSession(['currentTeam' => $this->team]); + + $this->get(route('profile.passkeys.confirm'))->assertRedirect(route('password.confirm')); + $this->get(route('password.confirm'))->assertOk(); + $this->post(route('password.confirm.store'), ['password' => 'wrong-password']) + ->assertSessionHasErrors('password'); + expect(session('auth.password_confirmed_at'))->toBeNull(); + + $this->post(route('password.confirm.store'), ['password' => 'password']) + ->assertRedirect(route('profile.passkeys.confirm')); + expect(session('auth.password_confirmed_at'))->toBeInt(); + $this->get(route('profile.passkeys.confirm')) + ->assertRedirect(route('profile')) + ->assertSessionHas('status', 'passkey-add'); + $this->getJson(route('passkey.registration-options'))->assertOk(); +})->with(['onboarding' => true, 'cloud without a subscription' => false]); + +it('records audit events for passkey changes', function (string $eventClass, string $auditName) { + $passkey = $this->user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + event(new $eventClass($this->user, $passkey)); + + expect(AuditEvent::query()->where('event', $auditName)->count())->toBe(1); +})->with([ + [PasskeyRegistered::class, 'auth.user.passkey_registered'], + [PasskeyVerified::class, 'auth.user.passkey_verified'], + [PasskeyDeleted::class, 'auth.user.passkey_deleted'], +]); diff --git a/tests/Feature/Auth/PasskeyInstanceToggleTest.php b/tests/Feature/Auth/PasskeyInstanceToggleTest.php new file mode 100644 index 0000000000..facac26208 --- /dev/null +++ b/tests/Feature/Auth/PasskeyInstanceToggleTest.php @@ -0,0 +1,39 @@ +withoutDefer(); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); +}); + +it('lets an instance admin turn passkeys off and keeps stored passkeys', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + $admin = User::factory()->create(); + $rootTeam->members()->attach($admin->id, ['role' => 'owner']); + $admin->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + $this->actingAs($admin); + session(['currentTeam' => $rootTeam]); + + Livewire::test(Advanced::class) + ->assertSet('is_passkey_enabled', true) + ->set('is_passkey_enabled', false) + ->call('instantSave'); + + expect(InstanceSettings::findOrFail(0)->is_passkey_enabled)->toBeFalse() + ->and($admin->passkeys()->count())->toBe(1); +}); diff --git a/tests/Feature/Auth/PasskeyRelyingPartyTest.php b/tests/Feature/Auth/PasskeyRelyingPartyTest.php new file mode 100644 index 0000000000..d6008717e1 --- /dev/null +++ b/tests/Feature/Auth/PasskeyRelyingPartyTest.php @@ -0,0 +1,100 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + $this->withoutVite(); +}); + +function setPasskeyInstance(?string $fqdn, string $appUrl, bool $enabled = true): void +{ + InstanceSettings::query()->where('id', 0)->update(['fqdn' => $fqdn, 'is_passkey_enabled' => $enabled]); + config()->set('app.url', $appUrl); + Once::flush(); +} + +it('resolves the relying party', function (?string $fqdn, string $appUrl, string $requestUrl, ?array $expected) { + setPasskeyInstance($fqdn, $appUrl); + + expect(PasskeyRelyingParty::resolve(Request::create($requestUrl)))->toBe($expected); +})->with([ + 'https fqdn' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'fqdn behind http proxy' => ['https://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'app url fallback keeps the port' => [null, 'https://dev.example.ts.net:8443', 'https://dev.example.ts.net:8443/login', + ['id' => 'dev.example.ts.net', 'origins' => ['https://dev.example.ts.net:8443']]], + 'localhost uses the request origin' => [null, 'http://localhost', 'http://localhost:8000/login', + ['id' => 'localhost', 'origins' => ['http://localhost:8000']]], + 'fqdn on a custom port' => ['https://coolify.example.com', 'http://localhost', 'https://coolify.example.com:8443/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com', 'https://coolify.example.com:8443']]], + 'fqdn with uppercase letters' => ['https://Coolify.Example.com', 'http://localhost', 'https://coolify.example.com/login', + ['id' => 'coolify.example.com', 'origins' => ['https://coolify.example.com']]], + 'http fqdn' => ['http://coolify.example.com', 'http://localhost', 'http://coolify.example.com/login', null], + 'raw ip' => [null, 'https://203.0.113.10', 'https://203.0.113.10/login', null], +]); + +it('is not available on a host that is not the instance host', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://203.0.113.10:8000/login')))->toBeFalse(); +}); + +it('is not available when the instance toggle is off', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + + expect(PasskeyRelyingParty::isAvailable(Request::create('http://localhost/login')))->toBeFalse(); +}); + +it('returns login options with the resolved relying party', function () { + setPasskeyInstance('https://coolify.example.com', 'http://localhost'); + + $this->getJson('https://coolify.example.com/passkeys/login/options') + ->assertOk() + ->assertJsonPath('options.rpId', 'coolify.example.com'); +}); + +it('returns 404 on passkey routes when passkeys are not available', function (bool $enabled, string $url) { + setPasskeyInstance(null, 'http://localhost', $enabled); + + $this->getJson($url)->assertNotFound(); +})->with([ + 'toggle off' => [false, 'http://localhost/passkeys/login/options'], + 'raw ip host' => [true, 'http://203.0.113.10:8000/passkeys/login/options'], +]); + +it('keeps the password login page reachable when passkeys are not available', function () { + setPasskeyInstance(null, 'http://localhost', enabled: false); + User::factory()->create(); + + $this->get('http://203.0.113.10:8000/login')->assertOk(); +}); + +it('rate limits the passkey routes by client ip', function () { + setPasskeyInstance(null, 'http://localhost'); + + foreach (range(1, 30) as $attempt) { + $this->getJson('/passkeys/login/options')->assertOk(); + } + + $this->getJson('/passkeys/login/options')->assertStatus(429); +}); + +it('does not need the instance settings on other fortify routes', function () { + InstanceSettings::query()->delete(); + Once::flush(); + + $this->post('/login', ['email' => 'nobody@example.com', 'password' => 'wrong-password']) + ->assertRedirect() + ->assertSessionHasErrors('email'); +}); diff --git a/tests/Feature/Auth/PasskeySetupTest.php b/tests/Feature/Auth/PasskeySetupTest.php new file mode 100644 index 0000000000..edd2b39c62 --- /dev/null +++ b/tests/Feature/Auth/PasskeySetupTest.php @@ -0,0 +1,42 @@ + 0]); + Once::flush(); +}); + +it('registers the fortify passkey routes', function () { + expect(Route::has('passkey.login-options'))->toBeTrue() + ->and(Route::has('passkey.login'))->toBeTrue() + ->and(Route::has('passkey.confirm'))->toBeTrue() + ->and(Route::has('passkey.store'))->toBeTrue(); +}); + +it('returns a stable 64 character hex user handle', function () { + $user = User::factory()->create(); + + $handle = $user->getPasskeyUserHandle(); + + expect($handle)->toMatch('/^[0-9a-f]{64}$/') + ->and(User::find($user->id)->getPasskeyUserHandle())->toBe($handle); +}); + +it('enables passkeys by default and stores passkeys for a user', function () { + $user = User::factory()->create(); + $user->passkeys()->create([ + 'name' => 'Laptop', + 'credential_id' => 'credential-1', + 'credential' => ['aaguid' => '00000000-0000-0000-0000-000000000000'], + ]); + + expect(instanceSettings()->is_passkey_enabled)->toBeTrue() + ->and($user->hasPasskeysEnabled())->toBeTrue(); +}); diff --git a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php index 54943eb9e5..2102223333 100644 --- a/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php +++ b/tests/Feature/Authorization/EnvironmentVariableValueHidingTest.php @@ -3,15 +3,20 @@ use App\Livewire\Project\Shared\EnvironmentVariable\All as EnvironmentVariableAll; use App\Livewire\Project\Shared\EnvironmentVariable\Show as EnvironmentVariableShow; use App\Models\Application; +use App\Models\AuditEvent; use App\Models\EnvironmentVariable; use App\Models\InstanceSettings; use App\Models\Project; use App\Models\Server; +use App\Models\Service; use App\Models\StandaloneDocker; +use App\Models\StandalonePostgresql; use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Str; use Livewire\Livewire; @@ -344,3 +349,181 @@ test('member keeps isValueHidden after switching to a team they own', function ( expect($component->get('isValueHidden'))->toBeTrue(); }); + +test('admin can limit log redaction to locked values for one application', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]) + ->assertSet('redact_all_env_values_in_logs', true) + ->assertSee('Environment values in logs') + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting') + ->assertHasNoErrors(); + + expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeFalse(); +}); + +test('admin can limit log redaction to locked values for one service', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Livewire::test(EnvironmentVariableAll::class, ['resource' => $service]) + ->assertSet('redact_all_env_values_in_logs', true) + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting'); + + expect($service->fresh()->redact_all_env_values_in_logs)->toBeFalse(); +}); + +test('admin can limit log redaction to locked values for one database and a member cannot', function () { + $database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-database', + 'postgres_password' => 'database-password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + session(['currentTeam' => $this->team]); + + $this->actingAs($this->member); + Livewire::test(EnvironmentVariableAll::class, ['resource' => $database]) + ->assertSee('Environment values in logs') + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting') + ->assertSet('redact_all_env_values_in_logs', true) + ->assertNotDispatched('success'); + expect($database->fresh()->redact_all_env_values_in_logs)->toBeTrue(); + + $this->actingAs($this->admin); + Livewire::test(EnvironmentVariableAll::class, ['resource' => $database]) + ->assertSet('redact_all_env_values_in_logs', true) + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting') + ->assertDispatched('success'); + expect($database->fresh()->redact_all_env_values_in_logs)->toBeFalse(); +}); + +test('member cannot change log redaction and the control returns to the stored value', function (string $resourceType, bool $storedValue) { + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + $resource = $resourceType === 'application' ? $this->application : Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $settings = $resource instanceof Application ? $resource->settings : $resource; + $settings->update(['redact_all_env_values_in_logs' => $storedValue]); + + Livewire::test(EnvironmentVariableAll::class, ['resource' => $resource]) + ->set('redact_all_env_values_in_logs', ! $storedValue) + ->call('saveLogRedactionSetting') + ->assertSet('redact_all_env_values_in_logs', $storedValue) + ->assertNotDispatched('success'); + + expect($settings->fresh()->redact_all_env_values_in_logs)->toBe($storedValue); +})->with(['application', 'service'])->with([true, false]); + +test('log redaction control returns to the stored value after a write failure', function (string $resourceType, bool $storedValue) { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $resource = $resourceType === 'application' ? $this->application : Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $settings = $resource instanceof Application ? $resource->settings : $resource; + $settings->update(['redact_all_env_values_in_logs' => $storedValue]); + $component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $resource]); + $event = 'eloquent.saving: '.get_class($settings); + Event::listen($event, function () { + throw new RuntimeException('Saving the log redaction setting failed.'); + }); + + try { + $component->set('redact_all_env_values_in_logs', ! $storedValue) + ->call('saveLogRedactionSetting') + ->assertSet('redact_all_env_values_in_logs', $storedValue) + ->assertNotDispatched('success'); + + expect($settings->fresh()->redact_all_env_values_in_logs)->toBe($storedValue); + } finally { + Event::forget($event); + } +})->with(['application', 'service'])->with([true, false]); + +test('saving the log redaction setting records an audit event for the application', function () { + $this->withoutDefer(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]) + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting'); + + $event = AuditEvent::query()->where('event', 'ui.application.settings_updated')->sole(); + + expect($event->metadata['changed_fields'])->toBe(['redact_all_env_values_in_logs']) + ->and($event->team_id)->toBe($this->team->id); +}); + +test('saving the log redaction setting records an audit event for the service', function () { + $this->withoutDefer(); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + Livewire::test(EnvironmentVariableAll::class, ['resource' => $service]) + ->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting'); + + expect(AuditEvent::query()->where('event', 'ui.service.updated')->get()->pluck('metadata.changed_fields')->all()) + ->toContain(['redact_all_env_values_in_logs']); +}); + +test('member of the resource team cannot change log redaction after switching to a team they own', function () { + $ownTeam = Team::factory()->create(); + $this->member->teams()->attach($ownTeam, ['role' => 'owner']); + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]); + + session(['currentTeam' => $ownTeam]); + Cache::flush(); + + $component->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting'); + + expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeTrue(); +}); + +test('admin of the resource team can change log redaction after switching to a team where they are a member', function () { + $otherTeam = Team::factory()->create(); + $this->admin->teams()->attach($otherTeam, ['role' => 'member']); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(EnvironmentVariableAll::class, ['resource' => $this->application]); + + session(['currentTeam' => $otherTeam]); + Cache::flush(); + + $component->set('redact_all_env_values_in_logs', false) + ->call('saveLogRedactionSetting'); + + expect($this->application->settings->fresh()->redact_all_env_values_in_logs)->toBeFalse(); +}); diff --git a/tests/Feature/Authorization/ResourceHeadingAuthorizationTest.php b/tests/Feature/Authorization/ResourceHeadingAuthorizationTest.php index 229848220a..99b8715aa3 100644 --- a/tests/Feature/Authorization/ResourceHeadingAuthorizationTest.php +++ b/tests/Feature/Authorization/ResourceHeadingAuthorizationTest.php @@ -110,7 +110,7 @@ test('admin sees deploy controls for application', function () { session(['currentTeam' => $this->team]); Livewire::test(ApplicationHeading::class, ['application' => $this->application]) - ->assertSee('Deploy') + ->assertSee('Redeploy') ->assertSee('Restart') ->assertSee('Stop'); }); diff --git a/tests/Feature/Authorization/ServiceConfigAuthorizationTest.php b/tests/Feature/Authorization/ServiceConfigAuthorizationTest.php index 2d37fd00d9..16ff1e9947 100644 --- a/tests/Feature/Authorization/ServiceConfigAuthorizationTest.php +++ b/tests/Feature/Authorization/ServiceConfigAuthorizationTest.php @@ -222,6 +222,16 @@ test('admin sees terminal link for service', function () { ->assertSee('Terminal'); }); +test('service configuration page sidebar links to the file browser', function () { + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Team::query()->update(['show_boarding' => false]); + + $this->get(route('project.service.configuration', $this->serviceParams)) + ->assertOk() + ->assertSee(route('project.service.files', $this->serviceParams), false); +}); + test('admin sees deploy button for service', function () { $this->actingAs($this->admin); session(['currentTeam' => $this->team]); diff --git a/tests/Feature/BackupAndNotificationRedactionTest.php b/tests/Feature/BackupAndNotificationRedactionTest.php new file mode 100644 index 0000000000..d27cb48ddc --- /dev/null +++ b/tests/Feature/BackupAndNotificationRedactionTest.php @@ -0,0 +1,263 @@ + false]); + Event::fake([BackupCreated::class]); + Notification::fake(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + // The notification fake only records notifications that have at least one enabled channel. + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->s3 = S3Storage::create([ + 'name' => 'Backups', + 'region' => 'us-east-1', + 'key' => 'redaction-s3-access-key', + 'secret' => 'redaction-s3-secret-value', + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $this->team->id, + 'is_usable' => true, + ]); +}); + +function redactionMysqlBackup(Team $team, Environment $environment, StandaloneDocker $destination, ?S3Storage $s3 = null): ScheduledDatabaseBackup +{ + $database = create_standalone_mysql($environment->id, $destination, [ + 'mysql_root_password' => str_repeat('x', 5), + 'mysql_database' => 'app', + 'status' => 'running:healthy', + ]); + + return ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => $s3 !== null, + 's3_storage_id' => $s3?->id, + 'database_type' => StandaloneMysql::class, + 'database_id' => $database->id, + 'team_id' => $team->id, + 'databases_to_backup' => 'app', + ]); +} + +/** + * @return array + */ +function backupRedactionToken(User $user, Team $team, array $abilities): array +{ + session(['currentTeam' => $team]); + $token = $user->createToken('backup-redaction', $abilities); + DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update(['team_id' => $team->id]); + + return ['Authorization' => 'Bearer '.$token->plainTextToken]; +} + +it('redacts the database password from the stored execution and the failure notification', function () { + $backup = redactionMysqlBackup($this->team, $this->environment, $this->destination); + $password = $backup->database->mysql_root_password; + Process::fake(function ($process) use ($password) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + if (str_contains($command, 'mysqldump')) { + return Process::result(errorOutput: "mysqldump: Got error: 1045: Access denied for user 'root' with password {$password}", exitCode: 1); + } + + return Process::result(output: ''); + }); + + (new DatabaseBackupJob($backup))->handle(); + + $execution = $backup->executions()->latest('id')->firstOrFail(); + expect($execution->status)->toBe('failed') + ->and($execution->message)->toContain('Access denied') + ->and($execution->message)->not->toContain($password); + Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => str_contains((string) $notification->output, 'Access denied') + && ! str_contains((string) $notification->output, $password)); +}); + +it('redacts the S3 key and secret from a permanently failed database backup', function () { + $backup = redactionMysqlBackup($this->team, $this->environment, $this->destination, $this->s3); + ScheduledDatabaseBackupExecution::create([ + 'uuid' => 'redaction-run', + 'scheduled_database_backup_id' => $backup->id, + 'database_name' => 'app', + 'status' => 'running', + ]); + $job = new DatabaseBackupJob($backup); + $job->executionUuidPrefix = 'redaction-run'; + + $job->failed(new RuntimeException('mc: alias set failed for key redaction-s3-access-key and secret redaction-s3-secret-value')); + + $message = $backup->executions()->sole()->message; + expect($message)->toContain('mc: alias set failed') + ->and($message)->not->toContain('redaction-s3-access-key') + ->and($message)->not->toContain('redaction-s3-secret-value'); + Notification::assertSentTo($this->team, BackupFailed::class, fn (BackupFailed $notification) => ! str_contains((string) $notification->output, 'redaction-s3-secret-value') + && ! str_contains((string) $notification->output, 'redaction-s3-access-key')); +}); + +it('redacts the S3 key and secret from a failed volume backup', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 's3_storage_id' => $this->s3->id, + 'save_s3' => true, + 'frequency' => '0 0 * * *', + ]); + $execution = $backup->executions()->create(['s3_storage_id' => $this->s3->id]); + + (new VolumeBackupJob($backup))->failed(new RuntimeException('mc: upload failed with secret redaction-s3-secret-value')); + + expect($execution->fresh()->message)->toContain('mc: upload failed') + ->and($execution->fresh()->message)->not->toContain('redaction-s3-secret-value'); + Notification::assertSentTo($this->team, VolumeBackupFailed::class, fn (VolumeBackupFailed $notification) => str_contains($notification->output, 'mc: upload failed') + && ! str_contains($notification->output, 'redaction-s3-secret-value')); +}); + +it('does not put the telegram bot token into the connection error', function () { + Http::fake(['*' => Http::failedConnection()]); + + try { + (new SendMessageToTelegramJob('Message', [], '123456:telegram-bot-secret-token', '42'))->handle(); + $this->fail('The connection error was not reported.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toContain('https://api.telegram.org') + ->and($exception->getMessage())->not->toContain('telegram-bot-secret-token') + ->and($exception->getPrevious())->toBeNull(); + } +}); + +it('does not put the secret webhook path into the connection error', function () { + Http::fake(['*' => Http::failedConnection()]); + + try { + (new SendWebhookJob(['event' => 'test'], 'https://example.com/hooks/webhook-secret-path'))->handle(); + $this->fail('The connection error was not reported.'); + } catch (RuntimeException $exception) { + expect($exception->getMessage())->toContain('https://example.com') + ->and($exception->getMessage())->not->toContain('webhook-secret-path') + ->and($exception->getPrevious())->toBeNull(); + } +}); + +it('does not share the provider response body when a channel is disabled', function () { + $rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + foreach ([$rootTeam, $this->team] as $team) { + $team->discordNotificationSettings->update(['discord_enabled' => true, 'discord_webhook_url' => 'https://example.com/discord']); + } + $settings = tap($this->team->telegramNotificationSettings)->update(['telegram_enabled' => true, 'telegram_token' => 'token', 'telegram_chat_id' => '123']); + Http::fake(['*' => Http::response(['ok' => false, 'description' => 'Unauthorized provider-body-marker'], 401)]); + + foreach (range(1, 3) as $attempt) { + (new SendMessageToTelegramJob('Message', [], 'token', '123', null, $settings->fresh()))->handle(); + } + + expect($settings->fresh()->telegram_enabled)->toBeFalse(); + Notification::assertSentTo($rootTeam, GeneralNotification::class, fn (GeneralNotification $notification) => str_contains($notification->message, 'HTTP 401') + && ! str_contains($notification->message, 'provider-body-marker')); + Notification::assertSentTo($this->team, GeneralNotification::class, fn (GeneralNotification $notification) => ! str_contains($notification->message, 'provider-body-marker')); +}); + +it('redacts secrets from server validation logs', function () { + $this->server->update(['validation_logs' => 'Could not connect: postgres://coolify:validation-db-secret@10.0.0.5/app']); + + expect($this->server->fresh()->validation_logs)->toContain('Could not connect') + ->and($this->server->fresh()->validation_logs)->not->toContain('validation-db-secret'); +}); + +describe('backup executions api', function () { + beforeEach(function () { + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner, ['role' => 'owner']); + $this->backup = redactionMysqlBackup($this->team, $this->environment, $this->destination); + ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'database_name' => 'app', + 'status' => 'failed', + 'message' => 'dump failed: mysql://root:api-db-secret@db:3306/app', + ]); + $this->executionsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups/{$this->backup->uuid}/executions"; + $this->backupsUrl = "/api/v1/databases/{$this->backup->database->uuid}/backups"; + }); + + it('hides the execution message from a token without read:sensitive', function () { + $headers = backupRedactionToken($this->owner, $this->team, ['read']); + + $executions = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk(); + $backups = $this->withHeaders($headers)->getJson($this->backupsUrl)->assertOk(); + + expect($executions->json('executions.0.status'))->toBe('failed') + ->and($executions->json('executions.0.message'))->toBeNull() + ->and($backups->json('0.executions.0.message'))->toBeNull() + ->and($executions->getContent().$backups->getContent())->not->toContain('dump failed'); + }); + + it('returns the redacted execution message to a token with read:sensitive', function () { + $headers = backupRedactionToken($this->owner, $this->team, ['read', 'read:sensitive']); + + $message = $this->withHeaders($headers)->getJson($this->executionsUrl)->assertOk()->json('executions.0.message'); + + expect($message)->toContain('dump failed') + ->and($message)->not->toContain('api-db-secret'); + }); + + it('does not return executions of another team', function () { + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($this->owner, ['role' => 'owner']); + $headers = backupRedactionToken($this->owner, $otherTeam, ['read', 'read:sensitive']); + + $this->withHeaders($headers)->getJson($this->executionsUrl)->assertNotFound(); + }); +}); diff --git a/tests/Feature/BackupEditValidationTest.php b/tests/Feature/BackupEditValidationTest.php index 66643ffc46..93d38c78e5 100644 --- a/tests/Feature/BackupEditValidationTest.php +++ b/tests/Feature/BackupEditValidationTest.php @@ -86,7 +86,6 @@ it('enables and disables S3 backups from the S3 title action', function () { $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 's3', ]) ->assertSee('Enable S3') ->call('toggleS3') @@ -117,7 +116,6 @@ it('shows and saves S3 retention while S3 backups are disabled', function () { Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, - 'section' => 'retention', ]) ->assertSee('S3 backups') ->set('databaseBackupRetentionAmountS3', 12) @@ -147,34 +145,18 @@ it('splits standalone database backup settings and executions across dedicated u $this->get($generalUrl) ->assertOk() ->assertSee('General') - ->assertSee('S3 storage') - ->assertSee('Retention') ->assertSee('Executions') ->assertSee('Danger Zone') ->assertSee('Frequency') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Clean failed backups') - ->assertDontSee('Delete schedule'); - - $this->get($generalUrl.'/s3') - ->assertOk() ->assertSeeText('No validated S3 storage') - ->assertDontSee('Local copy') - ->assertDontSee('Enable S3') - ->assertDontSee('Disable S3') - ->assertDontSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); - - $this->get($generalUrl.'/retention') - ->assertOk() ->assertSee('Local backups') ->assertSee('S3 backups') ->assertSee('Backups to keep') - ->assertDontSee('Frequency') - ->assertDontSee('Clean failed backups'); + ->assertDontSee('Clean failed backups') + ->assertDontSee('Delete schedule'); + + $this->get($generalUrl.'/s3')->assertRedirect($generalUrl); + $this->get($generalUrl.'/retention')->assertRedirect($generalUrl); $this->get($generalUrl.'/executions') ->assertOk() @@ -424,7 +406,7 @@ it('defaults to the first available storage when multiple storages are available ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) - ->assertSet('s3StorageId', $firstS3->id) + ->assertSet('s3StorageIds', [$firstS3->id]) ->set('saveS3', true) ->call('instantSave') ->assertDispatched('success'); @@ -461,7 +443,7 @@ it('accepts a root team S3 storage for the instance database backup', function ( Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => collect([$s3])]) ->set('saveS3', true) - ->set('s3StorageId', $s3->id) + ->set('s3StorageIds', [$s3->id]) ->call('instantSave') ->assertDispatched('success'); @@ -478,22 +460,21 @@ it('shows available S3 storages even when S3 backup is disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSee('First S3') ->assertSee('Second S3'); }); -it('shows only an empty S3 state when no storages are available', function () { +it('shows an empty S3 state when no storages are available', function () { $backup = createBackupForEditValidationTest($this->team, [ 'save_s3' => false, 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) ->assertSeeText('No validated S3 storage') ->assertSeeHtml('href="'.route('storage.index').'"') ->assertSeeText('Open S3 storage') - ->assertDontSee('Save') ->assertDontSee('Enable S3') ->assertDontSee('Disable S3') ->assertDontSee('S3 Storage') @@ -510,7 +491,6 @@ it('allows S3 backups to be disabled when no usable storage remains', function ( $component = Livewire::test(BackupEdit::class, [ 'backup' => $backup->fresh(), 'availableS3Storages' => collect(), - 'section' => 's3', ]) ->assertSet('saveS3', true) ->assertSeeText('No validated S3 storage') @@ -532,7 +512,7 @@ it('shows when S3 backups are currently disabled', function () { 's3_storage_id' => null, ]); - Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s, 'section' => 's3']) + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) // Disabled S3 backups are indicated by the "Enable S3" title action ->assertSee('S3 storage') ->assertSee('Enable S3') @@ -548,7 +528,7 @@ it('saves selected S3 storage immediately when it changes', function () { ]); Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) - ->set('s3StorageId', $secondS3->id) + ->set('s3StorageIds', [$secondS3->id]) ->assertDispatched('success'); $backup->refresh(); @@ -617,3 +597,84 @@ it('disables Back up now after refresh when the database stops', function () { expect($component->html())->toMatch('/]*wire:click="backupNow"/s'); }); + +it('shows the backup actions in the header bar on every database backup page', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $database->update(['status' => 'running:healthy']); + $generalUrl = route('project.database.backup.execution', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ]); + + foreach (['', '/executions', '/danger'] as $suffix) { + expect($this->get($generalUrl.$suffix)->assertOk()->getContent()) + ->toMatch('/x-teleport="#resource-action-hud-slot".*?wire:click="toggleEnabled".*?Disable backup.*?wire:click="backupNow"/s'); + } +}); + +it('hides the header Back up now button from team members', function () { + config(['cache.default' => 'array', 'app.maintenance.driver' => 'file']); + $backup = createBackupForEditValidationTest($this->team); + $database = $backup->database; + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + $this->get(route('project.database.backup.executions', [ + 'project_uuid' => $database->project()->uuid, + 'environment_uuid' => $database->environment->uuid, + 'database_uuid' => $database->uuid, + 'backup_uuid' => $backup->uuid, + ])) + ->assertOk() + ->assertDontSee('wire:click="backupNow"', false); +}); + +it('denies Back up now for team members', function () { + Queue::fake(); + $backup = createBackupForEditValidationTest($this->team); + $backup->database->update(['status' => 'running:healthy']); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('backupNow') + ->assertNoRedirect(); + + Queue::assertNotPushed(DatabaseBackupJob::class); +}); + +it('syncs the backup form when the header toggles the schedule', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + $component = Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ])->assertSet('backupEnabled', true); + + $backup->update(['enabled' => false]); + + $component->dispatch('backupEnabledChanged') + ->assertSet('backupEnabled', false) + ->call('submit'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); + +it('toggles the schedule from the form and notifies the page header', function () { + $backup = createBackupForEditValidationTest($this->team, ['enabled' => true]); + + Livewire::test(BackupEdit::class, [ + 'backup' => $backup->fresh(), + 'availableS3Storages' => $this->team->s3s, + ]) + ->call('toggleEnabled') + ->assertSet('backupEnabled', false) + ->assertDispatched('backupEnabledChanged'); + + expect((bool) $backup->fresh()->enabled)->toBeFalse(); +}); diff --git a/tests/Feature/BackupExecutionMessageRedactionApiTest.php b/tests/Feature/BackupExecutionMessageRedactionApiTest.php new file mode 100644 index 0000000000..82c02338cb --- /dev/null +++ b/tests/Feature/BackupExecutionMessageRedactionApiTest.php @@ -0,0 +1,70 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + + $this->database = StandalonePostgresql::query()->create([ + 'name' => 'backup-message-database', + 'postgres_password' => BACKUP_MESSAGE_DB_PASSWORD, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->backup = ScheduledDatabaseBackup::query()->create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'database_type' => $this->database->getMorphClass(), + 'database_id' => $this->database->id, + ]); + ScheduledDatabaseBackupExecution::query()->create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'failed', + 'message' => 'pg_dump failed: password '.BACKUP_MESSAGE_DB_PASSWORD.' rejected', + ]); + + $user = User::factory()->create(); + $this->team->members()->attach($user->id, ['role' => 'owner']); + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['read', 'read:sensitive'], + 'team_id' => $this->team->id, + ]); + $this->bearer = $token->getKey().'|'.$plainTextToken; +}); + +it('redacts the database password from backup execution messages', function (Closure $path, string $messageKey) { + $response = $this->withToken($this->bearer)->getJson($path())->assertOk(); + + expect($response->json($messageKey))->toBe('pg_dump failed: password '.REDACTED.' rejected'); +})->with([ + 'database list' => [fn () => '/api/v1/databases', '0.backup_configs.0.latest_log.message'], + 'database backups' => [fn () => '/api/v1/databases/'.test()->database->uuid.'/backups', '0.executions.0.message'], + 'backup executions' => [fn () => '/api/v1/databases/'.test()->database->uuid.'/backups/'.test()->backup->uuid.'/executions', 'executions.0.message'], +]); diff --git a/tests/Feature/BackupNowAvailabilityTest.php b/tests/Feature/BackupNowAvailabilityTest.php index 3d03994201..c1a35d979b 100644 --- a/tests/Feature/BackupNowAvailabilityTest.php +++ b/tests/Feature/BackupNowAvailabilityTest.php @@ -114,16 +114,23 @@ it('checks current database status before queuing from a stale page', function ( Queue::assertNotPushed(DatabaseBackupJob::class); })->with('database backup controls'); -it('refreshes service backup buttons when the service status check completes', function () { +it('refreshes service backup buttons when the service status changes', function (string $componentClass, string $event) { $database = $this->databases['service']; $database->update(['status' => 'exited:unhealthy']); - $component = Livewire::test(Index::class, ['service' => $this->service]); + $parameters = $componentClass === Index::class + ? ['service' => $this->service] + : ['backup' => $this->backups['service']->fresh(), 'availableS3Storages' => collect()]; + $component = Livewire::test($componentClass, $parameters); + $disabledButton = '/]*wire:click(\.stop)?="backupNow/s'; $database->update(['status' => 'running:healthy']); - $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); - expect($component->html())->not->toMatch('/]*wire:click.stop="backupNow/s'); + $component->dispatch('echo-private:team.'.currentTeam()->id.','.$event); + expect($component->html())->not->toMatch($disabledButton); $database->update(['status' => 'exited:unhealthy']); - $component->dispatch('echo-private:team.'.currentTeam()->id.',ServiceChecked'); - expect($component->html())->toMatch('/]*wire:click.stop="backupNow/s'); -}); + $component->dispatch('echo-private:team.'.currentTeam()->id.','.$event); + expect($component->html())->toMatch($disabledButton); +})->with([ + 'service list' => Index::class, + 'service settings' => BackupEdit::class, +])->with(['ServiceChecked', 'ServiceStatusChanged']); diff --git a/tests/Feature/BackupRetentionAndStaleDetectionTest.php b/tests/Feature/BackupRetentionAndStaleDetectionTest.php index 97e45599ff..8e235a6501 100644 --- a/tests/Feature/BackupRetentionAndStaleDetectionTest.php +++ b/tests/Feature/BackupRetentionAndStaleDetectionTest.php @@ -276,6 +276,8 @@ test('database S3 retention remains best effort when deletion fails', function ( ]); ScheduledDatabaseBackupExecution::whereKey($newestExecution->id)->update(['created_at' => now()]); ScheduledDatabaseBackupExecution::whereKey($oldExecution->id)->update(['created_at' => now()->subDay()]); + $newestExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); + $oldExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); diff --git a/tests/Feature/BackupRunSafetyTest.php b/tests/Feature/BackupRunSafetyTest.php index 7f3475d846..5a19a72a45 100644 --- a/tests/Feature/BackupRunSafetyTest.php +++ b/tests/Feature/BackupRunSafetyTest.php @@ -269,7 +269,7 @@ it('stores service database backups in the folder of earlier releases', function 'status' => 'running:healthy', ]); $backup = backupRunSafetySchedule($database, $this->team); - backupRunSafetyFakeRemoteCommands(['env | grep POSTGRES_' => "POSTGRES_USER=rallly\nPOSTGRES_DB=rallly"]); + backupRunSafetyFakeRemoteCommands(['| grep -E' => "POSTGRES_USER=rallly\nPOSTGRES_DB=rallly"]); (new DatabaseBackupJob($backup))->handle(); @@ -277,6 +277,32 @@ it('stores service database backups in the folder of earlier releases', function ->toContain("/rallly-rallly_db-{$service->uuid}/pg-dump-rallly-"); }); +it('backs up a service PostgreSQL database as the user from PGUSER or POSTGRESQL_USERNAME', function (string $env, string $user) { + $service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'environment_id' => $this->environment->id, + ]); + $database = ServiceDatabase::create([ + 'service_id' => $service->id, + 'name' => 'app_db', + 'image' => 'postgres:16-alpine', + 'status' => 'running:healthy', + ]); + $backup = backupRunSafetySchedule($database, $this->team); + $commands = backupRunSafetyFakeRemoteCommands(['| grep -E' => $env."\nPOSTGRES_DB=app"]); + + (new DatabaseBackupJob($backup))->handle(); + + expect($commands->first(fn (string $command) => str_contains($command, 'pg_dump'))) + ->toContain("--username '\\''{$user}'\\''"); +})->with([ + 'PGUSER' => ['PGUSER=pguser', 'pguser'], + 'Bitnami POSTGRESQL_USERNAME' => ['POSTGRESQL_USERNAME=bitnami', 'bitnami'], + 'POSTGRES_USER wins' => ["POSTGRES_USER=app\nPGUSER=pguser", 'app'], +]); + function backupRunSafetyServiceDatabase(Server $server, StandaloneDocker $destination, Environment $environment, string $image): ServiceDatabase { $service = Service::factory()->create([ diff --git a/tests/Feature/BackupS3DestinationRedactionTest.php b/tests/Feature/BackupS3DestinationRedactionTest.php new file mode 100644 index 0000000000..709bc05023 --- /dev/null +++ b/tests/Feature/BackupS3DestinationRedactionTest.php @@ -0,0 +1,168 @@ + false, 'broadcasting.default' => 'null']); + defined('CURLOPT_RESOLVE') || define('CURLOPT_RESOLVE', 10203); + Event::fake([BackupCreated::class]); + Notification::fake(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + // The notification fake only records notifications that have at least one enabled channel. + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'backup_failure_email_notifications' => true]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + 'ip' => '203.0.113.10', + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->storages = collect(['first', 'second'])->map(fn (string $name): S3Storage => S3Storage::create([ + 'name' => "{$name} storage", + 'region' => 'us-east-1', + 'key' => "{$name}-access-id-".($name === 'first' ? '7731' : '4482'), + 'secret' => "{$name}-hidden-value-".($name === 'first' ? '9921' : '5513'), + 'bucket' => "{$name}-bucket", + 'endpoint' => 'https://s3.amazonaws.com', + 'team_id' => $this->team->id, + 'is_usable' => true, + ])); + $this->uploadError = 'mc: upload rejected for '.implode(' / ', S3_DESTINATION_SECRETS); +}); + +function expectNoS3DestinationSecrets(?string $text): void +{ + foreach (S3_DESTINATION_SECRETS as $secret) { + expect((string) $text)->not->toContain($secret); + } +} + +it('redacts the credentials of every S3 destination from a database backup upload failure', function () { + $database = create_standalone_mysql($this->environment->id, $this->destination, [ + 'mysql_root_password' => str_repeat('x', 5), + 'mysql_database' => 'app', + 'status' => 'running:healthy', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $this->storages[0]->id, + 'database_type' => StandaloneMysql::class, + 'database_id' => $database->id, + 'team_id' => $this->team->id, + 'databases_to_backup' => 'app', + ]); + $backup->syncS3Storages($this->storages->pluck('id')->all()); + Process::fake(function ($process) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + + return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); + }); + $uploadError = $this->uploadError; + $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$backup->fresh()])->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('upload_to_s3')->andReturnUsing(fn () => throw new RuntimeException($uploadError)); + + $job->handle(); + + $execution = $backup->executions()->sole(); + expect($execution->status)->toBe('success') + ->and($execution->message)->toContain('mc: upload rejected') + ->and($execution->s3Replicas()->count())->toBe(2); + expectNoS3DestinationSecrets($execution->message); + $execution->s3Replicas()->get()->each(function ($replica) { + expect($replica->message)->toContain('mc: upload rejected'); + expectNoS3DestinationSecrets($replica->message); + }); + Notification::assertSentTo($this->team, BackupSuccessWithS3Warning::class, function (BackupSuccessWithS3Warning $notification) { + expect($notification->s3_error)->toContain('mc: upload rejected'); + expectNoS3DestinationSecrets($notification->s3_error); + + return true; + }); +}); + +it('redacts the credentials of every S3 destination from a volume backup upload failure', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $backup->syncS3Storages($this->storages->pluck('id')->all()); + $sshDisk = Storage::fake('ssh-keys'); + $disk = Mockery::mock(FilesystemAdapter::class); + $disk->shouldReceive('files')->zeroOrMoreTimes()->andReturn([]); + $disk->shouldReceive('delete')->zeroOrMoreTimes()->andReturnTrue(); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshDisk); + Storage::shouldReceive('build')->zeroOrMoreTimes()->andReturn($disk); + Process::fake([ + '*du -b*' => '128', + '*mc cp*' => Process::result(errorOutput: $this->uploadError, exitCode: 1), + '*' => '', + ]); + + (new VolumeBackupJob($backup->fresh()))->handle(); + + $execution = ScheduledVolumeBackupExecution::query()->sole(); + expect($execution->status)->toBe('success') + ->and($execution->message)->toContain('mc: upload rejected') + ->and($execution->s3Replicas()->count())->toBe(2); + expectNoS3DestinationSecrets($execution->message); + $execution->s3Replicas()->get()->each(function ($replica) { + expect($replica->message)->toContain('mc: upload rejected'); + expectNoS3DestinationSecrets($replica->message); + }); + Notification::assertSentTo($this->team, VolumeBackupSuccess::class, function (VolumeBackupSuccess $notification) { + expect($notification->warning)->toContain('mc: upload rejected'); + expectNoS3DestinationSecrets($notification->warning); + + return true; + }); +}); diff --git a/tests/Feature/BackupS3DestinationsFormTest.php b/tests/Feature/BackupS3DestinationsFormTest.php new file mode 100644 index 0000000000..61098268f7 --- /dev/null +++ b/tests/Feature/BackupS3DestinationsFormTest.php @@ -0,0 +1,322 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => str($name)->slug()->toString(), + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +/** + * @return array{0: Application, 1: LocalPersistentVolume, 2: StandalonePostgresql} + */ +function createResourcesForDestinationsFormTest(Team $team): array +{ + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $database = StandalonePostgresql::create([ + 'name' => 'pg-destinations', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return [$application, $volume, $database]; +} + +function createDatabaseBackupForDestinationsFormTest(StandalonePostgresql $database, array $overrides = []): ScheduledDatabaseBackup +{ + return ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => false, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $database->team()->id, + ...$overrides, + ]); +} + +beforeEach(function () { + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + [$this->application, $this->volume, $this->database] = createResourcesForDestinationsFormTest($this->team); + $this->firstStorage = createS3StorageForDestinationsFormTest($this->team, 'First S3'); + $this->secondStorage = createS3StorageForDestinationsFormTest($this->team, 'Second S3'); + $this->foreignStorage = createS3StorageForDestinationsFormTest($this->otherTeam, 'Foreign S3'); +}); + +it('saves two S3 destinations for a database backup schedule', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) + ->assertSee('First S3') + ->assertSee('Second S3') + ->assertSet('s3StorageIds', [$this->firstStorage->id]) + ->set('saveS3', true) + ->set('s3StorageIds', [(string) $this->firstStorage->id, (string) $this->secondStorage->id]) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup->refresh(); + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($this->firstStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('saves two S3 destinations for a volume backup schedule', function () { + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', [(string) $this->secondStorage->id, (string) $this->firstStorage->id]) + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup = ScheduledVolumeBackup::query()->sole(); + expect($backup->save_s3)->toBeTrue() + ->and($backup->s3_storage_id)->toBe($this->secondStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('drops S3 storages of another team after a team switch', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database, [ + 'save_s3' => true, + 's3_storage_id' => $this->firstStorage->id, + ]); + $backup->syncS3Storages([$this->firstStorage->id]); + + $databaseComponent = Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]); + $volumeComponent = Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]); + session(['currentTeam' => $this->otherTeam]); + + $databaseComponent->set('s3StorageIds', [$this->foreignStorage->id]) + ->assertHasErrors('s3StorageIds'); + expect($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->firstStorage->id]); + + $databaseComponent->set('s3StorageIds', [$this->secondStorage->id, $this->foreignStorage->id]) + ->assertHasNoErrors(); + expect($backup->fresh()->s3_storage_id)->toBe($this->secondStorage->id) + ->and($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->secondStorage->id]); + + $volumeComponent->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', [$this->foreignStorage->id]) + ->assertHasErrors('s3StorageIds'); + expect(ScheduledVolumeBackup::query()->count())->toBe(0); + + $volumeComponent->set('s3StorageIds', [$this->firstStorage->id, $this->foreignStorage->id]) + ->assertHasNoErrors(); + expect(ScheduledVolumeBackup::query()->sole()->s3Storages()->pluck('s3_storages.id')->all()) + ->toBe([$this->firstStorage->id]); +}); + +it('rejects S3 backups without a destination', function () { + $backup = createDatabaseBackupForDestinationsFormTest($this->database, [ + 'save_s3' => true, + 's3_storage_id' => $this->firstStorage->id, + ]); + $backup->syncS3Storages([$this->firstStorage->id]); + + Livewire::test(BackupEdit::class, ['backup' => $backup->fresh(), 'availableS3Storages' => $this->team->s3s]) + ->set('s3StorageIds', []) + ->call('submit') + ->assertHasErrors('s3StorageIds') + ->assertDispatched('error'); + + expect($backup->fresh()->save_s3)->toBeTruthy() + ->and($backup->fresh()->s3Storages()->pluck('s3_storages.id')->all())->toBe([$this->firstStorage->id]); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->set('frequency', 'daily') + ->set('saveToS3', true) + ->set('s3StorageIds', []) + ->call('save') + ->assertHasErrors('s3StorageIds'); + + expect(ScheduledVolumeBackup::query()->count())->toBe(0); +}); + +it('moves and removes one destination of a multi-destination schedule from the storage page', function () { + $thirdStorage = createS3StorageForDestinationsFormTest($this->team, 'Third S3'); + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'enabled' => true, + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->assertSet("selectedStorages.{$databaseBackup->id}", $this->secondStorage->id) + ->assertSet("selectedVolumeStorages.{$volumeBackup->id}", $this->secondStorage->id) + ->assertSee('pg-destinations') + ->assertSee('app-data'); + + Livewire::test(StorageResources::class, ['storage' => $this->firstStorage]) + ->set("selectedStorages.{$databaseBackup->id}", $thirdStorage->id) + ->call('moveBackup', $databaseBackup->id) + ->set("selectedVolumeStorages.{$volumeBackup->id}", $thirdStorage->id) + ->call('moveVolumeBackup', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->s3_storage_id)->toBe($thirdStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->secondStorage->id, $thirdStorage->id]); + } + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->call('disableS3', $databaseBackup->id) + ->call('disableVolumeS3', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($thirdStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->all())->toBe([$thirdStorage->id]); + } + + Livewire::test(StorageResources::class, ['storage' => $thirdStorage]) + ->call('disableS3', $databaseBackup->id) + ->call('disableVolumeS3', $volumeBackup->id); + + foreach ([$databaseBackup, $volumeBackup] as $backup) { + $backup->refresh(); + expect($backup->save_s3)->toBeFalsy() + ->and($backup->s3_storage_id)->toBeNull() + ->and($backup->s3Storages()->count())->toBe(0); + } +}); + +it('shows the copy status of each destination in the execution lists', function () { + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $databaseBackup->id, + 'status' => 'success', + 'filename' => '/backups/dump.dmp', + 's3_uploaded' => false, + ]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $volumeBackup->id, + 'status' => 'success', + 'filename' => '/backups/volume.tar.gz', + 's3_uploaded' => false, + ]); + foreach ([$databaseExecution, $volumeExecution] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $this->firstStorage->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->secondStorage->id, 's3_uploaded' => false, 'message' => 'Access denied']); + } + + Livewire::test(BackupExecutions::class, ['backup' => $databaseBackup]) + ->assertSeeInOrder(['First S3', 'Second S3 failed']) + ->assertSee('Access denied') + ->assertSee('Delete the selected backup permanently from every S3 storage'); + + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application, 'section' => 'executions']) + ->assertSeeInOrder(['First S3', 'Second S3 failed']) + ->assertSee('Delete the selected backup permanently from every S3 storage'); +}); + +it('keeps a selected destination that is unusable for a while when other settings are saved', function () { + $databaseBackup = createDatabaseBackupForDestinationsFormTest($this->database, ['save_s3' => true]); + $databaseBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $volumeBackup = ScheduledVolumeBackup::create([ + 'backupable_type' => $this->volume->getMorphClass(), + 'backupable_id' => $this->volume->id, + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + ]); + $volumeBackup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + $this->secondStorage->update(['is_usable' => false]); + + Livewire::test(BackupEdit::class, ['backup' => $databaseBackup->fresh(), 'availableS3Storages' => $this->team->s3s]) + ->assertSee('Second S3 (unavailable)') + ->set('timeout', 7200) + ->call('submit') + ->assertHasNoErrors(); + Livewire::test(VolumeBackups::class, ['storage' => $this->volume, 'resource' => $this->application]) + ->assertSee('Second S3 (unavailable)') + ->set('stopDuringBackup', true) + ->call('save') + ->assertHasNoErrors(); + + expect($databaseBackup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]) + ->and($volumeBackup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); diff --git a/tests/Feature/BackupS3DownloadFallbackTest.php b/tests/Feature/BackupS3DownloadFallbackTest.php new file mode 100644 index 0000000000..5eccab54b6 --- /dev/null +++ b/tests/Feature/BackupS3DownloadFallbackTest.php @@ -0,0 +1,183 @@ + 'file']); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); + $database = StandalonePostgresql::create([ + 'name' => 's3-download-pg', + 'image' => 'postgres:16-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'postgres', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + + $this->primaryS3 = createS3ForDownloadFallbackTest($this->team, 'primary'); + $this->replicaS3 = createS3ForDownloadFallbackTest($this->team, 'replica'); + + $this->databaseBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $this->primaryS3->id, + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + ]); + $this->databaseExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->databaseBackup->id, + 'status' => 'success', + 'database_name' => 'postgres', + 'filename' => '/data/coolify/backups/databases/team/pg/pg-dump-postgres-1.dmp', + 'local_storage_deleted' => true, + ]); + + $this->volumeBackup = $volume->scheduledBackups()->create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + ]); + $this->volumeExecution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $this->volumeBackup->id, + 's3_storage_id' => $this->primaryS3->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/team/app/volume-app-data-1.tar.gz', + 'local_storage_deleted' => true, + ]); +}); + +function createS3ForDownloadFallbackTest(Team $team, string $name, bool $isUsable = true): S3Storage +{ + return S3Storage::create([ + 'name' => $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => "{$name}-{$team->id}-bucket", + 'endpoint' => 'https://1.1.1.1', + 'team_id' => $team->id, + 'is_usable' => $isUsable, + ]); +} + +function signInForDownloadFallbackTest($testCase, Team $team, string $role = 'owner'): void +{ + $user = User::factory()->create(); + $user->teams()->attach($team, ['role' => $role]); + $testCase->actingAs($user); + session(['currentTeam' => $team]); +} + +/** + * @return array{0: string, 1: ScheduledDatabaseBackupExecution|ScheduledVolumeBackupExecution} + */ +function downloadFallbackTarget($testCase, string $type): array +{ + return $type === 'database' + ? ['download.backup', $testCase->databaseExecution] + : ['download.volume-backup', $testCase->volumeExecution]; +} + +function assertPresignedBackupRedirect($response, S3Storage $storage, string $filename): void +{ + $location = $response->assertRedirect()->headers->get('Location'); + $url = parse_url($location); + parse_str($url['query'], $query); + + expect("{$url['scheme']}://{$url['host']}{$url['path']}")->toBe("https://1.1.1.1/{$storage->bucket}".$filename) + ->and($query)->toHaveKeys(['X-Amz-Signature', 'X-Amz-Expires']) + ->and((int) $query['X-Amz-Expires'])->toBeLessThanOrEqual(15 * 60) + ->and($query['response-content-disposition'])->toContain('attachment; filename="'.basename($filename).'"'); +} + +it('redirects to a presigned URL of the primary S3 copy when no local copy exists', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->primaryS3, $execution->filename); +})->with(['database', 'volume']); + +it('uses a replica when the primary S3 copy is deleted or its storage is unusable', function (string $type, bool $primaryDeleted) { + [$route, $execution] = downloadFallbackTarget($this, $type); + if (! $primaryDeleted) { + $this->primaryS3->update(['is_usable' => false]); + } + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true, 's3_storage_deleted' => $primaryDeleted]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + assertPresignedBackupRedirect($this->get(route($route, $execution->id)), $this->replicaS3, $execution->filename); +})->with(['database', 'volume'])->with(['primary deleted' => true, 'primary unusable' => false]); + +it('keeps the not found message when no usable S3 copy exists', function (string $type, string $message) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $this->replicaS3->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound()->assertJson(['message' => $message]); +})->with([ + 'database' => ['database', 'Backup not available locally, but available on S3.'], + 'volume' => ['volume', 'Backup not found locally on the server.'], +]); + +it('never uses an S3 storage of another team', function (string $type) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $foreignS3 = createS3ForDownloadFallbackTest($this->otherTeam, 'foreign'); + $execution->s3Replicas()->create(['s3_storage_id' => $foreignS3->id, 's3_uploaded' => true]); + signInForDownloadFallbackTest($this, $this->team); + + $this->get(route($route, $execution->id))->assertNotFound(); +})->with(['database', 'volume']); + +it('forbids members and other teams from getting a presigned URL', function (string $type, string $user) { + [$route, $execution] = downloadFallbackTarget($this, $type); + $execution->s3Replicas()->create(['s3_storage_id' => $this->primaryS3->id, 's3_uploaded' => true]); + $user === 'member' + ? signInForDownloadFallbackTest($this, $this->team, 'member') + : signInForDownloadFallbackTest($this, $this->otherTeam); + + $this->get(route($route, $execution->id))->assertForbidden(); +})->with(['database', 'volume'])->with(['member', 'other team owner']); diff --git a/tests/Feature/BackupS3DownloadTest.php b/tests/Feature/BackupS3DownloadTest.php new file mode 100644 index 0000000000..a04d90950e --- /dev/null +++ b/tests/Feature/BackupS3DownloadTest.php @@ -0,0 +1,287 @@ + 'file', 'constants.ssh.mux_enabled' => false]); + Storage::fake('ssh-keys'); + Server::flushIdentityMap(); + Once::flush(); + InstanceSettings::forceCreate(['id' => 0]); + Team::factory()->create(['id' => 0]); + $this->downloadStorage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $this->downloadStorage->id]); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->otherTeam = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->database = StandalonePostgresql::create([ + 'name' => 'orders-db', 'postgres_user' => 'postgres', 'postgres_password' => 'password', 'postgres_db' => 'db', + 'image' => 'postgres:17', 'environment_id' => $environment->id, + 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass(), + ]); + $this->backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id, + 'database_type' => $this->database->getMorphClass(), + ]); + $this->execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $this->backup->id, + 'status' => 'success', + 'filename' => BACKUP_S3_DOWNLOAD_FILE, + ]); + $this->service = Service::factory()->create([ + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'environment_id' => $environment->id, + ]); + + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'admin']); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + Bus::fake([PrepareFileDownloadJob::class]); +}); + +function fakeBackupFileSize(int|string $size): void +{ + Process::fake(['*' => Process::result(output: (string) $size)]); +} + +it('uploads a database backup through S3 for an admin of its team after a team switch', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('download_file', $this->execution->id) + ->assertDispatched('info') + ->assertNotDispatched('open-backup-download') + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === BACKUP_S3_DOWNLOAD_FILE + && $job->serverId === $this->server->id + && $job->downloadName === 'pg-dump-orders-1.dmp' + && $job->s3StorageId === $this->downloadStorage->id + && $job->userId === $this->user->id); +}); + +it('streams through the download route when no download storage is set', function () { + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => null]); + Once::flush(); + Process::fake(); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('streams through the download route when the backup is larger than one S3 upload', function () { + fakeBackupFileSize(FileDownloadStorageService::MAX_SIZE + 1); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('lets the download route answer when the backup file is not on the server', function () { + fakeBackupFileSize(''); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id) + ->assertDispatched('open-backup-download', url: route('download.backup', $this->execution->id)); + + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('refuses team members', function () { + Process::fake(); + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]); + + $component->call('download_file', $this->execution->id) + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('does not download an execution of another backup', function () { + Process::fake(); + $foreignBackup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->otherTeam->id, + 'frequency' => 'daily', + 'database_id' => $this->database->id + 1000, + 'database_type' => $this->database->getMorphClass(), + ]); + $foreignExecution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $foreignBackup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/databases/other/secret.dmp', + ]); + + Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $foreignExecution->id) + ->assertDispatched('error', 'Backup execution not found.') + ->assertNotDispatched('open-backup-download'); + + Process::assertNothingRan(); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('uploads a volume backup through S3 and refuses members', function () { + fakeBackupFileSize(2048); + $application = Application::factory()->create([ + 'environment_id' => $this->database->environment_id, + 'destination_id' => $this->database->destination_id, + 'destination_type' => $this->database->destination_type, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => 'app-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $backup = $volume->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $execution = ScheduledVolumeBackupExecution::create([ + 'scheduled_volume_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/volumes/app/archive.tar.gz', + ]); + $component = Livewire::test(VolumeBackups::class, ['storage' => $volume, 'resource' => $application]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('downloadBackup', $execution->id)->assertCount('pendingDownloads', 1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/volumes/app/archive.tar.gz' + && $job->serverId === $this->server->id); + + $this->user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']); + $this->user->unsetRelation('teams'); + $component->call('downloadBackup', $execution->id)->assertForbidden(); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 1); +}); + +it('uploads a service database backup through S3', function () { + fakeBackupFileSize(1024); + $serviceDatabase = ServiceDatabase::create([ + 'service_id' => $this->service->id, + 'name' => 'service-db', + 'image' => 'postgres:16-alpine', + 'custom_type' => 'postgresql', + ]); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => 'daily', + 'database_id' => $serviceDatabase->id, + 'database_type' => $serviceDatabase->getMorphClass(), + ]); + $execution = ScheduledDatabaseBackupExecution::create([ + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 'filename' => '/data/coolify/backups/service/db.dmp', + ]); + + Livewire::test(ServiceBackupExecutions::class, ['service' => $this->service]) + ->call('downloadExecution', $execution->uuid) + ->assertCount('pendingDownloads', 1); + + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->container === null + && $job->path === '/data/coolify/backups/service/db.dmp'); +}); + +it('opens a finished link only for the user who asked for it', function () { + fakeBackupFileSize(1024); + $component = Livewire::test(BackupExecutions::class, ['backup' => $this->backup]) + ->call('download_file', $this->execution->id); + $downloadId = $component->get('pendingDownloads')[0]; + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id + 1, 'status' => 'ready', 'url' => 'https://s3.example.com/other']); + $component->call('checkDownloads')->assertNotDispatched('open-backup-download')->assertCount('pendingDownloads', 1); + + Cache::put(PrepareFileDownloadJob::cacheKey($downloadId), ['user_id' => $this->user->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + $component->call('checkDownloads') + ->assertDispatched('open-backup-download', url: 'https://s3.example.com/get') + ->assertCount('pendingDownloads', 0); +}); + +it('uploads the backup file from the server host with curl', function () { + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put?X-Amz-Signature=abc&x=1', 'headers' => ['Content-Disposition' => 'attachment']]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-host', $this->user->id, $this->server->id, null, BACKUP_S3_DOWNLOAD_FILE, false, 'pg.dmp', $this->downloadStorage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-host')))->toMatchArray(['status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'curl -fsS') + && str_contains($process->command, BACKUP_S3_DOWNLOAD_FILE) + && str_contains($process->command, 'X-Amz-Signature=abc&x=1') + && ! str_contains($process->command, 'docker exec')); +}); + +it('builds host file commands that the non-root sudo parser keeps whole', function () { + $nonRoot = Server::factory()->make(['user' => 'deploy']); + $upload = FileDownloadStorageService::buildHostFileUploadCommand("/data/it's.dmp", 'https://s3.example.com/put?a=1&b=2', ['Content-Disposition' => 'attachment']); + $size = FileDownloadStorageService::buildHostFileSizeCommand("/data/it's.dmp"); + + expect(parseCommandsByLineForSudo(collect([$upload, $size]), $nonRoot))->toBe(['sudo '.$upload, 'sudo '.$size]) + ->and(fn () => FileDownloadStorageService::buildHostFileSizeCommand('/data/a;rm -rf /'))->toThrow(Exception::class); +}); diff --git a/tests/Feature/BackupS3StorageListResourceTeamTest.php b/tests/Feature/BackupS3StorageListResourceTeamTest.php index 90f87493b4..d4b61b16b4 100644 --- a/tests/Feature/BackupS3StorageListResourceTeamTest.php +++ b/tests/Feature/BackupS3StorageListResourceTeamTest.php @@ -112,7 +112,7 @@ test('database backup page offers only usable S3 storages of the database team', 'database_id' => $this->postgres->id, 'database_type' => $this->postgres->getMorphClass(), ]); - $url = route('project.database.backup.s3', [ + $url = route('project.database.backup.execution', [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, 'database_uuid' => $this->postgres->uuid, diff --git a/tests/Feature/BoardingActionsLayoutTest.php b/tests/Feature/BoardingActionsLayoutTest.php new file mode 100644 index 0000000000..71e7b824e4 --- /dev/null +++ b/tests/Feature/BoardingActionsLayoutTest.php @@ -0,0 +1,84 @@ +toContain('class="mx-auto mt-6 flex w-full max-w-3xl flex-col items-center gap-3"'); +}); + +test('server validation opens in the centered process dialog', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('@click="processDialogOpen = true"') + ->not->toContain(''); +}); + +test('DigitalOcean is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your DigitalOcean account.') + ->toContain(''); +}); + +test('Hostinger is available as an onboarding server provider', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('') + ->toContain('') + ->toContain('Deploy servers directly from your Hostinger account.') + ->toContain(''); +}); + +test('server type details are shown on the relevant cards instead of a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->toContain('aria-label="About this machine"') + ->toContain('aria-label="About remote servers"') + ->toContain('Not recommended for production workloads due to resource contention.') + ->toContain('Any SSH-accessible server, including cloud VPS, bare metal, and self-hosted infrastructure.') + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('server type cards use the standard card hover treatment', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + $hoverClasses = 'shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md'; + preg_match_all('/min-h-36[^\"]*'.preg_quote($hoverClasses, '/').'/', $view, $matches); + + expect($matches[0])->toHaveCount(6) + ->and(substr_count($view, 'group relative cursor-pointer'))->toBe(6) + ->and($view)->not->toContain('hover:border-coollabs/35 hover:bg-coollabs/[0.03]') + ->and($view)->not->toContain('dark:hover:border-warning/25 dark:hover:bg-warning/[0.04]'); +}); + +test('existing SSH key selection does not show a redundant value tooltip', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view)->toContain('label="Existing SSH key" :options="$privateKeyOptions" :tooltip="false"'); +}); + +test('server connection step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); + +test('project step does not render a technical details panel', function () { + $view = file_get_contents(resource_path('views/livewire/boarding/index.blade.php')); + + expect($view) + ->not->toContain('') + ->not->toContain('') + ->not->toContain(''); +}); diff --git a/tests/Feature/CloudProviderLinkDropdownTest.php b/tests/Feature/CloudProviderLinkDropdownTest.php index 1f14482cf6..714b896e01 100644 --- a/tests/Feature/CloudProviderLinkDropdownTest.php +++ b/tests/Feature/CloudProviderLinkDropdownTest.php @@ -59,19 +59,66 @@ it('shows link cloud provider dropdown with available unlinked providers', funct 'name' => 'Test DigitalOcean Token', ]); + CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) ->assertSee('Link provider') ->assertSee('Hetzner') ->assertSee('DigitalOcean') ->assertSee('Vultr') + ->assertSee('Hostinger') ->assertSee('Hetzner Token') ->assertSee('DigitalOcean Token') ->assertSee('Vultr Token') + ->assertSee('Hostinger Token') ->assertSee('Server ID') ->assertSee('Droplet ID') ->assertSee('Instance ID') + ->assertSee('Virtual Machine ID') ->assertSee('Search by server IP') - ->assertSee('Search'); + ->assertSee('Search ID'); +}); + +it('links a manually added server to a Hostinger VPS by IP', function () { + $token = CloudProviderToken::query()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'test-hostinger-token', + 'name' => 'Test Hostinger Token', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + [ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'hostname' => 'hostinger.example.com', + 'state' => 'running', + 'ipv4' => [['address' => '1.2.3.4']], + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('selectedHostingerTokenId', $token->id) + ->call('searchHostingerVirtualMachine') + ->assertSet('matchedHostingerVirtualMachine.id', 17923) + ->call('linkToHostinger') + ->assertDispatched('success'); + + expect($this->server->fresh()->hostinger_virtual_machine_id)->toBe(17923) + ->and($this->server->fresh()->hostinger_virtual_machine_status)->toBe('running') + ->and($this->server->fresh()->cloud_provider_token_id)->toBe($token->id); }); it('hides link cloud provider dropdown when no providers can be linked', function () { diff --git a/tests/Feature/CloudProviderTokenShowTest.php b/tests/Feature/CloudProviderTokenShowTest.php index fad6be2287..1c657c644c 100644 --- a/tests/Feature/CloudProviderTokenShowTest.php +++ b/tests/Feature/CloudProviderTokenShowTest.php @@ -49,6 +49,24 @@ test('server cloud provider token cards show token descriptions', function () { ->assertSee('Used for production servers in the EU region.'); }); +test('server cloud provider token page supports Hostinger VPS servers', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Production Hostinger', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSet('provider', 'hostinger') + ->assertSet('providerName', 'Hostinger') + ->assertSee('Production Hostinger'); +}); + describe('after the user switches the session team', function () { beforeEach(function () { $this->withoutDefer(); diff --git a/tests/Feature/CloudflareTunnelApiSetupTest.php b/tests/Feature/CloudflareTunnelApiSetupTest.php new file mode 100644 index 0000000000..3597e7f0dc --- /dev/null +++ b/tests/Feature/CloudflareTunnelApiSetupTest.php @@ -0,0 +1,470 @@ +withoutDefer(); + $this->withoutVite(); + Server::flushIdentityMap(); + Queue::fake(); + config(['constants.ssh.mux_enabled' => false]); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->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, 'port' => 2222]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + + $this->token = IntegrationToken::factory()->for($this->team)->create([ + 'name' => 'Cloudflare tunnel', 'token' => 'cf-api-secret', 'capabilities' => ['dns', 'tunnel'], + ]); + DnsProviderZone::factory()->for($this->token)->create([ + 'provider_zone_id' => 'zone-1', 'name' => 'example.com', 'account_id' => 'acc-1', + ]); + $this->tunnelName = "coolify-{$this->server->uuid}"; +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * Fakes Cloudflare by "METHOD path" routes; any other request fails the test. + * + * @param array $routes + */ +function fakeCloudflareTunnelApi(array $routes): void +{ + Http::fake(function (Request $request) use ($routes) { + $key = $request->method().' '.parse_url($request->url(), PHP_URL_PATH); + if (! array_key_exists($key, $routes)) { + throw new RuntimeException("Unexpected Cloudflare request: {$key}"); + } + + return $routes[$key]; + }); +} + +function newCloudflareTunnelRoutes(array $overrides = []): array +{ + return array_merge([ + 'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => []]), + 'POST '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => ['id' => 'tun-1', 'token' => 'tunnel-secret']]), + 'PUT '.CF_TUNNEL_API.'/tun-1/configurations' => Http::response(['success' => true, 'result' => []]), + 'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => []]), + 'POST '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => ['id' => 'rec-1']]), + ], $overrides); +} + +function fakeCloudflaredActivity(string $expectedTunnelToken): void +{ + $activity = Activity::create(['log_name' => 'default', 'description' => 'queued', 'properties' => ['status' => 'queued']]); + ConfigureCloudflared::shouldRun()->once() + ->withArgs(fn (Server $server, string $tunnelToken, string $hostname) => $tunnelToken === $expectedTunnelToken && $hostname === 'ssh.example.com') + ->andReturn($activity); +} + +test('a new tunnel is created, routed to the server ssh port, and gets a proxied cname', function () { + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes()); + fakeCloudflaredActivity('tunnel-secret'); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'https://SSH.example.com/') + ->call('apiCloudflareConfig') + ->assertHasNoErrors() + ->assertDispatched('activityMonitor'); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && str_ends_with($request->url(), CF_TUNNEL_API) + && $request['name'] === $this->tunnelName && $request['config_src'] === 'cloudflare' + && $request->hasHeader('Authorization', 'Bearer cf-api-secret')); + Http::assertSent(fn (Request $request) => $request->method() === 'PUT' + && $request['config']['ingress'] === [ + ['hostname' => 'ssh.example.com', 'service' => 'ssh://localhost:2222'], + ['service' => 'http_status:404'], + ]); + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && str_ends_with($request->url(), CF_TUNNEL_DNS) + && $request['type'] === 'CNAME' && $request['content'] === 'tun-1.cfargotunnel.com' && $request['proxied'] === true); + + $settings = $this->server->settings->fresh(); + $record = ManagedDnsRecord::query()->sole(); + expect($settings->cloudflare_tunnel_id)->toBe('tun-1') + ->and($settings->cloudflare_tunnel_account_id)->toBe('acc-1') + ->and($settings->cloudflare_tunnel_integration_token_id)->toBe($this->token->id) + ->and($record->owned)->toBeTrue() + ->and($record->references()->sole()->resource_id)->toBe($this->server->id); +}); + +test('a re-run reuses the existing tunnel and its dns record', function () { + $record = ManagedDnsRecord::factory()->create([ + 'team_id' => $this->team->id, 'integration_token_id' => $this->token->id, + 'dns_provider_zone_id' => DnsProviderZone::query()->sole()->id, 'provider_record_id' => 'rec-1', + 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', 'owned' => true, + ]); + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([ + 'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => [['id' => 'tun-1', 'name' => $this->tunnelName]]]), + 'GET '.CF_TUNNEL_API.'/tun-1/token' => Http::response(['success' => true, 'result' => 'tunnel-secret-again']), + 'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => [[ + 'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', + 'proxied' => true, 'comment' => $record->ownershipComment(), + ]]]), + ])); + fakeCloudflaredActivity('tunnel-secret-again'); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertHasNoErrors(); + + Http::assertNotSent(fn (Request $request) => $request->method() === 'POST'); + expect($record->fresh()->owned)->toBeTrue() + ->and($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1'); +}); + +test('an existing dns record is replaced only after confirmation', function () { + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([ + 'GET '.CF_TUNNEL_DNS => Http::response(['success' => true, 'result' => [[ + 'id' => 'rec-old', 'type' => 'A', 'name' => 'ssh.example.com', 'content' => '203.0.113.10', 'proxied' => false, + ]]]), + 'PATCH '.CF_TUNNEL_DNS.'/rec-old' => Http::response(['success' => true, 'result' => ['id' => 'rec-old']]), + 'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]), + ])); + + $component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertSet('dnsConflict.current', '203.0.113.10') + ->assertSee('ssh.example.com already points to 203.0.113.10.'); + + Http::assertNotSent(fn (Request $request) => in_array($request->method(), ['PATCH', 'POST'], true) && str_contains($request->url(), 'dns_records')); + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1')); + expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull(); + + fakeCloudflaredActivity('tunnel-secret'); + $component->call('replaceDnsAndConfigure')->assertSet('dnsConflict', null); + + Http::assertSent(fn (Request $request) => $request->method() === 'PATCH' + && $request->data() === ['content' => 'tun-1.cfargotunnel.com', 'type' => 'CNAME', 'proxied' => true]); + expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1'); +}); + +test('a token added from the tunnel page is selected when it is the only tunnel token', function () { + $this->token->update(['capabilities' => ['dns']]); + + $component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Add Cloudflare token'); + + $this->token->update(['capabilities' => ['dns', 'tunnel']]); + + $component->dispatch('integrationTokenAdded') + ->assertSet('cloudflare_integration_token_id', $this->token->id) + ->assertDontSee('Add Cloudflare token'); +}); + +test('a missing tunnel permission is reported and nothing is saved', function () { + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([ + 'GET '.CF_TUNNEL_API => Http::response(['success' => false, 'errors' => [['code' => 10000, 'message' => 'Authentication error']]], 403), + ])); + ConfigureCloudflared::shouldRun()->never(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertDispatched('error', 'The Cloudflare token needs the Account → Cloudflare Tunnel → Edit permission.'); + + expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull() + ->and(ManagedDnsRecord::query()->count())->toBe(0); +}); + +test('a failed dns step deletes only a tunnel that the same run created', function (bool $tunnelExists, bool $deleted) { + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes([ + 'GET '.CF_TUNNEL_API => Http::response(['success' => true, 'result' => $tunnelExists ? [['id' => 'tun-1', 'name' => $this->tunnelName]] : []]), + 'GET '.CF_TUNNEL_API.'/tun-1/token' => Http::response(['success' => true, 'result' => 'tunnel-secret']), + 'POST '.CF_TUNNEL_DNS => Http::response(['success' => false], 403), + 'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]), + ])); + ConfigureCloudflared::shouldRun()->never(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertDispatched('error', 'The Cloudflare token needs the Zone → DNS → Edit permission.'); + + $deleteSent = fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1'); + $deleted ? Http::assertSent($deleteSent) : Http::assertNotSent($deleteSent); + expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBeNull(); +})->with([ + 'new tunnel' => [false, true], + 'existing tunnel' => [true, false], +]); + +describe('disable', function () { + beforeEach(function () { + Process::fake(); + Sleep::fake(); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + $this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]); + $this->server->settings->update([ + 'is_cloudflare_tunnel' => true, 'cloudflare_tunnel_id' => 'tun-1', + 'cloudflare_tunnel_account_id' => 'acc-1', 'cloudflare_tunnel_integration_token_id' => $this->token->id, + ]); + $this->record = ManagedDnsRecord::factory()->create([ + 'team_id' => $this->team->id, 'integration_token_id' => $this->token->id, + 'dns_provider_zone_id' => DnsProviderZone::query()->sole()->id, 'provider_record_id' => 'rec-1', + 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', 'owned' => true, + ]); + $this->record->addReference($this->server); + $this->server->update(['ip' => 'ssh.example.com', 'ip_previous' => '203.0.113.20']); + }); + + test('deletes the tunnel and the owned dns record', function () { + fakeCloudflareTunnelApi([ + 'DELETE '.CF_TUNNEL_API.'/tun-1/connections' => Http::response(['success' => true, 'result' => null]), + 'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response(['success' => true, 'result' => ['id' => 'tun-1']]), + 'GET '.CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => [ + 'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', + 'comment' => $this->record->ownershipComment(), + ]]), + 'DELETE '.CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => ['id' => 'rec-1']]), + ]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleCloudflareTunnels') + ->assertNotDispatched('warning'); + + // Detached, so stopping cloudflared does not cut the SSH session that runs through the tunnel. + Process::assertRan(fn ($process) => str_contains($process->command, "nohup setsid sh -c 'sleep 2; docker rm -f coolify-cloudflared' > /dev/null 2>&1 < /dev/null &")); + Sleep::assertNeverSlept(); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_API.'/tun-1')); + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_ends_with($request->url(), CF_TUNNEL_DNS.'/rec-1')); + + $settings = $this->server->settings->fresh(); + expect($settings->is_cloudflare_tunnel)->toBeFalsy() + ->and($settings->cloudflare_tunnel_id)->toBeNull() + ->and($settings->cloudflare_tunnel_integration_token_id)->toBeNull() + ->and(ManagedDnsRecord::query()->count())->toBe(0); + }); + + test('still disables locally and warns when cloudflare fails', function () { + fakeCloudflareTunnelApi([ + 'DELETE '.CF_TUNNEL_API.'/tun-1/connections' => Http::response([], 500), + 'DELETE '.CF_TUNNEL_API.'/tun-1' => Http::response([], 500), + 'GET '.CF_TUNNEL_DNS.'/rec-1' => Http::response([], 500), + ]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleCloudflareTunnels') + ->assertDispatched('warning', "Cloudflare could not delete the tunnel {$this->tunnelName}. Delete it in Cloudflare.") + ->assertDispatched('warning', 'The DNS record ssh.example.com could not be deleted. Delete it in Cloudflare.'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_cloudflare_tunnel)->toBeFalsy() + ->and($settings->cloudflare_tunnel_id)->toBeNull(); + Sleep::assertSleptTimes(4); + }); + + test('waits until the stopping connector disconnects before the tunnel is deleted', function () { + $deletes = 0; + Http::fake(function (Request $request) use (&$deletes) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return match (true) { + $request->method() === 'DELETE' && $path === CF_TUNNEL_API.'/tun-1' => ++$deletes === 1 + ? Http::response(['success' => false, 'errors' => [['code' => 1022, 'message' => 'Cannot delete tunnel because it has active connections']]], 400) + : Http::response(['success' => true, 'result' => ['id' => 'tun-1']]), + $request->method() === 'GET' && $path === CF_TUNNEL_DNS.'/rec-1' => Http::response(['success' => true, 'result' => [ + 'id' => 'rec-1', 'type' => 'CNAME', 'name' => 'ssh.example.com', 'content' => 'tun-1.cfargotunnel.com', + 'comment' => $this->record->ownershipComment(), + ]]), + default => Http::response(['success' => true, 'result' => null]), + }; + }); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleCloudflareTunnels') + ->assertNotDispatched('warning'); + + expect($deletes)->toBe(2); + Sleep::assertSleptTimes(1); + }); + + test('stops cloudflared detached for a non-root ssh user', function () { + $this->server->update(['user' => 'ubuntu']); + Http::fake(['*' => Http::response(['success' => true, 'result' => null])]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleCloudflareTunnels'); + + Process::assertRan(fn ($process) => str_contains($process->command, + "sudo bash -c 'nohup setsid sh -c '\\''sleep 2; docker rm -f coolify-cloudflared'\\'' > /dev/null 2>&1 < /dev/null &'")); + }); + + test('a tunnel that coolify did not create is disabled without cloudflare calls', function () { + Http::fake(); + $this->server->settings->update(['cloudflare_tunnel_id' => null, 'cloudflare_tunnel_account_id' => null]); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleCloudflareTunnels'); + + Http::assertNothingSent(); + expect($this->server->settings->fresh()->is_cloudflare_tunnel)->toBeFalsy(); + }); +}); + +describe('authorization', function () { + test('members without update rights cannot start the setup', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + Http::fake(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertForbidden(); + + Http::assertNothingSent(); + }); + + test('a token of another team is rejected', function () { + $otherToken = IntegrationToken::factory()->create(['capabilities' => ['dns', 'tunnel']]); + Http::fake(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->set('cloudflare_integration_token_id', $otherToken->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertHasErrors('cloudflare_integration_token_id'); + + Http::assertNothingSent(); + }); + + test('a token without the tunnel capability is not listed or accepted', function () { + $this->token->update(['capabilities' => ['dns']]); + Http::fake(); + + Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Enable the Cloudflare Tunnel capability on a Cloudflare token') + ->set('cloudflare_integration_token_id', $this->token->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertHasErrors('cloudflare_integration_token_id'); + + Http::assertNothingSent(); + }); + + test('the server team decides the tokens after the session team changes', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($admin->id, ['role' => 'owner']); + $otherTeamToken = IntegrationToken::factory()->for($otherTeam)->create(['capabilities' => ['dns', 'tunnel']]); + $this->actingAs($admin); + + $component = Livewire::test(CloudflareTunnel::class, ['server_uuid' => $this->server->uuid]); + session(['currentTeam' => $otherTeam]); + + fakeCloudflareTunnelApi(newCloudflareTunnelRoutes()); + $component->set('cloudflare_integration_token_id', $otherTeamToken->id) + ->set('api_ssh_domain', 'ssh.example.com') + ->call('apiCloudflareConfig') + ->assertHasErrors('cloudflare_integration_token_id'); + Http::assertNothingSent(); + + fakeCloudflaredActivity('tunnel-secret'); + $component->set('cloudflare_integration_token_id', $this->token->id) + ->call('apiCloudflareConfig') + ->assertHasNoErrors(); + + expect($this->server->settings->fresh()->cloudflare_tunnel_id)->toBe('tun-1'); + }); +}); + +describe('tunnel capability', function () { + test('the validator checks tunnel access of the zone account', function (int $tunnelStatus, bool $valid) { + Http::fake([ + 'api.cloudflare.com/client/v4/user/tokens/verify' => Http::response(['result' => ['status' => 'active']]), + 'api.cloudflare.com/client/v4/zones/zone-1/dns_records*' => Http::response(['success' => true, 'result' => []]), + 'api.cloudflare.com/client/v4/zones*' => Http::response(['success' => true, 'result' => [['id' => 'zone-1', 'account' => ['id' => 'acc-1']]]]), + 'api.cloudflare.com/client/v4/accounts/acc-1/cfd_tunnel*' => Http::response(['success' => $valid], $tunnelStatus), + ]); + + expect(app(CloudflareTokenValidator::class)->validate('cf-api-secret', ['dns', 'tunnel']))->toBe($valid); + })->with([ + 'permitted' => [200, true], + 'missing permission' => [403, false], + ]); + + test('selecting the tunnel capability also selects dns', function () { + Livewire::test(IntegrationTokenForm::class) + ->set('capabilities', []) + ->set('capabilities', ['tunnel']) + ->assertSet('capabilities', ['tunnel', 'dns']); + + $this->token->update(['capabilities' => ['dns']]); + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $this->token->uuid]) + ->set('capabilities', ['tunnel']) + ->assertSet('capabilities', ['tunnel', 'dns']); + }); +}); + +describe('cloudflare token template url', function () { + $permissionsOf = function (string $url): array { + parse_str(parse_url($url, PHP_URL_QUERY), $query); + + return json_decode($query['permissionGroupKeys'], true); + }; + + test('a dns token asks for dns edit and zone read', function () use ($permissionsOf) { + expect($permissionsOf(IntegrationToken::cloudflareTokenTemplateUrl(['dns'])))->toBe([ + ['key' => 'dns', 'type' => 'edit'], + ['key' => 'zone', 'type' => 'read'], + ]); + }); + + test('a tunnel token also asks for cloudflare tunnel edit', function () use ($permissionsOf) { + expect($permissionsOf(IntegrationToken::cloudflareTokenTemplateUrl(['dns', 'tunnel']))) + ->toContain(['key' => 'argotunnel', 'type' => 'edit']); + }); +}); diff --git a/tests/Feature/ContainerFilesystemServiceTest.php b/tests/Feature/ContainerFilesystemServiceTest.php new file mode 100644 index 0000000000..3d89dfdb26 --- /dev/null +++ b/tests/Feature/ContainerFilesystemServiceTest.php @@ -0,0 +1,381 @@ + false]); +}); + +function fsService(): ContainerFilesystemService +{ + $server = Server::factory()->make(['id' => 999]); + + return new ContainerFilesystemService($server, 'app-123'); +} + +function fsServer(): Server +{ + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'ip' => '203.0.113.10', + ]); +} + +it('sorts directories before files, then by name case-insensitively', function () { + $entries = [ + new FileEntry('README.md', 'file', 10, 100), + new FileEntry('src', 'dir', 0, 100), + new FileEntry('.env', 'file', 5, 100), + new FileEntry('assets', 'dir', 0, 100), + ]; + + $names = array_map(fn (FileEntry $e) => $e->name, FileEntry::sort($entries)); + + expect($names)->toBe(['assets', 'src', '.env', 'README.md']); +}); + +it('builds a listing command with a single stat call, escaped path and container name', function () { + $cmd = fsService()->buildListCommand('/var/www/html'); + + expect($cmd) + ->toContain('docker exec') + ->toContain('app-123') + ->toContain('stat -c') + ->toContain(escapeshellarg('/var/www/html')) + // Must embed a REAL tab, not a literal backslash-t: stat -c does not + // expand \t, so a literal escape would break the tab-delimited parse. + ->toContain("%F\t%s") + ->not->toContain('%F\\t%s') + // find batches names, so a huge folder cannot overflow the argument limit like a glob. + ->toContain('find . ! -name . -prune -exec stat -c') + ->not->toContain('* .*'); +}); + +it('rejects an unsafe listing path', function () { + fsService()->buildListCommand('/tmp/$(reboot)'); +})->throws(Exception::class); + +it('parses a tab-delimited listing into sorted FileEntry rows', function () { + $raw = implode("\n", [ + "file\t10\t1700000000\tREADME.md", + "dir\t0\t1700000001\tsrc", + "file\t5\t1700000002\t.env", + ]); + + $entries = fsService()->parseListing($raw); + + expect($entries)->toHaveCount(3); + expect($entries[0]->name)->toBe('src'); + expect($entries[0]->type)->toBe('dir'); + expect($entries[1]->name)->toBe('.env'); + expect($entries[2]->name)->toBe('README.md'); +}); + +it('strips the ./ prefix that find adds to listed names', function () { + $raw = implode("\n", [ + "directory\t0\t1\t755\troot\troot\t./src", + "regular file\t5\t2\t644\troot\troot\t./.env", + ]); + + $names = array_map(fn ($entry) => $entry->name, fsService()->parseListing($raw)); + + expect($names)->toBe(['src', '.env']); +}); + +it('parses an empty listing to an empty array', function () { + expect(fsService()->parseListing(null))->toBe([]); + expect(fsService()->parseListing(''))->toBe([]); +}); + +it('lists a directory by running the built command over SSH', function () { + Process::fake(['*' => Process::result(output: "dir\t0\t1\tsrc\nfile\t10\t2\tREADME.md")]); + + $server = fsServer(); + $entries = (new ContainerFilesystemService($server, 'app-123'))->list('/app'); + + expect($entries)->toHaveCount(2); + expect($entries[0]->name)->toBe('src'); +}); + +it('falls back to / when the container has no WorkingDir', function () { + Process::fake(['*' => Process::result(output: '')]); + + $server = fsServer(); + expect((new ContainerFilesystemService($server, 'app-123'))->defaultRoot())->toBe('/'); +}); + +it('refuses to read a file larger than the edit cap', function () { + // The combined read command emits TOOBIG in a single round trip. + Process::fake(['*' => Process::result(output: 'TOOBIG')]); + + $server = fsServer(); + (new ContainerFilesystemService($server, 'app-123'))->read('/app/big.bin'); +})->throws(RuntimeException::class); + +it('refuses to read a binary file', function () { + Process::fake(['*' => Process::result(output: 'BINARY')]); + + $server = fsServer(); + (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.bin'); +})->throws(RuntimeException::class); + +it('reads an editable text file in a single round trip', function () { + // "OK" header line then the base64 payload, from one docker exec. + Process::fake(['*' => Process::result(output: "OK\n".base64_encode("hello world\n"))]); + + $server = fsServer(); + $content = (new ContainerFilesystemService($server, 'app-123'))->read('/app/a.txt'); + + expect($content)->toBe("hello world\n"); +}); + +it('treats an empty file as editable text', function () { + // Empty file => "OK" header with no payload. + Process::fake(['*' => Process::result(output: 'OK')]); + + $server = fsServer(); + + expect((new ContainerFilesystemService($server, 'app-123'))->read('/app/empty.txt'))->toBe(''); +}); + +it('writes content near or above the shell argument limit via docker cp', function (int $size) { + Process::fake(['*' => Process::result(output: '')]); + $server = fsServer(); + + (new ContainerFilesystemService($server, 'app-123'))->write('/app/big.txt', str_repeat('a', $size)); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp')); +})->with([96 * 1024, 200_000]); + +it('base64-encodes content in the write command', function () { + $cmd = fsService()->buildWriteCommand('/app/a.txt', "hi\n"); + + expect($cmd) + ->toContain('base64 -d') + ->toContain(base64_encode("hi\n")) + ->toContain(escapeshellarg('/app/a.txt')); +}); + +it('builds mkdir, rename and delete commands with escaped paths', function () { + $svc = fsService(); + + expect($svc->buildMkdirCommand('/app/new dir')) + ->toContain('mkdir -p') + ->toContain(escapeshellarg('/app/new dir')); + expect($svc->buildRenameCommand('/app/a', '/app/b')) + ->toContain('mv') + ->toContain(escapeshellarg('/app/a')) + ->toContain(escapeshellarg('/app/b')); + expect($svc->buildDeleteCommand('/app/x')) + ->toContain('rm -rf') + ->toContain(escapeshellarg('/app/x')); +}); + +it('rejects unsafe paths in mutating builders', function () { + fsService()->buildDeleteCommand('/app/$(reboot)'); +})->throws(Exception::class); + +it('parses the 7-field stat listing with owner and group, mapping the file type', function () { + $raw = implode("\n", [ + "directory\t4096\t1700000000\t755\troot\troot\t.", + "directory\t4096\t1700000000\t755\troot\troot\t..", + "regular file\t497\t1700000001\t644\twww-data\twww-data\t50x.html", + "directory\t4096\t1700000002\t755\tapp\tapp\tassets", + "symbolic link\t11\t1700000003\t777\troot\troot\tlink", + ]); + + $entries = fsService()->parseListing($raw); + + // The stat . and .. rows are dropped. + expect($entries)->toHaveCount(3); + expect($entries[0]->name)->toBe('assets'); + expect($entries[0]->type)->toBe('dir'); + expect($entries[0]->owner)->toBe('app'); + expect($entries[0]->group)->toBe('app'); + expect($entries[1]->name)->toBe('50x.html'); + expect($entries[1]->type)->toBe('file'); + expect($entries[1]->owner)->toBe('www-data'); + expect($entries[2]->name)->toBe('link'); + expect($entries[2]->type)->toBe('symlink'); +}); + +it('parses permissions from the 5-field listing format', function () { + $raw = "file\t497\t1700000000\t644\t50x.html\ndir\t0\t1700000001\t755\tassets"; + + $entries = fsService()->parseListing($raw); + + expect($entries[0]->name)->toBe('assets'); + expect($entries[0]->perms)->toBe('755'); + expect($entries[1]->name)->toBe('50x.html'); + expect($entries[1]->perms)->toBe('644'); +}); + +it('builds a create-file command that does not truncate an existing file', function () { + $cmd = fsService()->buildCreateFileCommand('/app/new file.txt'); + + expect($cmd) + ->toContain('[ -e') + ->toContain(': >') + ->toContain(escapeshellarg('/app/new file.txt')); +}); + +it('builds a chmod command with a validated octal mode', function () { + $cmd = fsService()->buildChmodCommand('/app/x.sh', '0755'); + + expect($cmd) + ->toContain('chmod 0755 --') + ->toContain(escapeshellarg('/app/x.sh')); +}); + +it('rejects a non-octal chmod mode', function () { + fsService()->buildChmodCommand('/app/x.sh', 'rwx'); +})->throws(InvalidArgumentException::class); + +it('rejects an unsafe path in the chmod builder', function () { + fsService()->buildChmodCommand('/app/$(reboot)', '644'); +})->throws(Exception::class); + +it('uploads by scp-ing to the server then docker cp into the container', function () { + Process::fake(['*' => Process::result(output: '')]); + $server = fsServer(); + $local = tempnam(sys_get_temp_dir(), 'up'); + file_put_contents($local, 'data'); + + (new ContainerFilesystemService($server, 'app-123'))->upload($local, '/app/plugins/x.jar'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker cp') + && str_contains($process->command, 'app-123')); + @unlink($local); +}); + +it('rejects failed or corrupt read responses', function (string $output) { + Process::fake(['*' => Process::result(output: $output)]); + (new ContainerFilesystemService(fsServer(), 'app-123'))->read('/app/a.txt'); +})->with(['', 'ERROR', "OK\n!!!"])->throws(RuntimeException::class); + +it('executes read guards for real file bytes through the non-root sudo parser', function (string $content, bool $editable) { + $server = fsServer(); + $server->update(['user' => 'deploy']); + $path = tempnam(sys_get_temp_dir(), 'fs-read-'); + file_put_contents($path, $content); + Process::fake(function ($process) { + $lines = explode("\n", $process->command); + $body = implode("\n", array_slice($lines, 1, -1)); + $local = new Symfony\Component\Process\Process([ + 'bash', '-c', + 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body, + ]); + $local->run(); + + return Process::result(output: $local->getOutput(), errorOutput: $local->getErrorOutput(), exitCode: $local->getExitCode()); + }); + try { + $service = new ContainerFilesystemService($server, 'app-123'); + if ($editable) { + expect($service->read($path))->toBe($content); + } else { + expect(fn () => $service->read($path))->toThrow(RuntimeException::class); + } + } finally { + @unlink($path); + } +})->with([ + 'empty' => ['', true], + 'newline only' => ["\n\n", true], + 'text' => ["hello\n", true], + 'binary' => ["hello\0world", false], +]); + +it('rejects a symlink to an oversized file before reading its content', function () { + $server = fsServer(); + $path = tempnam(sys_get_temp_dir(), 'fs-size-'); + $link = $path.'-link'; + $handle = fopen($path, 'w'); + ftruncate($handle, LocalFileVolume::MAX_CONTENT_SIZE + 1); + fclose($handle); + symlink($path, $link); + Process::fake(function ($process) { + $body = implode("\n", array_slice(explode("\n", $process->command), 1, -1)); + $local = new Symfony\Component\Process\Process(['bash', '-c', 'sudo() { "$@"; }; docker() { shift 2; "$@"; }; export -f sudo docker;'."\n".$body]); + $local->run(); + + return Process::result(output: $local->getOutput(), exitCode: $local->getExitCode()); + }); + try { + expect(fn () => (new ContainerFilesystemService($server, 'app-123'))->read($link)) + ->toThrow(RuntimeException::class, 'File is not editable'); + } finally { + @unlink($link); + @unlink($path); + } +}); + +it('reads download type and size in one round trip', function (string $output, bool $isDir, int $size) { + Process::fake(['*' => Process::result(output: $output)]); + + $info = (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/target'); + + expect($info)->toBe(['isDir' => $isDir, 'size' => $size]); + Process::assertRanTimes(fn () => true, 1); +})->with([ + 'file' => ['file 497', false, 497], + 'folder in KiB' => ['dir 4', true, 4096], +]); + +it('rejects a download target that does not exist', function () { + Process::fake(['*' => Process::result(output: '')]); + + (new ContainerFilesystemService(fsServer(), 'app-123'))->downloadInfo('/app/missing'); +})->throws(RuntimeException::class, 'File not found.'); + +it('builds download commands that write to stdout instead of a temp file', function () { + $svc = fsService(); + + expect($svc->buildDownloadCommand("/app/it's.txt", false)) + ->toBe("docker exec 'app-123' cat -- ".escapeshellarg("/app/it's.txt")); + expect($svc->buildDownloadCommand('/app/dir', true)) + ->toBe("docker exec 'app-123' tar czf - -C '/app/dir' ."); +}); + +it('streams the remote command output to the writer in chunks', function () { + $server = fsServer(); + $svc = new class($server, 'app-123') extends ContainerFilesystemService + { + public ?string $remoteCommand = null; + + protected function sshCommand(string $command): string + { + $this->remoteCommand = $command; + + return 'head -c 200000 /dev/zero'; + } + }; + $received = ''; + $chunks = 0; + + $svc->streamDownload('/app/a.bin', false, function (string $chunk) use (&$received, &$chunks) { + $received .= $chunk; + $chunks++; + }); + + expect($svc->remoteCommand)->toEndWith("docker exec 'app-123' cat -- '/app/a.bin'"); + expect(strlen($received))->toBe(200000); + expect($chunks)->toBeGreaterThan(1); +}); diff --git a/tests/Feature/DashboardCanvasTest.php b/tests/Feature/DashboardCanvasTest.php new file mode 100644 index 0000000000..9204b3e77a --- /dev/null +++ b/tests/Feature/DashboardCanvasTest.php @@ -0,0 +1,132 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'web-server']); + $this->environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); +}); + +function dashboardCanvasApp(Environment $environment, Server $server, string $name): Application +{ + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + + return Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +function dashboardCanvasDeployment(Application $application, Server $server, ApplicationDeploymentStatus $status): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::query()->forceCreate([ + 'application_id' => (string) $application->id, + 'application_name' => $application->name, + 'deployment_uuid' => (string) str()->uuid(), + 'deployment_url' => '/deployment/'.$status->value, + 'server_id' => $server->id, + 'server_name' => $server->name, + 'status' => $status->value, + ]); +} + +function dashboardCanvasSession(object $test, User $user, Team $team): object +{ + return $test->actingAs($user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +test('the dashboard canvas marks applications with an active deployment and lists recent deployments', function () { + $deploying = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + $queued = dashboardCanvasApp($this->environment, $this->server, 'orders-api'); + $idle = dashboardCanvasApp($this->environment, $this->server, 'docs'); + dashboardCanvasDeployment($deploying, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + dashboardCanvasDeployment($queued, $this->server, ApplicationDeploymentStatus::QUEUED); + dashboardCanvasDeployment($idle, $this->server, ApplicationDeploymentStatus::FINISHED); + + $response = dashboardCanvasSession($this, $this->admin, $this->team) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk() + ->assertJsonPath('title', 'Dashboard') + ->assertJsonPath('zones.0.id', $this->server->uuid); + $nodes = collect($response->json('nodes'))->keyBy('id'); + + expect($nodes[$deploying->uuid]['deployment'])->toBe(['status' => 'in_progress', 'link' => '/deployment/in_progress', 'pull_request_id' => null]) + ->and($nodes[$queued->uuid]['deployment']['status'])->toBe('queued') + ->and($nodes[$idle->uuid])->not->toHaveKey('deployment') + // The deployment panel lists running and queued deployments first, then the finished ones. + ->and(collect($response->json('deployments'))->map(fn (array $row) => [$row['application'], $row['status']])->all())->toBe([ + [$deploying->uuid, 'in_progress'], + [$queued->uuid, 'queued'], + [$idle->uuid, 'finished'], + ]); +}); + +test('the dashboard canvas never shows another team servers, resources, or deployments', function () { + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'owner']); + $this->team->members()->attach($outsider->id, ['role' => 'member']); + $app = dashboardCanvasApp($this->environment, $this->server, 'storefront'); + dashboardCanvasDeployment($app, $this->server, ApplicationDeploymentStatus::IN_PROGRESS); + + $response = dashboardCanvasSession($this, $outsider, $otherTeam) + ->getJson('/canvas/api/dashboard/canvas') + ->assertOk(); + + expect($response->json('zones'))->toBe([]) + ->and($response->json('nodes'))->toBe([]) + ->and($response->json('deployments'))->toBe([]); +}); + +test('the dashboard opens the canvas again when the user left it there', function () { + $cookie = "coolify_{$this->admin->id}_dashboard_view"; + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withoutVite() + ->get(route('dashboard.canvas')) + ->assertOk() + ->assertSee('data-endpoint="/dashboard/canvas"', false) + ->assertCookie($cookie, 'canvas'); + + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard')) + ->assertRedirect(route('dashboard.canvas')); + + // The list link on the canvas opens the list and makes it the remembered view. + dashboardCanvasSession($this, $this->admin, $this->team) + ->withCookie($cookie, 'canvas') + ->get(route('dashboard', ['view' => 'list'])) + ->assertOk() + ->assertCookie($cookie, 'list'); +}); diff --git a/tests/Feature/DashboardDeploymentsTest.php b/tests/Feature/DashboardDeploymentsTest.php index 805aa49af8..d949a62e7e 100644 --- a/tests/Feature/DashboardDeploymentsTest.php +++ b/tests/Feature/DashboardDeploymentsTest.php @@ -55,9 +55,8 @@ function createDashboardDeployment(array $overrides = []): ApplicationDeployment it('hides the deployments section when there is nothing to show', function () { Livewire::test(ActiveDeployments::class) - ->assertDontSee('Active and recent deployment activity') ->assertDontSee('Running or queued right now') - ->assertDontSee('Latest completed deployments') + ->assertDontSee('Recent deployments') ->assertDontSee('No active deployments') ->assertDontSee('No recent deployments'); }); @@ -72,7 +71,7 @@ it('shows only the active section when there are active deployments', function ( ->assertSee('Deployments') ->assertSee('Running or queued right now') ->assertSee('Running Now') - ->assertDontSee('Latest completed deployments') + ->assertDontSee('Recent deployments') ->assertDontSee('No active deployments') ->assertDontSee('No recent deployments'); }); @@ -85,7 +84,7 @@ it('shows only the recent section when there are recent deployments', function ( Livewire::test(ActiveDeployments::class) ->assertSee('Deployments') - ->assertSee('Latest completed deployments') + ->assertSee('Recent deployments') ->assertSee('Finished Earlier') ->assertDontSee('Running or queued right now') ->assertDontSee('No active deployments') diff --git a/tests/Feature/DashboardViewModeTest.php b/tests/Feature/DashboardViewModeTest.php new file mode 100644 index 0000000000..008907103e --- /dev/null +++ b/tests/Feature/DashboardViewModeTest.php @@ -0,0 +1,55 @@ + InstanceSettings::query()->create(['id' => 0])); + + $this->user = User::factory()->create(); + $this->team = Team::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('shows the projects and servers sections as tables by default', function () { + Livewire::test(Dashboard::class) + ->assertSee("viewMode: 'table'", false) + ->assertDontSee("viewMode: 'grid'", false); +}); + +it('remembers the view of each dashboard section on its own', function () { + Livewire::test(Dashboard::class)->call('saveListView', 'dashboard_projects', 'grid'); + + expect(Cookie::queued("coolify_{$this->user->id}_dashboard_projects_view")?->getValue())->toBe('grid') + ->and(Cookie::queued("coolify_{$this->user->id}_dashboard_servers_view"))->toBeNull(); + + $this->withCookie("coolify_{$this->user->id}_dashboard_projects_view", 'grid') + ->get(route('dashboard')) + ->assertOk() + ->assertSee("viewMode: 'grid'", false) + ->assertSee("viewMode: 'table'", false); +}); + +it('links to all projects only when the dashboard hides some of them', function () { + Project::factory()->count(8)->create(['team_id' => $this->team->id]); + + Livewire::test(Dashboard::class)->assertDontSee(route('project.index'), false); + + Project::factory()->create(['team_id' => $this->team->id]); + + Livewire::test(Dashboard::class)->assertSee(route('project.index'), false); +}); diff --git a/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php new file mode 100644 index 0000000000..0191aa09d7 --- /dev/null +++ b/tests/Feature/DatabaseBackupMultipleS3DestinationsTest.php @@ -0,0 +1,434 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => strtolower($name).'-bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + ]); +} + +function multiS3Execution(ScheduledDatabaseBackup $backup, string $name, array $attributes = []): ScheduledDatabaseBackupExecution +{ + $execution = ScheduledDatabaseBackupExecution::create([ + 'uuid' => $name, + 'database_name' => 'app', + 'filename' => "/backup/{$name}.dmp", + 'scheduled_database_backup_id' => $backup->id, + 'status' => 'success', + 's3_uploaded' => true, + 'local_storage_deleted' => true, + ...$attributes, + ]); + + return $execution; +} + +/** + * Records the files that deleteBackupsS3() removes, keyed by bucket. + */ +function fakeMultiS3Deletes(): Collection +{ + $deleted = collect(); + Storage::shouldReceive('build')->andReturnUsing(function (array $config) use ($deleted) { + $disk = Mockery::mock(); + $disk->shouldReceive('delete')->andReturnUsing(function (array $files) use ($config, $deleted) { + $deleted->put($config['bucket'], [...$deleted->get($config['bucket'], []), ...$files]); + + return true; + }); + + return $disk; + }); + + return $deleted; +} + +/** + * Runs a backup with two S3 destinations, failing the upload to the storages named in $failingStorages. + * + * @return array{0: ScheduledDatabaseBackupExecution, 1: Collection} + */ +function runMultiS3BackupJob(array $failingStorages): array +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination, ['status' => 'running:healthy']); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = new ScheduledDatabaseBackup; + $backup->forceFill([ + 'enabled' => true, + 'save_s3' => true, + 'disable_local_backup' => true, + 's3_storage_id' => $first->id, + 'frequency' => 'daily', + 'databases_to_backup' => 'app', + 'database_id' => $database->id, + 'database_type' => $database->getMorphClass(), + 'team_id' => $team->id, + ])->save(); + $backup->syncS3Storages([$first->id, $second->id]); + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $command = is_array($process->command) ? implode(' ', $process->command) : $process->command; + $commands->push($command); + + return Process::result(output: str_contains($command, 'du -b') ? '128' : ''); + }); + + $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$backup->fresh()])->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('upload_to_s3')->andReturnUsing(function (S3Storage $storage) use ($failingStorages) { + if (in_array($storage->name, $failingStorages, true)) { + throw new RuntimeException('access denied'); + } + }); + $job->handle(); + + return [ScheduledDatabaseBackupExecution::query()->sole(), $commands]; +} + +it('applies S3 retention to each destination separately', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + 'database_backup_retention_amount_s3' => 1, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $old = multiS3Execution($backup, 'old'); + $new = multiS3Execution($backup, 'new', ['s3_uploaded' => false]); + ScheduledDatabaseBackupExecution::whereKey($old->id)->update(['created_at' => now()->subDay()]); + $old->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $old->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $new->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $new->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => false, 'message' => 'denied']); + $deleted = fakeMultiS3Deletes(); + + removeOldBackups($backup); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/old.dmp']]) + ->and($old->s3Replicas()->where('s3_storage_id', $first->id)->sole()->s3_storage_deleted)->toBeTrue() + ->and($old->s3Replicas()->where('s3_storage_id', $second->id)->sole()->s3_storage_deleted)->toBeFalse() + ->and($old->fresh()->s3_storage_deleted)->toBeFalse() + ->and($backup->executions()->count())->toBe(2); +}); + +it('deletes the execution record when its last S3 copy expires', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + 'database_backup_retention_amount_s3' => 1, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $old = multiS3Execution($backup, 'old'); + $new = multiS3Execution($backup, 'new'); + ScheduledDatabaseBackupExecution::whereKey($old->id)->update(['created_at' => now()->subDay()]); + foreach ([$old, $new] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + } + $deleted = fakeMultiS3Deletes(); + + removeOldBackups($backup); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/old.dmp'], 'second-bucket' => ['/backup/old.dmp']]) + ->and($old->fresh())->toBeNull() + ->and(DatabaseBackupS3Replica::where('execution_id', $old->id)->exists())->toBeFalse() + ->and($new->fresh())->not->toBeNull(); +}); + +it('marks copies in a deleted storage as gone without calling S3', function () { + $team = Team::factory()->create(); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'orphaned'); + $execution->s3Replicas()->create(['s3_storage_id' => null, 's3_uploaded' => true]); + Storage::shouldReceive('build')->never(); + + removeOldBackups($backup); + + expect($execution->fresh())->toBeNull(); +}); + +it('keeps an execution whose local file still exists after its S3 copies expire', function () { + $team = Team::factory()->create(); + $storage = multiS3Storage($team, 'First'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'local-copy', ['local_storage_deleted' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $storage->id, 's3_uploaded' => true, 's3_storage_deleted' => true]); + + removeOldBackups($backup); + + expect($execution->fresh())->not->toBeNull(); +}); + +it('deletes the S3 copies of an execution from every destination that has one', function () { + $team = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $failed = multiS3Storage($team, 'Failed'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]); + $execution = multiS3Execution($backup, 'manual', ['s3_uploaded' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $failed->id, 's3_uploaded' => false]); + $deleted = fakeMultiS3Deletes(); + + $execution->deleteS3Copies(); + + expect($deleted->keys()->sort()->values()->all())->toBe(['first-bucket', 'second-bucket']) + ->and($execution->fresh()->s3_storage_deleted)->toBeTrue() + ->and($execution->hasLiveS3Copies())->toBeFalse(); +}); + +describe('backup job', function () { + beforeEach(function () { + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Event::fake([BackupCreated::class]); + Notification::fake(); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + }); + + it('uploads to every destination and deletes the local file when all uploads succeed', function () { + [$execution, $commands] = runMultiS3BackupJob([]); + + expect($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->local_storage_deleted)->toBeTrue() + ->and($execution->s3Replicas()->pluck('s3_uploaded')->all())->toBe([true, true]) + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeTrue(); + }); + + it('continues to later destinations and keeps the local file when a replica write fails', function (array $failingStorages) { + $event = 'eloquent.creating: '.DatabaseBackupS3Replica::class; + Event::listen($event, function (DatabaseBackupS3Replica $replica): void { + if (S3Storage::find($replica->s3_storage_id)?->name === 'First') { + throw new RuntimeException('replica persistence failed'); + } + }); + + try { + [$execution, $commands] = runMultiS3BackupJob($failingStorages); + } finally { + Event::forget($event); + } + + $replica = $execution->s3Replicas()->with('s3')->sole(); + expect($replica->s3->name)->toBe('Second') + ->and($replica->s3_uploaded)->toBeTrue() + ->and($execution->s3_storage_deleted)->toBeFalse() + ->and($execution->s3_uploaded)->toBeFalse() + ->and($execution->local_storage_deleted)->toBeFalse() + ->and($execution->message)->toContain('First: replica persistence failed') + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeFalse(); + + if ($failingStorages !== []) { + expect($execution->message)->toContain('First: access denied'); + } + })->with([ + 'successful upload' => [[]], + 'failed upload' => [['First']], + ]); + + it('keeps the local file and names the failed destination when one upload fails', function () { + [$execution, $commands] = runMultiS3BackupJob(['Second']); + + $replicas = $execution->s3Replicas()->with('s3')->get()->keyBy(fn ($replica) => $replica->s3->name); + expect($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeFalse() + ->and($execution->local_storage_deleted)->toBeFalse() + ->and($execution->message)->toContain('Second: access denied') + ->and($replicas['First']->s3_uploaded)->toBeTrue() + ->and($replicas['Second']->s3_uploaded)->toBeFalse() + ->and($replicas['Second']->message)->toBe('access denied') + ->and($commands->contains(fn (string $command) => str_contains($command, 'rm -f') && str_contains($command, $execution->filename)))->toBeFalse(); + }); +}); + +it('deletes an execution from every S3 destination through the API', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0])); + $sshKeys = Storage::fake('ssh-keys'); + $sshMux = Storage::fake('ssh-mux'); + Process::fake(); + $team = Team::factory()->create(); + $user = User::factory()->create(); + $team->members()->attach($user, ['role' => 'owner']); + session(['currentTeam' => $team]); + $token = $user->createToken('test-token', ['*'])->plainTextToken; + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ]); + $backup->syncS3Storages([$first->id, $second->id]); + $execution = multiS3Execution($backup, 'api', ['local_storage_deleted' => false]); + $execution->s3Replicas()->create(['s3_storage_id' => $first->id, 's3_uploaded' => true]); + $execution->s3Replicas()->create(['s3_storage_id' => $second->id, 's3_uploaded' => true]); + $deleted = fakeMultiS3Deletes(); + Storage::shouldReceive('disk')->with('ssh-keys')->andReturn($sshKeys); + Storage::shouldReceive('disk')->with('ssh-mux')->andReturn($sshMux); + + $this->withHeaders(['Authorization' => 'Bearer '.$token]) + ->deleteJson("/api/v1/databases/{$database->uuid}/backups/{$backup->uuid}/executions/{$execution->uuid}?delete_s3=true") + ->assertOk(); + + expect($deleted->all())->toBe(['first-bucket' => ['/backup/api.dmp'], 'second-bucket' => ['/backup/api.dmp']]) + ->and($execution->fresh())->toBeNull(); +}); + +it('copies the destinations of the same team to a cloned schedule', function () { + $team = Team::factory()->create(); + $otherTeam = Team::factory()->create(); + $first = multiS3Storage($team, 'First'); + $second = multiS3Storage($team, 'Second'); + $foreign = multiS3Storage($otherTeam, 'Foreign'); + $attributes = [ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'database_type' => 'App\Models\StandalonePostgresql', + 'database_id' => 1, + 'team_id' => $team->id, + ]; + $backup = ScheduledDatabaseBackup::create($attributes); + $backup->syncS3Storages([$first->id, $second->id]); + $backup->s3Storages()->attach($foreign->id); + $copy = ScheduledDatabaseBackup::create($attributes); + + $backup->copyS3StoragesTo($copy); + + expect($copy->s3Storages()->pluck('s3_storages.id')->sort()->values()->all())->toBe([$first->id, $second->id]); +}); + +it('requires a live S3 copy for local retention only when local backups are disabled', function (bool $disabled, ?array $replica, bool $deleted) { + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $database = create_standalone_postgresql($environment->id, $destination); + $backup = ScheduledDatabaseBackup::create([ + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 'disable_local_backup' => $disabled, + 'database_backup_retention_amount_locally' => 1, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + 'team_id' => $team->id, + ]); + $older = multiS3Execution($backup, 'older', ['local_storage_deleted' => false, 'created_at' => now()->subDay()]); + $newer = multiS3Execution($backup, 'newer', ['local_storage_deleted' => false]); + if ($replica !== null) { + $storage = multiS3Storage($team, 'Retention'); + foreach ([$older, $newer] as $execution) { + $execution->s3Replicas()->create(['s3_storage_id' => $storage->id, ...$replica]); + } + } + $commands = collect(); + Process::fake(function ($process) use ($commands) { + $commands->push(is_array($process->command) ? implode(' ', $process->command) : $process->command); + + return Process::result(); + }); + + removeOldBackups($backup); + + expect($commands->contains(fn (string $command): bool => str_contains($command, '/backup/older.dmp')))->toBe($deleted); + if ($deleted && $replica === null) { + $this->assertModelMissing($older); + } else { + expect($older->fresh()->local_storage_deleted)->toBe($deleted); + } + expect($newer->fresh()->local_storage_deleted)->toBeFalse(); +})->with([ + 'no replica' => [true, null, false], + 'failed upload' => [true, ['s3_uploaded' => false], false], + 'deleted replica' => [true, ['s3_uploaded' => true, 's3_storage_deleted' => true], false], + 'live replica' => [true, ['s3_uploaded' => true, 's3_storage_deleted' => false], true], + 'normal local retention' => [false, null, true], +]); diff --git a/tests/Feature/DatabaseFlushTest.php b/tests/Feature/DatabaseFlushTest.php new file mode 100644 index 0000000000..476aed154d --- /dev/null +++ b/tests/Feature/DatabaseFlushTest.php @@ -0,0 +1,112 @@ + 0]); + $this->withoutDefer(); +}); + +function createFlushableDatabase(string $model, array $attributes = []): StandaloneRedis|StandaloneKeydb|StandaloneDragonfly +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + 'user' => 'deploy', + ]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + + return $model::create([ + 'name' => 'cache', + 'image' => 'redis:7', + 'environment_id' => Environment::factory()->create(['project_id' => $project->id])->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$attributes, + ]); +} + +function actingAsFlushUser(Team $team, string $role): void +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + test()->actingAs($user); + session(['currentTeam' => $team]); +} + +it('flushes every key with the engine CLI', function (string $model, array $attributes, string $cli) { + $database = createFlushableDatabase($model, $attributes); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'OK')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(true) + ->assertDispatched('success'); + + Process::assertRan(fn ($process) => str_contains($process->command, "sudo bash -c 'docker exec {$database->uuid} sh -c '\\''REDISCLI_AUTH=\"\$REDIS_PASSWORD\" {$cli} FLUSHALL ASYNC'\\'''")); + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->where('resource_uuid', $database->uuid)->exists())->toBeTrue(); +})->with([ + 'redis' => [StandaloneRedis::class, [], 'redis-cli'], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'secret'], 'keydb-cli'], + 'dragonfly with tls' => [StandaloneDragonfly::class, ['dragonfly_password' => 'secret', 'enable_ssl' => true], 'redis-cli --tls --cacert /etc/dragonfly/certs/coolify-ca.crt --cert /etc/dragonfly/certs/server.crt --key /etc/dragonfly/certs/server.key'], +]); + +it('reports an error when the database does not reply OK', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(['*' => Process::result(output: 'NOAUTH Authentication required.')]); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertReturned(false) + ->assertDispatched('error', 'NOAUTH Authentication required.'); + + expect(AuditEvent::query()->where('event', 'ui.database.flushed')->exists())->toBeFalse(); +}); + +it('rejects an incorrect password without flushing', function () { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($database->team(), 'owner'); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'wrong-password') + ->assertReturned('The provided password is incorrect.'); + + Process::assertNothingRan(); +}); + +it('forbids flushing without permission to manage the database', function (string $role, bool $sameTeam) { + $database = createFlushableDatabase(StandaloneRedis::class); + actingAsFlushUser($sameTeam ? $database->team() : Team::factory()->create(), $role); + Process::fake(); + + Livewire::test(Danger::class, ['resource' => $database]) + ->call('flush', 'password') + ->assertDispatched('error'); + + Process::assertNothingRan(); +})->with([ + 'team member' => ['member', true], + 'admin of another team' => ['admin', false], +]); diff --git a/tests/Feature/DeployCanvasPageTest.php b/tests/Feature/DeployCanvasPageTest.php new file mode 100644 index 0000000000..7b79b9cc98 --- /dev/null +++ b/tests/Feature/DeployCanvasPageTest.php @@ -0,0 +1,172 @@ + 0]); + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->otherTeam = Team::factory()->create(['show_boarding' => false]); + $this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'canvas-team-project']); + $this->otherProject = Project::factory()->create(['team_id' => $this->otherTeam->id, 'name' => 'canvas-other-project']); + $this->user = User::factory()->create(); +}); + +function actingInCanvasTeam(object $test, Team $team): object +{ + return $test->actingAs($test->user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +test('guests are sent to the login page', function () { + $this->get('/canvas')->assertRedirect('/login'); +}); + +test('team members can open the deploy canvas', function () { + $this->team->members()->attach($this->user->id, ['role' => 'member']); + + actingInCanvasTeam($this, $this->team) + ->withoutVite() + ->get('/canvas') + ->assertOk() + ->assertSee('data-deploy-canvas', false); +}); + +test('the deploy canvas page lists the service template logos for the service step', function () { + $this->team->members()->attach($this->user->id, ['role' => 'member']); + $template = get_service_templates()->first(fn ($template) => filled(data_get($template, 'logo')) && basename(data_get($template, 'logo')) !== 'default.webp'); + $key = get_service_templates()->search($template); + + $html = actingInCanvasTeam($this, $this->team)->withoutVite()->get('/canvas')->assertOk()->getContent(); + + preg_match('/ bug\n[Verify your account](https://evil.example.com)\n# Big heading", + ]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('') + ->toContain('<script>') + ->not->toContain('href="https://evil.example.com"') + ->toContain('[Verify your account](https://evil.example.com)') + ->not->toContain('

Big heading

') + ->toContain('# Big heading'); +}); + +it('lets team admins toggle commit details from email notification settings', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->assertSet('deploymentCommitDetailsEmailNotifications', false) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertHasNoErrors(); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +it('does not let team members change the commit details setting', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertSet('deploymentCommitDetailsEmailNotifications', false); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('does not let admins of another team change the commit details setting', function () { + $otherTeam = Team::factory()->create(); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'admin']); + $this->actingAs($outsider); + session(['currentTeam' => $otherTeam]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails'); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse() + ->and($otherTeam->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +function bigMarkdownCommitMessage(): string +{ + return implode("\n", [ + 'feat(checkout): rebuild payment flow', + '', + '# Heading one', + 'Setext heading', + '==============', + '## Heading two', + '- bullet one', + '* bullet two', + '+ bullet three', + '1. first step', + '2. second step', + '> quoted text', + ' indented code line', + '```php', + 'echo "hello";', + '```', + '| Column A | Column B |', + '| -------- | -------- |', + '| cell 1 | cell 2 |', + '**bold** _italic_ ~~strike~~ `inline code`', + '[Reset your password](https://evil.example.com/reset)', + '![tracking pixel](https://evil.example.com/pixel.png)', + '', + '', + '
click me', + '---', + 'Co-authored-by: Jane Doe ', + 'Emoji check 🚀 ✅', + '', + str_repeat('Long body sentence for truncation. ', 60), + 'THIS_TAIL_SHOULD_BE_TRUNCATED', + ]); +} + +it('renders a big markdown commit message as plain safe text', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit_message' => bigMarkdownCommitMessage()]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('feat(checkout): rebuild payment flow') + ->toContain('# Heading one') + ->toContain('==============') + ->toContain('- bullet one') + ->toContain('1. first step') + ->toContain('```php') + ->toContain('| Column A | Column B |') + ->toContain('**bold** _italic_ ~~strike~~ `inline code`') + ->toContain('Co-authored-by: Jane Doe <jane@example.com>') + ->toContain('Emoji check 🚀 ✅') + ->not->toContain("\u{FFFD}") + ->toContain('Long body sentence for truncation.') + ->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED') + ->not->toContain('href="https://evil.example.com') + ->not->toContain('src="https://evil.example.com') + ->not->toContain('not->toContain('application, $deployment, $this->team); + + expect($html) + ->toContain('Fix caf') + ->toContain('menu (latin-1 byte)') + ->toContain('View Deployment Logs'); +}); + +it('builds commit details without querying the deployment queue', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $notification = new $notificationClass($this->application, $deployment->deployment_uuid, null, $deployment); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($queries->filter(fn (string $query): bool => str_contains($query, 'application_deployment_queues')))->toBeEmpty() + ->and($notification->commit_sha)->toBe(DEPLOYMENT_EMAIL_COMMIT_SHA); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('keeps the old constructor working without a deployment record', function () { + enableCommitDetails($this->team); + + $html = (string) (new DeploymentSuccess($this->application, 'legacy-call'))->toMail($this->team)->render(); + + expect($html) + ->toContain('View Deployment Logs') + ->not->toContain('Commit message:'); +}); + +it('passes the in-memory deployment record from the deployment job', function () { + Notification::fake(); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'deployment_success_email_notifications' => true]); + $deployment = createDeploymentRow($this->application); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + foreach (['application' => $this->application, 'deployment_uuid' => $deployment->deployment_uuid, 'preview' => null, 'application_deployment_queue' => $deployment] as $name => $value) { + $reflection->getProperty($name)->setValue($job, $value); + } + + $reflection->getMethod('sendDeploymentNotification')->invoke($job, DeploymentSuccess::class); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === DEPLOYMENT_EMAIL_COMMIT_SHA + && str_starts_with($notification->commit_message, 'Add checkout page')); +}); diff --git a/tests/Feature/DeploymentsIndicatorTest.php b/tests/Feature/DeploymentsIndicatorTest.php index 5391826444..c89ce6c12d 100644 --- a/tests/Feature/DeploymentsIndicatorTest.php +++ b/tests/Feature/DeploymentsIndicatorTest.php @@ -74,3 +74,42 @@ it('does not show deployments of applications owned by another team', function ( ->assertDontSee('Indicator App') ->assertDontSee('Active deployments'); }); + +it('hides the indicator on the page of the only application that is deploying', function () { + $this->get(route('project.application.configuration', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])) + ->assertSuccessful() + ->assertDontSee('aria-label="Active deployments"', false); +}); + +it('keeps the indicator on an application page when another application is deploying', function () { + $otherApplication = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'Other App', + ]); + + ApplicationDeploymentQueue::create([ + 'application_id' => $otherApplication->id, + 'application_name' => $otherApplication->name, + 'deployment_uuid' => 'deploy-indicator-'.fake()->uuid(), + 'deployment_url' => '/deployments/other', + 'server_id' => $this->server->id, + 'server_name' => $this->server->name, + 'status' => ApplicationDeploymentStatus::QUEUED->value, + 'pull_request_id' => 0, + ]); + + $this->get(route('project.application.configuration', [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'application_uuid' => $this->application->uuid, + ])) + ->assertSuccessful() + ->assertSee('2 deployments', false) + ->assertSee('aria-label="Active deployments"', false); +}); diff --git a/tests/Feature/DevScriptTest.php b/tests/Feature/DevScriptTest.php index 1864c012f9..232bde15de 100644 --- a/tests/Feature/DevScriptTest.php +++ b/tests/Feature/DevScriptTest.php @@ -245,6 +245,30 @@ BASH); ->not->toContain('--profile testing-host up'); }); +it('runs the host qemu command with a clean local environment', function () { + if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) { + $this->markTestSkipped('KVM and root access are required for this launcher branch.'); + } + + file_put_contents($this->devRoot.'/bin/php', <<<'BASH' +#!/usr/bin/env bash +[[ "$APP_ENV" == local && "$APP_CONFIG_CACHE" == */bootstrap/cache/dev-qemu-*.php ]] || { + echo 'This command may only run in development mode.' >&2 + exit 1 +} +printf 'php %s ENV=%s CACHE=%s\n' "$*" "$APP_ENV" "$APP_CONFIG_CACHE" >> "$DEV_TEST_LOG" +BASH); + + $process = runDevScript('wt', ['start', 'ubuntu-root'], [ + 'COOLIFY_DEV_SERVER_BACKEND' => 'auto', + 'APP_ENV' => 'production', + 'APP_CONFIG_CACHE' => '/tmp/stale-config.php', + ]); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) + ->and(devScriptLog())->toContain('php artisan dev:qemu ubuntu-root --as-localhost ENV=local CACHE='); +}); + it('starts a lima vm as localhost on macos and seeds its forwarded ssh port', function () { ($this->fakeLima)(); runDevScript('main', ['start']); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php index ad6d1d76d0..60c273a36a 100644 --- a/tests/Feature/DevelopmentQemuVmTest.php +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -173,16 +173,41 @@ it('can change the localhost qemu profile without adding a server', function () it('seeds localhost through the container command', function () { $this->seed(ServerSeeder::class); + Process::fake(); expect(Artisan::call('dev:qemu:seed', [ 'profile' => 'ubuntu-root', '--as-localhost' => true, ]))->toBe(Command::SUCCESS) ->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10'); + + Process::assertRan(fn ($process) => str_contains($process->command, 'docker run -d --name coolify-db') + && str_contains($process->command, 'POSTGRES_DB=')); +}); + +it('starts the instance database container with sudo on a non-root localhost vm', function () { + $this->seed(ServerSeeder::class); + Process::fake(); + + Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-non-root', '--as-localhost' => true]); + + Process::assertRan(fn ($process) => str_contains($process->command, 'if ! sudo docker inspect coolify-db') + && str_contains($process->command, 'sudo docker run -d --name coolify-db') + && str_contains($process->command, 'sudo docker start coolify-db')); +}); + +it('still seeds localhost when the instance database container cannot start', function () { + $this->seed(ServerSeeder::class); + Process::fake(fn () => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1)); + + expect(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root', '--as-localhost' => true]))->toBe(Command::SUCCESS) + ->and(Artisan::output())->toContain('Could not start the coolify-db container') + ->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10'); }); it('seeds localhost with the forwarded ssh address of a lima vm', function () { $this->seed(ServerSeeder::class); + Process::fake(); expect(Artisan::call('dev:qemu:seed', [ 'profile' => 'debian-non-root', @@ -210,22 +235,31 @@ it('replaces the seeded qemu server with the selected non-root equivalent', func it('reuses an existing vm without touching other managed vms', function () { $storagePath = sys_get_temp_dir().'/coolify-qemu-reuse-test-'.uniqid(); - config(['development-qemu.storage_path' => $storagePath]); + config(['development-qemu.storage_path' => $storagePath, 'development-qemu.memory' => 1024]); File::ensureDirectoryExists($storagePath); File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data'); File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'vm data'); - Process::fake([ - '* net-dumpxml *' => Process::result(output: ''), - '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), - '* dominfo *' => Process::result(output: 'exists'), - '* domstate *' => Process::result(output: "shut off\n"), - '*' => Process::result(), - ]); + $ran = []; + Process::fake(function ($process) use (&$ran) { + $ran[] = $process->command; + + return match (true) { + str_contains($process->command, ' net-dumpxml ') => Process::result(output: ''), + str_contains($process->command, ' network inspect ') => Process::result(output: "172.18.0.0/16\n"), + str_contains($process->command, ' dominfo ') => Process::result(output: 'exists'), + str_contains($process->command, ' domstate ') => Process::result(output: "shut off\n"), + default => Process::result(), + }; + }); StartDevelopmentQemuVm::run('ubuntu-non-root'); - Process::assertRan(fn ($process) => $process->command === "virsh start 'coolify-dev-ubuntu-non-root'"); + $commands = collect($ran); + $resize = $commands->search("virsh setmaxmem 'coolify-dev-ubuntu-non-root' 1024M --config"); + expect($resize)->not->toBeFalse() + ->and($commands->search("virsh setmem 'coolify-dev-ubuntu-non-root' 1024M --config"))->toBeGreaterThan($resize) + ->and($commands->search("virsh start 'coolify-dev-ubuntu-non-root'"))->toBeGreaterThan($resize); Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh undefine')); @@ -249,6 +283,7 @@ it('does not restart an already running vm', function () { StartDevelopmentQemuVm::run('ubuntu-root'); Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh setmem')); Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'fsockopen')); }); diff --git a/tests/Feature/DockerCleanupExecutionsAuthorizationTest.php b/tests/Feature/DockerCleanupExecutionsAuthorizationTest.php new file mode 100644 index 0000000000..d45eb8760e --- /dev/null +++ b/tests/Feature/DockerCleanupExecutionsAuthorizationTest.php @@ -0,0 +1,60 @@ + 'file']); + InstanceSettings::forceCreate(['id' => 0]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'member']); + $this->otherTeam = Team::factory()->create(); + $this->user->teams()->attach($this->otherTeam, ['role' => 'owner']); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->execution = DockerCleanupExecution::create([ + 'server_id' => $this->server->id, + 'status' => 'running', + 'message' => implode("\n", array_fill(0, 150, 'cleanup output')), + ]); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('denies log actions after server team access is removed', function (string $action) { + $component = Livewire::test(DockerCleanupExecutions::class, ['server' => $this->server]) + ->call('selectExecution', $this->execution->id); + + $this->team->members()->detach($this->user->id); + $this->user->refresh(); + session(['currentTeam' => $this->otherTeam]); + + $arguments = $action === 'selectExecution' ? [$this->execution->id] : []; + $component->call($action, ...$arguments)->assertForbidden(); +})->with(['refreshExecutions', 'selectExecution', 'polling', 'loadMoreLogs']); + +it('allows log actions for a server team member after switching session teams', function () { + $component = Livewire::test(DockerCleanupExecutions::class, ['server' => $this->server]); + session(['currentTeam' => $this->otherTeam]); + + $component->call('refreshExecutions') + ->call('selectExecution', $this->execution->id) + ->assertSet('selectedExecution.id', $this->execution->id) + ->assertSet('isPollingActive', true) + ->call('loadMoreLogs') + ->assertSet('currentPage', 2); + + $this->execution->update(['status' => 'success', 'message' => 'finished cleanup']); + + $component->call('polling') + ->assertSet('isPollingActive', false) + ->assertSee('finished cleanup'); +}); diff --git a/tests/Feature/DockerCleanupJobTest.php b/tests/Feature/DockerCleanupJobTest.php index 769a0dae13..a4ec2ac720 100644 --- a/tests/Feature/DockerCleanupJobTest.php +++ b/tests/Feature/DockerCleanupJobTest.php @@ -529,7 +529,7 @@ it('scans application images with a fixed number of remote commands and keeps th } }); -it('removes stopped Coolify containers but keeps the proxy, databases, applications and services', function () { +it('removes stopped Coolify containers but keeps the proxy, databases, applications and services', function (bool $nonRoot) { $command = (new ReflectionMethod(CleanupDocker::class, 'buildContainerPruneCommand'))->invoke(new CleanupDocker); preg_match("/--format '([^']+)'/", $command, $format); $stoppedContainers = collect([ @@ -547,6 +547,14 @@ it('removes stopped Coolify containers but keeps the proxy, databases, applicati file_put_contents("$bin/docker", "#!/bin/sh\nif [ \"\$1\" = ps ]; then cat $bin/containers; else echo \"\$@\"; fi\n"); chmod("$bin/docker", 0755); + if ($nonRoot) { + $server = new Server; + $server->user = 'coolify'; + $command = parseCommandsByLineForSudo(collect([$command]), $server)[0]; + file_put_contents("$bin/sudo", "#!/bin/sh\nexec \"\$@\"\n"); + chmod("$bin/sudo", 0755); + } + $output = shell_exec('PATH='.escapeshellarg("$bin:".getenv('PATH')).' /bin/sh -c '.escapeshellarg($command)); array_map('unlink', glob("$bin/*")); @@ -554,7 +562,7 @@ it('removes stopped Coolify containers but keeps the proxy, databases, applicati expect($command)->toContain('--filter label=coolify.managed=true') ->and(trim((string) $output))->toBe('rm helper'); -}); +})->with(['root' => false, 'non-root sudo parser' => true]); it('broadcasts DockerCleanupDone without the cleanup log so large logs fit the Reverb message size', function () { $server = dockerCleanupReachableServer(); diff --git a/tests/Feature/EnvironmentCanvasTest.php b/tests/Feature/EnvironmentCanvasTest.php new file mode 100644 index 0000000000..d424ee22b6 --- /dev/null +++ b/tests/Feature/EnvironmentCanvasTest.php @@ -0,0 +1,451 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.driver' => 'file', + ]); + InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0], ['is_api_enabled' => true])); + Server::flushIdentityMap(); + + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->admin = User::factory()->create(); + $this->team->members()->attach($this->admin->id, ['role' => 'admin']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'main-server', 'ip' => '203.0.113.10']); + $this->destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'canvas-env']); +}); + +function environmentCanvasActingAs(User $user, Team $team): mixed +{ + return test()->actingAs($user)->withSession(['currentTeam' => $team]) + ->withHeader('X-Canvas-Team', (string) $team->id); +} + +function environmentCanvasUrl(Project $project, Environment $environment, string $suffix = ''): string +{ + return "/canvas/api/projects/{$project->uuid}/environments/{$environment->uuid}/canvas{$suffix}"; +} + +function environmentCanvasApplication(Environment $environment, StandaloneDocker $destination, array $attributes = []): Application +{ + return Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ...$attributes, + ]); +} + +function environmentCanvasPostgres(Environment $environment, StandaloneDocker $destination): StandalonePostgresql +{ + return create_standalone_postgresql($environment->id, $destination, [ + 'name' => 'main-db', + 'postgres_password' => 'canvas-secret-password', + ], 'postgres:17-alpine'); +} + +describe('GET canvas', function () { + test('returns zones, nodes, links and positions of the environment', function () { + $extraServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'extra-server']); + $extraDestination = StandaloneDocker::query()->where('server_id', $extraServer->id)->firstOrFail(); + + $application = environmentCanvasApplication($this->environment, $this->destination, [ + 'name' => 'web', + 'git_branch' => 'main', + 'fqdn' => 'https://web.example.com, https://www.example.com,', + 'status' => 'running:healthy', + ]); + $application->additional_servers()->attach($extraServer->id, ['standalone_docker_id' => $extraDestination->id, 'status' => 'running:healthy']); + LocalPersistentVolume::create(['name' => 'web-data', 'mount_path' => '/data', 'resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]); + + $service = Service::factory()->create([ + 'name' => 'stack', + 'environment_id' => $this->environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + ServiceApplication::forceCreate(['name' => 'ghost', 'service_id' => $service->id, 'fqdn' => 'https://ghost.example.com', 'status' => 'running:healthy']); + ServiceDatabase::forceCreate(['name' => 'mysql', 'service_id' => $service->id, 'status' => 'running:healthy']); + + $database = environmentCanvasPostgres($this->environment, $this->destination); + + $link = ResourceLink::create([ + 'environment_id' => $this->environment->id, + 'source_type' => $application->getMorphClass(), + 'source_id' => $application->id, + 'target_type' => $database->getMorphClass(), + 'target_id' => $database->id, + 'label' => 'DATABASE_URL', + ]); + $this->environment->forceFill(['canvas_positions' => [$application->uuid => ['x' => 10, 'y' => -20], 'gone-resource' => ['x' => 1, 'y' => 1]]])->save(); + + environmentCanvasActingAs($this->admin, $this->team); + $response = $this + ->getJson(environmentCanvasUrl($this->project, $this->environment)) + ->assertOk(); + + $response->assertJsonPath('project', ['uuid' => $this->project->uuid, 'name' => $this->project->name, 'link' => route('project.show', ['project_uuid' => $this->project->uuid])]) + ->assertJsonPath('environment', ['uuid' => $this->environment->uuid, 'name' => 'canvas-env', 'link' => route('project.resource.index', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])]) + ->assertJsonPath('can_edit', true) + ->assertJsonPath('links', [['id' => $link->uuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'DATABASE_URL']]) + ->assertJsonPath('positions', [$application->uuid => ['x' => 10, 'y' => -20]]); + + expect(collect($response->json('zones'))->keyBy('id')->all())->toEqual([ + $this->server->uuid => ['id' => $this->server->uuid, 'name' => 'main-server', 'ip' => '203.0.113.10', 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $this->server->uuid])], + $extraServer->uuid => ['id' => $extraServer->uuid, 'name' => 'extra-server', 'ip' => $extraServer->ip, 'is_reachable' => false, 'is_usable' => false, 'link' => route('server.show', ['server_uuid' => $extraServer->uuid])], + ]); + + $nodes = collect($response->json('nodes'))->keyBy('id'); + expect($nodes)->toHaveCount(3) + ->and($nodes[$application->uuid])->toEqual([ + 'id' => $application->uuid, + 'type' => 'application', + 'name' => 'web', + 'status' => 'running:healthy', + 'zone' => $this->server->uuid, + 'extra_zones' => [$extraServer->uuid], + 'urls' => ['https://web.example.com', 'https://www.example.com'], + 'kind' => 'nixpacks', + 'subtitle' => 'Nixpacks · main', + 'children' => [], + 'link' => $application->link(), + 'logo' => ['src' => resource_logo_url('public-repo'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => 'https://web.example.com/favicon.ico'], + 'saved' => ['x' => 10, 'y' => -20], + ]) + ->and($nodes[$service->uuid])->toEqual([ + 'id' => $service->uuid, + 'type' => 'service', + 'name' => 'stack', + 'status' => 'running:healthy', + 'zone' => $this->server->uuid, + 'extra_zones' => [], + 'urls' => ['https://ghost.example.com'], + 'kind' => null, + 'subtitle' => 'Service · 2 containers', + 'children' => [ + ['name' => 'ghost', 'type' => 'application', 'status' => 'running:healthy'], + ['name' => 'mysql', 'type' => 'database', 'status' => 'running:healthy'], + ], + 'link' => $service->link(), + 'logo' => null, + 'saved' => null, + ]) + ->and($nodes[$database->uuid])->toMatchArray([ + 'type' => 'database', + 'name' => 'main-db', + 'zone' => $this->server->uuid, + 'urls' => [], + 'kind' => 'postgresql', + 'subtitle' => 'PostgreSQL · postgres:17-alpine', + 'logo' => ['src' => resource_logo_url('postgres'), 'dark' => null, 'fallback' => null, 'padded' => false, 'favicon' => null], + 'saved' => null, + ]) + ->and($response->getContent())->not->toContain('canvas-secret-password'); + }); + + test('drops links whose resource was deleted', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + $database = environmentCanvasPostgres($this->environment, $this->destination); + ResourceLink::create([ + 'environment_id' => $this->environment->id, + 'source_type' => $application->getMorphClass(), + 'source_id' => $application->id, + 'target_type' => $database->getMorphClass(), + 'target_id' => $database->id, + ]); + $database->forceDelete(); + + environmentCanvasActingAs($this->admin, $this->team) + ->getJson(environmentCanvasUrl($this->project, $this->environment)) + ->assertOk() + ->assertJsonPath('links', []); + }); + + test('hides server ips and editing from a member', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + environmentCanvasApplication($this->environment, $this->destination); + + environmentCanvasActingAs($member, $this->team) + ->getJson(environmentCanvasUrl($this->project, $this->environment)) + ->assertOk() + ->assertJsonPath('zones.0.ip', null) + ->assertJsonPath('can_edit', false); + }); + + test('finds the environment by name', function () { + environmentCanvasActingAs($this->admin, $this->team) + ->getJson("/canvas/api/projects/{$this->project->uuid}/environments/canvas-env/canvas") + ->assertOk() + ->assertJsonPath('environment.uuid', $this->environment->uuid); + }); + + test('does not run more queries for more applications', function () { + environmentCanvasActingAs($this->admin, $this->team); + $countQueries = function (): int { + DB::flushQueryLog(); + DB::enableQueryLog(); + $this + ->getJson(environmentCanvasUrl($this->project, $this->environment)) + ->assertOk(); + DB::disableQueryLog(); + + return collect(DB::getQueryLog())->reject(fn (array $query) => str_contains($query['query'], 'audit_events'))->count(); + }; + environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => 'https://a.example.com']); + $countQueries(); + $queriesForOne = $countQueries(); + + foreach (range(1, 4) as $index) { + environmentCanvasApplication($this->environment, $this->destination, ['fqdn' => "https://a{$index}.example.com"]); + } + $queriesForFive = $countQueries(); + + expect($queriesForFive)->toBe($queriesForOne); + }); +}); + +describe('PATCH canvas positions', function () { + test('merges positions, ignores unknown resources and prunes removed ones', function () { + $first = environmentCanvasApplication($this->environment, $this->destination); + $second = environmentCanvasApplication($this->environment, $this->destination); + $this->environment->forceFill(['canvas_positions' => [ + $first->uuid => ['x' => 1, 'y' => 2], + 'removed-resource' => ['x' => 5, 'y' => 5], + ]])->save(); + $otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]); + $foreign = environmentCanvasApplication($otherEnvironment, $this->destination); + + environmentCanvasActingAs($this->admin, $this->team) + ->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [ + $second->uuid => ['x' => -300, 'y' => 400], + $foreign->uuid => ['x' => 9, 'y' => 9], + 'unknown' => ['x' => 'not-a-number'], + ]]) + ->assertOk() + ->assertExactJson(['positions' => [ + $first->uuid => ['x' => 1, 'y' => 2], + $second->uuid => ['x' => -300, 'y' => 400], + ]]); + + expect($this->environment->refresh()->canvas_positions)->toBe([ + $first->uuid => ['x' => 1, 'y' => 2], + $second->uuid => ['x' => -300, 'y' => 400], + ]); + }); + + test('rejects coordinates out of range', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + + environmentCanvasActingAs($this->admin, $this->team) + ->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [ + $application->uuid => ['x' => 100001, 'y' => 0], + ]]) + ->assertUnprocessable() + ->assertJsonValidationErrors(["positions.{$application->uuid}.x"]); + }); +}); + +describe('canvas links', function () { + test('creates, relabels and deletes a link', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + $database = environmentCanvasPostgres($this->environment, $this->destination); + environmentCanvasActingAs($this->admin, $this->team); + + $response = $this + ->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [ + 'source' => $application->uuid, + 'target' => $database->uuid, + 'label' => 'uses', + ]) + ->assertCreated() + ->assertJson(['source' => $application->uuid, 'target' => $database->uuid, 'label' => 'uses']); + $linkUuid = $response->json('id'); + expect(ResourceLink::query()->whereUuid($linkUuid)->firstOrFail()->created_by)->toBe($this->admin->id); + + $this + ->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}"), ['label' => 'reads']) + ->assertOk() + ->assertExactJson(['id' => $linkUuid, 'source' => $application->uuid, 'target' => $database->uuid, 'label' => 'reads']); + + $this + ->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$linkUuid}")) + ->assertOk() + ->assertJson(['message' => 'Link deleted.']); + + expect(ResourceLink::query()->count())->toBe(0); + }); + + test('rejects a link to itself', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + + environmentCanvasActingAs($this->admin, $this->team) + ->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [ + 'source' => $application->uuid, + 'target' => $application->uuid, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['target']); + }); + + test('rejects a resource of another environment', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + $otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]); + $foreign = environmentCanvasApplication($otherEnvironment, $this->destination); + + environmentCanvasActingAs($this->admin, $this->team) + ->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), [ + 'source' => $application->uuid, + 'target' => $foreign->uuid, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['target']); + + expect(ResourceLink::query()->count())->toBe(0); + }); + + test('rejects a duplicate link but allows the opposite direction', function () { + $application = environmentCanvasApplication($this->environment, $this->destination); + $database = environmentCanvasPostgres($this->environment, $this->destination); + environmentCanvasActingAs($this->admin, $this->team); + $url = environmentCanvasUrl($this->project, $this->environment, '/links'); + + $this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid])->assertCreated(); + $this->postJson($url, ['source' => $application->uuid, 'target' => $database->uuid]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['target']); + $this->postJson($url, ['source' => $database->uuid, 'target' => $application->uuid])->assertCreated(); + }); + + test('returns 404 for a link of another environment', function () { + $otherEnvironment = Environment::factory()->create(['project_id' => $this->project->id]); + $first = environmentCanvasApplication($otherEnvironment, $this->destination); + $second = environmentCanvasApplication($otherEnvironment, $this->destination); + $link = ResourceLink::create([ + 'environment_id' => $otherEnvironment->id, + 'source_type' => $first->getMorphClass(), + 'source_id' => $first->id, + 'target_type' => $second->getMorphClass(), + 'target_id' => $second->id, + ]); + environmentCanvasActingAs($this->admin, $this->team); + + $this + ->patchJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}"), ['label' => 'x']) + ->assertNotFound(); + $this + ->deleteJson(environmentCanvasUrl($this->project, $this->environment, "/links/{$link->uuid}")) + ->assertNotFound(); + + expect($link->fresh())->not->toBeNull(); + }); +}); + +describe('canvas authorization', function () { + test('a member can read the canvas but cannot change it', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $application = environmentCanvasApplication($this->environment, $this->destination); + $database = environmentCanvasPostgres($this->environment, $this->destination); + environmentCanvasActingAs($member, $this->team); + + $this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertOk(); + $this + ->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]]) + ->assertForbidden(); + $this + ->postJson(environmentCanvasUrl($this->project, $this->environment, '/links'), ['source' => $application->uuid, 'target' => $database->uuid]) + ->assertForbidden(); + + expect($this->environment->refresh()->canvas_positions)->toBeNull() + ->and(ResourceLink::query()->count())->toBe(0); + }); + + test('returns 404 for a project of another team', function () { + $otherTeam = Team::factory()->create(); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + $application = environmentCanvasApplication($this->environment, $this->destination); + environmentCanvasActingAs($otherUser, $otherTeam); + + $this->getJson(environmentCanvasUrl($this->project, $this->environment))->assertNotFound(); + $this + ->patchJson(environmentCanvasUrl($this->project, $this->environment, '/positions'), ['positions' => [$application->uuid => ['x' => 1, 'y' => 1]]]) + ->assertNotFound(); + }); + test('is not part of the public API', function () { + $plainTextToken = 'environment-canvas-test-token'; + $token = $this->admin->tokens()->create([ + 'name' => 'environment-canvas-test', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['root'], + 'team_id' => $this->team->id, + ]); + + $this->withHeaders(['Authorization' => "Bearer {$token->getKey()}|{$plainTextToken}"]) + ->getJson("/api/v1/projects/{$this->project->uuid}/environments/{$this->environment->uuid}/canvas") + ->assertNotFound(); + }); +}); + +describe('canvas page', function () { + test('add resource opens the deploy canvas for this environment', function () { + $deployCanvasUrl = route('deploy-canvas', ['project' => $this->project->uuid, 'environment' => $this->environment->uuid]); + + $this->actingAs($this->admin)->withSession(['currentTeam' => $this->team]) + ->withoutVite() + ->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])) + ->assertOk() + ->assertSee('data-new="'.e($deployCanvasUrl).'"', false) + ->assertSee('href="'.e($deployCanvasUrl).'"', false) + ->assertDontSee(route('project.resource.create', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]), false); + }); + + test('a team member can open the canvas page', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $this->actingAs($member)->withSession(['currentTeam' => $this->team]) + ->withoutVite() + ->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])) + ->assertOk() + ->assertSee('data-environment-canvas', false) + ->assertSee('data-environment="'.$this->environment->uuid.'"', false); + }); + + test('another team gets a 404', function () { + $otherTeam = Team::factory()->create(['show_boarding' => false]); + $otherUser = User::factory()->create(); + $otherTeam->members()->attach($otherUser->id, ['role' => 'owner']); + + $this->actingAs($otherUser)->withSession(['currentTeam' => $otherTeam]) + ->withoutVite() + ->get(route('project.environment.canvas', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid])) + ->assertNotFound(); + }); +}); diff --git a/tests/Feature/FileDownloadStorageTest.php b/tests/Feature/FileDownloadStorageTest.php new file mode 100644 index 0000000000..2cf526c1c0 --- /dev/null +++ b/tests/Feature/FileDownloadStorageTest.php @@ -0,0 +1,244 @@ + 0]); + Once::flush(); + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->storage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); +}); + +function lifecycleStorage(MockHandler $handler): S3Storage +{ + $storage = new class extends S3Storage + { + public S3Client $client; + + public function filesystem(): FilesystemAdapter + { + $adapter = Mockery::mock(AwsS3V3Adapter::class); + $adapter->shouldReceive('getClient')->andReturn($this->client); + + return $adapter; + } + }; + $storage->bucket = 'downloads'; + $storage->client = new S3Client([ + 'region' => 'us-east-1', + 'version' => 'latest', + 'credentials' => ['key' => 'k', 'secret' => 's'], + 'handler' => $handler, + ]); + + return $storage; +} + +it('adds the lifecycle rule and keeps the other bucket rules', function () { + $put = null; + $handler = new MockHandler([ + new Result(['Rules' => [ + ['ID' => 'keep-me', 'Filter' => ['Prefix' => 'logs/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 30]], + ['ID' => FileDownloadStorageService::LIFECYCLE_RULE_ID, 'Filter' => ['Prefix' => 'old/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 9]], + ]]), + function (CommandInterface $command) use (&$put) { + $put = $command->toArray(); + + return new Result([]); + }, + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); + + expect($put['LifecycleConfiguration']['Rules'])->toBe([ + ['ID' => 'keep-me', 'Filter' => ['Prefix' => 'logs/'], 'Status' => 'Enabled', 'Expiration' => ['Days' => 30]], + ['ID' => FileDownloadStorageService::LIFECYCLE_RULE_ID, 'Filter' => ['Prefix' => FileDownloadStorageService::PREFIX], 'Status' => 'Enabled', 'Expiration' => ['Days' => 1]], + ]); +}); + +it('adds the lifecycle rule to a bucket without a lifecycle configuration', function () { + $put = null; + $handler = new MockHandler([ + fn (CommandInterface $command) => new S3Exception('none', $command, ['code' => 'NoSuchLifecycleConfiguration']), + function (CommandInterface $command) use (&$put) { + $put = $command->toArray(); + + return new Result([]); + }, + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); + + expect($put['LifecycleConfiguration']['Rules'])->toHaveCount(1); +}); + +it('fails when the provider rejects the lifecycle rule', function () { + $handler = new MockHandler([ + new Result(['Rules' => []]), + fn (CommandInterface $command) => new S3Exception('not implemented', $command, ['code' => 'NotImplemented']), + ]); + + (new FileDownloadStorageService)->ensureLifecycleRule(lifecycleStorage($handler)); +})->throws(RuntimeException::class, 'Could not add the auto-delete lifecycle rule'); + +it('saves the download storage only after the lifecycle rule is added', function (bool $ruleAdded) { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->rootTeam]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('ensureBucketIsPrivate')->once(); + $expectation = $downloads->shouldReceive('ensureLifecycleRule')->once(); + if (! $ruleAdded) { + $expectation->andThrow(new RuntimeException('Could not add the auto-delete lifecycle rule')); + } + app()->instance(FileDownloadStorageService::class, $downloads); + + $component = Livewire::test(Advanced::class) + ->set('file_download_storage', 's3:'.$this->storage->id) + ->call('instantSave'); + + if ($ruleAdded) { + $component->assertSet('file_download_storage', 's3:'.$this->storage->id); + expect(InstanceSettings::find(0)->file_download_s3_storage_id)->toBe($this->storage->id); + } else { + $component->assertSet('file_download_storage', 'stream')->assertDispatched('error'); + expect(InstanceSettings::find(0)->file_download_s3_storage_id)->toBeNull(); + } +})->with([true, false]); + +it('builds one S3 upload script that the non-root sudo parser keeps whole', function () { + $command = (new ContainerFilesystemService(Server::factory()->make(), 'app-123')) + ->buildS3UploadCommand("/app/it's.txt", false, 'https://s3.example.com/b/k?X-Amz-Signature=abc&x=1', ['Content-Disposition' => 'attachment; filename="a.txt"']); + + expect($command)->toStartWith('sh -c ') + ->and($command)->toContain('mktemp', 'trap', 'curl -fsS', 'X-Amz-Signature=abc&x=1'); + expect(parseCommandsByLineForSudo(collect([$command]), Server::factory()->make(['user' => 'deploy']))) + ->toBe(['sudo '.$command]); +}); + +it('uploads to S3, stores the link, and queues the delete', function () { + $this->freezeTime(); + Storage::fake('ssh-keys'); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake([DeleteFileDownloadJob::class]); + Process::fake(['*' => Process::result(output: '')]); + $privateKey = PrivateKey::factory()->create(['team_id' => $this->rootTeam->id]); + $server = Server::factory()->create(['team_id' => $this->rootTeam->id, 'private_key_id' => $privateKey->id]); + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('uploadUrl')->andReturn(['url' => 'https://s3.example.com/put', 'headers' => []]); + $downloads->shouldReceive('downloadUrl')->andReturn('https://s3.example.com/get'); + + (new PrepareFileDownloadJob('dl-1', 7, $server->id, 'app-123', '/data/a.txt', false, 'a.txt', $this->storage->id))->handle($downloads); + + expect(Cache::get(PrepareFileDownloadJob::cacheKey('dl-1')))->toBe(['user_id' => 7, 'status' => 'ready', 'url' => 'https://s3.example.com/get']); + Process::assertRan(fn ($process) => str_contains($process->command, 'https://s3.example.com/put')); + Bus::assertDispatched(DeleteFileDownloadJob::class, fn (DeleteFileDownloadJob $job) => $job->s3StorageId === $this->storage->id + && str_starts_with($job->key, FileDownloadStorageService::PREFIX) + && $job->delay->equalTo(now()->addHours(6))); +}); + +it('makes download links that expire after 15 minutes', function () { + $this->storage->update(['endpoint' => 'https://1.1.1.1']); + $url = (new FileDownloadStorageService)->downloadUrl($this->storage, FileDownloadStorageService::PREFIX.'abc'); + + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + expect((int) $query['X-Amz-Expires'])->toBeBetween(890, 900); +}); + +it('stores a failure when the upload fails', function () { + Bus::fake([DeleteFileDownloadJob::class]); + $this->storage->update(['is_usable' => false]); + + (new PrepareFileDownloadJob('dl-2', 7, 1, 'app-123', '/data/a.txt', false, 'a.txt', $this->storage->id))->handle(new FileDownloadStorageService); + + $result = Cache::get(PrepareFileDownloadJob::cacheKey('dl-2')); + expect($result)->toMatchArray(['user_id' => 7, 'status' => 'failed']) + ->and($result['message'])->not->toContain('s3.example.com', 'X-Amz'); +}); + +it('only deletes objects under the download prefix', function () { + $downloads = Mockery::mock(FileDownloadStorageService::class); + $downloads->shouldReceive('delete')->once()->with(Mockery::type(S3Storage::class), FileDownloadStorageService::PREFIX.'abc'); + + (new DeleteFileDownloadJob($this->storage->id, FileDownloadStorageService::PREFIX.'abc'))->handle($downloads); + (new DeleteFileDownloadJob($this->storage->id, 'backups/db.dump'))->handle($downloads); +}); + +function anonymousListingService(MockHandler $handler): FileDownloadStorageService +{ + return new class($handler) extends FileDownloadStorageService + { + public function __construct(private MockHandler $handler) {} + + protected function anonymousClient(S3Storage $storage): S3Client + { + return new S3Client(['region' => 'us-east-1', 'version' => 'latest', 'credentials' => false, 'handler' => $this->handler]); + } + }; +} + +it('refuses a bucket that anonymous users can list', function () { + $downloads = anonymousListingService(new MockHandler([new Result(['Contents' => []])])); + + $downloads->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'allows anonymous listing'); + +it('accepts a bucket that denies anonymous listing', function () { + $downloads = anonymousListingService(new MockHandler([ + fn (CommandInterface $command) => new S3Exception('denied', $command, ['code' => 'AccessDenied']), + ])); + + $downloads->ensureBucketIsPrivate($this->storage); + + expect(true)->toBeTrue(); +}); + +it('refuses the bundled MinIO because servers and browsers cannot reach it', function () { + $this->storage->forceFill(['uuid' => 'minio', 'endpoint' => 'http://coolify-minio:9000'])->save(); + + (new FileDownloadStorageService)->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'uses an internal Docker host name'); + +it('explains an unreachable endpoint instead of accepting it', function () { + $downloads = anonymousListingService(new MockHandler([ + fn (CommandInterface $command) => new S3Exception('cURL error 6: Could not resolve host', $command), + ])); + + $downloads->ensureBucketIsPrivate($this->storage); +})->throws(RuntimeException::class, 'Could not connect to "Downloads"'); diff --git a/tests/Feature/GetLogsExportRedactionTest.php b/tests/Feature/GetLogsExportRedactionTest.php new file mode 100644 index 0000000000..dfb016efab --- /dev/null +++ b/tests/Feature/GetLogsExportRedactionTest.php @@ -0,0 +1,220 @@ +create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + + $this->server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $this->server->settings->fill(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false])->save(); + $this->server = Server::with('settings')->findOrFail($this->server->id); + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + $destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]; + + $this->application = Application::factory()->create(['environment_id' => $environment->id, ...$destinationAttributes]); + $this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $this->server->id, ...$destinationAttributes]); + $this->serviceApplication = ServiceApplication::create(['name' => 'web', 'service_id' => $this->service->id]); + + foreach ([$this->application, $this->service] as $resource) { + EnvironmentVariable::query()->create([ + 'key' => 'EXPORT_CANARY', + 'value' => GET_LOGS_EXPORT_CANARY, + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + 'is_preview' => false, + ]); + } + + $this->actingAs($user); + session(['currentTeam' => $team]); + + Process::fake(['*' => Process::result(output: '2026-09-28T10:00:01Z booted with '.GET_LOGS_EXPORT_CANARY)]); +}); + +it('redacts the resource environment values when copying or downloading logs', function (string $resource, string $action) { + $parameters = $resource === 'application' + ? ['resource' => $this->application] + : ['resource' => $this->service, 'servicesubtype' => $this->serviceApplication]; + + Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => 'test-container', ...$parameters]) + ->call($action) + ->assertReturned('2026-09-28T10:00:01Z booted with '.REDACTED); +})->with(['application', 'service'])->with(['copyLogs', 'downloadAllLogs']); + +it('redacts stored and effective database passwords in exported logs', function (string $path, bool $remote) { + $database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-database', + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + 'postgres_password' => 'xy', + 'status' => 'running:healthy', + ]); + EnvironmentVariable::query()->create([ + 'key' => 'POSTGRES_PASSWORD', + 'value' => $remote ? '{{vault.DB_PASSWORD}}' : 'effective-database-canary', + 'resourceable_type' => $database->getMorphClass(), + 'resourceable_id' => $database->id, + 'is_preview' => false, + ]); + if ($remote) { + Http::preventStrayRequests(); + Http::fake([ + 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['DB_PASSWORD' => 'effective-database-canary']), + ]); + $token = IntegrationToken::factory()->create([ + 'team_id' => $database->team()->id, + 'provider' => 'doppler', + 'capabilities' => ['secrets'], + ]); + $database->secretManagerLink()->create(['integration_token_id' => $token->id]); + } + Process::fake(fn ($process) => match (true) { + str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), + str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy effective-database-canary'), + default => Process::result(output: ''), + }); + + if ($path === 'api') { + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + $user = auth()->user(); + $accessToken = $user->createToken('database-logs', ['read', 'read:sensitive']); + $accessToken->accessToken->update(['team_id' => $database->team()->id]); + $token = $accessToken->plainTextToken; + auth()->forgetGuards(); + $this->withToken($token)->getJson("/api/v1/databases/{$database->uuid}/logs") + ->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED); + } else { + Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database]) + ->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED); + } +})->with(['copyLogs', 'downloadAllLogs', 'api'])->with([false, true]); + +it('still exports database logs when its remote secret source fails', function (string $path) { + $database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-failing-source', + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + 'postgres_password' => 'xy', + 'status' => 'running:healthy', + ]); + EnvironmentVariable::query()->create([ + 'key' => 'POSTGRES_PASSWORD', + 'value' => '{{vault.DB_PASSWORD}}', + 'resourceable_type' => $database->getMorphClass(), + 'resourceable_id' => $database->id, + 'is_preview' => false, + ]); + Http::preventStrayRequests(); + Http::fake(['https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['message' => 'down'], 500)]); + $token = IntegrationToken::factory()->create([ + 'team_id' => $database->team()->id, + 'provider' => 'doppler', + 'capabilities' => ['secrets'], + ]); + $database->secretManagerLink()->create(['integration_token_id' => $token->id]); + Process::fake(fn ($process) => match (true) { + str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => $database->uuid, 'Labels' => '', 'State' => 'running'])), + str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains($process->command, 'docker logs') => Process::result(output: 'booted xy {{vault.DB_PASSWORD}}'), + default => Process::result(output: ''), + }); + + if ($path === 'api') { + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + $accessToken = auth()->user()->createToken('database-logs', ['read', 'read:sensitive']); + $accessToken->accessToken->update(['team_id' => $database->team()->id]); + auth()->forgetGuards(); + $this->withToken($accessToken->plainTextToken)->getJson("/api/v1/databases/{$database->uuid}/logs") + ->assertOk()->assertJsonPath('logs', 'booted '.REDACTED.' '.REDACTED); + } else { + Livewire::test(GetLogs::class, ['server' => $this->server, 'container' => $database->uuid, 'resource' => $database]) + ->call($path)->assertReturned('booted '.REDACTED.' '.REDACTED); + } +})->with(['copyLogs', 'downloadAllLogs', 'api']); + +it('redacts database environment values with the same rules as other resources', function (bool $redactAll, string $expected) { + $database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-rules-database', + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + 'postgres_password' => 'xy', + ]); + $database->update(['redact_all_env_values_in_logs' => $redactAll]); + foreach ([['TZ', 'UTC', false], ['APP_MODE', 'production-database-mode', false], ['LOCKED', 'q7', true]] as [$key, $value, $locked]) { + EnvironmentVariable::query()->create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $database->getMorphClass(), + 'resourceable_id' => $database->id, + 'is_preview' => false, + 'is_shown_once' => $locked, + ]); + } + + $secrets = EnvironmentVariable::logRedactionSecretsFor($database->fresh()); + + expect(sanitizeLogsForExport('tz UTC mode production-database-mode locked q7 password xy', $secrets))->toBe($expected); +})->with([ + 'hide all values' => [true, 'tz UTC mode '.REDACTED.' locked '.REDACTED.' password '.REDACTED], + 'hide locked values only' => [false, 'tz UTC mode production-database-mode locked '.REDACTED.' password '.REDACTED], +]); + +it('redacts stored database credentials even without environment variables', function (string $model, array $attributes) { + $database = new $model($attributes); + if ($database instanceof StandaloneMongodb) { + $database->mongo_initdb_root_password = encrypt($attributes['mongo_initdb_root_password']); + } + $database->setRelation('runtime_environment_variables', collect()); + + $secrets = EnvironmentVariable::logRedactionSecretsFor($database); + + expect(sanitizeLogsForExport('booted '.implode(' ', array_values($attributes)), $secrets)) + ->toBe('booted '.implode(' ', array_fill(0, count($attributes), REDACTED))); +})->with([ + 'postgres' => [StandalonePostgresql::class, ['postgres_password' => 'xy']], + 'mysql' => [StandaloneMysql::class, ['mysql_password' => 'xy', 'mysql_root_password' => 'zw']], + 'mariadb' => [StandaloneMariadb::class, ['mariadb_password' => 'xy', 'mariadb_root_password' => 'zw']], + 'mongodb' => [StandaloneMongodb::class, ['mongo_initdb_root_password' => 'xy']], + 'clickhouse' => [StandaloneClickhouse::class, ['clickhouse_admin_password' => 'xy']], + 'keydb' => [StandaloneKeydb::class, ['keydb_password' => 'xy']], + 'dragonfly' => [StandaloneDragonfly::class, ['dragonfly_password' => 'xy']], +]); diff --git a/tests/Feature/GetRollbackImagesTest.php b/tests/Feature/GetRollbackImagesTest.php new file mode 100644 index 0000000000..a9937a70ac --- /dev/null +++ b/tests/Feature/GetRollbackImagesTest.php @@ -0,0 +1,82 @@ +create(); + // The server is not reachable, so only registry images are listed and no SSH command runs. + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::factory()->create(['server_id' => $server->id, 'network' => 'rollback-test']); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'nixpacks', + 'docker_registry_image_name' => 'ghcr.io/coollabs/app', + ]); +}); + +function rollbackDeployment(Application $application, string $commit, string $finishedAt, array $attributes = []): void +{ + ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, + 'deployment_uuid' => fake()->uuid(), + 'commit' => $commit, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'pull_request_id' => 0, + ...$attributes, + ])->forceFill(['updated_at' => $finishedAt])->saveQuietly(); +} + +it('lists the commits of successful deployments as registry images, newest first and once per commit', function () { + rollbackDeployment($this->application, 'aaaaaaa', '2026-10-01 10:00:00'); + rollbackDeployment($this->application, 'bbbbbbb', '2026-10-02 10:00:00'); + rollbackDeployment($this->application, 'aaaaaaa', '2026-10-03 10:00:00'); + rollbackDeployment($this->application, 'ccccccc', '2026-10-04 10:00:00', ['status' => ApplicationDeploymentStatus::FAILED->value]); + rollbackDeployment($this->application, 'ddddddd', '2026-10-04 10:00:00', ['pull_request_id' => 5]); + rollbackDeployment($this->application, 'HEAD', '2026-10-04 10:00:00'); + + $result = GetRollbackImages::run($this->application); + + expect($result['current'])->toBeNull() + ->and($result['images'])->toBe([ + ['tag' => 'aaaaaaa', 'created_at' => '2026-10-03 10:00:00', 'is_current' => false, 'source' => 'registry'], + ['tag' => 'bbbbbbb', 'created_at' => '2026-10-02 10:00:00', 'is_current' => false, 'source' => 'registry'], + ]); +}); + +it('limits the registry images to the most recent deployments', function () { + foreach (range(1, GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT + 2) as $day) { + rollbackDeployment($this->application, sprintf('%07d', $day), sprintf('2026-10-%02d 10:00:00', $day)); + } + + $images = GetRollbackImages::run($this->application)['images']; + + expect($images)->toHaveCount(GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT) + ->and($images[0]['tag'])->toBe(sprintf('%07d', GetRollbackImages::REGISTRY_DEPLOYMENTS_LIMIT + 2)); +}); + +it('lists no registry images when the application does not push commit images', function (array $attributes) { + $this->application->update($attributes); + rollbackDeployment($this->application, 'aaaaaaa', '2026-10-01 10:00:00'); + + expect(GetRollbackImages::run($this->application->fresh())['images'])->toBe([]); +})->with([ + 'no registry image name' => [['docker_registry_image_name' => null]], + 'docker image build pack' => [['build_pack' => 'dockerimage']], + 'docker compose build pack' => [['build_pack' => 'dockercompose']], + 'dockerfile without git' => [['build_pack' => 'dockerfile', 'dockerfile' => 'FROM nginx']], +]); diff --git a/tests/Feature/GitSourceTypeIsolationTest.php b/tests/Feature/GitSourceTypeIsolationTest.php new file mode 100644 index 0000000000..4cb185973f --- /dev/null +++ b/tests/Feature/GitSourceTypeIsolationTest.php @@ -0,0 +1,96 @@ +team = Team::factory()->create(); + $this->githubApp = GithubApp::create([ + 'name' => 'github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $this->team->id, + ]); + $this->giteaApp = GiteaApp::create([ + 'name' => 'gitea-app', + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'team_id' => $this->team->id, + ]); + // Both sources share the same id, so only source_type tells them apart. + expect($this->giteaApp->id)->toBe($this->githubApp->id); +}); + +function createSourceTypeIsolationApplication(Team $team, GiteaApp $source): Application +{ + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'gitea-sourced-app', + 'git_repository' => 'test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'repository_project_id' => 987654321, + 'source_id' => $source->id, + 'source_type' => GiteaApp::class, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +it('does not route a GitHub App webhook to an application of another source type with the same id', function () { + Queue::fake(); + createSourceTypeIsolationApplication($this->team, $this->giteaApp); + + $body = json_encode([ + 'action' => 'closed', + 'number' => 42, + 'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'], + 'pull_request' => [ + 'html_url' => 'https://github.com/test-org/test-repo/pull/42', + 'title' => 'Change', + 'author_association' => 'OWNER', + 'head' => ['ref' => 'feature', 'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290'], + 'base' => ['ref' => 'main'], + ], + ], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + expect($response->getContent())->toContain('Nothing to do'); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); +}); + +it('does not block GitHub App deletion because of an application of another source type with the same id', function () { + createSourceTypeIsolationApplication($this->team, $this->giteaApp); + + $this->githubApp->delete(); + + expect(GithubApp::find($this->githubApp->id))->toBeNull(); +}); diff --git a/tests/Feature/GiteaOAuthCallbackStateTest.php b/tests/Feature/GiteaOAuthCallbackStateTest.php new file mode 100644 index 0000000000..d694653ab1 --- /dev/null +++ b/tests/Feature/GiteaOAuthCallbackStateTest.php @@ -0,0 +1,153 @@ + Http::withOptions(SafeExternalUrl::httpClientOptions( + $url, + resolver: fn (string $host): array => ['93.184.216.34'], + ))); + + $this->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->giteaApp = GiteaApp::create([ + 'name' => 'Self-hosted Gitea', + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitea/redirect', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +function putGiteaOAuthState(string $state, int $giteaAppId, int $teamId): string +{ + $key = GiteaSource::oauthStateCacheKey($state); + Cache::put($key, ['gitea_app_id' => $giteaAppId, 'team_id' => $teamId], now()->addMinutes(60)); + + return $key; +} + +describe('Gitea OAuth callback state validation', function () { + test('rejects a callback without a state', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any')->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback whose state is the source UUID', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any&state='.$this->giteaApp->uuid) + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects an unknown or expired state', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=not-a-real-state') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a state issued for a different team', function () { + Http::fake(); + $otherTeam = Team::factory()->create(); + putGiteaOAuthState('state-for-other-team', $this->giteaApp->id, $otherTeam->id); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=state-for-other-team') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback from a team member who cannot administer the source', function () { + Http::fake(); + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + putGiteaOAuthState('member-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=member-state') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('requires authentication', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=any')->assertRedirect(route('login')); + }); + + test('exchanges the code at the Gitea token endpoint, stores the tokens, and consumes the state', function () { + Http::fake([ + 'gitea.example.com/login/oauth/access_token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'token_type' => 'bearer', + ]), + ]); + $key = putGiteaOAuthState('a-valid-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=valid-code&state=a-valid-state') + ->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid])); + + Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://gitea.example.com/login/oauth/access_token' + && $request['grant_type'] === 'authorization_code' + && $request['code'] === 'valid-code' + && $request['client_id'] === 'client-id' + && $request['client_secret'] === 'client-secret' + && $request['redirect_uri'] === 'https://coolify.example.com/webhooks/source/gitea/redirect'); + + $fresh = $this->giteaApp->refresh(); + expect($fresh->access_token)->toBe('new-access-token') + ->and($fresh->refresh_token)->toBe('new-refresh-token') + ->and($fresh->expires_at)->toBeGreaterThanOrEqual(time() + 3590) + ->and($fresh->expires_at)->toBeLessThanOrEqual(time() + 3600) + ->and(Cache::get($key))->toBeNull(); + }); + + test('does not store tokens when the token exchange fails', function () { + Http::fake([ + 'gitea.example.com/login/oauth/access_token' => Http::response(['error_description' => 'bad code'], 400), + ]); + putGiteaOAuthState('failing-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=bad&state=failing-state') + ->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid])) + ->assertSessionHas('error', 'Gitea OAuth failed: bad code'); + + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); +}); diff --git a/tests/Feature/GiteaPrivateRepositoryTest.php b/tests/Feature/GiteaPrivateRepositoryTest.php new file mode 100644 index 0000000000..8eb893f5e0 --- /dev/null +++ b/tests/Feature/GiteaPrivateRepositoryTest.php @@ -0,0 +1,127 @@ + $name, + 'api_url' => giteaPickerBaseUrl().'/api/v1', + 'html_url' => giteaPickerBaseUrl(), + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'access_token' => 'gitea-access-token', + 'refresh_token' => 'gitea-refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +} + +beforeEach(function () { + Queue::fake(); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'name' => 'dest-gitea', + 'network' => 'coolify-gitea', + ]); + + $this->giteaApp = giteaPickerSourceForTeam($this->team, 'Own Gitea'); + + $this->otherTeam = Team::factory()->create(); + $this->otherGiteaApp = giteaPickerSourceForTeam($this->otherTeam, 'Foreign Gitea'); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('lists only the current team Gitea Apps', function () { + Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->assertSee('Own Gitea') + ->assertDontSee('Foreign Gitea'); +}); + +it('loads Gitea repositories and branches and creates an application from the selection', function () { + Http::fake([ + '1.1.1.1/api/v1/user/repos*' => Http::response([ + ['id' => 42, 'name' => 'web', 'full_name' => 'acme/web', 'default_branch' => 'develop', 'html_url' => giteaPickerBaseUrl().'/acme/web', 'owner' => ['login' => 'acme']], + ], 200), + '1.1.1.1/api/v1/repos/acme/web/branches*' => Http::response([ + ['name' => 'main'], + ['name' => 'develop'], + ], 200), + ]); + + Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->set('parameters', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]) + ->set('query', ['destination' => $this->destination->uuid]) + ->call('loadRepositories', $this->giteaApp->id) + ->assertSet('current_step', 'repository') + ->assertSet('selected_project_id', 42) + ->assertSet('selected_repository_path', 'acme/web') + ->call('loadBranches') + ->assertSet('selected_branch_name', 'develop') + ->call('submit') + ->assertHasNoErrors(); + + $application = Application::query()->sole(); + expect($application->repository_project_id)->toBe(42) + ->and($application->git_repository)->toBe('acme/web') + ->and($application->git_branch)->toBe('develop') + ->and($application->source_type)->toBe(GiteaApp::class) + ->and($application->source_id)->toBe($this->giteaApp->id); +}); + +it('rejects another team Gitea App without calling its API', function () { + Http::fake(); + + expect(fn () => Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->call('loadRepositories', $this->otherGiteaApp->id)) + ->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + expect(Application::count())->toBe(0); +}); + +it('locks the selected Gitea App so the client cannot switch it before submit', function () { + expect(fn () => Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->set('gitea_app_id', $this->otherGiteaApp->id)) + ->toThrow(CannotUpdateLockedPropertyException::class); +}); diff --git a/tests/Feature/GiteaSourceChangeViewTest.php b/tests/Feature/GiteaSourceChangeViewTest.php new file mode 100644 index 0000000000..0fc92ea783 --- /dev/null +++ b/tests/Feature/GiteaSourceChangeViewTest.php @@ -0,0 +1,214 @@ + 'My Gitea', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ], $attributes)); +} + +beforeEach(function () { + $this->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]); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => null, + 'public_ipv4' => null, + 'public_ipv6' => null, + ]); + + $this->giteaApp = makeGiteaChangeViewApp($this->team); +}); + +describe('Gitea source setup view', function () { + test('shows incomplete-setup guidance with the credential form', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSee('Finish connecting this Gitea App before using it as a source') + ->assertSee('https://gitea.com/user/settings/applications') + ->assertSee('Client ID') + ->assertSee('Client secret') + ->assertSee('Save') + ->assertDontSee('Test connection'); + }); + + test('derives api url when gitea url changes', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('htmlUrl', 'https://gitea.example.com') + ->assertSet('apiUrl', 'https://gitea.example.com/api/v1'); + }); + + test('saves and reloads credentials and organization', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('clientId', 'gitea-client-id') + ->set('clientSecretInput', 'super-secret-value') + ->set('organization', 'myorg') + ->call('submit') + ->assertDispatched('success'); + + $this->giteaApp->refresh(); + expect($this->giteaApp->client_secret)->toBe('super-secret-value') + ->and($this->giteaApp->organization)->toBe('myorg'); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSet('clientId', 'gitea-client-id') + ->assertSet('clientSecretInput', 'super-secret-value') + ->assertSet('organization', 'myorg'); + }); + + test('uses the gitea redirect path for a custom public endpoint', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('use_custom_webhook_endpoint', true) + ->set('custom_webhook_endpoint', 'http://100.75.155.70:8000') + ->assertSet('redirectUri', 'http://100.75.155.70:8000/webhooks/source/gitea/redirect'); + + expect($this->giteaApp->refresh()->redirect_uri) + ->toBe('http://100.75.155.70:8000/webhooks/source/gitea/redirect'); + }); + + test('builds the gitea oauth url and caches the state payload', function () { + $this->giteaApp->update(['client_id' => 'gitea-client-id']); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class); + + $state = $component->get('oauthState'); + $url = $component->instance()->getOAuthUrl(); + + expect($url)->toStartWith('https://gitea.com/login/oauth/authorize?'); + parse_str(parse_url($url, PHP_URL_QUERY), $query); + expect($query)->toMatchArray([ + 'client_id' => 'gitea-client-id', + 'response_type' => 'code', + 'scope' => 'read:user read:repository read:organization', + 'state' => $state, + ])->and($query['redirect_uri'])->toEndWith('/webhooks/source/gitea/redirect'); + + expect(Change::oauthStateCacheKey($state))->toBe('gitea-app-oauth-state:'.hash('sha256', $state)) + ->and(Cache::get(Change::oauthStateCacheKey($state)))->toBe([ + 'gitea_app_id' => $this->giteaApp->id, + 'team_id' => $this->team->id, + ]); + }); + + test('shows the webhook url and secret for a connected app', function () { + $this->giteaApp->update([ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'webhook_secret' => 'gitea-webhook-secret', + ]); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSee('/webhooks/source/gitea/events') + ->assertSet('webhookSecret', 'gitea-webhook-secret') + ->assertSee('Test connection'); + }); + + test('test connection reports the authenticated gitea login', function () { + $this->giteaApp->update([ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + ]); + + Http::fake([ + 'gitea.com/api/v1/user' => Http::response(['login' => 'gitea-user'], 200), + ]); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->call('testConnection') + ->assertDispatched('success', 'Connection successful! Authenticated as: gitea-user'); + }); + + test('redirects after the gitea app is deleted', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->call('delete') + ->assertRedirect(route('source.all')); + + $this->assertModelMissing($this->giteaApp); + }); +}); + +describe('Gitea source authorization', function () { + test('user of another team cannot open a team gitea source', function () { + $otherTeam = Team::factory()->create(); + $foreign = makeGiteaChangeViewApp($otherTeam, ['client_secret' => 'foreign-secret']); + + Livewire::withQueryParams(['gitea_app_uuid' => $foreign->uuid]) + ->test(Change::class) + ->assertStatus(404); + }); + + test('unrelated users cannot see system-wide source secrets', function () { + $otherTeam = Team::factory()->create(); + $shared = makeGiteaChangeViewApp($otherTeam, [ + 'is_system_wide' => true, + 'client_secret' => 'shared-client-secret', + 'webhook_secret' => 'shared-webhook-secret', + ]); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $shared->uuid]) + ->test(Change::class) + ->assertSet('clientSecretInput', null) + ->assertSet('webhookSecret', null); + + expect($component->html()) + ->not->toContain('shared-client-secret') + ->not->toContain('shared-webhook-secret'); + }); + + test('member of the source team cannot update it after switching to a team they own', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($member->id, ['role' => 'owner']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class); + + session(['currentTeam' => $ownTeam]); + + $component->set('name', 'hijacked') + ->call('submit') + ->assertDispatched('error'); + $component->call('delete'); + + $this->assertModelExists($this->giteaApp); + expect($this->giteaApp->refresh()->name)->toBe('My Gitea'); + }); +}); diff --git a/tests/Feature/GiteaSourceCreateModalTest.php b/tests/Feature/GiteaSourceCreateModalTest.php new file mode 100644 index 0000000000..7e411e7883 --- /dev/null +++ b/tests/Feature/GiteaSourceCreateModalTest.php @@ -0,0 +1,65 @@ +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]); +}); + +describe('Gitea source create modal', function () { + test('renders the create form', function () { + Livewire::test(Create::class) + ->assertSee('Self-hosted Gitea') + ->assertSee('Organization') + ->assertSee('Continue'); + }); + + test('creates a gitea app with defaults for gitea.com and redirects to it', function () { + $component = Livewire::test(Create::class) + ->set('name', 'my-gitea') + ->set('organization', 'myorg,myuser') + ->call('createGiteaApp'); + + $app = GiteaApp::where('name', 'my-gitea')->first(); + expect($app)->not->toBeNull() + ->and($app->html_url)->toBe('https://gitea.com') + ->and($app->api_url)->toBe('https://gitea.com/api/v1') + ->and($app->custom_user)->toBe('git') + ->and($app->custom_port)->toBe(22) + ->and($app->organization)->toBe('myorg,myuser') + ->and($app->team_id)->toBe($this->team->id) + ->and(strlen((string) $app->webhook_secret))->toBe(32); + + $component->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $app->uuid])); + }); + + test('derives api url when html url changes', function () { + Livewire::test(Create::class) + ->set('html_url', 'https://gitea.example.com') + ->assertSet('api_url', 'https://gitea.example.com/api/v1'); + }); + + test('team member cannot create a gitea app', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Create::class) + ->set('name', 'member-gitea') + ->call('createGiteaApp'); + + expect(GiteaApp::where('name', 'member-gitea')->exists())->toBeFalse(); + }); +}); diff --git a/tests/Feature/GiteaSourceListTest.php b/tests/Feature/GiteaSourceListTest.php new file mode 100644 index 0000000000..879d928095 --- /dev/null +++ b/tests/Feature/GiteaSourceListTest.php @@ -0,0 +1,59 @@ + 0]); + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->giteaApp = GiteaApp::create([ + 'name' => 'Team Gitea', + 'organization' => 'acme', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +test('team sources include gitea apps and count them as deletion blockers', function () { + expect($this->team->sources()->pluck('uuid'))->toContain($this->giteaApp->uuid) + ->and($this->team->deletionBlockers())->toMatchArray(['sources' => 1]); +}); + +test('sources page lists gitea apps with a link to their settings', function () { + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->get(route('source.all')) + ->assertOk() + ->assertSee('New Gitea App') + ->assertSee('Team Gitea') + ->assertSee('Gitea · acme') + ->assertSee(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid]), false); +}); + +test('deleting a team transfers system-wide gitea apps and removes team gitea apps', function () { + $systemWide = GiteaApp::create([ + 'name' => 'Shared Gitea', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'team_id' => $this->team->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + $this->team->delete(); + + expect($systemWide->refresh()->team_id)->toBe(0) + ->and(GiteaApp::find($this->giteaApp->id))->toBeNull(); +}); diff --git a/tests/Feature/GithubRunnerFlowTest.php b/tests/Feature/GithubRunnerFlowTest.php index 8884763e08..2be3ec6d50 100644 --- a/tests/Feature/GithubRunnerFlowTest.php +++ b/tests/Feature/GithubRunnerFlowTest.php @@ -705,6 +705,42 @@ describe('provisioning', function () { Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id); }); + it('stops provisioning without touching GitHub or the host when the server is transferred meanwhile', function (bool $cancelled) { + Queue::fake(); + Process::fake(['*' => Process::result(output: '29.8.0')]); + $server = runnerTestServer($this->team); + runnerTestConfig($server, $this->githubApp); + $execution = runnerTestExecution($this->githubApp); + Http::fake([ + 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), + 'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201), + 'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, 204), + 'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server, $execution, $cancelled) { + // The transfer commits while GitHub registers the runner. Without the cancel, it commits right + // after the job read the execution, so only the server shows the transfer. + $server->settings()->update(['force_disabled' => true]); + $server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly(); + if ($cancelled) { + $execution->finish(GithubRunnerStatus::Cancelled); + } + + return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201); + }, + ]); + + (new ProvisionGithubRunnerJob($execution->id))->handle(); + + expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled) + ->and($execution->fresh()->ready_at)->toBeNull(); + Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh')); + Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id); + + (new CleanupGithubRunnerJob($execution->id))->handle(); + + Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, 'docker rm')); + Http::assertNotSent(fn ($request) => $request->method() === 'DELETE'); + })->with(['the transfer cancelled the execution' => true, 'the transfer commits after the last check' => false]); + it('cleans up cancellation during remote provisioning without marking the runner ready', function (bool $duringStart) { Queue::fake(); runnerTestConfig(runnerTestServer($this->team), $this->githubApp); @@ -891,6 +927,19 @@ describe('server deletion', function () { expect(GithubApp::find($this->githubApp->id))->toBeNull(); }); + it('keeps the GitHub runners of a transferred server registered when it deletes the archive', function () { + Queue::fake(); + $running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]); + $this->server->update(['server_metadata' => ['transfer' => ['status' => 'transferred']]]); + + $this->server->delete(); + DeleteServer::run($this->server->id); + + expect(Server::withTrashed()->find($this->server->id))->toBeNull() + ->and(GithubRunnerExecution::whereKey($running->id)->exists())->toBeFalse(); + Queue::assertNotPushed(DeregisterGithubRunnerJob::class); + }); + it('removes the runner from GitHub and ignores GitHub errors', function (int $status) { Http::fake([ 'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]), diff --git a/tests/Feature/GlobalSearchNavigationTest.php b/tests/Feature/GlobalSearchNavigationTest.php index abe039fd80..99c2d5ab79 100644 --- a/tests/Feature/GlobalSearchNavigationTest.php +++ b/tests/Feature/GlobalSearchNavigationTest.php @@ -91,3 +91,20 @@ it('does not leak admin-only pages to a member through the team search cache', f expect(globalSearchNavigationLinks($team))->toContain(...adminOnlyNavigationLinks()); expect(globalSearchNavigationLinks($team, 'member'))->not->toContain(...adminOnlyNavigationLinks()); }); + +it('opens the deploy canvas from the new canvas quick action', function () { + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + + $component = Livewire::test(GlobalSearch::class)->call('openSearchModal'); + + expect(collect($component->get('creatableItems'))->firstWhere('type', 'canvas')) + ->toMatchArray(['name' => 'Canvas', 'category' => 'Quick Actions', 'route' => 'deploy-canvas']); + + $component->call('navigateToResource', 'canvas') + ->assertDispatched('closeSearchModal') + ->assertRedirect(route('deploy-canvas')); +}); diff --git a/tests/Feature/HelperVersionTest.php b/tests/Feature/HelperVersionTest.php index 6c203887fb..908b3ba484 100644 --- a/tests/Feature/HelperVersionTest.php +++ b/tests/Feature/HelperVersionTest.php @@ -4,8 +4,11 @@ use App\Jobs\CheckHelperImageJob; use App\Models\InstanceSettings; use App\Models\Team; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Notification; +use Illuminate\Support\Sleep; uses(RefreshDatabase::class); @@ -19,13 +22,9 @@ beforeEach(function () { ]); }); -it('uses a newer helper version after fetching it from the CDN', function () { +it('uses a newer helper version from the local versions file without asking the CDN', function () { Http::preventStrayRequests(); - Http::fake([ - 'https://cdn.example.com/coolify/versions.json' => Http::response([ - 'coolify' => ['helper' => ['version' => '1.0.18']], - ]), - ]); + Cache::put('coolify:versions:all', ['coolify' => ['helper' => ['version' => '1.0.18']]], 3600); (new CheckHelperImageJob)->handle(); @@ -33,6 +32,21 @@ it('uses a newer helper version after fetching it from the CDN', function () { ->and(getHelperVersion())->toBe('1.0.18'); }); +it('stores the helper version when the update check refreshes versions.json from the CDN', function () { + File::partialMock()->shouldReceive('put')->once()->with(base_path('versions.json'), Mockery::type('string')); + config(['constants.coolify.version' => '4.0.0']); + Http::preventStrayRequests(); + Http::fake([ + 'https://cdn.example.com/coolify/versions.json' => Http::response([ + 'coolify' => ['v4' => ['version' => '4.0.0'], 'helper' => ['version' => '1.0.19']], + ]), + ]); + + refresh_versions_file(); + + expect(InstanceSettings::findOrFail(0)->helper_version)->toBe('1.0.19'); +}); + it('does not use a stored helper version older than the bundled version', function () { InstanceSettings::findOrFail(0)->update(['helper_version' => '1.0.16']); @@ -43,12 +57,14 @@ it('keeps the stored helper version and does not notify when the CDN is unavaila Team::factory()->create(['id' => 0])->discordNotificationSettings()->update(['discord_enabled' => true]); InstanceSettings::findOrFail(0)->update(['helper_version' => '1.0.17']); Notification::fake(); + Sleep::fake(); + File::partialMock()->shouldNotReceive('put'); Http::preventStrayRequests(); Http::fake([ - 'https://cdn.example.com/coolify/versions.json' => Http::response('error code: 1200', 503), + 'https://cdn.example.com/coolify/versions.json' => Http::response('error code: 1200', 503, ['Retry-After' => '60']), ]); - (new CheckHelperImageJob)->handle(); + expect(refresh_versions_file())->toBeNull(); expect(InstanceSettings::findOrFail(0)->helper_version)->toBe('1.0.17'); Notification::assertNothingSent(); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 7a16d15446..b878d21c31 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -431,6 +431,62 @@ test('ConvertGpusCount', function () { ]); }); +test('ConvertInteractiveShortFlag', function () { + // -i keeps STDIN open so `docker attach` can send input (e.g. game server consoles). + expect(convertDockerRunToCompose('-i'))->toBe([ + 'stdin_open' => true, + ]); +}); + +test('ConvertTtyShortFlag', function () { + expect(convertDockerRunToCompose('-t'))->toBe([ + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyCombinedShortFlag', function () { + expect(convertDockerRunToCompose('-it'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertTtyInteractiveCombinedShortFlag', function () { + expect(convertDockerRunToCompose('-ti'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyLongFlags', function () { + expect(convertDockerRunToCompose('--interactive --tty'))->toBe([ + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('ConvertInteractiveTtyWithOtherOptions', function () { + expect(convertDockerRunToCompose('-it --cap-add SYS_ADMIN'))->toBe([ + 'cap_add' => ['SYS_ADMIN'], + 'stdin_open' => true, + 'tty' => true, + ]); +}); + +test('DoesNotSetInteractiveTtyForUnrelatedShortFlags', function () { + // -d (detach) and other short flags without i/t must not enable stdin/tty. + expect(convertDockerRunToCompose('-d'))->toBe([]); +}); + +it('does not read interactive flags inside entrypoint arguments', function () { + expect(convertDockerRunToCompose('--entrypoint "python -i -t script.py"')) + ->toBe(['entrypoint' => 'python -i -t script.py']); +}); + +it('does not treat arbitrary short words as interactive flags', function () { + expect(convertDockerRunToCompose('-invalid'))->toBe([]); +}); + test('ConvertRuntimeHardeningOptions', function () { $input = '--add-host host.docker.internal:host-gateway --add-host=db:10.0.0.5 --pids-limit 100 --runtime runc --read-only --tmpfs /run:rw,size=64m --tmpfs /tmp'; $output = convertDockerRunToCompose($input); @@ -462,6 +518,10 @@ test('UnsupportedDockerRunOptionsIsEmptyForSupportedOptions', function () { ->and(unsupportedDockerRunOptions(null))->toBe([]); }); +test('UnsupportedDockerRunOptionsSkipsInteractiveAndTtyFlags', function () { + expect(unsupportedDockerRunOptions('-it -t --interactive --tty --cap-add NET_ADMIN'))->toBe([]); +}); + test('SeccompProfileMountFlags', function () { $input = '--security-opt seccomp=/etc/docker/seccomp.json --security-opt "seccomp:/srv/app profile.json" --security-opt seccomp=/etc/../root/x.json --security-opt seccomp=unconfined --security-opt apparmor=unconfined'; expect(seccompProfileMountFlags($input))->toBe("--mount 'type=bind,source=/etc/docker/seccomp.json,target=/etc/docker/seccomp.json,readonly'") diff --git a/tests/Feature/HostingerApiTest.php b/tests/Feature/HostingerApiTest.php new file mode 100644 index 0000000000..3351b2dd10 --- /dev/null +++ b/tests/Feature/HostingerApiTest.php @@ -0,0 +1,324 @@ +whereKey(0)->delete(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + 'is_api_enabled' => true, + ])); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->bearerToken = $this->user->createToken('test-token', ['*'])->plainTextToken; + $this->hostingerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-api-token', + ]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); +}); + +it('gets Hostinger data centers', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonFragment(['id' => 19, 'city' => 'Amsterdam']); +}); + +it('gets Hostinger SSH keys and post-install scripts', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response([ + 'data' => [['id' => 42, 'name' => 'Operations']], + ]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'data' => [['id' => 73, 'name' => 'Bootstrap Coolify']], + ]), + ]); + + $query = '?cloud_provider_token_id='.$this->hostingerToken->uuid; + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/ssh-keys'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 42, 'name' => 'Operations']); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts'.$query) + ->assertSuccessful() + ->assertJsonFragment(['id' => 73, 'name' => 'Bootstrap Coolify']); +}); + +it('gets only Hostinger KVM plans through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'prices' => []], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'prices' => []], + ]), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/catalog?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertSuccessful() + ->assertJsonCount(1) + ->assertJsonFragment(['id' => 'hostingercom-vps-kvm1']) + ->assertJsonMissing(['id' => 'hostingercom-vps-kvmminecraftalex']); +}); + +it('returns 403 with the Hostinger message when Hostinger denies a provider list', function (int $hostingerStatus) { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response([ + 'message' => '[VPS:2000] Unauthorized', + ], $hostingerStatus), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/post-install-scripts?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertForbidden() + ->assertExactJson(['message' => 'Hostinger denied access to post-install scripts: [VPS:2000] Unauthorized']); +})->with([401, 403]); + +it('returns 429 when Hostinger rate limits a provider list', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + 'message' => 'Too many requests.', + ], 429, ['Retry-After' => '30']), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/templates?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertStatus(429) + ->assertHeader('Retry-After', '30') + ->assertExactJson(['message' => 'Rate limit exceeded. Please try again later.']); +}); + +it('returns a generic error when a Hostinger provider list fails', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + 'message' => 'Invalid request', + ], 422), + ]); + + $this->withToken($this->bearerToken) + ->getJson('/api/v1/hostinger/data-centers?cloud_provider_token_id='.$this->hostingerToken->uuid) + ->assertServerError() + ->assertExactJson(['message' => 'Failed to fetch Hostinger data centers.']); +}); + +it('creates a Hostinger VPS server through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'name' => 'api-hostinger.example.com', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_backups' => true, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 17923, + 'ip' => '203.0.113.10', + ]); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === true); +}); + +it('turns paid Hostinger backups off when the API request does not enable them', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['enable_backups'] === false); +}); + +it('requires the deploy ability to run a post-install script or add SSH keys on a Hostinger VPS', function (array $extra) { + Http::fake(); + $writeToken = $this->user->createToken('write-token', ['read', 'write'])->plainTextToken; + + $this->withToken($writeToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ...$extra, + ]) + ->assertForbidden() + ->assertJsonPath('message', fn (string $message) => str_starts_with($message, 'Missing required permissions: deploy.')); + + Http::assertNothingSent(); + $this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]); +})->with([ + 'post-install script' => [['post_install_script_id' => 73]], + 'extra SSH keys' => [['public_key_ids' => [42]]], +]); + +it('lets a deploy token run a post-install script on a Hostinger VPS', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => [ + 'id' => 17923, + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + $deployToken = $this->user->createToken('deploy-token', ['read', 'write', 'deploy'])->plainTextToken; + + $this->withToken($deployToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_uuid' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + 'post_install_script_id' => 73, + ]) + ->assertCreated(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['post_install_script_id'] === 73); +}); + +it('keeps a charged Hostinger VPS when the purchase returns an error through the API', function () { + $listRequests = 0; + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/2008976/setup' => Http::response([ + 'id' => 2008976, + 'state' => 'creating', + 'ipv4' => [['address' => '72.61.180.163']], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => function ($request) use (&$listRequests) { + if ($request->method() === 'POST') { + return Http::response(['message' => '[VPS:2004] Wrong hostname FQDN format'], 422); + } + + return Http::response($listRequests++ === 0 ? [] : [['id' => 2008976, 'state' => 'initial']]); + }, + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1077, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertCreated() + ->assertJsonFragment([ + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); + + $this->assertDatabaseHas('servers', [ + 'team_id' => $this->team->id, + 'hostinger_virtual_machine_id' => 2008976, + 'ip' => '72.61.180.163', + ]); +}); + +it('reports a Hostinger order that is waiting for payment through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'id' => 2957086, + 'status' => 'payment_initiated', + ], 202), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/servers/hostinger', [ + 'cloud_provider_token_id' => $this->hostingerToken->uuid, + 'item_id' => 'hostingercom-vps-kvm2-usd-1m', + 'data_center_id' => 19, + 'template_id' => 1130, + 'private_key_uuid' => $this->privateKey->uuid, + ]) + ->assertStatus(202) + ->assertJsonFragment(['message' => 'Hostinger order 2957086 is waiting for payment. Finish the VPS setup in hPanel.']); + + $this->assertDatabaseMissing('servers', ['team_id' => $this->team->id]); +}); + +it('accepts Hostinger cloud provider tokens through the API', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([]), + ]); + + $this->withToken($this->bearerToken) + ->postJson('/api/v1/cloud-tokens', [ + 'provider' => 'hostinger', + 'token' => 'another-hostinger-token', + 'name' => 'Another Hostinger Token', + ]) + ->assertCreated(); + + $this->assertDatabaseHas('cloud_provider_tokens', [ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'name' => 'Another Hostinger Token', + ]); +}); diff --git a/tests/Feature/HostingerServerCreationTest.php b/tests/Feature/HostingerServerCreationTest.php new file mode 100644 index 0000000000..264fc523a4 --- /dev/null +++ b/tests/Feature/HostingerServerCreationTest.php @@ -0,0 +1,485 @@ + 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([ + 'id' => 0, + ])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->token = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hostinger', + 'token' => 'hostinger-token', + 'name' => 'Production Hostinger', + ]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('offers Hostinger as a server provider', function () { + Livewire::test(Create::class) + ->assertSee('Hostinger') + ->assertSee(route('server.create.type', ['type' => 'hostinger']), false); + + Livewire::test(CreatePage::class, ['type' => 'hostinger']) + ->assertSet('title', 'Hostinger') + ->assertSet('tokenProvider', 'hostinger'); +}); + +it('uses the current cloud provider UI and Hostinger affiliate link', function () { + Livewire::test(ByHostinger::class) + ->assertSee('Hostinger account') + ->assertSee('https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io', false) + ->assertSee("through Coolify's affiliate link.", false); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->assertSee('Hostinger server') + ->assertSee('Advanced options') + ->assertSee('Buy and create'); +}); + +it('leaves paid Hostinger backups off by default', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->assertSet('enable_backups', false) + ->set('loading_data', false) + ->assertSee('Enable weekly Hostinger backups (extra cost)') + ->assertSee('Hostinger charges extra for backups. Check the price in hPanel before you enable them.'); +}); + +it('selects Hostinger account SSH keys with the same dropdown as Hetzner', function () { + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', [['id' => 42, 'name' => 'Operations']]) + ->assertSee('Extra SSH keys') + ->assertSee('Existing keys from the Hostinger account.') + ->assertSee('Search SSH keys') + ->assertSee('Operations') + ->assertSeeHtml('x-text="current">Select SSH keys') + ->assertDontSeeHtml('Operations') + ->assertDontSeeHtml('id="hostinger-public-key-42-'); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->set('loading_data', false) + ->set('hostinger_public_keys', []) + ->assertSee('No account keys found'); +}); + +it('purchases a Hostinger VPS and creates the linked Coolify server', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'category' => 'VPS', + 'prices' => [ + [ + 'id' => 'hostingercom-vps-kvm2-usd-1m', + 'currency' => 'USD', + 'price' => 1799, + 'first_period_price' => 899, + 'period' => 1, + 'period_unit' => 'month', + ], + ], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'order' => ['id' => 2957086, 'status' => 'completed'], + 'virtual_machine' => [ + 'id' => 17923, + 'hostname' => 'coolify-hostinger.example.com', + 'state' => 'creating', + 'ipv4' => [['address' => '203.0.113.10']], + ], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->set('enable_backups', true) + ->call('submit') + ->assertHasNoErrors(); + + $server = Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail(); + + expect($server->name)->toBe('coolify-hostinger.example.com') + ->and($server->ip)->toBe('203.0.113.10') + ->and($server->hostinger_virtual_machine_status)->toBe('creating') + ->and($server->cloud_provider_token_id)->toBe($this->token->id); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['item_id'] === 'hostingercom-vps-kvm2-usd-1m' + && $request['setup']['hostname'] === 'coolify-hostinger.example.com' + && $request['setup']['public_key']['key'] === $this->privateKey->getPublicKey()); +}); + +it('keeps a purchased Hostinger VPS linked while its public IP is pending', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => null]]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => []], + ]), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'message' => 'Still provisioning', + ], 503), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'pending-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->firstOrFail()->ip) + ->toBe(Server::PLACEHOLDER_IP); +}); + +it('loads the Hostinger form when optional provider lists are not authorized', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam']]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([['id' => 1130, 'name' => 'Ubuntu']]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'prices' => [['id' => 'kvm2-monthly']]], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['message' => '[VPS:2000] Unauthorized'], 403), + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('provider_data_error', null) + ->assertSet('hostinger_public_keys', []) + ->assertSet('post_install_scripts', []) + ->assertSee('Buy and create') + ->set('server_name', 'optional-lists.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'kvm2-monthly') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + expect(Server::query()->where('hostinger_virtual_machine_id', 17923)->exists())->toBeTrue(); +}); + +it('revalidates the selected Hostinger price before making a purchase', function () { + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + [ + 'id' => 'hostingercom-vps-kvm2', + 'name' => 'KVM 2', + 'prices' => [['id' => 'hostingercom-vps-kvm2-usd-1m']], + ], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'coolify-hostinger.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_template_id', 1130) + ->set('selected_price_id', 'tampered-price-id') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +function fakeHostingerProvisioningOptions(array $overrides = []): void +{ + $price = fn (string $plan, int $period, string $unit, string $suffix) => [ + 'id' => "hostingercom-vps-{$plan}-usd-{$suffix}", + 'currency' => 'USD', + 'price' => 1000, + 'first_period_price' => 500, + 'period' => $period, + 'period_unit' => $unit, + ]; + $prices = fn (string $plan) => [ + $price($plan, 2, 'year', '2y'), + $price($plan, 1, 'month', '1m'), + $price($plan, 1, 'year', '1y'), + ]; + + Http::fake(array_merge([ + 'https://developers.hostinger.com/api/vps/v1/data-centers' => Http::response([ + ['id' => 19, 'name' => 'nl-ams', 'city' => 'Amsterdam', 'location' => 'nl'], + ]), + 'https://developers.hostinger.com/api/vps/v1/templates' => Http::response([ + ['id' => 1121, 'name' => 'AlmaLinux 9 with CyberPanel'], + ['id' => 1130, 'name' => 'Ubuntu 24.04 LTS'], + ['id' => 1077, 'name' => 'Debian 13'], + ['id' => 1150, 'name' => 'Ubuntu 24.04 with Coolify'], + ]), + 'https://developers.hostinger.com/api/billing/v1/catalog*' => Http::response([ + ['id' => 'hostingercom-vps-kvm8', 'name' => 'KVM 8', 'metadata' => ['cpus' => 8], 'prices' => $prices('kvm8')], + ['id' => 'hostingercom-vps-kvmminecraftalex', 'name' => 'Game Panel 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvmminecraftalex')], + ['id' => 'hostingercom-vps-kvm1', 'name' => 'KVM 1', 'metadata' => ['cpus' => 1], 'prices' => $prices('kvm1')], + ['id' => 'hostingercom-vps-kvm2', 'name' => 'KVM 2', 'metadata' => ['cpus' => 2], 'prices' => $prices('kvm2')], + ]), + 'https://developers.hostinger.com/api/vps/v1/public-keys' => Http::response(['data' => []]), + 'https://developers.hostinger.com/api/vps/v1/post-install-scripts' => Http::response(['data' => []]), + ], $overrides)); +} + +it('lists only Hostinger KVM plans sorted by size and billing period', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData'); + + expect(collect($component->instance()->priceOptions)->pluck('id')->all())->toBe([ + 'hostingercom-vps-kvm1-usd-1m', + 'hostingercom-vps-kvm1-usd-1y', + 'hostingercom-vps-kvm1-usd-2y', + 'hostingercom-vps-kvm2-usd-1m', + 'hostingercom-vps-kvm2-usd-1y', + 'hostingercom-vps-kvm2-usd-2y', + 'hostingercom-vps-kvm8-usd-1m', + 'hostingercom-vps-kvm8-usd-1y', + 'hostingercom-vps-kvm8-usd-2y', + ]); + + $component->assertDontSee('Game Panel'); +}); + +it('splits Hostinger templates into plain operating systems and operating systems with applications', function () { + fakeHostingerProvisioningOptions(); + + $component = Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->assertSet('selected_os_template_id', 1130) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1130) + ->assertSee('Operating system with application') + ->assertSee('can conflict with the Coolify proxy on ports 80 and 443', false); + + expect(collect($component->instance()->osTemplates)->pluck('name')->all()) + ->toBe(['Debian 13', 'Ubuntu 24.04 LTS']) + ->and(collect($component->instance()->appTemplates)->pluck('name')->all()) + ->toBe(['AlmaLinux 9 with CyberPanel', 'Ubuntu 24.04 with Coolify']); +}); + +it('keeps only one Hostinger operating system selection', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('selected_app_template_id', 1150) + ->assertSet('selected_os_template_id', null) + ->assertSet('selected_template_id', 1150) + ->set('selected_os_template_id', 1077) + ->assertSet('selected_app_template_id', null) + ->assertSet('selected_template_id', 1077); +}); + +it('purchases a Hostinger VPS with an operating system that includes an application', function () { + fakeHostingerProvisioningOptions([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines' => Http::response([ + 'virtual_machine' => ['id' => 17923, 'state' => 'creating', 'ipv4' => [['address' => '203.0.113.10']]], + ]), + ]); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'app-template.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvm2-usd-1m') + ->set('selected_app_template_id', 1150) + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasNoErrors(); + + Http::assertSent(fn ($request) => $request->method() === 'POST' + && $request->url() === 'https://developers.hostinger.com/api/vps/v1/virtual-machines' + && $request['setup']['template_id'] === 1150); +}); + +it('rejects a Game Panel Hostinger plan before making a purchase', function () { + fakeHostingerProvisioningOptions(); + + Livewire::test(ByHostinger::class, ['selectedTokenUuid' => $this->token->uuid]) + ->call('loadHostingerData') + ->set('server_name', 'game-panel.example.com') + ->set('selected_data_center_id', 19) + ->set('selected_price_id', 'hostingercom-vps-kvmminecraftalex-usd-1m') + ->set('private_key_id', $this->privateKey->id) + ->call('submit') + ->assertHasErrors('selected_price_id'); + + Http::assertNotSent(fn ($request) => $request->method() === 'POST'); +}); + +it('starts a stopped Hostinger VPS from the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923/start' => Http::response([ + 'id' => 456, + 'state' => 'starting', + ]), + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->call('startHostingerVirtualMachine') + ->assertSet('hostingerVirtualMachineStatus', 'starting') + ->assertDispatched('success'); + + expect($server->fresh()->hostinger_virtual_machine_status)->toBe('starting'); +}); + +it('labels the Hostinger status refresh button on the server page', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', + ]); + + Livewire::test(Show::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger · Running') + ->assertSee('Refresh provider status') + ->assertSeeHtml('wire:click.prevent="checkHostingerVirtualMachineStatus(true)"'); +}); + +it('does not validate a stopped Hostinger VPS over SSH', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'stopped', + ]); + + Http::fake([ + 'https://developers.hostinger.com/api/vps/v1/virtual-machines/17923' => Http::response([ + 'id' => 17923, + 'state' => 'stopped', + 'ipv4' => [['address' => $server->ip]], + ]), + ]); + + Livewire::test(ValidateAndInstall::class, ['server' => $server]) + ->call('validateConnection') + ->assertSet('error', 'Hostinger VPS is stopped. Power it on before validating.'); +}); + +it('warns that deleting from Coolify does not cancel the Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.') + ->assertSee('https://hpanel.hostinger.com/', false) + ->assertSeeHtml('Hostinger deletes a VPS only at the end of its paid period.') + ->assertSee('The VPS on Hostinger will NOT be deleted, and Hostinger will keep billing for it.'); +}); + +it('passes the Hostinger deletion choice to the server deletion', function () { + Queue::fake(); + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'cloud_provider_token_id' => $this->token->id, + 'hostinger_virtual_machine_id' => 17923, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('Also delete VPS from Hostinger (at the end of the paid period)') + ->call('delete', 'password', ['delete_from_hostinger']); + + Queue::assertPushed(JobDecorator::class, fn (JobDecorator $job) => $job->getAction() instanceof DeleteServer + && $job->getParameters()[9] === true + && $job->getParameters()[10] === 17923); +}); + +it('keeps the default deletion warning for servers without a Hostinger VPS', function () { + $server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + Livewire::test(Delete::class, ['server_uuid' => $server->uuid]) + ->assertSee('This operation is permanent and cannot be undone.') + ->assertDontSee('Hostinger deletes a VPS'); +}); diff --git a/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php new file mode 100644 index 0000000000..551e0cef0f --- /dev/null +++ b/tests/Feature/InstanceBackupMultipleS3DestinationsTest.php @@ -0,0 +1,137 @@ + $name, + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => str($name)->slug()->toString(), + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => $team->id, + ]); +} + +beforeEach(function () { + Server::flushIdentityMap(); + $this->rootTeam = Team::find(0) ?? Team::factory()->create(['id' => 0]); + $server = Server::factory()->create([ + 'id' => 0, + 'team_id' => 0, + 'ip' => '127.0.0.1', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => 0])->id, + ]); + $server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail()->forceFill(['id' => 0])->save(); + InstanceSettings::forceCreate(['id' => 0]); + Once::flush(); + + $this->database = new StandalonePostgresql; + $this->database->forceFill([ + 'id' => 0, + 'name' => 'coolify-db', + 'postgres_user' => 'coolify', + 'postgres_password' => 'password', + 'postgres_db' => 'coolify', + 'status' => 'running', + 'destination_type' => StandaloneDocker::class, + 'destination_id' => 0, + ])->save(); + $this->backup = ScheduledDatabaseBackup::create([ + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 0 * * *', + 'database_id' => 0, + 'database_type' => StandalonePostgresql::class, + 'team_id' => 0, + ]); + + $this->firstStorage = instanceBackupS3Storage($this->rootTeam, 'First S3'); + $this->secondStorage = instanceBackupS3Storage($this->rootTeam, 'Second S3'); + $this->otherTeam = Team::factory()->create(); + $this->foreignStorage = instanceBackupS3Storage($this->otherTeam, 'Foreign S3'); + + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'admin']); + $user->teams()->attach($this->otherTeam->id, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => ['id' => 0]]); +}); + +it('offers every root team S3 storage on the instance backup page', function () { + Livewire::test(SettingsBackup::class) + ->assertOk() + ->assertSee('First S3') + ->assertSee('Second S3') + ->assertDontSee('Foreign S3'); +}); + +it('saves several S3 destinations for the instance backup and drops storages of other teams', function () { + // The session team can change in another tab after the page loads. + session(['currentTeam' => ['id' => $this->otherTeam->id]]); + + Livewire::test(BackupEdit::class, ['backup' => $this->backup->fresh(), 'availableS3Storages' => collect()]) + ->set('saveS3', true) + ->set('s3StorageIds', [$this->firstStorage->id, $this->secondStorage->id, $this->foreignStorage->id]) + ->call('submit') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $backup = $this->backup->fresh(); + + expect($backup->save_s3)->toBeTruthy() + ->and($backup->s3_storage_id)->toBe($this->firstStorage->id) + ->and($backup->s3Storages()->pluck('s3_storages.id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('uploads the instance backup to every selected destination', function () { + $this->backup->forceFill(['save_s3' => true, 'databases_to_backup' => 'coolify'])->save(); + $this->backup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + Process::fake(fn ($process) => Process::result(output: str_contains(implode(' ', (array) $process->command), 'du -b') ? '128' : '')); + + $uploadedTo = collect(); + $job = Mockery::mock(DatabaseBackupJob::class.'[upload_to_s3]', [$this->backup->fresh()])->shouldAllowMockingProtectedMethods(); + $job->shouldReceive('upload_to_s3')->andReturnUsing(fn (S3Storage $storage) => $uploadedTo->push($storage->id)); + $job->handle(); + + $execution = ScheduledDatabaseBackupExecution::query()->sole(); + + expect($uploadedTo->sort()->values()->all())->toBe([$this->firstStorage->id, $this->secondStorage->id]) + ->and($execution->status)->toBe('success') + ->and($execution->s3_uploaded)->toBeTrue() + ->and($execution->s3Replicas()->where('s3_uploaded', true)->pluck('s3_storage_id')->sort()->values()->all()) + ->toBe([$this->firstStorage->id, $this->secondStorage->id]); +}); + +it('links the instance backup from the storage resources page', function () { + $this->backup->forceFill(['save_s3' => true])->save(); + $this->backup->syncS3Storages([$this->firstStorage->id, $this->secondStorage->id]); + + Livewire::test(StorageResources::class, ['storage' => $this->secondStorage]) + ->assertSee('coolify-db') + ->assertSeeHtml(route('settings.backup')); +}); diff --git a/tests/Feature/Jobs/DatabaseBackupJobTest.php b/tests/Feature/Jobs/DatabaseBackupJobTest.php index a699a60eee..7bb4f6bca7 100644 --- a/tests/Feature/Jobs/DatabaseBackupJobTest.php +++ b/tests/Feature/Jobs/DatabaseBackupJobTest.php @@ -52,7 +52,7 @@ test('scheduled database backup casts full dump selection to boolean', function ->and((new ScheduledDatabaseBackup(['dump_all' => '1']))->dump_all)->toBeTrue(); }); -test('upload_to_s3 throws exception and disables s3 when storage is null', function () { +test('s3 upload throws exception and disables s3 when no destination exists', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -64,11 +64,7 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $s3Property = $reflection->getProperty('s3'); - $s3Property->setValue($job, null); - - $method = $reflection->getMethod('upload_to_s3'); + $method = (new ReflectionClass($job))->getMethod('uploadToS3Destinations'); expect(fn () => $method->invoke($job)) ->toThrow(Exception::class, 'S3 storage configuration is missing or has been deleted'); @@ -78,7 +74,7 @@ test('upload_to_s3 throws exception and disables s3 when storage is null', funct expect($backup->s3_storage_id)->toBeNull(); }); -test('upload_to_s3 exception message reports the previous s3 storage id', function () { +test('s3 upload exception message reports the previous s3 storage id', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -90,10 +86,7 @@ test('upload_to_s3 exception message reports the previous s3 storage id', functi $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $reflection->getProperty('s3')->setValue($job, null); - - expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + expect(fn () => (new ReflectionClass($job))->getMethod('uploadToS3Destinations')->invoke($job)) ->toThrow(Exception::class, 'S3 storage ID: 12345'); $backup->refresh(); @@ -101,7 +94,7 @@ test('upload_to_s3 exception message reports the previous s3 storage id', functi expect($backup->s3_storage_id)->toBeNull(); }); -test('upload_to_s3 exception message reports null when no previous s3 storage id exists', function () { +test('s3 upload exception message reports null when no previous s3 storage id exists', function () { $backup = ScheduledDatabaseBackup::create([ 'frequency' => '0 0 * * *', 'save_s3' => true, @@ -113,10 +106,7 @@ test('upload_to_s3 exception message reports null when no previous s3 storage id $job = new DatabaseBackupJob($backup); - $reflection = new ReflectionClass($job); - $reflection->getProperty('s3')->setValue($job, null); - - expect(fn () => $reflection->getMethod('upload_to_s3')->invoke($job)) + expect(fn () => (new ReflectionClass($job))->getMethod('uploadToS3Destinations')->invoke($job)) ->toThrow(Exception::class, 'S3 storage ID: null'); }); @@ -281,7 +271,7 @@ test('retention cleanup failure does not fail a successful database backup', fun 'local_storage_deleted' => true, 'created_at' => now()->subDay(), ]); - ScheduledDatabaseBackupExecution::create([ + $newExecution = ScheduledDatabaseBackupExecution::create([ 'uuid' => 'new-backup', 'database_name' => 'database', 'filename' => '/backup/new.dmp', @@ -290,6 +280,8 @@ test('retention cleanup failure does not fail a successful database backup', fun 's3_uploaded' => true, 'local_storage_deleted' => true, ]); + $oldExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); + $newExecution->s3Replicas()->create(['s3_storage_id' => $s3->id, 's3_uploaded' => true]); $disk = Mockery::mock(); $disk->shouldReceive('delete')->once()->with(['/backup/old.dmp'])->andReturnFalse(); Storage::shouldReceive('build')->once()->andReturn($disk); diff --git a/tests/Feature/ListboxComponentTest.php b/tests/Feature/ListboxComponentTest.php index 3e7f5dede3..9911562826 100644 --- a/tests/Feature/ListboxComponentTest.php +++ b/tests/Feature/ListboxComponentTest.php @@ -97,6 +97,24 @@ test('searchable listbox renders a search field with its placeholders', function ->toContain('No matching server'); }); +test('searchable listbox with allowCustom offers the typed value as an option', function () { + $html = Blade::render(<<<'BLADE' + + BLADE); + + expect($html) + ->toContain('x-if="customOption"') + ->toContain('!customOption'); +}); + +test('listbox without allowCustom offers no custom option', function () { + $html = Blade::render(<<<'BLADE' + + BLADE); + + expect($html)->not->toContain('x-if="customOption"'); +}); + test('listbox without searchable renders no search field', function () { $html = Blade::render(<<<'BLADE' diff --git a/tests/Feature/Livewire/FileBrowserTest.php b/tests/Feature/Livewire/FileBrowserTest.php new file mode 100644 index 0000000000..34698719d5 --- /dev/null +++ b/tests/Feature/Livewire/FileBrowserTest.php @@ -0,0 +1,770 @@ +withoutVite(); + Storage::fake('ssh-keys'); + InstanceSettings::forceCreate(['id' => 0]); + config(['constants.ssh.mux_enabled' => false]); + + $this->team = Team::factory()->create(); + + $this->admin = User::factory()->create(); + $this->admin->teams()->attach($this->team, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->member->teams()->attach($this->team, ['role' => 'member']); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $privateKey->id, + ]); + $this->server->settings()->update(['is_reachable' => true, 'is_usable' => true]); + + StandaloneDocker::withoutEvents(function () { + $this->destination = StandaloneDocker::firstOrCreate( + ['server_id' => $this->server->id, 'network' => 'coolify'], + ['uuid' => (string) Str::uuid(), 'name' => 'test-docker'] + ); + }); + + $this->project = Project::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test Project', + 'team_id' => $this->team->id, + ]); + $this->environment = $this->project->environments()->first(); +}); + +it('blocks non-admin members from the file browser', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->member); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->assertForbidden(); +}); + +it('shows a stopped state when the container is not running', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'exited', + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->assertSet('containerRunning', false) + ->assertSee('Start the container to browse its files'); +}); + +it('loads the default directory in a deferred request for a running database', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/var/lib/postgresql')) // defaultRoot inspect + ->push(Process::result(output: "dir\t0\t1\tdata\nfile\t10\t2\tpostgresql.conf"))]); // list + + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'Test DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSet('containerRunning', true) + ->assertSet('currentPath', '/var/lib/postgresql') + ->assertSet('container', $database->uuid) + ->assertSee('data') + ->assertSee('postgresql.conf'); +}); + +function fbRunningDatabase($environment, $destination): StandalonePostgresql +{ + return StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'DB', + 'postgres_user' => 'postgres', + 'postgres_password' => 'password', + 'postgres_db' => 'testdb', + 'image' => 'postgres:15', + 'status' => 'running', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +it('creates a directory and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: '')) // initial list (empty) + ->push(Process::result(output: '')) // mkdir + ->push(Process::result(output: "dir\t0\t1\tplugins"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('createDirectory', 'plugins') + ->assertSee('plugins'); +}); + +it('creates a file and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: '')) // initial list (empty) + ->push(Process::result(output: '')) // createFile + ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('createFile', 'config.yml') + ->assertSee('config.yml'); +}); + +it('changes permissions and re-lists', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t10\t1\t644\tapp.sh")) // initial list + ->push(Process::result(output: '')) // chmod + ->push(Process::result(output: "file\t10\t1\t755\tapp.sh"))]); // re-list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->assertSee('644') + ->call('chmodEntry', 'app.sh', '755') + ->assertSee('755'); +}); + +it('sets the editor language and content from the file when opening', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t12\t1\t644\tconfig.yml")) // initial list + ->push(Process::result(output: "OK\n".base64_encode("a: 1\n")))]); // single-round-trip read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->assertSet('editorOpen', true) + ->assertSet('editorLanguage', 'yaml') + ->assertSet('editorContent', "a: 1\n") + ->assertDispatched('load-file-editor', content: "a: 1\n", language: 'yaml'); +}); + +it('resolves a Monaco language for common extension-less files', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t12\t1\t644\tDockerfile")) // initial list + ->push(Process::result(output: "OK\n".base64_encode("FROM alpine\n")))]); // read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'Dockerfile') + ->assertSet('editorLanguage', 'dockerfile'); +}); + +it('refuses to open a binary or oversized file in the editor', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: "file\t99999999\t1\tbig.bin")) // initial list + ->push(Process::result(output: 'TOOBIG'))]); // single-round-trip read + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'big.bin') + ->assertSet('editingPath', null); +}); + +it('rejects client changes to container access state', function (string $property, mixed $value) { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) // defaultRoot + ->push(Process::result(output: ''))]); // initial list + + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set($property, $value); +})->with([ + ['container', 'someone-elses-container'], + ['type', 'service'], + ['containerRunning', false], + ['availableContainers', ['someone-elses-container']], +])->throws(CannotUpdateLockedPropertyException::class); + +it('rechecks resource access before file operations', function (string $action, array $arguments) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + $otherTeam = Team::factory()->create(); + $this->project->update(['team_id' => $otherTeam->id]); + $component->call($action, ...$arguments)->assertForbidden(); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], + ['loadFiles', []], + ['selectContainer', ['unknown']], + ['saveEditor', []], + ['deleteEntry', ['config.yml']], + ['download', ['config.yml']], +]); + +it('rejects a client attempt to replace the editor path', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set('editingPath', '/etc/passwd'); +})->throws(CannotUpdateLockedPropertyException::class); + +it('rejects unsafe child names without deleting the current folder', function (string $name) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('deleteEntry', $name) + ->assertDispatched('error'); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'rm -rf')); +})->with(['', '.', '..', '../data', 'a/b', 'a\\b']); + +it('saves the opened file and clears editor state', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "OK\n".base64_encode('before'))) + ->push(Process::result(output: '')) + ->push(Process::result(output: ''))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', "it's.txt") + ->set('editorContent', 'after') + ->call('saveEditor') + ->assertSet('editingPath', null) + ->assertSet('editorOpen', false) + ->assertSet('editorContent', '') + ->assertDispatched('success', 'Saved.'); +}); + +it('clears the editor when selecting a container', function () { + Process::fake(['*' => Process::result(output: 'OK')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->assertSet('editorOpen', true) + ->call('selectContainer', $database->uuid) + ->assertSet('editingPath', null) + ->assertSet('editorOpen', false); +}); + +it('renames and deletes files with apostrophes', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('renameEntry', "it's.txt", "that's.txt") + ->assertDispatched('success', 'Renamed.') + ->call('deleteEntry', "that's.txt") + ->assertDispatched('success', 'Deleted.'); +}); + +it('uploads a file and clears the upload state', function () { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->set('upload', UploadedFile::fake()->create("it's.txt", 1, 'text/plain')) + ->call('uploadFile') + ->assertSet('upload', null) + ->assertDispatched('success', 'Uploaded.'); +}); + +it('issues a one-time download token and redirects to the streaming route', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'dir 4'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertRedirectContains('/files/download/'); + + $token = Str::afterLast($component->effects['redirect'], '/'); + expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray([ + 'user_id' => $this->admin->id, + 'container' => $database->uuid, + 'path' => '/data/folder', + 'name' => 'folder.tar.gz', + 'is_dir' => true, + ]); +}); + +it('refuses downloads above the size cap', function () { + $overCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "dir {$overCapKb}"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertNoRedirect() + ->assertDispatched('error'); +}); + +it('only lets the token owner use a download token, and only once', function () { + $database = fbRunningDatabase($this->environment, $this->destination); + $download = [ + 'user_id' => $this->admin->id, + 'resource_type' => $database::class, + 'resource_id' => $database->id, + 'server_id' => $this->server->id, + 'container' => $database->uuid, + 'path' => '/data/a.txt', + 'name' => 'a.txt', + 'is_dir' => false, + ]; + $otherAdmin = User::factory()->create(); + $otherAdmin->teams()->attach($this->team, ['role' => 'admin']); + + Cache::put(FileBrowserDownloadController::cacheKey('token-a'), $download, 60); + $this->actingAs($otherAdmin)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'token-a')) + ->assertNotFound(); + expect(Cache::has(FileBrowserDownloadController::cacheKey('token-a')))->toBeFalse(); + + $this->actingAs($this->admin)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'unknown')) + ->assertNotFound(); +}); + +it('blocks members from the download route', function () { + $this->actingAs($this->member)->withSession(['currentTeam' => $this->team]) + ->get(route('project.files.download', 'any')) + ->assertForbidden(); +}); + +it('denies resources outside the current team even when the user belongs to both teams', function () { + $database = fbRunningDatabase($this->environment, $this->destination); + $otherTeam = Team::factory()->create(); + $this->member->teams()->attach($otherTeam, ['role' => 'admin']); + $this->actingAs($this->member); + session(['currentTeam' => $otherTeam]); + Process::fake(); + Livewire::test(FileBrowser::class, ['resource' => $database])->assertForbidden(); + Process::assertNothingRan(); +}); + +it('does not use a container that stopped after mounting', function (string $action, array $arguments) { + Process::fake(['*' => Process::result(output: '')]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + $database->update(['status' => 'exited']); + $component->call($action, ...$arguments); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], +])->throws(RuntimeException::class, 'Container is not running.'); + +it('lists files for a running application and excludes stopped and preview containers', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + $containers = collect([ + ['ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=0'], + ['ID' => 'stopped', 'Names' => 'app-stopped', 'State' => 'exited', 'Labels' => 'coolify.pullRequestId=0'], + ['ID' => 'preview', 'Names' => 'app-preview', 'State' => 'running', 'Labels' => 'coolify.pullRequestId=42'], + ])->map(fn ($container) => json_encode($container))->implode("\n"); + Process::fake(function ($process) use ($containers) { + $output = match (true) { + str_contains($process->command, 'docker ps') => $containers, + str_contains($process->command, 'docker inspect') => '/app', + default => "file\t12\t1\t644\tconfig.yml", + }; + + return Process::result(output: $output); + }); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->call('loadFiles') + ->assertSet('availableContainers', ['app-base']) + ->assertSet('container', 'app-base') + ->assertSet('currentPath', '/app') + ->assertSee('config.yml') + ->call('refresh') + ->assertSet('container', 'app-base') + ->assertSee('config.yml'); +}); + +it('renders before SSH starts and loads files in a later request', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: "file\t0\t1\t644\tconfig.yml"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + Process::assertNothingRan(); + $component->assertSet('filesLoaded', false) + ->assertSet('container', null) + ->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('currentPath', '/data') + ->assertSee('config.yml'); +}); + +it('does not start SSH if the resource stops before deferred loading', function () { + Process::fake(); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + $database->update(['status' => 'exited']); + + $component->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('containerRunning', false); + Process::assertNothingRan(); +}); + +it('allows retrying a failed initial container lookup', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running', + ]); + $fail = true; + Process::fake(function ($process) use (&$fail) { + if (str_contains($process->command, 'docker ps')) { + if ($fail) { + $fail = false; + + return Process::result(errorOutput: 'Permission denied', exitCode: 1); + } + + return Process::result(output: json_encode([ + 'ID' => 'base', 'Names' => 'app-base', 'State' => 'running', 'Labels' => '', + ])); + } + + return Process::result(output: str_contains($process->command, 'docker inspect') ? '/app' : ''); + }); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $application]) + ->call('loadFiles') + ->assertSet('filesLoaded', false) + ->assertSet('loadFailed', true) + ->assertSet('container', null) + ->call('loadFiles') + ->assertSet('filesLoaded', true) + ->assertSet('loadFailed', false) + ->assertSet('container', 'app-base'); +}); + +function fbConfigureDownloadStorage(): S3Storage +{ + Team::factory()->create(['id' => 0]); + $storage = S3Storage::create([ + 'name' => 'Downloads', + 'region' => 'us-east-1', + 'key' => 'key', + 'secret' => 'secret', + 'bucket' => 'downloads', + 'endpoint' => 'https://s3.example.com', + 'is_usable' => true, + 'team_id' => 0, + ]); + InstanceSettings::find(0)->update(['file_download_s3_storage_id' => $storage->id]); + Once::flush(); + + return $storage; +} + +it('queues an S3 upload instead of streaming when download storage is set', function () { + Bus::fake([PrepareFileDownloadJob::class]); + $storage = fbConfigureDownloadStorage(); + $overStreamCapKb = intdiv(ContainerFilesystemService::MAX_DOWNLOAD_SIZE, 1024) + 1; + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "dir {$overStreamCapKb}"))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'folder') + ->assertNoRedirect() + ->assertDispatched('info'); + + expect($component->get('pendingDownloads'))->toHaveCount(1); + Bus::assertDispatched(PrepareFileDownloadJob::class, fn (PrepareFileDownloadJob $job) => $job->downloadId === $component->get('pendingDownloads')[0] + && $job->userId === $this->admin->id + && $job->path === '/data/folder' + && $job->isDir + && $job->downloadName === 'folder.tar.gz' + && $job->s3StorageId === $storage->id); +}); + +it('refuses S3 downloads above the S3 size cap', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file '.(FileDownloadStorageService::MAX_SIZE + 1)))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + + Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'big.bin') + ->assertDispatched('error'); + Bus::assertNotDispatched(PrepareFileDownloadJob::class); +}); + +it('opens a finished S3 download once and ignores entries of other users', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'a.txt'); + $downloadId = $component->get('pendingDownloads')[0]; + $cacheKey = PrepareFileDownloadJob::cacheKey($downloadId); + + Cache::put($cacheKey, ['user_id' => $this->member->id, 'status' => 'ready', 'url' => 'https://s3.example.com/other'], 60); + $component->call('checkDownloads') + ->assertNotDispatched('open-file-download') + ->assertSet('pendingDownloads', [$downloadId]); + + Cache::put($cacheKey, ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); + $component->call('checkDownloads') + ->assertDispatched('open-file-download', url: 'https://s3.example.com/get') + ->assertSet('pendingDownloads', []); + expect(Cache::has($cacheKey))->toBeFalse(); +}); + +it('does not hand out S3 links after the user lost access to the resource', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('download', 'a.txt'); + Cache::put(PrepareFileDownloadJob::cacheKey($component->get('pendingDownloads')[0]), ['user_id' => $this->admin->id, 'status' => 'ready', 'url' => 'https://s3.example.com/get'], 60); + + $this->project->update(['team_id' => Team::factory()->create()->id]); + + $component->call('checkDownloads') + ->assertForbidden() + ->assertNotDispatched('open-file-download'); +}); + +it('limits how many S3 downloads a user can start', function () { + Bus::fake([PrepareFileDownloadJob::class]); + fbConfigureDownloadStorage(); + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10')) + ->push(Process::result(output: 'file 10'))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database])->call('loadFiles'); + + foreach (range(1, 4) as $attempt) { + $component->call('download', 'a.txt'); + } + + expect($component->get('pendingDownloads'))->toHaveCount(3); + Bus::assertDispatchedTimes(PrepareFileDownloadJob::class, 3); + $component->assertDispatched('error', 'Too many downloads are in progress. Wait for them to finish, then try again.'); +}); + +it('blocks navigation before a container is selected', function (string $action, array $arguments) { + Process::fake(); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]); + + expect(fn () => $component->call($action, ...$arguments)) + ->toThrow(RuntimeException::class, 'Container is not running.'); + expect($component->instance()->currentPath)->toBe(''); + Process::assertNothingRan(); +})->with([ + ['open', ['folder']], + ['goTo', ['/data']], + ['refresh', []], +]); + +it('reduces upload names to one filename component', function (string $name, string $expected) { + $component = new FileBrowser; + $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); + + expect($sanitize->invoke($component, $name))->toBe($expected); +})->with([ + ['../../etc/cron.d/payload', 'payload'], + ['..\\..\\etc\\cron.d\\payload', 'payload'], + ['../nested\\folder/payload', 'payload'], + ["it's.txt", "it's.txt"], + ['.env', '.env'], + ['my file.txt', 'my file.txt'], +]); + +it('rejects empty and all-dot upload names', function (string $name) { + $component = new FileBrowser; + $sanitize = new ReflectionMethod(FileBrowser::class, 'sanitizeUploadName'); + + expect(fn () => $sanitize->invoke($component, $name)) + ->toThrow(RuntimeException::class, 'Invalid file name.'); +})->with(['', '/', '\\', '.', '..', '...', '../..', '..\\..']); + +it('refuses to save an opened file after its container identity changes', function () { + Process::fake(['*' => Process::sequence() + ->push(Process::result(output: '/data')) + ->push(Process::result(output: '')) + ->push(Process::result(output: "OK\n".base64_encode('before')))]); + $database = fbRunningDatabase($this->environment, $this->destination); + $this->actingAs($this->admin); + session(['currentTeam' => $this->team]); + $component = Livewire::test(FileBrowser::class, ['resource' => $database]) + ->call('loadFiles') + ->call('openEditor', 'config.yml') + ->set('editorContent', 'after'); + + $database->update(['uuid' => (string) Str::uuid()]); + Process::swap(new Factory); + Process::fake(); + + expect(fn () => $component->call('saveEditor')) + ->toThrow(RuntimeException::class, 'Container is not running.'); + expect($component->instance()->editingPath)->toBe('/data/config.yml'); + Process::assertNothingRan(); +}); diff --git a/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php new file mode 100644 index 0000000000..130b4c5ebe --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsChartPayloadTest.php @@ -0,0 +1,133 @@ + true, 'cpu' => 10.0, 'memUsed' => 1, 'memTotal' => 2, + 'diskUsed' => 1, 'diskTotal' => 2, 'diskPercent' => 50.0, 'load1' => 0.1, 'netRx' => 0.0, 'netTx' => 0.0]; + } + + public function containersCurrent(): array + { + return []; + } + + public function history(string $metric, string $range): array + { + return [[1000, 10.0], [2000, 20.0]]; + } + + public function networkHistory(string $range): array + { + return ['rx' => [[1000, 5.0]], 'tx' => [[1000, 1.0]]]; + } +} + +class ChartTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new ChartStubClient($server); + } +} + +class DenseChartStubClient extends ChartStubClient +{ + public function history(string $metric, string $range): array + { + // 300 points, like a downsampled 24h host series. + return array_map(fn ($i) => [$i * 1000, (float) $i], range(1, 300)); + } +} + +class DenseTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new DenseChartStubClient($server); + } +} + +beforeEach(function () { + // The settings hook starts Sentinel when metrics turn on. A sync queue would run it and make a real SSH call. + Queue::fake(); + Concurrency::setDefaultInstance('sync'); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +it('dispatches a chart payload with every trend series', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(ChartTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Livewire JSON-serializes event params, so whole floats arrive as ints; + // compare loosely (10 == 10.0). + return $payload['cpu'] == [[1000, 10], [2000, 20]] + && $payload['networkRx'] == [[1000, 5]] + && array_key_exists('load', $payload); + }); +}); + +it('includes KPI-tile spark series aligned to one shared time axis', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(ChartTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Union of every series' buckets, sorted; each spark projects onto it, + // leaving gaps as null (network only has the 1000 bucket → tail is null). + return $payload['sparkCategories'] == [1000, 2000] + && $payload['cpuSpark'] == [10, 20] + && $payload['memSpark'] == [10, 20] + && $payload['diskSpark'] == [10, 20] + && $payload['netSpark'] === [6, null]; + }); +}); + +it('caps KPI-tile sparks to a readable point count on a shared axis', function () { + $s = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $s->settings->update(['is_metrics_enabled' => true]); + + Livewire::test(DenseTestableMetrics::class) + ->call('loadData') + ->assertDispatched('refreshChartData-fleet-metrics', function ($event, $params) { + $payload = $params[0]; + + // Dense trend charts keep their detail; sparks are thinned to <= 40 points, + // and every spark shares the same length as the axis (grouped-comparable). + return count($payload['cpu']) === 300 + && count($payload['sparkCategories']) <= 40 + && count($payload['cpuSpark']) === count($payload['sparkCategories']) + && count($payload['memSpark']) === count($payload['sparkCategories']) + && count($payload['netSpark']) === count($payload['sparkCategories']); + }); +}); diff --git a/tests/Feature/Livewire/Fleet/MetricsDataTest.php b/tests/Feature/Livewire/Fleet/MetricsDataTest.php new file mode 100644 index 0000000000..90693900af --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsDataTest.php @@ -0,0 +1,449 @@ + serverUuid => summary */ + public static array $summaries = []; + + /** @var array serverUuid => containers */ + public static array $containers = []; + + public static array $histories = []; + + public static array $failingMetrics = []; + + public function summary(): ?array + { + return self::$summaries[$this->server->uuid] ?? null; + } + + public function containersCurrent(): array + { + return self::$containers[$this->server->uuid] ?? []; + } + + public function history(string $metric, string $range): array + { + if (in_array($metric, self::$failingMetrics, true)) { + throw new RuntimeException('metric unavailable'); + } + + return self::$histories[$this->server->uuid][$metric] ?? []; + } + + public function networkHistory(string $range): array + { + return ['rx' => [], 'tx' => []]; + } +} + +class TestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new StubMetricsClient($server); + } +} + +class ProcessTestableMetrics extends Metrics +{ + protected function metricsClient(Server $server): SentinelMetricsClient + { + return new ConcurrentMetricsClient($server); + } +} + +beforeEach(function () { + // The settings hook starts Sentinel when metrics turn on. A sync queue would run it and make a real SSH call. + Queue::fake(); + Concurrency::setDefaultInstance('sync'); + StubMetricsClient::$summaries = []; + StubMetricsClient::$containers = []; + StubMetricsClient::$histories = []; + StubMetricsClient::$failingMetrics = []; + + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $this->team = $team; +}); + +function metricsServer($team, $privateKey): Server +{ + $server = Server::factory()->create(['team_id' => $team->id, 'private_key_id' => $privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + return $server; +} + +function onlineSummary(array $overrides = []): array +{ + return array_merge([ + 'online' => true, 'cpu' => 30.0, 'memUsed' => 100, 'memTotal' => 200, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.4, 'netRx' => 5.0, 'netTx' => 1.0, + ], $overrides); +} + +it('keeps an unreachable server in the table but excludes it from KPIs', function () { + $ok = metricsServer($this->team, $this->privateKey); + $dead = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 30.0]), + $dead->uuid => null, + ]; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(2); + + $deadRow = collect($component->get('serverRows'))->firstWhere('uuid', $dead->uuid); + expect($deadRow['online'])->toBeFalse(); + expect($deadRow['cpu'])->toBeNull(); + + expect($component->get('kpis')['serversOnline'])->toBe(1); + expect($component->get('kpis')['cpuAvg'])->toBe(30.0); +}); + +it('ranks containers across servers by the selected metric', function () { + $s = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [$s->uuid => onlineSummary(['diskPercent' => 50.0])]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'low', 'cpu' => 5.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0], + ['id' => 'high', 'cpu' => 88.0, 'memUsed' => 20, 'memPercent' => 2.0, 'diskBytes' => 2, 'net' => 0.0], + ]]; + + $component = Livewire::test(TestableMetrics::class); + + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('high'); +}); + +it('shows the KPI tiles, a stale badge for offline servers, and the container toggle', function () { + $ok = metricsServer($this->team, $this->privateKey); + $ok->update(['name' => 'web-1']); + $dead = metricsServer($this->team, $this->privateKey); + $dead->update(['name' => 'web-2']); + + StubMetricsClient::$summaries = [ + $ok->uuid => onlineSummary(['cpu' => 95.0, 'diskPercent' => 95.0]), + $dead->uuid => null, + ]; + + Livewire::test(TestableMetrics::class) + ->assertSee('web-1') + ->assertSee('web-2') + ->assertSee('Fleet CPU') + ->assertSee('healthy') + ->assertSee('attention') + ->assertSee('Offline'); +}); + +it('re-ranks containers on metric switch without re-fetching', function () { + $s = metricsServer($this->team, $this->privateKey); + + StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 0.0], + ['id' => 'b', 'cpu' => 90.0, 'memUsed' => 100, 'memPercent' => 1.0, 'diskBytes' => 9, 'net' => 0.0], + ]]; + + $component = Livewire::test(TestableMetrics::class); + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('b'); // cpu + + // Empty the stub so a re-fetch would change nothing/ break; the re-rank must use cached rows. + StubMetricsClient::$containers = []; + $component->set('containerMetric', 'memory'); + + expect(array_column($component->get('topContainers'), 'id')[0])->toBe('a'); // memory, still ranked +}); + +it('cannot see another team’s servers', function () { + $otherTeam = Team::factory()->create(); + // Reuse the existing key id: the factory's private key is hardcoded, so a second + // PrivateKey::create collides on fingerprint. Only the server's team_id matters here. + $server = Server::factory()->create(['team_id' => $otherTeam->id, 'private_key_id' => $this->privateKey->id]); + $server->settings->update(['is_metrics_enabled' => true]); + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('serverRows'))->toHaveCount(0); +}); + +it('drops the "Fleet" tile prefix when scoped to a single server', function () { + $s = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; + + Livewire::test(TestableMetrics::class) + ->assertSee('Fleet CPU') + ->set('serverUuid', $s->uuid) + ->assertDontSee('Fleet') + ->assertSee('for the selected server'); +}); + +it('renders container metric values without leaking a blade directive', function () { + $s = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 2048.0], + ]]; + + Livewire::test(TestableMetrics::class) + ->set('containerMetric', 'network') + ->assertDontSee('@break') + ->assertSee('/s'); +}); + +it('falls back to cpu for an unknown container metric', function () { + $s = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$s->uuid => [ + ['id' => 'a', 'cpu' => 5.0, 'memUsed' => 900, 'memPercent' => 9.0, 'diskBytes' => 1, 'net' => 0.0], + ]]; + + Livewire::withQueryParams(['cmetric' => 'foo']) + ->test(TestableMetrics::class) + ->assertOk() + ->assertSet('containerMetric', 'cpu') + ->set('containerMetric', 'bar') + ->assertSet('containerMetric', 'cpu'); +}); + +it('keys container rows by server so shared container ids do not collide', function () { + $one = metricsServer($this->team, $this->privateKey); + $two = metricsServer($this->team, $this->privateKey); + $proxy = ['id' => 'coolify-proxy', 'cpu' => 1.0, 'memUsed' => 10, 'memPercent' => 1.0, 'diskBytes' => 1, 'net' => 0.0]; + + StubMetricsClient::$summaries = [$one->uuid => onlineSummary(), $two->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$one->uuid => [$proxy], $two->uuid => [$proxy]]; + + Livewire::test(TestableMetrics::class) + ->assertSeeHtml('wire:key="fleet-container-'.$one->uuid.'-coolify-proxy"') + ->assertSeeHtml('wire:key="fleet-container-'.$two->uuid.'-coolify-proxy"'); +}); + +it('passes the spark time axis to the KPI sparklines on first paint', function () { + $s = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$s->uuid => onlineSummary()]; + + $component = Livewire::test(TestableMetrics::class); + $component->set('chartData', ['sparkCategories' => [1700000100000], 'cpuSpark' => [12.5]]); + + $component->assertSeeHtml('1700000100000'); +}); + +it('removes a transferred server on refresh', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + $component = Livewire::test(TestableMetrics::class); + expect($component->get('serverRows'))->toHaveCount(1); + + $server->update(['team_id' => Team::factory()->create()->id]); + $component->call('loadData'); + + expect($component->get('serverRows'))->toBe([]); + expect($component->get('containersRaw'))->toBe([]); + expect($component->get('kpis')['serversTotal'])->toBe(0); +}); + +it('permits team members to read metrics', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + expect(Livewire::test(TestableMetrics::class)->get('kpis')['serversOnline'])->toBe(1); +}); + +it('keeps host trends when an older Sentinel has no summary endpoint', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$histories = [$server->uuid => ['cpu' => [[1000, 25.0]], 'memory' => [[1000, 100.0]]]]; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]); + expect($component->get('chartData')['memory'])->toBe([[1000, 100.0]]); + expect($component->get('topContainers'))->toBe([]); +}); + +it('keeps other metrics when one independent operation fails', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + StubMetricsClient::$histories = [$server->uuid => ['cpu' => [[1000, 25.0]]]]; + StubMetricsClient::$failingMetrics = ['disk']; + + $component = Livewire::test(TestableMetrics::class); + + expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]); + expect($component->get('chartData')['disk'])->toBe([]); + expect($component->get('kpis')['serversOnline'])->toBe(1); +}); + +it('resolves container names for the instance team with id zero', function () { + $rootTeam = Team::factory()->create(['id' => 0]); + auth()->user()->teams()->attach($rootTeam, ['role' => 'owner']); + auth()->user()->unsetRelation('teams'); + session(['currentTeam' => $rootTeam]); + $project = Project::factory()->create(['team_id' => 0]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create(['environment_id' => $environment->id]); + + $meta = Livewire::test(TestableMetrics::class)->instance()->containerMeta($application->uuid); + + expect($meta['name'])->toBe($application->name); + expect($meta['link'])->toContain($application->uuid); +}); + +it('denies metrics refresh after team membership is removed', function () { + $this->withoutVite(); + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + $component = Livewire::test(TestableMetrics::class); + + auth()->user()->teams()->detach($this->team); + auth()->user()->unsetRelation('teams'); + + $component->call('loadData')->assertForbidden(); +}); + +it('clears stale container rankings after a server transfer', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$server->uuid => [['id' => 'private-container', 'cpu' => 10]]]; + $component = Livewire::test(TestableMetrics::class); + + $server->update(['team_id' => Team::factory()->create()->id]); + $component->set('containerMetric', 'memory'); + + expect($component->get('topContainers'))->toBe([]); + expect($component->get('serverRows'))->toBe([]); +}); + +it('merges metrics from real concurrent workers and isolates endpoint failures', function () { + Concurrency::setDefaultInstance('process'); + metricsServer($this->team, $this->privateKey); + metricsServer($this->team, $this->privateKey); + + $component = Livewire::test(ProcessTestableMetrics::class); + + expect($component->get('kpis')['serversOnline'])->toBe(2); + expect($component->get('topContainers'))->toHaveCount(2); + expect($component->get('chartData')['cpu'])->toBe([[1000, 25.0]]); + expect($component->get('chartData')['memory'])->toBe([[1000, 200.0]]); + expect($component->get('chartData')['disk'])->toBe([]); + expect($component->get('chartData')['networkRx'])->toBe([[1000, 10.0]]); +}); + +it('clears stale metrics after the last server is deleted', function () { + $server = metricsServer($this->team, $this->privateKey); + StubMetricsClient::$summaries = [$server->uuid => onlineSummary()]; + StubMetricsClient::$containers = [$server->uuid => [['id' => 'deleted-container', 'cpu' => 10]]]; + $component = Livewire::test(TestableMetrics::class); + + $server->delete(); + $component->set('containerMetric', 'memory'); + + expect($component->get('topContainers'))->toBe([]); + expect($component->get('serverRows'))->toBe([]); +}); + +it('repairs the Sentinel token before creating worker clients', function () { + $server = metricsServer($this->team, $this->privateKey); + $server->settings->updateQuietly(['sentinel_token' => 'invalid token']); + app()->bind(SentinelMetricsClient::class, fn ($app, $parameters) => new ConcurrentMetricsClient($parameters['server'])); + + loadLazy(Livewire::test(Metrics::class)); + + $token = $server->settings->fresh()->sentinel_token; + expect(ServerSetting::isValidSentinelToken($token))->toBeTrue(); + expect($token)->not->toBe('invalid token'); +}); + +/** A server that Coolify can reach, with metrics off. */ +function reachableServerWithoutMetrics(int $teamId, PrivateKey $privateKey): Server +{ + $server = Server::factory()->create(['team_id' => $teamId, 'private_key_id' => $privateKey->id]); + $server->settings->forceFill(['is_reachable' => true, 'is_usable' => true])->saveQuietly(); + + return $server; +} + +it('enables metrics on each eligible server of the team from the empty state', function () { + $first = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + $second = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + $build = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + $build->settings->update(['server_role' => 'build', 'is_build_server' => true]); + $unreachable = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + $foreign = reachableServerWithoutMetrics(Team::factory()->create()->id, $this->privateKey); + + $component = Livewire::test(TestableMetrics::class) + ->assertSee('Enable metrics on 2 servers') + ->call('enableMetrics'); + + expect($first->settings->fresh()->is_metrics_enabled)->toBeTruthy(); + expect($second->settings->fresh()->is_metrics_enabled)->toBeTruthy(); + expect($build->settings->fresh()->is_metrics_enabled)->toBeFalsy(); + expect($unreachable->settings->fresh()->is_metrics_enabled)->toBeFalsy(); + expect($foreign->settings->fresh()->is_metrics_enabled)->toBeFalsy(); + StartSentinel::assertPushed(2); + + expect(array_column($component->get('serverRows'), 'uuid'))->toEqualCanonicalizing([$first->uuid, $second->uuid]); + $component->assertDontSee('No server metrics yet'); +}); + +it('keeps enabling metrics on the other servers when one server fails', function () { + $broken = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + $healthy = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + DB::statement("CREATE TRIGGER fail_settings_update BEFORE UPDATE ON server_settings WHEN NEW.server_id = {$broken->id} BEGIN SELECT RAISE(ABORT, 'write failed'); END"); + + Livewire::test(TestableMetrics::class) + ->call('enableMetrics') + ->assertDispatched('success', fn (string $event, array $params) => str_contains($params[0], 'Metrics enabled on 1 server.')) + ->assertDispatched('error', fn (string $event, array $params) => str_contains($params[0], $broken->name)); + + expect($broken->settings->fresh()->is_metrics_enabled)->toBeFalsy(); + expect($healthy->settings->fresh()->is_metrics_enabled)->toBeTruthy(); + StartSentinel::assertPushed(1); +}); + +it('does not let a team member enable metrics', function () { + $server = reachableServerWithoutMetrics($this->team->id, $this->privateKey); + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(TestableMetrics::class) + ->assertSee('No server metrics yet') + ->assertDontSee('Enable metrics on '.$server->name) + ->call('enableMetrics'); + + expect($server->settings->fresh()->is_metrics_enabled)->toBeFalsy(); + StartSentinel::assertNotPushed(); +}); diff --git a/tests/Feature/Livewire/Fleet/MetricsPageTest.php b/tests/Feature/Livewire/Fleet/MetricsPageTest.php new file mode 100644 index 0000000000..ad44bf6d72 --- /dev/null +++ b/tests/Feature/Livewire/Fleet/MetricsPageTest.php @@ -0,0 +1,33 @@ + InstanceSettings::query()->create(['id' => 0])); + $user = User::factory()->create(); + $team = Team::factory()->create(); + $user->teams()->attach($team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $team]); + $this->team = $team; +}); + +it('renders the metrics page with an empty state when no server has metrics enabled', function () { + $component = loadLazy(Livewire::test(Metrics::class)); + + $component + ->assertOk() + ->assertSee('Metrics') + ->assertSee('Enable metrics on a server'); +}); + +it('registers the metrics route', function () { + expect(route('metrics'))->toEndWith('/metrics'); +}); diff --git a/tests/Feature/Livewire/ServerTransferUiTest.php b/tests/Feature/Livewire/ServerTransferUiTest.php index 31be7a217d..614e92de9f 100644 --- a/tests/Feature/Livewire/ServerTransferUiTest.php +++ b/tests/Feature/Livewire/ServerTransferUiTest.php @@ -1,26 +1,47 @@ 'local']); + // A claim writes the owner marker on the host over SSH. + Process::fake(); + Storage::fake('ssh-keys'); + // An import validates the claimed server over SSH; the sync test queue would run it. + Queue::fake([ValidateAndInstallServerJob::class]); + config(['app.env' => 'production']); + $this->withoutVite(); InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); + // The default password of UserFactory. + $this->userPassword = 'password'; // ggignore $this->team->members()->attach($this->user->id, ['role' => 'owner']); $this->actingAs($this->user); session(['currentTeam' => $this->team]); @@ -34,47 +55,53 @@ beforeEach(function () { ]); }); -test('server transfer pages are unavailable outside development mode', function (string $uri) { - config(['app.env' => 'production']); - - $this->get($uri)->assertNotFound(); -})->with([ - '/servers/import', - fn () => '/server/'.$this->server->uuid.'/transfer', -]); - -test('server transfer links are hidden outside development mode', function () { - config(['app.env' => 'production']); - - $this->get('/servers') - ->assertOk() - ->assertDontSee('/servers/import'); - - $this->get('/server/'.$this->server->uuid) - ->assertOk() - ->assertDontSee('Transfer', escape: false); +test('server transfer page is available in production', function () { + $this->get('/server/'.$this->server->uuid.'/transfer')->assertOk(); }); -test('transfer state is hidden outside development mode', function (bool $managementDisabled) { - app(ServerTransferClaimer::class)->markTransferred($this->server, managementDisabled: $managementDisabled); - config(['app.env' => 'production']); - - $this->get('/servers') +test('server transfer link is shown to admins in production', function () { + $this->get('/server/'.$this->server->uuid) ->assertOk() - ->assertDontSee('Transferable') - ->assertDontSee('Transferred away'); + ->assertSee('/server/'.$this->server->uuid.'/transfer', escape: false); +}); + +test('taking back a transferred server needs a typed confirmation', function () { + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://target.example.com'); $this->get('/server/'.$this->server->uuid) ->assertOk() - ->assertDontSee('Transfer', escape: false) - ->assertDontSee('/transfer"', escape: false) - ->assertDontSee('another instance') - ->assertDontSee('Enable management') - ->assertDontSee('Disable management'); -})->with([ - 'management disabled' => [true], - 'transferred away' => [false], -]); + ->assertSee('Take back a transferred server?') + ->assertSee('Changes made on https://target.example.com after', escape: false); +}); + +test('team members cannot open the transfer page', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertRedirect(route('server.index')); + + $this->get('/server/'.$this->server->uuid) + ->assertOk() + ->assertDontSee('/server/'.$this->server->uuid.'/transfer', escape: false); +}); + +test('a member who owns another team cannot use that role to transfer the server', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $otherTeam = Team::factory()->create(); + $otherTeam->members()->attach($member->id, ['role' => 'owner']); + $this->actingAs($member); + + session(['currentTeam' => $this->team]); + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertRedirect(route('server.index')); + + session(['currentTeam' => $otherTeam]); + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertStatus(404); +}); test('transfer page renders for owned server', function () { Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) @@ -82,40 +109,14 @@ test('transfer page renders for owned server', function () { ->assertSee('Transfer server') ->assertSee('Target instance URL') ->assertSee('Target API token') - ->assertSee('Transfer server') - ->assertSee('Manual transfer'); -}); - -test('transfer import page renders for admin', function () { - Livewire::test(TransferImport::class) - ->assertOk() - ->assertSee('Transfer file') - ->assertSee('Dry run') - ->assertSee('Import server'); -}); - -test('export bundle streams download from livewire', function () { - Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) - ->call('exportBundle') - ->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json'); -}); - -test('team members cannot export a transfer bundle', function () { - $member = User::factory()->create(); - $this->team->members()->attach($member->id, ['role' => 'member']); - $this->actingAs($member); - session(['currentTeam' => $this->team]); - - Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) - ->call('exportBundle') - ->assertNoFileDownloaded(); + ->assertDontSee('Manual transfer') + ->assertDontSee('Passphrase'); }); test('transfer page badge shows the management state of the server', function (string $state, string $label, array $hidden) { $claimer = app(ServerTransferClaimer::class); match ($state) { 'managed' => null, - 'disabled' => $claimer->markTransferred($this->server, managementDisabled: true), 'transferred' => $claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'), }; @@ -123,53 +124,194 @@ test('transfer page badge shows the management state of the server', function (s ->assertSee($label) ->assertDontSee($hidden); })->with([ - 'managed' => ['managed', 'Managed here', ['Transferable', 'Transferred away']], - 'management disabled' => ['disabled', 'Transferable', ['Transferred away', 'Managed here']], - 'transferred' => ['transferred', 'Transferred away', ['Transferable', 'Managed here']], + 'managed' => ['managed', 'Managed here', ['Transferred away']], + 'transferred' => ['transferred', 'Transferred away', ['Managed here']], ]); -test('export bundle is encrypted only when a passphrase is set', function (?string $passphrase, string $fileName) { +test('transfer to another instance runs in a queued job', function () { + Queue::fake(); + Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]); + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) - ->set('passphrase', $passphrase) - ->call('exportBundle') - ->assertFileDownloaded('server-transfer-'.$this->server->uuid.$fileName); + ->set('targetUrl', 'https://8.8.8.8') + ->set('targetToken', 'target-token') + ->call('migrateServer') + ->assertHasNoErrors() + ->assertSet('targetToken', '') + ->assertSee('Transfer in progress'); + + Queue::assertPushed(MigrateServerToInstanceJob::class); + expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running'); +}); + +test('transfer page shows the error of a failed transfer', function () { + $this->server->forceFill(['server_metadata' => ['migration' => [ + 'status' => 'failed', + 'target_url' => 'https://8.8.8.8', + 'error' => 'Target import failed (HTTP 401): Unauthenticated.', + ]]])->save(); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Transfer failed') + ->assertSee('Target import failed (HTTP 401): Unauthenticated.'); +}); + +test('a transferred server cannot delete its cloud provider machine', function () { + Queue::fake(); + $this->server->forceFill(['hetzner_server_id' => 4242])->save(); + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'); + + $component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]); + expect(collect($component->viewData('checkboxes'))->pluck('id'))->not->toContain('delete_from_hetzner'); + + $component->call('delete', $this->userPassword, ['delete_from_hetzner']); + + DeleteServer::assertPushed(fn ($action, array $parameters) => $parameters[1] === false && $parameters[2] === 4242); +}); + +test('a transferred server does not show Hostinger VPS deletion guidance', function () { + $this->server->forceFill(['hostinger_virtual_machine_id' => 4242])->save(); + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'); + + Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Hostinger deletes') + ->assertDontSee('hpanel.hostinger.com'); +}); + +test('a managed server can still delete its cloud provider machine', function () { + $this->server->forceFill(['hetzner_server_id' => 4242])->save(); + + $component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]); + + expect(collect($component->viewData('checkboxes'))->pluck('id'))->toContain('delete_from_hetzner'); +}); + +test('deleting a transferred server deletes its resources from this instance only', function () { + Queue::fake(); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $application = Application::factory()->create([ + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'); + + $component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]); + expect(collect($component->viewData('checkboxes'))->pluck('id'))->not->toContain('force_delete_resources'); + + $component->call('delete', $this->userPassword); + + Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($application) && $job->deleteFromCoolifyOnly); + expect(Server::find($this->server->id))->toBeNull(); +}); + +test('deleting a transferred server forgets the DNS records of its resources without releasing them at the provider', function () { + Queue::fake(); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]); + // An archive that still tracks a record, for example from a transfer before records were forgotten at claim time. + $this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save(); + $token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']); + $zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]); + $record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]); + $record->addReference($application); + + Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid])->call('delete', $this->userPassword); + (new DeleteResourceJob($application->fresh(), deleteFromCoolifyOnly: true))->handle(); + + Queue::assertNotPushed(ReleaseManagedDnsRecordsJob::class); + expect(ManagedDnsRecord::find($record->id))->toBeNull() + ->and(Application::withTrashed()->find($application->id))->toBeNull(); +}); + +test('deleting an application from Coolify only does not touch the server', function () { + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $application = Application::factory()->create([ + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + Process::fake(); + + (new DeleteResourceJob($application, deleteFromCoolifyOnly: true))->handle(); + + Process::assertNothingRan(); + expect(Application::withTrashed()->find($application->id))->toBeNull(); +}); + +test('transfer with a token that the target does not accept shows an error and does not start', function () { + Queue::fake(); + Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'Unauthenticated.'], 401)]); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->set('targetUrl', 'https://8.8.8.8') + ->set('targetToken', 'wrong-token') + ->call('migrateServer') + ->assertDispatched('error') + ->assertDontSee('Transfer in progress') + ->assertSee('A check failed. The transfer did not start.') + ->assertSee('OK: this instance can connect to the server over SSH.') + ->assertSee('FAILED: the target instance accepts the API token.') + ->assertSee('See the transfer log below for details.') + ->assertDontSee('checks failed:'); + + Queue::assertNotPushed(MigrateServerToInstanceJob::class); +}); + +test('transfer page links to the server on the target and lists the steps after a finished transfer', function () { + $this->server->forceFill(['server_metadata' => ['migration' => [ + 'status' => 'succeeded', + 'target_url' => 'https://8.8.8.8', + 'checklist' => ['Set the webhook URL of GitHub App deploy-bot to https://8.8.8.8/webhooks/source/github/events.'], + 'target_server_url' => 'https://8.8.8.8/server/abc', + ]]])->save(); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Transfer finished') + ->assertSee('Finish the transfer on the target') + ->assertSee('href="https://8.8.8.8/server/abc"', escape: false) + ->assertSee('Set the webhook URL of GitHub App deploy-bot to https://8.8.8.8/webhooks/source/github/events.') + ->assertDontSee('deploy-bot', escape: false); +}); + +test('transfer page hides the post-transfer checklist', function (string $status, array $checklist) { + $this->server->forceFill(['server_metadata' => ['migration' => [ + 'status' => $status, + 'target_url' => 'https://8.8.8.8', + 'error' => 'Target import failed (HTTP 401): Unauthenticated.', + 'checklist' => $checklist, + ]]])->save(); + + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Finish the transfer on the target') + ->assertDontSee('Set the webhook URL'); })->with([ - 'null' => [null, '.json'], - 'empty string' => ['', '.json'], - 'only spaces' => [' ', '.json'], - 'set' => ['secret-pass', '.encrypted.json'], + 'finished transfer without steps' => ['succeeded', []], + 'failed transfer' => ['failed', ['Set the webhook URL of GitHub App deploy-bot.']], ]); -test('import dry run from pasted json', function () { - $bundle = app(ServerTransferExporter::class)->export($this->server); +test('the transfer page of an archive offers a transfer back instead of a transfer', function () { + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8'); + Http::fake(['https://8.8.8.8/api/v1/servers/*/migrate' => Http::response(['migration' => ['status' => 'running']], 202)]); - // Free IP for dry-run validation on same team would still report existing server - Livewire::test(TransferImport::class) - ->set('bundleJson', json_encode($bundle)) - ->call('dryRun') - ->assertSet('lastResult.dry_run', true) - ->assertSet('lastResult.server_uuid', $this->server->uuid) - ->assertSee('Dry run result') - ->assertSee('Nothing written'); + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('managerUrl', 'https://8.8.8.8') + ->assertDontSee('Transfer to another instance') + ->set('managerToken', 'token-of-b') + ->call('transferBack') + ->assertDispatched('success') + ->assertSee('Transfer back started'); + + Http::assertSentCount(1); }); -test('import creates server after source removal', function () { - $bundle = app(ServerTransferExporter::class)->export($this->server); - $uuid = $this->server->uuid; +test('team members cannot start a transfer back', function () { + app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8'); + Http::fake(); + $component = Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]); + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); - $this->server->forceDelete(); - $this->privateKey->delete(); + $component->set('managerToken', 'token-of-b')->call('transferBack'); - Livewire::test(TransferImport::class) - ->set('bundleJson', json_encode($bundle)) - ->set('preserveUuids', true) - ->set('adoptMode', true) - ->call('importBundle') - ->assertSet('importedServerUuid', $uuid) - ->assertSet('lastResult.dry_run', false) - ->assertSet('lastResult.claimed', true); - - $server = Server::where('uuid', $uuid)->first(); - expect($server)->not->toBeNull() - ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed'); + Http::assertNothingSent(); }); diff --git a/tests/Feature/LocalhostServerDetailsTest.php b/tests/Feature/LocalhostServerDetailsTest.php index c106830184..af8c9cc8b1 100644 --- a/tests/Feature/LocalhostServerDetailsTest.php +++ b/tests/Feature/LocalhostServerDetailsTest.php @@ -95,7 +95,7 @@ it('renders stored localhost server details instead of the fetch action', functi ->assertSee('29.4.3') ->assertSee('Compose version') ->assertSee('2.32.4') - ->assertSee('Refresh server details') + ->assertSee('Refresh details') ->assertDontSee('Fetch server details'); }); @@ -125,6 +125,22 @@ it('fetches localhost server details from the overview action', function () { ->and($server->composeVersion())->toBe('2.32.4'); }); +it('does not fetch server details before the server is validated', function () { + $this->server->settings()->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + Process::fake(); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('refreshServerMetadata') + ->assertDispatched('error', 'Validate the server connection before fetching details.'); + + Process::assertNothingRan(); + expect($this->server->fresh()->server_metadata)->toBeNull(); +}); + it('refetches localhost compose version from the overview refresh action', function () { $this->server->update([ 'server_metadata' => [ @@ -184,3 +200,23 @@ it('collects localhost server details after a successful connection check', func ->and($server->settings->is_reachable)->toBeTrue() ->and($server->settings->is_usable)->toBeTrue(); }); + +it('shows validation-only menus as disabled until the server is validated', function () { + $this->server->settings()->update([ + 'is_reachable' => false, + 'is_usable' => false, + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Docker Cleanup') + ->assertSee('aria-disabled="true"', false) + ->assertDontSee(route('server.docker-cleanup', ['server_uuid' => $this->server->uuid]), false); + + $this->server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + ]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee(route('server.docker-cleanup', ['server_uuid' => $this->server->uuid]), false); +}); diff --git a/tests/Feature/LogDrain/CloudwatchLogDrainTest.php b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php new file mode 100644 index 0000000000..26034973b9 --- /dev/null +++ b/tests/Feature/LogDrain/CloudwatchLogDrainTest.php @@ -0,0 +1,244 @@ +user = User::factory()->create(); + $this->team = $this->user->teams()->first(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('enables the CloudWatch drain and persists its options', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', '/coolify/prod') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->call('toggleLogDrain', 'cloudwatch') + ->assertHasNoErrors() + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_group)->toBe('/coolify/prod') + ->and($settings->logdrain_cloudwatch_region)->toBe('us-east-1') + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('requires a log group before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('requires a region before enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->call('toggleLogDrain', 'cloudwatch') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('rejects stream prefixes that could break the compose file', function (string $prefix) { + StartLogDrain::mock()->shouldNotReceive('handle'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', $prefix) + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +})->with(['docker/${HOME}', 'docker:', '{{.ID}}', 'with space']); + +it('enables the CloudWatch drain from the status dropdown', function () { + StartLogDrain::mock()->shouldReceive('handle')->once()->andReturn('ok'); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('logDrainCloudwatchStreamPrefix', 'docker/') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeTrue() + ->and($settings->logdrain_cloudwatch_stream_prefix)->toBe('docker/'); +}); + +it('disables other drains when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainNewRelicEnabled', false) + ->assertSet('isLogDrainCloudwatchEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_newrelic_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('reverts the CloudWatch flag when starting the drain fails', function () { + StartLogDrain::mock()->shouldReceive('handle')->andThrow(new RuntimeException('runtime boom')); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch') + ->assertSet('isLogDrainCloudwatchEnabled', false); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('forbids team members from enabling CloudWatch', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $member = User::factory()->create(); + $member->teams()->attach($this->team, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + expect($this->server->settings->fresh()->is_logdrain_cloudwatch_enabled)->toBeFalse(); +}); + +it('does not deploy the Fluent Bit container for CloudWatch', function () { + StopLogDrain::mock()->shouldReceive('handle')->once()->andReturn('removed'); + $this->server->settings->update([ + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + + $result = StartLogDrain::run($this->server->fresh()); + + expect($result)->toContain('awslogs'); +}); + +it('turns off the legacy Highlight drain when CloudWatch is enabled', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update([ + 'is_logdrain_highlight_enabled' => true, + 'logdrain_highlight_project_id' => 'legacy-project', + ]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('success'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('turns off the legacy Highlight drain when switching drains with toggleLogDrain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('toggleLogDrain', 'cloudwatch'); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_highlight_enabled)->toBeFalsy() + ->and($settings->is_logdrain_cloudwatch_enabled)->toBeTrue(); +}); + +it('keeps the legacy Highlight drain when saving without enabling another drain', function () { + StartLogDrain::mock()->shouldReceive('handle')->andReturn('ok'); + $this->server->settings->update(['is_logdrain_highlight_enabled' => true]); + + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->call('submit') + ->assertDispatched('success'); + + expect($this->server->settings->fresh()->is_logdrain_highlight_enabled)->toBeTruthy(); +}); + +it('rejects a save that would enable two log drains at once', function () { + StartLogDrain::mock()->shouldNotReceive('handle'); + $this->server->settings->update([ + 'is_logdrain_newrelic_enabled' => true, + 'logdrain_newrelic_license_key' => 'abc123', + 'logdrain_newrelic_base_uri' => 'https://log-api.newrelic.com', + ]); + + // The UI disables the CloudWatch dropdown here; this simulates a crafted request. + Livewire::test(LogDrains::class, ['server_uuid' => $this->server->uuid]) + ->set('logDrainCloudwatchGroup', 'coolify') + ->set('logDrainCloudwatchRegion', 'us-east-1') + ->set('isLogDrainCloudwatchEnabled', true) + ->call('instantSave') + ->assertDispatched('error') + ->assertSet('isLogDrainCloudwatchEnabled', false) + ->assertSet('isLogDrainNewRelicEnabled', true); + + $settings = $this->server->settings->fresh(); + expect($settings->is_logdrain_cloudwatch_enabled)->toBeFalse() + ->and($settings->is_logdrain_newrelic_enabled)->toBeTruthy(); +}); + +it('keeps the Fluent Bit drain running if CloudWatch is also enabled, matching the containers logging config', function () { + Process::fake(); + StopLogDrain::mock()->shouldReceive('handle')->andReturn('removed'); + $this->server->update(['private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]); + $this->server->settings->update([ + 'is_logdrain_axiom_enabled' => true, + 'logdrain_axiom_dataset_name' => 'ds', + 'logdrain_axiom_api_key' => 'key', + 'is_logdrain_cloudwatch_enabled' => true, + 'logdrain_cloudwatch_group' => 'coolify', + 'logdrain_cloudwatch_region' => 'us-east-1', + ]); + $server = $this->server->fresh(); + + expect(generate_log_drain_configuration($server)['driver'])->toBe('fluentd'); + + StartLogDrain::run($server); + + Process::assertRan(fn ($process) => str_contains($process->command, 'log-drains/docker-compose.yml up -d')); +}); diff --git a/tests/Feature/LogRedactionChannelTest.php b/tests/Feature/LogRedactionChannelTest.php new file mode 100644 index 0000000000..29e2a9b6b1 --- /dev/null +++ b/tests/Feature/LogRedactionChannelTest.php @@ -0,0 +1,30 @@ + true, 'constants.coolify.self_hosted' => true]); + $configuration = config("logging.channels.{$channel}"); + $configuration['driver'] = 'monolog'; + $configuration['handler'] = TestHandler::class; + config([ + "logging.channels.{$channel}" => $configuration, + 'logging.channels.emergency.path' => '/dev/null', + ]); + $logger = (new LogManager(app()))->channel($channel); + $handler = $logger->getLogger()->getHandlers()[0]; + + expect($handler)->toBeInstanceOf(TestHandler::class); + + $logger->error('password=coolify-canary-channel-secret', [ + 'token' => 'coolify-canary-context-secret', + 'user_email' => 'admin@example.com', + ]); + + $record = $handler->getRecords()[0]; + + expect($record->message)->toBe('password='.REDACTED); + expect($record->context['token'])->toBe(REDACTED); + expect($record->context['user_email'])->toBe($channel === 'audit' ? 'admin@example.com' : REDACTED); +})->with(['single', 'audit']); diff --git a/tests/Feature/LogRedactionRemoteSecretsTest.php b/tests/Feature/LogRedactionRemoteSecretsTest.php new file mode 100644 index 0000000000..7bbdd24542 --- /dev/null +++ b/tests/Feature/LogRedactionRemoteSecretsTest.php @@ -0,0 +1,152 @@ +team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $this->team->id])->id]); + $this->resourceAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + $this->serverId = $server->id; +}); + +function makeRemoteSecretResource(string $type): Application|Service|StandalonePostgresql +{ + $attributes = test()->resourceAttributes; + + return match ($type) { + 'application' => Application::factory()->create($attributes), + 'service' => Service::factory()->create([...$attributes, 'server_id' => test()->serverId]), + 'database' => StandalonePostgresql::query()->create([...$attributes, 'name' => 'remote-secret-db', 'postgres_password' => 'stored-db-password']), + }; +} + +function addRemoteSecretVariable(Application|Service|StandalonePostgresql $resource, string $key, string $value, bool $locked = false, bool $preview = false): void +{ + EnvironmentVariable::query()->create([ + 'key' => $key, + 'value' => $value, + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + 'is_preview' => $preview, + 'is_shown_once' => $locked, + ]); +} + +function linkDopplerSource(Application|Service|StandalonePostgresql $resource): void +{ + $token = IntegrationToken::factory()->create([ + 'team_id' => test()->team->id, + 'provider' => 'doppler', + 'capabilities' => ['secrets'], + ]); + $resource->secretManagerLink()->create(['integration_token_id' => $token->id]); +} + +it('hides resolved remote secrets of applications and services as locked values', function (string $type, bool $locked) { + Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['API_TOKEN' => 'resolved-remote-api-token'])]); + $resource = makeRemoteSecretResource($type); + if ($type === 'application') { + $resource->settings->update(['redact_all_env_values_in_logs' => false]); + } else { + $resource->update(['redact_all_env_values_in_logs' => false]); + } + linkDopplerSource($resource); + addRemoteSecretVariable($resource, 'API_TOKEN', '{{vault.API_TOKEN}}', locked: $locked); + + $secrets = EnvironmentVariable::logRedactionSecretsFor($resource->fresh()); + + expect($secrets['locked'])->toContain('resolved-remote-api-token') + ->and(sanitizeLogsForExport('token resolved-remote-api-token', $secrets))->toBe('token '.REDACTED); +})->with(['application', 'service'])->with(['locked' => true, 'unlocked' => false]); + +it('hides resolved remote secrets of application preview variables', function () { + Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['PREVIEW_TOKEN' => 'resolved-preview-token'])]); + $application = makeRemoteSecretResource('application'); + linkDopplerSource($application); + addRemoteSecretVariable($application, 'PREVIEW_TOKEN', '{{vault.PREVIEW_TOKEN}}', preview: true); + + expect(EnvironmentVariable::logRedactionSecretsFor($application->fresh(), includePreview: true)['locked']) + ->toContain('resolved-preview-token'); +}); + +it('fetches the secret source once per call', function () { + Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['FIRST' => 'first-remote-value', 'SECOND' => 'second-remote-value'])]); + $application = makeRemoteSecretResource('application'); + linkDopplerSource($application); + addRemoteSecretVariable($application, 'FIRST', '{{vault.FIRST}}'); + addRemoteSecretVariable($application, 'SECOND', '{{vault.SECOND}}', locked: true); + + $secrets = EnvironmentVariable::logRedactionSecretsFor($application->fresh()); + + expect($secrets['locked'])->toContain('first-remote-value', 'second-remote-value'); + Http::assertSentCount(1); +}); + +it('does not contact a secret source for resources without remote references', function (string $type) { + $resource = makeRemoteSecretResource($type); + linkDopplerSource($resource); + addRemoteSecretVariable($resource, 'PLAIN', 'plain-value', locked: true); + + expect(EnvironmentVariable::logRedactionSecretsFor($resource->fresh())['locked'])->toContain('plain-value'); + Http::assertNothingSent(); +})->with(['application', 'service', 'database']); + +it('keeps exporting logs when remote secrets cannot be resolved', function (string $type, string $failure) { + Log::spy(); + $resource = makeRemoteSecretResource($type); + if ($failure === 'source fails') { + Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['message' => 'unavailable'], 500)]); + linkDopplerSource($resource); + } elseif ($failure === 'missing key') { + Http::fake([DOPPLER_DOWNLOAD_URL => Http::response(['PRESENT' => 'present-remote-value'])]); + linkDopplerSource($resource); + } + addRemoteSecretVariable($resource, 'LOCKED_PLAIN', 'locked-plain-value', locked: true); + addRemoteSecretVariable($resource, 'BROKEN', '{{vault.PRESENT}}-{{vault.MISSING}}'); + addRemoteSecretVariable($resource, 'ALSO_BROKEN', '{{vault.MISSING}}'); + + $secrets = EnvironmentVariable::logRedactionSecretsFor($resource->fresh()); + + expect($secrets['locked'])->toContain('locked-plain-value', '{{vault.PRESENT}}-{{vault.MISSING}}', '{{vault.MISSING}}'); + if ($failure === 'missing key') { + expect($secrets['locked'])->toContain('present-remote-value'); + } + if ($type === 'database') { + expect($secrets['locked'])->toContain('stored-db-password'); + } + Http::assertSentCount($failure === 'no source' ? 0 : 1); + Log::shouldHaveReceived('warning')->once()->withArgs(fn (string $message, array $context): bool => ! str_contains(json_encode($context), 'present-remote-value')); +})->with(['application', 'service', 'database'])->with(['source fails', 'missing key', 'no source']); + +it('skips the secret source when the caller already holds the remote values', function () { + $application = makeRemoteSecretResource('application'); + linkDopplerSource($application); + addRemoteSecretVariable($application, 'API_TOKEN', '{{vault.API_TOKEN}}', locked: true); + + expect(EnvironmentVariable::logRedactionSecretsFor($application->fresh(), resolveRemoteSecrets: false)['locked']) + ->toContain('{{vault.API_TOKEN}}'); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/LogRedactionSecretsQueryCountTest.php b/tests/Feature/LogRedactionSecretsQueryCountTest.php new file mode 100644 index 0000000000..88a126c9a2 --- /dev/null +++ b/tests/Feature/LogRedactionSecretsQueryCountTest.php @@ -0,0 +1,106 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->first() + ?? StandaloneDocker::factory()->create(['server_id' => $server->id]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + for ($i = 0; $i < $plainVariables; $i++) { + EnvironmentVariable::query()->create([ + 'key' => "PLAIN_{$i}", + 'value' => "plain-value-number-{$i}", + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + for ($i = 0; $i < $sharedVariables; $i++) { + SharedEnvironmentVariable::query()->create([ + 'key' => "SHARED_{$i}", + 'value' => "shared-value-number-{$i}", + 'type' => 'team', + 'team_id' => $team->id, + 'is_shown_once' => $i % 2 === 0, + ]); + EnvironmentVariable::query()->create([ + 'key' => "REF_{$i}", + 'value' => "{{team.SHARED_{$i}}}", + 'resourceable_type' => $application->getMorphClass(), + 'resourceable_id' => $application->id, + 'is_preview' => false, + ]); + } + + return Application::query()->findOrFail($application->id); +} + +function countLogRedactionQueries(Application $application): int +{ + DB::flushQueryLog(); + DB::enableQueryLog(); + EnvironmentVariable::logRedactionSecretsFor($application, includePreview: true); + $count = count(DB::getQueryLog()); + DB::disableQueryLog(); + + return $count; +} + +it('collects log redaction secrets with a query count that does not grow with plain variables', function () { + $small = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 2, sharedVariables: 0)); + $large = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 40, sharedVariables: 0)); + + expect($large)->toBe($small); +}); + +it('collects log redaction secrets with a query count that does not grow with shared references', function () { + $small = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 0, sharedVariables: 2)); + $large = countLogRedactionQueries(makeLogRedactionQueryFixture(plainVariables: 0, sharedVariables: 20)); + + expect($large)->toBe($small); +}); + +it('still classifies plain, shared, and locked shared values correctly', function () { + $application = makeLogRedactionQueryFixture(plainVariables: 1, sharedVariables: 2); + + $secrets = EnvironmentVariable::logRedactionSecretsFor($application); + + expect($secrets['known'])->toContain('plain-value-number-0', 'shared-value-number-1') + ->and($secrets['locked'])->toContain('shared-value-number-0') + ->and($secrets['locked'])->not->toContain('shared-value-number-1'); +}); + +it('does not create a circular relation on the resource', function () { + $application = makeLogRedactionQueryFixture(plainVariables: 2, sharedVariables: 1); + + EnvironmentVariable::logRedactionSecretsFor($application); + + expect($application->environment_variables->every(fn (EnvironmentVariable $variable): bool => ! $variable->relationLoaded('resourceable')))->toBeTrue() + ->and(json_encode($application->toArray()))->toBeString(); +}); + +it('indexes application settings by application', function () { + expect(Schema::hasIndex('application_settings', ['application_id']))->toBeTrue(); +}); diff --git a/tests/Feature/LogRedactionSettingApiTest.php b/tests/Feature/LogRedactionSettingApiTest.php new file mode 100644 index 0000000000..f0575f5ef2 --- /dev/null +++ b/tests/Feature/LogRedactionSettingApiTest.php @@ -0,0 +1,125 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $this->service = Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $this->server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $this->database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-database', + 'postgres_password' => 'database-password', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +}); + +/** + * @param list $abilities + */ +function logRedactionApiToken(Team $team, string $role, array $abilities = ['write']): string +{ + $user = User::factory()->create(); + $team->members()->attach($user->id, ['role' => $role]); + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + +function logRedactionSettingOf(string $resource): bool +{ + return match ($resource) { + 'application' => test()->application->settings->fresh()->redact_all_env_values_in_logs, + 'service' => test()->service->fresh()->redact_all_env_values_in_logs, + 'database' => test()->database->fresh()->redact_all_env_values_in_logs, + }; +} + +function logRedactionPath(string $resource): string +{ + return match ($resource) { + 'application' => '/api/v1/applications/'.test()->application->uuid, + 'service' => '/api/v1/services/'.test()->service->uuid, + 'database' => '/api/v1/databases/'.test()->database->uuid, + }; +} + +it('lets a team admin limit log redaction to locked values', function (string $resource) { + $this->withToken(logRedactionApiToken($this->team, 'admin')) + ->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false]) + ->assertOk(); + + expect(logRedactionSettingOf($resource))->toBeFalse(); +})->with(['application', 'service', 'database']); + +it('rejects a non-boolean log redaction value', function (string $resource) { + $this->withToken(logRedactionApiToken($this->team, 'admin')) + ->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => 'nope']) + ->assertUnprocessable(); + + expect(logRedactionSettingOf($resource))->toBeTrue(); +})->with(['application', 'service', 'database']); + +it('forbids a team member from changing log redaction', function (string $resource) { + $this->withToken(logRedactionApiToken($this->team, 'member')) + ->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false]) + ->assertForbidden(); + + expect(logRedactionSettingOf($resource))->toBeTrue(); +})->with(['application', 'service', 'database']); + +it('forbids a read-only token from changing log redaction', function (string $resource) { + $this->withToken(logRedactionApiToken($this->team, 'owner', ['read'])) + ->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false]) + ->assertForbidden(); + + expect(logRedactionSettingOf($resource))->toBeTrue(); +})->with(['application', 'service', 'database']); + +it('does not let an admin of another team change log redaction', function (string $resource) { + $this->withToken(logRedactionApiToken(Team::factory()->create(), 'owner')) + ->patchJson(logRedactionPath($resource), ['redact_all_env_values_in_logs' => false]) + ->assertNotFound(); + + expect(logRedactionSettingOf($resource))->toBeTrue(); +})->with(['application', 'service', 'database']); diff --git a/tests/Feature/LogRedactionSwitchTest.php b/tests/Feature/LogRedactionSwitchTest.php new file mode 100644 index 0000000000..5135446857 --- /dev/null +++ b/tests/Feature/LogRedactionSwitchTest.php @@ -0,0 +1,29 @@ + false, 'constants.coolify.self_hosted' => true]); + $handler = new TestHandler; + $logger = new Logger(new MonologLogger('test', [$handler])); + + (new RedactSensitiveData(new SensitiveDataRedactor))($logger); + $logger->error('password=coolify-canary-raw-secret'); + + expect($handler->getRecords()[0]->message)->toBe('password=coolify-canary-raw-secret'); +}); + +it('always redacts framework logs on cloud', function () { + config(['logging.redaction_enabled' => false, 'constants.coolify.self_hosted' => false]); + $handler = new TestHandler; + $logger = new Logger(new MonologLogger('test', [$handler])); + + (new RedactSensitiveData(new SensitiveDataRedactor))($logger); + $logger->error('password=coolify-canary-cloud-secret'); + + expect($handler->getRecords()[0]->message)->toBe('password='.REDACTED); +}); diff --git a/tests/Feature/LoginAndMcpAccessTest.php b/tests/Feature/LoginAndMcpAccessTest.php index 1c63176fcc..b99dbd7627 100644 --- a/tests/Feature/LoginAndMcpAccessTest.php +++ b/tests/Feature/LoginAndMcpAccessTest.php @@ -1,7 +1,6 @@ match(Request::create('/api/v1/mcp/disable', 'POST')); expect($mcp->gatherMiddleware())->not->toContain(ApiAllowed::class) - ->and($mcp->gatherMiddleware())->toContain(ApiIpAllowed::class) + ->and($mcp->gatherMiddleware())->toContain('api.ip.allowed') ->and($enable->gatherMiddleware())->not->toContain(ApiAllowed::class) ->and($disable->gatherMiddleware())->not->toContain(ApiAllowed::class) - ->and($enable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write') - ->and($disable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', ApiIpAllowed::class, 'api.ability:write'); + ->and($enable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', 'api.ip.allowed', 'api.ability:write') + ->and($disable->gatherMiddleware())->toContain('auth:sanctum', 'api.token.team', 'api.ip.allowed', 'api.ability:write'); }); diff --git a/tests/Feature/Mcp/CallsRestApiTest.php b/tests/Feature/Mcp/CallsRestApiTest.php new file mode 100644 index 0000000000..618e63db6d --- /dev/null +++ b/tests/Feature/Mcp/CallsRestApiTest.php @@ -0,0 +1,41 @@ +attributes->set('can_read_sensitive', true); + + return response()->json(['data' => [ + ['uuid' => 'database-uuid', $key => 'credential'], + ['uuid' => 'another-uuid', $key => ['credential']], + ]]); + }); + + $bridge = new class + { + use BuildsResponse; + use CallsRestApi { + callRestApi as public call; + } + }; + + expect($bridge->call('POST', 'mcp-credential-test'))->toBe([ + 'status' => 200, + 'body' => ['data' => [ + ['uuid' => 'database-uuid'], + ['uuid' => 'another-uuid'], + ]], + ]); +})->with([ + 'internal_db_url', 'external_db_url', 'password', + 'postgres_password', 'dragonfly_password', 'keydb_password', + 'redis_password', 'mongo_initdb_root_password', + 'mariadb_password', 'mariadb_root_password', + 'mysql_password', 'mysql_root_password', 'clickhouse_admin_password', +]); diff --git a/tests/Feature/Mcp/McpApplicationToolsTest.php b/tests/Feature/Mcp/McpApplicationToolsTest.php new file mode 100644 index 0000000000..ffec7af91e --- /dev/null +++ b/tests/Feature/Mcp/McpApplicationToolsTest.php @@ -0,0 +1,530 @@ +set('app.maintenance.store', 'array'); + Queue::fake(); + Bus::fake(); + Process::fake(); + Server::flushIdentityMap(); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id]); + $this->application = mcpAppCreateApplication($this->environment, $this->destination, 'mine'); + + $this->otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id]); + $this->otherDestination = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail(); + $this->otherProject = Project::factory()->create(['team_id' => $this->otherTeam->id]); + $this->otherEnvironment = $this->otherProject->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->otherProject->id]); + $this->otherApplication = mcpAppCreateApplication($this->otherEnvironment, $this->otherDestination, 'theirs'); +}); + +function mcpAppCreateApplication(Environment $environment, StandaloneDocker $destination, string $name): Application +{ + $application = Application::factory()->create([ + 'name' => $name, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $application->settings->fill(['is_container_label_readonly_enabled' => false])->save(); + + return $application; +} + +function mcpAppToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-app', $abilities)->plainTextToken; +} + +function mcpAppCall(string $token, string $tool, array $arguments = []) +{ + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpAppText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpAppIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +/** + * @return array + */ +function mcpAppPublicData(array $overrides = []): array +{ + return array_merge([ + 'project_uuid' => test()->project->uuid, + 'environment_uuid' => test()->environment->uuid, + 'server_uuid' => test()->server->uuid, + 'git_repository' => 'https://gitlab.com/coolify/test-static-app', + 'git_branch' => 'main', + 'build_pack' => 'static', + 'ports_exposes' => '80', + 'autogenerate_domain' => false, + 'name' => 'mcp-created', + ], $overrides); +} + +function mcpAppCreatedCount(): int +{ + return Application::where('name', 'mcp-created')->count(); +} + +// create_application + +test('create_application creates a public app in the token team, not the session team', function () { + $token = mcpAppToken($this->user); + $sessionTeam = Team::factory()->create(); + $sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $sessionProject = Project::factory()->create(['team_id' => $sessionTeam->id]); + session(['currentTeam' => $sessionTeam]); + + $response = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData()]); + + expect(mcpAppIsError($response))->toBeFalse(mcpAppText($response)); + $application = Application::where('name', 'mcp-created')->sole(); + expect($application->environment->project->team_id)->toBe($this->team->id) + ->and($application->destination_id)->toBe($this->destination->id); + + $intoSessionTeam = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'project_uuid' => $sessionProject->uuid, + 'environment_name' => 'production', + 'name' => 'session-team-app', + ])]); + expect(mcpAppIsError($intoSessionTeam))->toBeTrue() + ->and(Application::where('name', 'session-team-app')->exists())->toBeFalse(); +}); + +test('create_application creates a docker image app', function () { + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'docker_image', 'data' => [ + 'project_uuid' => $this->project->uuid, + 'environment_uuid' => $this->environment->uuid, + 'server_uuid' => $this->server->uuid, + 'docker_registry_image_name' => 'nginx', + 'docker_registry_image_tag' => 'latest', + 'ports_exposes' => '80', + 'autogenerate_domain' => false, + 'name' => 'mcp-created', + ]]); + + expect(mcpAppIsError($response))->toBeFalse(mcpAppText($response)) + ->and(Application::where('name', 'mcp-created')->sole()->build_pack)->toBe('dockerimage'); +}); + +test('create_application rejects a project or server of another team', function (string $field) { + $data = mcpAppPublicData([$field => $field === 'project_uuid' ? $this->otherProject->uuid : $this->otherServer->uuid]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => $data]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppCreatedCount())->toBe(0); +})->with(['project_uuid', 'server_uuid']); + +test('create_application rejects a destination of another team', function () { + $extra = StandaloneDocker::forceCreate([ + 'name' => 'second', + 'network' => 'second-network', + 'server_id' => $this->server->id, + ]); + expect($this->server->destinations()->count())->toBeGreaterThan(1); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'destination_uuid' => $this->otherDestination->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('destination_uuid') + ->and(mcpAppCreatedCount())->toBe(0); + + $ok = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData([ + 'destination_uuid' => $extra->uuid, + ])]); + expect(mcpAppIsError($ok))->toBeFalse(mcpAppText($ok)) + ->and(Application::where('name', 'mcp-created')->sole()->destination_id)->toBe($extra->id); +}); + +test('create_application rejects a private key of another team', function () { + $foreignKey = PrivateKey::factory()->create(['team_id' => $this->otherTeam->id]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'deploy_key', 'data' => mcpAppPublicData([ + 'git_repository' => 'git@gitlab.com:coolify/private-app.git', + 'private_key_uuid' => $foreignKey->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('Private Key not found') + ->and(mcpAppCreatedCount())->toBe(0) + ->and(mcpAppText($response))->not->toContain('PRIVATE KEY'); +}); + +test('create_application rejects a GitHub App of another team', function () { + $foreignApp = GithubApp::forceCreate([ + 'name' => 'Their GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'team_id' => $this->otherTeam->id, + 'is_system_wide' => false, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->otherTeam->id])->id, + ]); + + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'github_app', 'data' => mcpAppPublicData([ + 'git_repository' => 'their-org/their-repo', + 'github_app_uuid' => $foreignApp->uuid, + ])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('Github App not found') + ->and(mcpAppCreatedCount())->toBe(0); +}); + +test('create_application rejects read-only and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + foreach ([mcpAppToken($this->user, ['read']), mcpAppToken($member, ['read']), mcpAppToken($member, ['write'])] as $token) { + $response = mcpAppCall($token, 'create_application', ['action' => 'public', 'data' => mcpAppPublicData()]); + expect(mcpAppIsError($response))->toBeTrue(); + } + + expect(mcpAppCreatedCount())->toBe(0); +}); + +test('create_application instant_deploy needs the deploy ability', function () { + $response = mcpAppCall(mcpAppToken($this->user), 'create_application', ['action' => 'public', 'data' => mcpAppPublicData(['instant_deploy' => true])]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('deploy') + ->and(mcpAppCreatedCount())->toBe(0) + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +// manage_application + +test('manage_application accepts scalar and null unused path parameters', function (mixed $destinationUuid) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => 'update', + 'uuid' => $this->application->uuid, + 'destination_uuid' => $destinationUuid, + 'data' => ['name' => 'changed'], + ]); + + $response->assertOk()->assertJsonMissingPath('error'); + expect(mcpAppIsError($response))->toBeFalse() + ->and($this->application->fresh()->name)->toBe('changed'); +})->with([ + 'string' => ['unused'], + 'integer' => [123], + 'float' => [1.5], + 'boolean' => [false], + 'null' => [null], +]); + +test('manage_application rejects non-scalar unused path parameters before updating', function (mixed $destinationUuid) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => 'update', + 'uuid' => $this->application->uuid, + 'destination_uuid' => $destinationUuid, + 'data' => ['name' => 'changed'], + ]); + + $response->assertOk()->assertJsonPath('result.isError', true)->assertJsonMissingPath('error'); + expect(mcpAppText($response))->toContain('destination_uuid must be a string.') + ->and($this->application->fresh()->name)->toBe('mine'); +})->with([ + 'array' => [['invalid']], + 'object' => [(object) ['uuid' => 'invalid']], +]); + +test('manage_application updates, clones and moves an application of the token team', function () { + $token = mcpAppToken($this->user); + + $updated = mcpAppCall($token, 'manage_application', ['action' => 'update', 'uuid' => $this->application->uuid, 'data' => ['name' => 'renamed']]); + expect(mcpAppIsError($updated))->toBeFalse(mcpAppText($updated)) + ->and($this->application->fresh()->name)->toBe('renamed'); + + $cloned = mcpAppCall($token, 'manage_application', ['action' => 'clone', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->destination->uuid, 'name' => 'cloned']]); + expect(mcpAppIsError($cloned))->toBeFalse(mcpAppText($cloned)) + ->and(Application::where('name', 'cloned')->sole()->environment_id)->toBe($this->environment->id); + + $target = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'staging']); + $moved = mcpAppCall($token, 'manage_application', ['action' => 'move', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $target->uuid]]); + expect(mcpAppIsError($moved))->toBeFalse(mcpAppText($moved)) + ->and($this->application->fresh()->environment_id)->toBe($target->id); +}); + +test('manage_application deletes an application only with confirm=true and a deploy token', function () { + $writeOnly = mcpAppCall(mcpAppToken($this->user), 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); + expect(mcpAppIsError($writeOnly))->toBeTrue() + ->and(mcpAppText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(Application::find($this->application->id))->not->toBeNull(); + + $token = mcpAppToken($this->user, ['write', 'deploy']); + + $denied = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid]); + expect(mcpAppIsError($denied))->toBeTrue() + ->and(mcpAppText($denied))->toContain('confirm=true') + ->and(Application::find($this->application->id))->not->toBeNull(); + + $deleted = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); + expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) + ->and(Application::find($this->application->id))->toBeNull(); + Bus::assertDispatched(DeleteResourceJob::class); +}); + +test('manage_application needs confirm for move and remove_server', function (string $action) { + $response = mcpAppCall(mcpAppToken($this->user), 'manage_application', [ + 'action' => $action, + 'uuid' => $this->application->uuid, + 'destination_uuid' => $this->destination->uuid, + 'data' => [], + ]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(mcpAppText($response))->toContain('confirm=true') + ->and($this->application->fresh()->environment_id)->toBe($this->environment->id); +})->with(['move', 'remove_server']); + +test('manage_application cannot touch an application of another team', function (string $action, array $data) { + $before = $this->otherApplication->fresh()->toArray(); + + $response = mcpAppCall(mcpAppToken($this->user, ['write', 'deploy']), 'manage_application', [ + 'action' => $action, + 'uuid' => $this->otherApplication->uuid, + 'destination_uuid' => $this->otherDestination->uuid, + 'confirm' => true, + 'data' => $data, + ]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(Application::find($this->otherApplication->id))->not->toBeNull() + ->and($this->otherApplication->fresh()->name)->toBe($before['name']) + ->and($this->otherApplication->fresh()->environment_id)->toBe($before['environment_id']) + ->and(Application::where('name', 'hijacked')->exists())->toBeFalse(); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder', 'name' => 'hijacked']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'add_server' => ['add_server', ['destination_uuid' => 'placeholder']], + 'remove_server' => ['remove_server', []], +]); + +test('manage_application rejects a destination or environment of another team', function () { + $token = mcpAppToken($this->user); + + $clone = mcpAppCall($token, 'manage_application', ['action' => 'clone', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->otherDestination->uuid, 'name' => 'hijacked']]); + expect(mcpAppIsError($clone))->toBeTrue() + ->and(Application::where('name', 'hijacked')->exists())->toBeFalse(); + + $move = mcpAppCall($token, 'manage_application', ['action' => 'move', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->otherEnvironment->uuid]]); + expect(mcpAppIsError($move))->toBeTrue() + ->and($this->application->fresh()->environment_id)->toBe($this->environment->id); + + $addServer = mcpAppCall($token, 'manage_application', ['action' => 'add_server', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $this->otherDestination->uuid]]); + expect(mcpAppIsError($addServer))->toBeTrue() + ->and($this->application->fresh()->additional_networks)->toHaveCount(0); +}); + +test('manage_application adds and removes an additional server of the token team', function () { + $token = mcpAppToken($this->user); + $secondServer = Server::factory()->create(['team_id' => $this->team->id]); + $secondDestination = StandaloneDocker::where('server_id', $secondServer->id)->firstOrFail(); + + $added = mcpAppCall($token, 'manage_application', ['action' => 'add_server', 'uuid' => $this->application->uuid, 'data' => ['destination_uuid' => $secondDestination->uuid]]); + expect(mcpAppIsError($added))->toBeFalse(mcpAppText($added)) + ->and($this->application->fresh()->additional_networks)->toHaveCount(1); + + $removed = mcpAppCall($token, 'manage_application', ['action' => 'remove_server', 'uuid' => $this->application->uuid, 'destination_uuid' => $secondDestination->uuid, 'confirm' => true]); + expect(mcpAppIsError($removed))->toBeFalse(mcpAppText($removed)) + ->and($this->application->fresh()->additional_networks)->toHaveCount(0); +}); + +test('manage_application rejects read-only and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + foreach ([mcpAppToken($this->user, ['read']), mcpAppToken($member, ['read']), mcpAppToken($member, ['write'])] as $token) { + $update = mcpAppCall($token, 'manage_application', ['action' => 'update', 'uuid' => $this->application->uuid, 'data' => ['name' => 'nope']]); + $delete = mcpAppCall($token, 'manage_application', ['action' => 'delete', 'uuid' => $this->application->uuid, 'confirm' => true]); + expect(mcpAppIsError($update))->toBeTrue() + ->and(mcpAppIsError($delete))->toBeTrue(); + } + + expect($this->application->fresh()->name)->toBe('mine'); +}); + +// manage_preview and get_application_preview + +test('manage_preview creates, updates and deletes a preview of the token team', function () { + $writeToken = mcpAppToken($this->user, ['write']); + $deployToken = mcpAppToken($this->user, ['deploy']); + + $writeOnly = mcpAppCall($writeToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + expect(mcpAppIsError($writeOnly))->toBeTrue() + ->and(mcpAppText($writeOnly))->toContain('deploy'); + + $created = mcpAppCall($deployToken, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 7, 'git_type' => 'github', 'instant_deploy' => false]]); + expect(mcpAppIsError($created))->toBeFalse(mcpAppText($created)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); + + $updated = mcpAppCall($writeToken, 'manage_preview', ['action' => 'update', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'data' => ['domains' => 'https://pr7.example.com']]); + expect(mcpAppIsError($updated))->toBeFalse(mcpAppText($updated)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->sole()->fqdn)->toBe('https://pr7.example.com'); + + $writeOnlyDelete = mcpAppCall($writeToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); + expect(mcpAppIsError($writeOnlyDelete))->toBeTrue() + ->and(mcpAppText($writeOnlyDelete))->toContain('Missing required permissions: deploy'); + + $writeDeployToken = mcpAppToken($this->user, ['write', 'deploy']); + $denied = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7']); + expect(mcpAppIsError($denied))->toBeTrue() + ->and(mcpAppText($denied))->toContain('confirm=true'); + + $deleted = mcpAppCall($writeDeployToken, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '7', 'confirm' => true]); + expect(mcpAppIsError($deleted))->toBeFalse(mcpAppText($deleted)) + ->and(ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', 7)->exists())->toBeFalse(); +}); + +test('manage_preview cannot touch previews of another team', function () { + $foreignPreview = ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 3, 'pull_request_html_url' => '', 'fqdn' => 'https://theirs.example.com']); + $token = mcpAppToken($this->user, ['write', 'deploy']); + + $create = mcpAppCall($token, 'manage_preview', ['action' => 'create', 'uuid' => $this->otherApplication->uuid, 'data' => ['pull_request_id' => 9, 'git_type' => 'github', 'instant_deploy' => false]]); + $update = mcpAppCall($token, 'manage_preview', ['action' => 'update', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '3', 'data' => ['domains' => 'https://hijacked.example.com']]); + $delete = mcpAppCall($token, 'manage_preview', ['action' => 'delete', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '3', 'confirm' => true]); + + expect(mcpAppIsError($create))->toBeTrue() + ->and(mcpAppIsError($update))->toBeTrue() + ->and(mcpAppIsError($delete))->toBeTrue() + ->and(ApplicationPreview::where('application_id', $this->otherApplication->id)->where('pull_request_id', 9)->exists())->toBeFalse() + ->and($foreignPreview->fresh())->not->toBeNull() + ->and($foreignPreview->fresh()->fqdn)->toBe('https://theirs.example.com'); +}); + +test('manage_preview rejects read-only tokens', function () { + ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 4, 'pull_request_html_url' => '']); + $token = mcpAppToken($this->user, ['read']); + + expect(mcpAppIsError(mcpAppCall($token, 'manage_preview', ['action' => 'create', 'uuid' => $this->application->uuid, 'data' => ['pull_request_id' => 5, 'git_type' => 'github']])))->toBeTrue() + ->and(mcpAppIsError(mcpAppCall($token, 'manage_preview', ['action' => 'delete', 'uuid' => $this->application->uuid, 'pull_request_id' => '4', 'confirm' => true])))->toBeTrue() + ->and(ApplicationPreview::where('application_id', $this->application->id)->count())->toBe(1); +}); + +test('get_application_preview returns a preview of the token team only', function () { + ApplicationPreview::create(['application_id' => $this->application->id, 'pull_request_id' => 12, 'pull_request_html_url' => '', 'fqdn' => 'https://pr12.example.com']); + ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 12, 'pull_request_html_url' => '', 'fqdn' => 'https://theirs.example.com']); + $token = mcpAppToken($this->user, ['read']); + + $mine = mcpAppCall($token, 'get_application_preview', ['action' => 'get', 'uuid' => $this->application->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($mine))->toBeFalse(mcpAppText($mine)) + ->and(mcpAppText($mine))->toContain('pr12.example.com'); + + $theirs = mcpAppCall($token, 'get_application_preview', ['action' => 'get', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($theirs))->toBeTrue() + ->and(mcpAppText($theirs))->not->toContain('theirs.example.com'); +}); + +test('get_application_preview logs need read:sensitive and stay inside the token team', function () { + ApplicationPreview::create(['application_id' => $this->otherApplication->id, 'pull_request_id' => 12, 'pull_request_html_url' => '']); + + $readOnly = mcpAppCall(mcpAppToken($this->user, ['read']), 'get_application_preview', ['action' => 'logs', 'uuid' => $this->application->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($readOnly))->toBeTrue() + ->and(mcpAppText($readOnly))->toContain('read:sensitive'); + + $foreign = mcpAppCall(mcpAppToken($this->user, ['read', 'read:sensitive']), 'get_application_preview', ['action' => 'logs', 'uuid' => $this->otherApplication->uuid, 'pull_request_id' => '12']); + expect(mcpAppIsError($foreign))->toBeTrue() + ->and(mcpAppText($foreign))->toContain('not found'); + Process::assertNothingRan(); +}); + +// rollback_application and list_rollback_images + +test('rollback_application needs confirm and the deploy ability, then queues a rollback', function () { + $writeOnly = mcpAppCall(mcpAppToken($this->user, ['write']), 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($writeOnly))->toBeTrue() + ->and(mcpAppText($writeOnly))->toContain('Tool [rollback_application] not found'); + + $deployToken = mcpAppToken($this->user, ['deploy']); + $unconfirmed = mcpAppCall($deployToken, 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($unconfirmed))->toBeTrue() + ->and(mcpAppText($unconfirmed))->toContain('confirm=true') + ->and(ApplicationDeploymentQueue::count())->toBe(0); + + $queued = mcpAppCall($deployToken, 'rollback_application', ['action' => 'rollback', 'uuid' => $this->application->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + expect(mcpAppIsError($queued))->toBeFalse(mcpAppText($queued)) + ->and(ApplicationDeploymentQueue::where('application_id', $this->application->id)->where('commit', 'abc1234')->where('rollback', true)->exists())->toBeTrue(); +}); + +test('rollback_application cannot roll back an application of another team', function () { + $response = mcpAppCall(mcpAppToken($this->user, ['deploy']), 'rollback_application', ['action' => 'rollback', 'uuid' => $this->otherApplication->uuid, 'confirm' => true, 'data' => ['commit' => 'abc1234']]); + + expect(mcpAppIsError($response))->toBeTrue() + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('list_rollback_images lists images for the token team only', function () { + $token = mcpAppToken($this->user, ['read']); + + $mine = mcpAppCall($token, 'list_rollback_images', ['action' => 'list', 'uuid' => $this->application->uuid]); + expect(mcpAppIsError($mine))->toBeFalse(mcpAppText($mine)) + ->and(mcpAppText($mine))->toContain('images'); + + $theirs = mcpAppCall($token, 'list_rollback_images', ['action' => 'list', 'uuid' => $this->otherApplication->uuid]); + expect(mcpAppIsError($theirs))->toBeTrue() + ->and(mcpAppText($theirs))->toContain('not found'); +}); diff --git a/tests/Feature/Mcp/McpDataToolsTest.php b/tests/Feature/Mcp/McpDataToolsTest.php new file mode 100644 index 0000000000..dd96ca9cd9 --- /dev/null +++ b/tests/Feature/Mcp/McpDataToolsTest.php @@ -0,0 +1,799 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + Server::flushIdentityMap(); + Queue::fake(); + Process::fake(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + $this->mine = mcpDataSeedTeam($this->team); + $this->theirs = mcpDataSeedTeam($this->otherTeam); + + session(['currentTeam' => $this->team]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +/** + * @return array{server: Server, destination: StandaloneDocker, project: Project, environment: Environment, database: StandalonePostgresql, service: Service, service_app: ServiceApplication, service_db: ServiceDatabase, application: Application} + */ +function mcpDataSeedTeam(Team $team): array +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $database = StandalonePostgresql::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'db-'.$team->id, + 'postgres_user' => 'postgres', + 'postgres_password' => 'pg-secret-'.$team->id, + 'postgres_db' => 'app', + 'image' => 'postgres:17', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + $service = Service::factory()->create([ + 'name' => 'svc-'.$team->id, + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + $serviceApp = ServiceApplication::create(['uuid' => (string) Str::uuid(), 'name' => 'web', 'service_id' => $service->id, 'image' => 'nginx:alpine']); + $serviceDb = ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'name' => 'postgres', 'service_id' => $service->id, 'image' => 'postgres:17']); + $application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); + + return [ + 'server' => $server, + 'destination' => $destination, + 'project' => $project, + 'environment' => $environment, + 'database' => $database, + 'service' => $service, + 'service_app' => $serviceApp, + 'service_db' => $serviceDb, + 'application' => $application, + ]; +} + +function mcpDataToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-data', $abilities)->plainTextToken; +} + +function mcpDataCall(string $token, string $tool, array $arguments = []) +{ + // Each call authenticates its own token; the guard would otherwise keep the user of the previous call. + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpDataText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +/** + * A tool error (result.isError) or a JSON-RPC error, for example the MCP endpoint rejecting a member token with write abilities. + */ +function mcpDataIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpDataJson($response): array +{ + return json_decode(mcpDataText($response), true) ?? []; +} + +function mcpDataMember(Team $team): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + + return $member; +} + +function mcpDataDbEnv(StandalonePostgresql $database, string $key, string $value, bool $shownOnce = false): EnvironmentVariable +{ + return EnvironmentVariable::create([ + 'key' => $key, + 'value' => $value, + 'is_shown_once' => $shownOnce, + 'resourceable_type' => $database->getMorphClass(), + 'resourceable_id' => $database->id, + ]); +} + +function mcpDataS3(Team $team): S3Storage +{ + return S3Storage::create([ + 'name' => 'backups-'.$team->id, + 'region' => 'us-east-1', + 'key' => 'AKIADATATEST', + 'secret' => 's3-secret-'.$team->id, + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); +} + +// create_database + +test('create_database creates a database in the token team and never returns connection URLs', function (array $abilities) { + $response = mcpDataCall(mcpDataToken($this->user, $abilities), 'create_database', ['action' => 'postgresql', 'data' => [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'from-mcp', + 'postgres_password' => 'VeryS3cretPass', + 'is_public' => true, + 'public_port' => 15432, + ]]); + + expect(mcpDataIsError($response))->toBeFalse(); + $uuid = mcpDataJson($response)['data']['uuid']; + $database = StandalonePostgresql::whereUuid($uuid)->sole(); + expect($database->name)->toBe('from-mcp') + ->and($database->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataText($response))->not->toContain('VeryS3cretPass') + ->and(mcpDataText($response))->not->toContain('internal_db_url') + ->and(mcpDataText($response))->not->toContain('external_db_url') + ->and(mcpDataText($response))->not->toContain('postgres_password'); +})->with([ + 'write' => [['write']], + 'root' => [['root']], + 'write and read:sensitive' => [['write', 'read:sensitive']], +]); + +test('create_database rejects a project, server, or destination of another team', function (string $field) { + $own = $this->mine; + StandaloneDocker::create(['name' => 'second', 'network' => 'second-net', 'server_id' => $own['server']->id]); + $data = [ + 'project_uuid' => $own['project']->uuid, + 'environment_uuid' => $own['environment']->uuid, + 'server_uuid' => $own['server']->uuid, + 'destination_uuid' => $own['destination']->uuid, + 'name' => 'cross-team', + ]; + $data[$field] = match ($field) { + 'project_uuid' => $this->theirs['project']->uuid, + 'environment_uuid' => $this->theirs['environment']->uuid, + 'server_uuid' => $this->theirs['server']->uuid, + 'destination_uuid' => $this->theirs['destination']->uuid, + }; + + $response = mcpDataCall(mcpDataToken($this->user), 'create_database', ['action' => 'postgresql', 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(StandalonePostgresql::where('name', 'cross-team')->exists())->toBeFalse(); +})->with(['project_uuid', 'environment_uuid', 'server_uuid', 'destination_uuid']); + +test('create_database rejects read-only and member tokens, and instant_deploy without deploy', function () { + $data = [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'denied', + ]; + $member = mcpDataMember($this->team); + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'create_database', ['action' => 'redis', 'data' => $data]); + $memberResponse = mcpDataCall(mcpDataToken($member, ['write']), 'create_database', ['action' => 'redis', 'data' => $data]); + $instant = mcpDataCall(mcpDataToken($this->user), 'create_database', ['action' => 'redis', 'data' => $data + ['instant_deploy' => true]]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataText($read))->toContain('Tool [create_database] not found') + ->and(mcpDataIsError($memberResponse))->toBeTrue() + ->and(mcpDataIsError($instant))->toBeTrue() + ->and(mcpDataText($instant))->toContain('deploy'); + expect(StandalonePostgresql::where('name', 'denied')->exists())->toBeFalse(); +}); + +// manage_database + +test('manage_database updates a database of the token team', function () { + $response = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'update', 'uuid' => $this->mine['database']->uuid, 'data' => ['name' => 'renamed']]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and($this->mine['database']->fresh()->name)->toBe('renamed'); +}); + +test('manage_database cannot touch a database of another team', function (string $action, array $data) { + $foreign = $this->theirs['database']; + + $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue(); + $fresh = StandalonePostgresql::withTrashed()->find($foreign->id); + expect($fresh->trashed())->toBeFalse() + ->and($fresh->name)->toBe($foreign->name) + ->and($fresh->environment_id)->toBe($foreign->environment_id) + ->and(StandalonePostgresql::count())->toBe(2); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'import' => ['import', ['source' => 's3', 's3_storage_uuid' => 'x', 'path' => '/a.sql']], +]); + +test('manage_database rejects a destination, environment, or S3 storage of another team', function () { + $own = $this->mine['database']; + $token = mcpDataToken($this->user, ['write', 'deploy']); + $foreignS3 = mcpDataS3($this->otherTeam); + + $clone = mcpDataCall($token, 'manage_database', ['action' => 'clone', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->theirs['destination']->uuid]]); + $move = mcpDataCall($token, 'manage_database', ['action' => 'move', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->theirs['environment']->uuid]]); + $import = mcpDataCall($token, 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['source' => 's3', 's3_storage_uuid' => $foreignS3->uuid, 'path' => '/backup.sql']]); + + expect(mcpDataIsError($clone))->toBeTrue() + ->and(mcpDataIsError($move))->toBeTrue() + ->and(mcpDataIsError($import))->toBeTrue() + ->and(StandalonePostgresql::count())->toBe(2) + ->and($own->fresh()->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataText($import))->not->toContain('s3-secret'); +}); + +test('manage_database clones to an own destination and moves to an own environment', function () { + $own = $this->mine['database']; + $token = mcpDataToken($this->user); + $target = Environment::factory()->create(['project_id' => $this->mine['project']->id]); + + $clone = mcpDataCall($token, 'manage_database', ['action' => 'clone', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->mine['destination']->uuid, 'name' => 'the-clone']]); + $move = mcpDataCall($token, 'manage_database', ['action' => 'move', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $target->uuid]]); + + expect(mcpDataIsError($clone))->toBeFalse() + ->and(StandalonePostgresql::where('name', 'the-clone')->sole()->environment_id)->toBe($this->mine['environment']->id) + ->and(mcpDataIsError($move))->toBeFalse() + ->and($own->fresh()->environment_id)->toBe($target->id); +}); + +test('manage_database destructive actions need confirm=true', function (string $action, array $data) { + $own = $this->mine['database']; + + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => $action, 'uuid' => $own->uuid, 'data' => $data]); + + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and(StandalonePostgresql::count())->toBe(2) + ->and($own->fresh())->not->toBeNull(); +})->with([ + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'x']], + 'move' => ['move', ['environment_uuid' => 'x']], + 'import' => ['import', ['source' => 'server', 'path' => '/tmp/a.sql']], +]); + +test('manage_database delete with confirm=true deletes an own database', function () { + $own = $this->mine['database']; + + $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + expect(mcpDataIsError($writeOnly))->toBeTrue() + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(StandalonePostgresql::find($own->id))->not->toBeNull(); + + $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => 'delete', 'uuid' => $own->uuid, 'confirm' => true]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(StandalonePostgresql::find($own->id))->toBeNull(); +}); + +test('manage_database import needs deploy and runs REST validation', function () { + $own = $this->mine['database']; + $writeOnly = mcpDataToken($this->user); + + foreach ([['source' => 's3', 's3_storage_uuid' => 'x', 'path' => '/a.sql'], ['source' => 'server', 'path' => '/etc/passwd']] as $data) { + $denied = mcpDataCall($writeOnly, 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => $data]); + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('Missing required permissions: deploy'); + } + + $invalid = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_database', ['action' => 'import', 'uuid' => $own->uuid, 'confirm' => true, 'data' => ['source' => 's3']]); + expect(mcpDataIsError($invalid))->toBeTrue() + ->and(mcpDataText($invalid))->toContain('s3_storage_uuid'); +}); + +test('manage_database rejects read-only and member tokens', function () { + $own = $this->mine['database']; + $member = mcpDataMember($this->team); + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_database', ['action' => 'update', 'uuid' => $own->uuid, 'data' => ['name' => 'nope']]); + $memberResponse = mcpDataCall(mcpDataToken($member, ['read']), 'manage_database', ['action' => 'update', 'uuid' => $own->uuid, 'data' => ['name' => 'nope']]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($memberResponse))->toBeTrue() + ->and($own->fresh()->name)->not->toBe('nope'); +}); + +// get_database_import + +test('get_database_import returns an own import and hides imports of other teams', function () { + $own = $this->mine['database']; + $foreign = $this->theirs['database']; + $makeActivity = fn (Team $team, StandalonePostgresql $database): Activity => Activity::create([ + 'log_name' => 'default', + 'description' => 'import', + 'properties' => ['team_id' => $team->id, 'type_uuid' => $database->uuid, 'operation' => 'database_import', 'status' => 'finished'], + ]); + $ownActivity = $makeActivity($this->team, $own); + $foreignActivity = $makeActivity($this->otherTeam, $foreign); + $token = mcpDataToken($this->user, ['read']); + + $ok = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $own->uuid, 'activity_id' => (string) $ownActivity->id]); + $foreignUuid = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $foreign->uuid, 'activity_id' => (string) $foreignActivity->id]); + $foreignId = mcpDataCall($token, 'get_database_import', ['action' => 'get', 'resource' => 'database', 'uuid' => $own->uuid, 'activity_id' => (string) $foreignActivity->id]); + + expect(mcpDataIsError($ok))->toBeFalse() + ->and(mcpDataJson($ok)['data']['status'])->toBe('finished') + ->and(mcpDataJson($ok)['data']['output'])->toBeNull() + ->and(mcpDataIsError($foreignUuid))->toBeTrue() + ->and(mcpDataIsError($foreignId))->toBeTrue(); +}); + +test('get_database_import rejects a service database of another team', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'get_database_import', [ + 'action' => 'get', + 'resource' => 'service_database', + 'uuid' => $this->mine['service']->uuid, + 'database_uuid' => $this->theirs['service_db']->uuid, + 'activity_id' => '1', + ]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->toContain('not found'); +}); + +// list_service_templates + +test('list_service_templates lists template metadata without Compose content', function () { + $templates = get_service_templates(); + $type = (string) $templates->keys()->first(); + + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'list_service_templates', ['search' => $type, 'per_page' => 100]); + + expect(mcpDataIsError($response))->toBeFalse(); + $items = collect(mcpDataJson($response)['data']); + expect($items->pluck('type'))->toContain($type) + ->and(array_keys($items->first()))->toEqualCanonicalizing(['type', 'slogan', 'category', 'tags', 'documentation', 'port']) + ->and(mcpDataText($response))->not->toContain((string) data_get($templates->get($type), 'compose')); +}); + +test('list_service_templates paginates and requires a valid token', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'list_service_templates', ['per_page' => 2]); + $payload = mcpDataJson($response); + + expect($payload['data'])->toHaveCount(2) + ->and($payload['_pagination']['total'])->toBe(get_service_templates()->count()) + ->and($payload['_pagination']['next']['args']['page'])->toBe(2); + + $none = mcpDataCall(mcpDataToken($this->user, []), 'list_service_templates'); + expect(mcpDataIsError($none))->toBeTrue(); +}); + +// create_service + +test('create_service creates a Compose service in the token team', function () { + $response = mcpDataCall(mcpDataToken($this->user), 'create_service', ['action' => 'compose', 'data' => [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'mcp-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]]); + + expect(mcpDataIsError($response))->toBeFalse(); + $service = Service::whereUuid(mcpDataJson($response)['data']['uuid'])->sole(); + expect($service->name)->toBe('mcp-service') + ->and($service->environment_id)->toBe($this->mine['environment']->id) + ->and($service->server_id)->toBe($this->mine['server']->id); +}); + +test('create_service rejects a project, server, or destination of another team', function (string $field) { + $own = $this->mine; + StandaloneDocker::create(['name' => 'second', 'network' => 'second-net', 'server_id' => $own['server']->id]); + $data = [ + 'project_uuid' => $own['project']->uuid, + 'environment_uuid' => $own['environment']->uuid, + 'server_uuid' => $own['server']->uuid, + 'destination_uuid' => $own['destination']->uuid, + 'name' => 'cross-team-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]; + $data[$field] = match ($field) { + 'project_uuid' => $this->theirs['project']->uuid, + 'environment_uuid' => $this->theirs['environment']->uuid, + 'server_uuid' => $this->theirs['server']->uuid, + 'destination_uuid' => $this->theirs['destination']->uuid, + }; + + $response = mcpDataCall(mcpDataToken($this->user), 'create_service', ['action' => 'compose', 'data' => $data]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(Service::where('name', 'cross-team-service')->exists())->toBeFalse(); +})->with(['project_uuid', 'environment_uuid', 'server_uuid', 'destination_uuid']); + +test('create_service rejects read-only and member tokens', function () { + $data = [ + 'project_uuid' => $this->mine['project']->uuid, + 'environment_uuid' => $this->mine['environment']->uuid, + 'server_uuid' => $this->mine['server']->uuid, + 'name' => 'denied-service', + 'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx:alpine\n"), + ]; + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'create_service', ['action' => 'compose', 'data' => $data]); + $member = mcpDataCall(mcpDataToken(mcpDataMember($this->team), ['write']), 'create_service', ['action' => 'compose', 'data' => $data]); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($member))->toBeTrue() + ->and(Service::where('name', 'denied-service')->exists())->toBeFalse(); +}); + +// manage_service + +test('manage_service updates a service, its application, and its database', function () { + $own = $this->mine; + $token = mcpDataToken($this->user); + + $service = mcpDataCall($token, 'manage_service', ['action' => 'update', 'uuid' => $own['service']->uuid, 'data' => ['name' => 'renamed-service']]); + $app = mcpDataCall($token, 'manage_service', ['action' => 'update_application', 'uuid' => $own['service']->uuid, 'app_uuid' => $own['service_app']->uuid, 'data' => ['human_name' => 'Web']]); + $db = mcpDataCall($token, 'manage_service', ['action' => 'update_database', 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['human_name' => 'DB']]); + + expect(mcpDataIsError($service))->toBeFalse() + ->and($own['service']->fresh()->name)->toBe('renamed-service') + ->and(mcpDataIsError($app))->toBeFalse() + ->and($own['service_app']->fresh()->human_name)->toBe('Web') + ->and(mcpDataIsError($db))->toBeFalse() + ->and($own['service_db']->fresh()->human_name)->toBe('DB'); +}); + +test('manage_service cannot touch a service of another team', function (string $action, array $data) { + $foreign = $this->theirs; + + $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', [ + 'action' => $action, + 'uuid' => $foreign['service']->uuid, + 'app_uuid' => $foreign['service_app']->uuid, + 'database_uuid' => $foreign['service_db']->uuid, + 'confirm' => true, + 'data' => $data, + ]); + + expect(mcpDataIsError($response))->toBeTrue(); + $fresh = Service::withTrashed()->find($foreign['service']->id); + expect($fresh->trashed())->toBeFalse() + ->and($fresh->name)->toBe($foreign['service']->name) + ->and($fresh->environment_id)->toBe($foreign['environment']->id) + ->and($foreign['service_app']->fresh()->human_name)->toBeNull() + ->and($foreign['service_db']->fresh()->human_name)->toBeNull() + ->and(Service::count())->toBe(2); +})->with([ + 'update' => ['update', ['name' => 'hijacked']], + 'delete' => ['delete', []], + 'clone' => ['clone', ['destination_uuid' => 'placeholder']], + 'move' => ['move', ['environment_uuid' => 'placeholder']], + 'update_application' => ['update_application', ['human_name' => 'hijacked']], + 'update_database' => ['update_database', ['human_name' => 'hijacked']], + 'import_database' => ['import_database', ['source' => 's3', 's3_storage_uuid' => 'x', 'path' => '/a.sql']], +]); + +test('manage_service cannot reach an application or database of another team through an own service', function () { + $token = mcpDataToken($this->user); + + $app = mcpDataCall($token, 'manage_service', ['action' => 'update_application', 'uuid' => $this->mine['service']->uuid, 'app_uuid' => $this->theirs['service_app']->uuid, 'data' => ['human_name' => 'hijacked']]); + $db = mcpDataCall($token, 'manage_service', ['action' => 'update_database', 'uuid' => $this->mine['service']->uuid, 'database_uuid' => $this->theirs['service_db']->uuid, 'data' => ['human_name' => 'hijacked']]); + + expect(mcpDataIsError($app))->toBeTrue() + ->and(mcpDataIsError($db))->toBeTrue() + ->and($this->theirs['service_app']->fresh()->human_name)->toBeNull() + ->and($this->theirs['service_db']->fresh()->human_name)->toBeNull(); +}); + +test('manage_service rejects a destination, environment, or S3 storage of another team', function () { + $own = $this->mine; + $token = mcpDataToken($this->user, ['write', 'deploy']); + $foreignS3 = mcpDataS3($this->otherTeam); + + $clone = mcpDataCall($token, 'manage_service', ['action' => 'clone', 'uuid' => $own['service']->uuid, 'confirm' => true, 'data' => ['destination_uuid' => $this->theirs['destination']->uuid]]); + $move = mcpDataCall($token, 'manage_service', ['action' => 'move', 'uuid' => $own['service']->uuid, 'confirm' => true, 'data' => ['environment_uuid' => $this->theirs['environment']->uuid]]); + $import = mcpDataCall($token, 'manage_service', ['action' => 'import_database', 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'confirm' => true, 'data' => ['source' => 's3', 's3_storage_uuid' => $foreignS3->uuid, 'path' => '/a.sql']]); + + expect(mcpDataIsError($clone))->toBeTrue() + ->and(mcpDataIsError($move))->toBeTrue() + ->and(mcpDataIsError($import))->toBeTrue() + ->and(Service::count())->toBe(2) + ->and($own['service']->fresh()->environment_id)->toBe($own['environment']->id); +}); + +test('manage_service destructive actions need confirm=true', function (string $action) { + $own = $this->mine; + + $denied = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => $action, 'uuid' => $own['service']->uuid, 'database_uuid' => $own['service_db']->uuid, 'data' => ['destination_uuid' => 'x']]); + + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($own['service']->fresh())->not->toBeNull(); +})->with(['delete', 'clone', 'move', 'import_database']); + +test('manage_service delete with confirm=true deletes an own service', function () { + $writeOnly = mcpDataCall(mcpDataToken($this->user), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + expect(mcpDataIsError($writeOnly))->toBeTrue() + ->and(mcpDataText($writeOnly))->toContain('Missing required permissions: deploy') + ->and(Service::find($this->mine['service']->id))->not->toBeNull(); + + $response = mcpDataCall(mcpDataToken($this->user, ['write', 'deploy']), 'manage_service', ['action' => 'delete', 'uuid' => $this->mine['service']->uuid, 'confirm' => true]); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(Service::find($this->mine['service']->id))->toBeNull(); +}); + +test('manage_service rejects read-only tokens', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_service', ['action' => 'update', 'uuid' => $this->mine['service']->uuid, 'data' => ['name' => 'nope']]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and($this->mine['service']->fresh()->name)->not->toBe('nope'); +}); + +// manage_env + +test('manage_env creates, updates, and deletes variables without returning values', function (string $resource) { + $target = match ($resource) { + 'application' => $this->mine['application'], + 'database' => $this->mine['database'], + 'service' => $this->mine['service'], + }; + $token = mcpDataToken($this->user); + $env = fn (string $key) => EnvironmentVariable::where('resourceable_type', $target->getMorphClass())->where('resourceable_id', $target->id)->where('key', $key)->where('is_preview', false)->first(); + + $create = mcpDataCall($token, 'manage_env', ['action' => 'create', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['key' => 'MCP_KEY', 'value' => 'first-secret-value']]); + $update = mcpDataCall($token, 'manage_env', ['action' => 'update', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['key' => 'MCP_KEY', 'value' => 'second-secret-value']]); + + expect(mcpDataIsError($create))->toBeFalse() + ->and(mcpDataIsError($update))->toBeFalse() + ->and($env('MCP_KEY')->value)->toBe('second-secret-value'); + foreach ([$create, $update] as $response) { + expect(mcpDataText($response))->not->toContain('secret-value'); + } + + $uuid = $env('MCP_KEY')->uuid; + $denied = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => $resource, 'uuid' => $target->uuid, 'env_uuid' => $uuid]); + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($env('MCP_KEY'))->not->toBeNull(); + + $deleted = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => $resource, 'uuid' => $target->uuid, 'env_uuid' => $uuid, 'confirm' => true]); + expect(mcpDataIsError($deleted))->toBeFalse() + ->and($env('MCP_KEY'))->toBeNull(); +})->with(['application', 'database', 'service']); + +test('manage_env bulk updates variables without returning values', function (string $resource) { + $target = match ($resource) { + 'application' => $this->mine['application'], + 'database' => $this->mine['database'], + 'service' => $this->mine['service'], + }; + + $bulk = mcpDataCall(mcpDataToken($this->user), 'manage_env', ['action' => 'bulk_update', 'resource' => $resource, 'uuid' => $target->uuid, 'data' => ['data' => [['key' => 'MCP_BULK', 'value' => 'bulk-secret-value']]]]); + + expect(mcpDataIsError($bulk))->toBeFalse() + ->and(mcpDataText($bulk))->not->toContain('bulk-secret-value') + ->and(EnvironmentVariable::where('resourceable_type', $target->getMorphClass())->where('resourceable_id', $target->id)->where('key', 'MCP_BULK')->where('is_preview', false)->sole()->value)->toBe('bulk-secret-value'); +})->with(['application', 'database', 'service']); + +test('manage_env cannot change variables of another team', function (string $action) { + $foreign = $this->theirs['database']; + $foreignEnv = mcpDataDbEnv($foreign, 'THEIR_KEY', 'their-value'); + $token = mcpDataToken($this->user); + + $viaForeignUuid = mcpDataCall($token, 'manage_env', ['action' => $action, 'resource' => 'database', 'uuid' => $foreign->uuid, 'env_uuid' => $foreignEnv->uuid, 'confirm' => true, 'data' => ['key' => 'THEIR_KEY', 'value' => 'hijacked', 'data' => [['key' => 'THEIR_KEY', 'value' => 'hijacked']]]]); + $viaOwnUuid = mcpDataCall($token, 'manage_env', ['action' => 'delete', 'resource' => 'database', 'uuid' => $this->mine['database']->uuid, 'env_uuid' => $foreignEnv->uuid, 'confirm' => true]); + + expect(mcpDataIsError($viaForeignUuid))->toBeTrue() + ->and(mcpDataIsError($viaOwnUuid))->toBeTrue() + ->and($foreignEnv->fresh()->value)->toBe('their-value') + ->and(EnvironmentVariable::where('resourceable_id', $foreign->id)->where('resourceable_type', $foreign->getMorphClass())->count())->toBe(1); +})->with(['create', 'update', 'bulk_update', 'delete']); + +test('manage_env rejects read-only and member tokens', function () { + $own = $this->mine['database']; + $data = ['action' => 'create', 'resource' => 'database', 'uuid' => $own->uuid, 'data' => ['key' => 'DENIED', 'value' => 'x']]; + + $read = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_env', $data); + $member = mcpDataCall(mcpDataToken(mcpDataMember($this->team), ['write']), 'manage_env', $data); + + expect(mcpDataIsError($read))->toBeTrue() + ->and(mcpDataIsError($member))->toBeTrue() + ->and(EnvironmentVariable::where('key', 'DENIED')->exists())->toBeFalse(); +}); + +// manage_shared_env + +test('manage_shared_env creates, updates, and deletes shared variables in the token team', function (string $resource) { + $args = match ($resource) { + 'team' => [], + 'project' => ['uuid' => $this->mine['project']->uuid], + 'environment' => ['uuid' => $this->mine['project']->uuid, 'environment_name_or_uuid' => $this->mine['environment']->uuid], + 'server' => ['uuid' => $this->mine['server']->uuid], + }; + $token = mcpDataToken($this->user); + + $create = mcpDataCall($token, 'manage_shared_env', ['action' => 'create', 'resource' => $resource, 'data' => ['key' => 'SHARED_MCP', 'value' => 'shared-value']] + $args); + expect(mcpDataIsError($create))->toBeFalse(); + $envId = mcpDataJson($create)['data']['id']; + expect($envId)->not->toBeNull(); + $env = SharedEnvironmentVariable::where('key', 'SHARED_MCP')->sole(); + expect($env->team_id)->toBe($this->team->id) + ->and($env->type)->toBe($resource); + + $update = mcpDataCall($token, 'manage_shared_env', ['action' => 'update', 'resource' => $resource, 'env_id' => (string) $envId, 'data' => ['value' => 'updated-value']] + $args); + expect(mcpDataIsError($update))->toBeFalse() + ->and($env->fresh()->value)->toBe('updated-value') + ->and(mcpDataText($update))->not->toContain('updated-value'); + + $denied = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $envId] + $args); + expect(mcpDataIsError($denied))->toBeTrue() + ->and(mcpDataText($denied))->toContain('confirm=true') + ->and($env->fresh())->not->toBeNull(); + + $deleted = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $envId, 'confirm' => true] + $args); + expect(mcpDataIsError($deleted))->toBeFalse() + ->and(SharedEnvironmentVariable::find($env->id))->toBeNull(); +})->with(['team', 'project', 'environment', 'server']); + +test('manage_shared_env cannot create in or change shared variables of another team', function (string $resource) { + $foreignArgs = match ($resource) { + 'team' => [], + 'project' => ['uuid' => $this->theirs['project']->uuid], + 'environment' => ['uuid' => $this->theirs['project']->uuid, 'environment_name_or_uuid' => $this->theirs['environment']->uuid], + 'server' => ['uuid' => $this->theirs['server']->uuid], + }; + $foreignEnv = SharedEnvironmentVariable::create([ + 'key' => 'THEIR_SHARED', + 'value' => 'their-shared-value', + 'type' => $resource, + 'team_id' => $this->otherTeam->id, + 'project_id' => in_array($resource, ['project', 'environment'], true) ? $this->theirs['project']->id : null, + 'environment_id' => $resource === 'environment' ? $this->theirs['environment']->id : null, + 'server_id' => $resource === 'server' ? $this->theirs['server']->id : null, + ]); + $token = mcpDataToken($this->user); + + $update = mcpDataCall($token, 'manage_shared_env', ['action' => 'update', 'resource' => $resource, 'env_id' => (string) $foreignEnv->id, 'data' => ['value' => 'hijacked']] + $foreignArgs); + $delete = mcpDataCall($token, 'manage_shared_env', ['action' => 'delete', 'resource' => $resource, 'env_id' => (string) $foreignEnv->id, 'confirm' => true] + $foreignArgs); + + expect(mcpDataIsError($update))->toBeTrue() + ->and(mcpDataIsError($delete))->toBeTrue() + ->and($foreignEnv->fresh()->value)->toBe('their-shared-value'); + + if ($resource !== 'team') { + $create = mcpDataCall($token, 'manage_shared_env', ['action' => 'create', 'resource' => $resource, 'data' => ['key' => 'INJECTED', 'value' => 'x']] + $foreignArgs); + expect(mcpDataIsError($create))->toBeTrue() + ->and(SharedEnvironmentVariable::where('key', 'INJECTED')->exists())->toBeFalse(); + } +})->with(['team', 'project', 'environment', 'server']); + +test('manage_shared_env rejects read-only tokens', function () { + $response = mcpDataCall(mcpDataToken($this->user, ['read']), 'manage_shared_env', ['action' => 'create', 'resource' => 'team', 'data' => ['key' => 'DENIED_SHARED', 'value' => 'x']]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(SharedEnvironmentVariable::where('key', 'DENIED_SHARED')->exists())->toBeFalse(); +}); + +// get_env_values + +test('get_env_values returns values for an owner token with read:sensitive', function () { + $own = $this->mine['database']; + mcpDataDbEnv($own, 'DB_VISIBLE', 'visible-value'); + mcpDataDbEnv($own, 'DB_ONCE', 'shown-once-value', shownOnce: true); + SharedEnvironmentVariable::create(['key' => 'TEAM_VISIBLE', 'value' => 'team-value', 'type' => 'team', 'team_id' => $this->team->id]); + $token = mcpDataToken($this->user, ['read', 'read:sensitive']); + + $database = mcpDataCall($token, 'get_env_values', ['action' => 'list', 'resource' => 'database', 'uuid' => $own->uuid]); + $team = mcpDataCall($token, 'get_env_values', ['action' => 'list', 'resource' => 'team']); + + expect(mcpDataIsError($database))->toBeFalse() + ->and(collect(mcpDataJson($database)['data'])->firstWhere('key', 'DB_VISIBLE')['value'])->toBe('visible-value') + ->and(mcpDataText($database))->not->toContain('shown-once-value') + ->and(mcpDataIsError($team))->toBeFalse() + ->and(collect(mcpDataJson($team)['data'])->firstWhere('key', 'TEAM_VISIBLE')['value'])->toBe('team-value'); +}); + +test('get_env_values never returns values without read:sensitive or to members', function (array $abilities, bool $asMember) { + $own = $this->mine['database']; + mcpDataDbEnv($own, 'DB_HIDDEN', 'hidden-value'); + $user = $asMember ? mcpDataMember($this->team) : $this->user; + + $response = mcpDataCall(mcpDataToken($user, $abilities), 'get_env_values', ['action' => 'list', 'resource' => 'database', 'uuid' => $own->uuid]); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->not->toContain('hidden-value'); +})->with([ + 'read token' => [['read'], false], + 'write token' => [['read', 'write'], false], + 'member with read:sensitive' => [['read', 'read:sensitive'], true], +]); + +test('get_env_values cannot read variables of another team', function (string $resource) { + $foreign = $this->theirs; + mcpDataDbEnv($foreign['database'], 'THEIR_DB', 'their-db-value'); + SharedEnvironmentVariable::create(['key' => 'THEIR_PROJECT', 'value' => 'their-project-value', 'type' => 'project', 'team_id' => $this->otherTeam->id, 'project_id' => $foreign['project']->id]); + $args = match ($resource) { + 'application' => ['uuid' => $foreign['application']->uuid], + 'database' => ['uuid' => $foreign['database']->uuid], + 'service' => ['uuid' => $foreign['service']->uuid], + 'project' => ['uuid' => $foreign['project']->uuid], + 'environment' => ['uuid' => $foreign['project']->uuid, 'environment_name_or_uuid' => $foreign['environment']->uuid], + 'server' => ['uuid' => $foreign['server']->uuid], + }; + + $response = mcpDataCall(mcpDataToken($this->user, ['read', 'read:sensitive']), 'get_env_values', ['action' => 'list', 'resource' => $resource] + $args); + + expect(mcpDataIsError($response))->toBeTrue() + ->and(mcpDataText($response))->not->toContain('their-'); +})->with(['application', 'database', 'service', 'project', 'environment', 'server']); + +test('get_env_values for the team returns only the token team variables', function () { + SharedEnvironmentVariable::create(['key' => 'THEIR_TEAM', 'value' => 'their-team-value', 'type' => 'team', 'team_id' => $this->otherTeam->id]); + + $response = mcpDataCall(mcpDataToken($this->user, ['read', 'read:sensitive']), 'get_env_values', ['action' => 'list', 'resource' => 'team']); + + expect(mcpDataIsError($response))->toBeFalse() + ->and(mcpDataText($response))->not->toContain('their-team-value'); +}); diff --git a/tests/Feature/Mcp/McpEndpointTest.php b/tests/Feature/Mcp/McpEndpointTest.php index f514caea93..b1376df7cf 100644 --- a/tests/Feature/Mcp/McpEndpointTest.php +++ b/tests/Feature/Mcp/McpEndpointTest.php @@ -164,7 +164,6 @@ test('MCP endpoint lists tools for an authenticated token', function () { 'get_service', 'get_project', 'list_deployments', - 'get_logs', 'list_env_keys', ); expect($toolNames)->not->toContain( @@ -172,7 +171,61 @@ test('MCP endpoint lists tools for an authenticated token', function () { 'create_storage', 'update_storage', ); - expect($toolNames)->toContain('coolify_help', 'control', 'deploy'); + expect($toolNames)->toContain('coolify_help') + ->and($toolNames)->not->toContain('control', 'deploy', 'get_logs', 'manage_project'); +}); + +test('MCP tools/list shows write tools only to tokens with the ability', function () { + $names = fn (array $abilities): array => collect(mcpListTools($this->user->createToken('t', $abilities)->plainTextToken)->assertOk()->json('result.tools'))->pluck('name')->all(); + + app('auth')->forgetGuards(); + $read = $names(['read']); + app('auth')->forgetGuards(); + $deploy = $names(['read', 'deploy']); + app('auth')->forgetGuards(); + $root = $names(['root']); + + expect($read)->not->toContain('control', 'manage_project', 'create_application', 'get_logs', 'get_env_values') + ->and($deploy)->toContain('control', 'deploy', 'rollback_application') + ->and($deploy)->not->toContain('manage_project', 'get_logs') + ->and($root)->toContain('control', 'manage_project', 'get_logs', 'get_env_values') + ->and(count($read))->toBeLessThan(count($root)); +}); + +test('MCP grouped tools only list the actions the token can use', function () { + $token = $this->user->createToken('read', ['read'])->plainTextToken; + + $proxy = collect(mcpListTools($token)->json('result.tools'))->firstWhere('name', 'manage_proxy'); + + expect($proxy['inputSchema']['properties']['action']['enum'])->toBe(['get']); +}); + +test('MCP hidden tools cannot be called', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + $token = $this->user->createToken('read', ['read'])->plainTextToken; + + $response = mcpCallTool($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid, 'confirm' => true]); + + expect($response->json('error.message'))->toBe('Tool [manage_project] not found.') + ->and($project->fresh())->not->toBeNull(); +}); + +test('MCP tools declare read-only and destructive annotations', function () { + $token = $this->user->createToken('mcp-root', ['root'])->plainTextToken; + + $tools = collect(mcpListTools($token)->assertOk()->json('result.tools'))->keyBy('name'); + $writePrefixes = ['manage_', 'create_', 'rollback_', 'docker_cleanup', 'cloud_provider', 'control', 'deploy', 'cancel_deployment']; + + foreach ($tools as $name => $tool) { + $isWriteTool = collect($writePrefixes)->contains(fn (string $prefix): bool => str_starts_with($name, $prefix)); + + expect($tool['annotations'])->toHaveKey('readOnlyHint', ! $isWriteTool); + if ($isWriteTool) { + expect($tool['annotations'])->toHaveKey('destructiveHint', true); + } + } + expect($tools->has('list_projects'))->toBeTrue() + ->and($tools->has('manage_project'))->toBeTrue(); }); test('MCP endpoint accepts the legacy initialize handshake', function () { @@ -193,6 +246,25 @@ test('MCP endpoint accepts the legacy initialize handshake', function () { expect($response->json('result.protocolVersion'))->toBe('2025-06-18'); }); +test('MCP initialize negotiates when the client requests an unsupported protocol version', function () { + $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; + + $response = mcpPost([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2026-07-28', + 'capabilities' => (object) [], + 'clientInfo' => ['name' => 'test', 'version' => '1'], + ], + ], $token); + + $response->assertOk(); + expect($response->json('error'))->toBeNull(); + expect($response->json('result.protocolVersion'))->toBe('2025-11-25'); +}); + test('MCP endpoint serves 2026-07-28 protocol requests with MCP headers', function () { Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); $token = $this->user->createToken('mcp-read', ['read'])->plainTextToken; diff --git a/tests/Feature/Mcp/McpImproveTest.php b/tests/Feature/Mcp/McpImproveTest.php index 2847b37e26..62caecfe3a 100644 --- a/tests/Feature/Mcp/McpImproveTest.php +++ b/tests/Feature/Mcp/McpImproveTest.php @@ -83,10 +83,12 @@ test('get_logs returns structured next_tools when not running', function () { }); test('coolify_help returns essentials catalog', function () { - $response = mcpImproveCall('coolify_help', ['intent' => 'essentials']); + $response = mcpImproveCall('coolify_help'); $response->assertOk(); $body = mcpImproveJson($response); - expect($body['data']['catalog']['essentials']['tools'])->toContain('coolify_help', 'control', 'search_resources'); + expect($body['data']['start_here'])->toContain('search_resources') + ->and($body['data']['read_tools'])->toHaveKey('list_applications') + ->and($body['data']['change_tools'])->not->toHaveKey('control'); }); test('list_unhealthy_resources sample_only returns summary', function () { @@ -107,9 +109,9 @@ test('control requires deploy ability and stop requires confirm', function () { 'action' => 'start', 'uuid' => $this->application->uuid, ]); - expect($denied->json('result.isError'))->toBeTrue(); - expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + expect($denied->json('error.message'))->toBe('Tool [control] not found.'); + app('auth')->forgetGuards(); $deployToken = $this->user->createToken('mcp-deploy-stop', ['read', 'deploy'])->plainTextToken; $stop = mcpImproveCall('control', [ 'resource' => 'application', @@ -135,5 +137,6 @@ test('tools list includes coolify_help and control', function () { ]); $response->assertOk(); $names = collect($response->json('result.tools'))->pluck('name')->all(); - expect($names)->toContain('coolify_help', 'control', 'deploy', 'cancel_deployment'); + expect($names)->toContain('coolify_help') + ->and($names)->not->toContain('control', 'deploy', 'cancel_deployment'); }); diff --git a/tests/Feature/Mcp/McpLogRedactionTest.php b/tests/Feature/Mcp/McpLogRedactionTest.php new file mode 100644 index 0000000000..2bf6d01c59 --- /dev/null +++ b/tests/Feature/Mcp/McpLogRedactionTest.php @@ -0,0 +1,157 @@ + 'array', + 'session.driver' => 'array', + 'queue.default' => 'sync', + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true]); + $settings->id = 0; + $settings->save(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = StandaloneDocker::query()->where('server_id', $this->server->id)->firstOrFail(); + $environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first(); + $destinationAttributes = ['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]; + + $this->application = Application::factory()->create(['environment_id' => $environment->id, 'status' => 'running:healthy', ...$destinationAttributes]); + $this->service = Service::factory()->create(['environment_id' => $environment->id, 'server_id' => $this->server->id, ...$destinationAttributes]); + + foreach ([$this->application, $this->service] as $resource) { + EnvironmentVariable::query()->create([ + 'key' => 'MCP_CANARY', + 'value' => MCP_LOG_CANARY, + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + 'is_preview' => false, + ]); + } +}); + +/** + * @param list $abilities + * @param array $arguments + * @return array + */ +function mcpLogRedactionCall(array $abilities, string $tool, array $arguments): array +{ + auth()->forgetGuards(); + $token = test()->user->createToken('mcp-log-redaction', $abilities)->plainTextToken; + + $response = test()->withToken($token)->withHeaders(['Accept' => 'application/json, text/event-stream'])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ])->assertOk(); + + return $response->json(); +} + +function fakeMcpApplicationContainer(Closure $logs): void +{ + Process::fake(fn ($process) => match (true) { + str_contains($process->command, 'docker ps') => Process::result(output: json_encode(['ID' => 'abc123', 'Names' => 'app', 'Labels' => '', 'State' => 'running'])), + str_contains($process->command, 'docker inspect') => Process::result(output: json_encode(['State' => ['Status' => 'running']])), + str_contains($process->command, 'docker logs') => $logs(), + default => Process::result(output: ''), + }); +} + +it('redacts the application environment values in get_logs output', function () { + fakeMcpApplicationContainer(fn () => Process::result(output: 'booted with '.MCP_LOG_CANARY)); + + $response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'application', 'uuid' => $this->application->uuid]); + $data = json_decode($response['result']['content'][0]['text'], true)['data']; + + expect($data['logs'])->toBe('booted with '.REDACTED) + ->and(json_encode($response))->not->toContain(MCP_LOG_CANARY); +}); + +it('redacts the application environment values in get_logs error messages', function () { + fakeMcpApplicationContainer(fn () => Process::result(errorOutput: 'logs failed near '.MCP_LOG_CANARY, exitCode: 1)); + + $response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'application', 'uuid' => $this->application->uuid]); + $data = json_decode($response['result']['content'][0]['text'], true)['data']; + + expect($data['reason'])->toBe('log_fetch_failed') + ->and($data['message'])->toBe('logs failed near '.REDACTED) + ->and(json_encode($response))->not->toContain(MCP_LOG_CANARY); +}); + +it('redacts the service environment values in control error messages', function () { + RestartService::partialMock()->shouldReceive('handle')->andThrow(new RuntimeException('restart failed near '.MCP_LOG_CANARY)); + + $response = mcpLogRedactionCall(['read', 'deploy'], 'control', ['resource' => 'service', 'action' => 'restart', 'uuid' => $this->service->uuid]); + + expect(json_encode($response))->toContain('restart failed near') + ->not->toContain(MCP_LOG_CANARY); +}); + +it('redacts standalone database passwords in get_logs output and errors', function (bool $fails) { + $database = StandalonePostgresql::query()->create([ + 'name' => 'log-redaction-database', + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + 'postgres_password' => 'xy', + 'status' => 'running:healthy', + ]); + fakeMcpApplicationContainer(fn () => $fails + ? Process::result(errorOutput: 'failed near xy', exitCode: 1) + : Process::result(output: 'booted xy')); + + $response = mcpLogRedactionCall(['read', 'read:sensitive'], 'get_logs', ['resource' => 'database', 'uuid' => $database->uuid]); + $data = json_decode($response['result']['content'][0]['text'], true)['data']; + + expect($data[$fails ? 'message' : 'logs'])->toBe(($fails ? 'failed near ' : 'booted ').REDACTED); +})->with([false, true]); + +it('redacts standalone database passwords in control error messages', function () { + $database = StandalonePostgresql::query()->create([ + 'name' => 'control-redaction-database', + 'environment_id' => $this->application->environment_id, + 'destination_id' => $this->application->destination_id, + 'destination_type' => $this->application->destination_type, + 'postgres_password' => 'control-db-password', + ]); + RestartDatabase::partialMock()->shouldReceive('handle')->andThrow(new RuntimeException('restart failed near control-db-password')); + + $response = mcpLogRedactionCall(['read', 'deploy'], 'control', ['resource' => 'database', 'action' => 'restart', 'uuid' => $database->uuid]); + + expect(json_encode($response))->toContain('restart failed near') + ->not->toContain('control-db-password'); +}); diff --git a/tests/Feature/Mcp/McpOperationsToolsTest.php b/tests/Feature/Mcp/McpOperationsToolsTest.php new file mode 100644 index 0000000000..db2a0c60fb --- /dev/null +++ b/tests/Feature/Mcp/McpOperationsToolsTest.php @@ -0,0 +1,618 @@ +set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + Server::flushIdentityMap(); + Process::fake(); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->own = mcpOpsSeedTeam($this->team); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + $this->other = mcpOpsSeedTeam($this->otherTeam); + + session(['currentTeam' => $this->team]); + Queue::fake(); +}); + +/** + * @return array + */ +function mcpOpsSeedTeam(Team $team): array +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destinationAttributes = [ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]; + + $application = Application::factory()->create($destinationAttributes); + $database = StandalonePostgresql::create([ + 'name' => 'ops-postgres-'.$team->id, + 'image' => 'postgres:17-alpine', + 'postgres_user' => 'postgres', + 'postgres_password' => 'db-password-'.$team->id, + 'postgres_db' => 'postgres', + ...$destinationAttributes, + ]); + $service = Service::factory()->create($destinationAttributes); + $serviceApplication = ServiceApplication::create([ + 'uuid' => new_public_id(), + 'name' => 'ops-service-app', + 'image' => 'nginx:alpine', + 'service_id' => $service->id, + ]); + $serviceDatabase = ServiceDatabase::create([ + 'uuid' => new_public_id(), + 'name' => 'ops-service-db', + 'image' => 'postgres:17-alpine', + 'service_id' => $service->id, + ]); + $volume = LocalPersistentVolume::create([ + 'name' => $application->uuid.'-data', + 'mount_path' => '/data', + 'resource_id' => $application->id, + 'resource_type' => $application->getMorphClass(), + ]); + $s3 = S3Storage::create([ + 'name' => 'ops-s3-'.$team->id, + 'region' => 'us-east-1', + 'key' => 'S3KEY'.$team->id, + 'secret' => 'S3SECRET'.$team->id, + 'bucket' => 'bucket', + 'endpoint' => 'https://s3.example.com', + 'team_id' => $team->id, + 'is_usable' => true, + ]); + + return compact('server', 'destination', 'project', 'environment', 'application', 'database', 'service', 'serviceApplication', 'serviceDatabase', 'volume', 's3'); +} + +function mcpOpsToken(User $user, array $abilities = ['read', 'write']): string +{ + return $user->createToken('mcp-ops', $abilities)->plainTextToken; +} + +function mcpOpsCall(string $token, string $tool, array $arguments = []) +{ + auth()->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpOpsText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpOpsIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpOpsMember(Team $team): User +{ + $member = User::factory()->create(); + $team->members()->attach($member->id, ['role' => 'member']); + + return $member; +} + +// ---------------------------------------------------------------- control + +test('control starts, restarts, and stops a service application and service database of the token team', function () { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + + $start = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'start', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); + expect(mcpOpsIsError($start))->toBeFalse() + ->and(mcpOpsText($start))->toContain('deploy request queued'); + DeployServiceApplication::assertPushed(); + + $restart = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceDatabase']->uuid]); + expect(mcpOpsIsError($restart))->toBeFalse() + ->and(mcpOpsText($restart))->toContain('restart request queued'); + RestartServiceApplication::assertPushed(); + + $stop = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid, 'confirm' => true]); + expect(mcpOpsIsError($stop))->toBeFalse(); + StopServiceApplication::assertPushed(); +}); + +test('control rejects service children of another team, also under an own service_uuid', function (string $resource, string $childKey, bool $ownParent) { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + $parent = $ownParent ? $this->own['service'] : $this->other['service']; + + $response = mcpOpsCall($token, 'control', ['resource' => $resource, 'action' => 'restart', 'service_uuid' => $parent->uuid, 'uuid' => $this->other[$childKey]->uuid]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('not found'); + Queue::assertNothingPushed(); +})->with([ + 'foreign app, own service' => ['service_application', 'serviceApplication', true], + 'foreign app, foreign service' => ['service_application', 'serviceApplication', false], + 'foreign db, own service' => ['service_database', 'serviceDatabase', true], + 'foreign db, foreign service' => ['service_database', 'serviceDatabase', false], +]); + +test('control service child stop needs confirm and service_uuid', function () { + $token = mcpOpsToken($this->user, ['read', 'deploy']); + + $noConfirm = mcpOpsCall($token, 'control', ['resource' => 'service_application', 'action' => 'stop', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]); + expect(mcpOpsIsError($noConfirm))->toBeTrue() + ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); + + $noParent = mcpOpsCall($token, 'control', ['resource' => 'service_database', 'action' => 'start', 'uuid' => $this->own['serviceDatabase']->uuid]); + expect(mcpOpsIsError($noParent))->toBeTrue() + ->and(mcpOpsText($noParent))->toContain('service_uuid'); + + Queue::assertNothingPushed(); +}); + +test('control service child rejects read-only and member tokens', function () { + $args = ['resource' => 'service_application', 'action' => 'restart', 'service_uuid' => $this->own['service']->uuid, 'uuid' => $this->own['serviceApplication']->uuid]; + + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'control', $args)))->toBeTrue(); + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'control', $args)))->toBeTrue(); + + Queue::assertNothingPushed(); +}); + +// ---------------------------------------------------------------- deploy by tag + +test('deploy by tag deploys only the token team resources with that tag', function () { + $ownTag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($ownTag->id); + $foreignTag = Tag::create(['name' => 'prod', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true]); + + expect(mcpOpsIsError($response))->toBeFalse() + ->and(mcpOpsText($response))->toContain($this->own['application']->uuid) + ->and(ApplicationDeploymentQueue::where('application_id', $this->own['application']->id)->count())->toBe(1) + ->and(ApplicationDeploymentQueue::where('application_id', $this->other['application']->id)->count())->toBe(0); +}); + +test('deploy by a tag that only another team has deploys nothing', function () { + $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', ['tag' => 'theirs', 'confirm' => true]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(ApplicationDeploymentQueue::count())->toBe(0); +}); + +test('deploy by tag needs confirm and exactly one of uuid or tag', function (array $args, string $message) { + $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($tag->id); + $args = array_map(fn ($value) => $value === 'APP' ? $this->own['application']->uuid : $value, $args); + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'deploy']), 'deploy', $args); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain($message) + ->and(ApplicationDeploymentQueue::count())->toBe(0); +})->with([ + 'no confirm' => [['tag' => 'prod'], 'confirm=true'], + 'both' => [['tag' => 'prod', 'uuid' => 'APP', 'confirm' => true], 'exactly one'], + 'neither' => [[], 'exactly one'], + 'tag with pull request' => [['tag' => 'prod', 'pull_request_id' => 3, 'confirm' => true], 'pull_request_id'], +]); + +test('deploy by tag rejects read-only and member tokens', function () { + $tag = Tag::create(['name' => 'prod', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($tag->id); + + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken($this->user, ['read', 'write']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); + expect(mcpOpsIsError(mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'deploy']), 'deploy', ['tag' => 'prod', 'confirm' => true])))->toBeTrue(); + expect(ApplicationDeploymentQueue::count())->toBe(0); +}); + +// ---------------------------------------------------------------- manage_scheduled_task + +test('manage_scheduled_task creates, updates, executes, and deletes tasks of the token team', function (string $resource, string $key) { + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $owner = $this->own[$key]; + + $created = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'create', 'resource' => $resource, 'uuid' => $owner->uuid, 'data' => ['name' => 'cleanup', 'command' => 'echo SECRETCMD', 'frequency' => 'daily']]); + expect(mcpOpsIsError($created))->toBeFalse() + ->and(mcpOpsText($created))->not->toContain('SECRETCMD'); + $task = ScheduledTask::where('name', 'cleanup')->sole(); + expect($task->team_id)->toBe($this->team->id) + ->and($task->{$resource.'_id'})->toBe($owner->id); + + $updated = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'update', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'data' => ['frequency' => 'hourly']]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($task->fresh()->frequency)->toBe('hourly'); + + $executed = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'execute', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($executed))->toBeFalse(); + Queue::assertPushed(ScheduledTaskJob::class); + + $deleted = mcpOpsCall($token, 'manage_scheduled_task', ['action' => 'delete', 'resource' => $resource, 'uuid' => $owner->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($task->fresh())->toBeNull(); +})->with([ + 'application' => ['application', 'application'], + 'service' => ['service', 'service'], +]); + +test('manage_scheduled_task cannot reach tasks or resources of another team', function (string $action, bool $ownParent) { + $foreignTask = ScheduledTask::factory()->create(['team_id' => $this->otherTeam->id, 'application_id' => $this->other['application']->id, 'name' => 'theirs']); + $parent = $ownParent ? $this->own['application'] : $this->other['application']; + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'write', 'deploy']), 'manage_scheduled_task', [ + 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'task_uuid' => $foreignTask->uuid, + 'confirm' => true, 'data' => $action === 'create' ? ['name' => 'evil', 'command' => 'id', 'frequency' => 'daily'] : ($action === 'update' ? ['name' => 'hijacked'] : []), + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignTask->fresh()?->name)->toBe('theirs') + ->and(ScheduledTask::where('name', 'evil')->exists())->toBeFalse(); + Queue::assertNotPushed(ScheduledTaskJob::class); +})->with([ + 'create on foreign app' => ['create', false], + 'update via own app' => ['update', true], + 'update via foreign app' => ['update', false], + 'delete via own app' => ['delete', true], + 'execute via own app' => ['execute', true], + 'execute via foreign app' => ['execute', false], +]); + +test('manage_scheduled_task needs confirm for delete and execute and rejects read-only tokens', function () { + $task = ScheduledTask::factory()->create(['team_id' => $this->team->id, 'application_id' => $this->own['application']->id]); + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + + foreach (['delete', 'execute'] as $action) { + $response = mcpOpsCall($token, 'manage_scheduled_task', ['action' => $action, 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_scheduled_task', ['action' => 'execute', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($readOnly))->toBeTrue(); + + $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read']), 'manage_scheduled_task', ['action' => 'delete', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'task_uuid' => $task->uuid, 'confirm' => true]); + expect(mcpOpsIsError($member))->toBeTrue(); + + expect($task->fresh())->not->toBeNull(); + Queue::assertNotPushed(ScheduledTaskJob::class); +}); + +// ---------------------------------------------------------------- manage_backup + +test('manage_backup creates, updates, runs, and deletes a backup schedule of the token team', function () { + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $database = $this->own['database']; + + $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); + expect(mcpOpsIsError($created))->toBeFalse() + ->and(mcpOpsText($created))->not->toContain('S3SECRET') + ->and(mcpOpsText($created))->not->toContain('S3KEY'); + $backup = ScheduledDatabaseBackup::where('database_id', $database->id)->sole(); + expect($backup->team_id)->toBe($this->team->id) + ->and($backup->s3_storage_id)->toBe($this->own['s3']->id); + + $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['frequency' => 'hourly', 'backup_now' => true]]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($backup->fresh()->frequency)->toBe('hourly'); + Queue::assertPushed(DatabaseBackupJob::class); + + $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); + $deletedExecution = mcpOpsCall($token, 'manage_backup', ['action' => 'delete_execution', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deletedExecution))->toBeFalse() + ->and($execution->fresh())->toBeNull(); + + $deleted = mcpOpsCall($token, 'manage_backup', ['action' => 'delete', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($backup->fresh())->toBeNull(); +}); + +test('manage_backup rejects an S3 storage of another team', function () { + $token = mcpOpsToken($this->user); + $database = $this->own['database']; + + $created = mcpOpsCall($token, 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); + expect(mcpOpsIsError($created))->toBeTrue() + ->and(ScheduledDatabaseBackup::where('database_id', $database->id)->exists())->toBeFalse(); + + $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); + $updated = mcpOpsCall($token, 'manage_backup', ['action' => 'update', 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'data' => ['save_s3' => true, 's3_storage_uuids' => [$this->other['s3']->uuid], 'frequency' => 'hourly']]); + expect(mcpOpsIsError($updated))->toBeTrue() + ->and($backup->fresh()->s3_storage_id)->toBeNull() + ->and($backup->fresh()->frequency)->toBe('daily') + ->and($backup->fresh()->s3Storages()->exists())->toBeFalse(); +}); + +test('manage_backup cannot reach backups of another team', function (string $action, bool $ownParent) { + $foreignDatabase = $this->other['database']; + $foreignBackup = ScheduledDatabaseBackup::create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily', 'database_id' => $foreignDatabase->id, 'database_type' => $foreignDatabase->getMorphClass()]); + $foreignExecution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $foreignBackup->id, 'database_name' => 'postgres', 'status' => 'success']); + $parent = $ownParent ? $this->own['database'] : $foreignDatabase; + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_backup', [ + 'action' => $action, 'uuid' => $parent->uuid, 'scheduled_backup_uuid' => $foreignBackup->uuid, 'execution_uuid' => $foreignExecution->uuid, + 'confirm' => true, 'data' => $action === 'delete' || $action === 'delete_execution' ? [] : ['frequency' => 'hourly', 'backup_now' => true], + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignBackup->fresh()?->frequency)->toBe('daily') + ->and($foreignExecution->fresh())->not->toBeNull() + ->and(ScheduledDatabaseBackup::where('team_id', $this->team->id)->exists())->toBeFalse(); + Queue::assertNotPushed(DatabaseBackupJob::class); +})->with([ + 'create on foreign db' => ['create', false], + 'update via own db' => ['update', true], + 'update via foreign db' => ['update', false], + 'delete via own db' => ['delete', true], + 'delete execution via own db' => ['delete_execution', true], + 'delete execution via foreign db' => ['delete_execution', false], +]); + +test('manage_backup needs confirm for deletes and rejects read-only tokens', function () { + $database = $this->own['database']; + $backup = ScheduledDatabaseBackup::create(['team_id' => $this->team->id, 'frequency' => 'daily', 'database_id' => $database->id, 'database_type' => $database->getMorphClass()]); + $execution = ScheduledDatabaseBackupExecution::create(['scheduled_database_backup_id' => $backup->id, 'database_name' => 'postgres', 'status' => 'success']); + $token = mcpOpsToken($this->user); + + foreach (['delete', 'delete_execution'] as $action) { + $response = mcpOpsCall($token, 'manage_backup', ['action' => $action, 'uuid' => $database->uuid, 'scheduled_backup_uuid' => $backup->uuid, 'execution_uuid' => $execution->uuid]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_backup', ['action' => 'create', 'uuid' => $database->uuid, 'data' => ['frequency' => 'daily']]); + expect(mcpOpsIsError($readOnly))->toBeTrue() + ->and($backup->fresh())->not->toBeNull() + ->and($execution->fresh())->not->toBeNull() + ->and(ScheduledDatabaseBackup::count())->toBe(1); +}); + +// ---------------------------------------------------------------- manage_storage + +test('manage_storage creates, updates, and deletes storages of the token team', function () { + $token = mcpOpsToken($this->user); + $application = $this->own['application']; + + $created = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'name' => 'cache', 'mount_path' => '/cache']]); + expect(mcpOpsIsError($created))->toBeFalse(); + $volume = LocalPersistentVolume::where('name', $application->uuid.'-cache')->sole(); + + $updated = mcpOpsCall($token, 'manage_storage', ['action' => 'update', 'resource' => 'application', 'uuid' => $application->uuid, 'data' => ['type' => 'persistent', 'uuid' => $volume->uuid, 'mount_path' => '/cache2']]); + expect(mcpOpsIsError($updated))->toBeFalse() + ->and($volume->fresh()->mount_path)->toBe('/cache2'); + + $dbCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'database', 'uuid' => $this->own['database']->uuid, 'data' => ['type' => 'persistent', 'name' => 'extra', 'mount_path' => '/extra']]); + expect(mcpOpsIsError($dbCreated))->toBeFalse(); + + $serviceCreated = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'svc', 'mount_path' => '/svc', 'resource_uuid' => $this->own['serviceApplication']->uuid]]); + expect(mcpOpsIsError($serviceCreated))->toBeFalse() + ->and(LocalPersistentVolume::where('resource_id', $this->own['serviceApplication']->id)->where('mount_path', '/svc')->exists())->toBeTrue(); + + $deleted = mcpOpsCall($token, 'manage_storage', ['action' => 'delete', 'resource' => 'application', 'uuid' => $application->uuid, 'storage_uuid' => $volume->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($volume->fresh())->toBeNull(); +}); + +test('manage_storage sets, runs, and removes a volume backup without returning S3 secrets', function () { + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; + + $set = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'set_backup', 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->own['s3']->uuid]]); + expect(mcpOpsIsError($set))->toBeFalse() + ->and(mcpOpsText($set))->not->toContain('S3SECRET') + ->and(mcpOpsText($set))->not->toContain('S3KEY'); + $backup = ScheduledVolumeBackup::query()->sole(); + expect($backup->s3_storage_id)->toBe($this->own['s3']->id); + + $run = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'run_backup', 'confirm' => true]); + expect(mcpOpsIsError($run))->toBeFalse(); + Queue::assertPushed(VolumeBackupJob::class); + + $removed = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => 'remove_backup', 'confirm' => true]); + expect(mcpOpsIsError($removed))->toBeFalse() + ->and($backup->fresh())->toBeNull(); +}); + +test('manage_storage rejects an S3 storage and a service child of another team', function () { + $token = mcpOpsToken($this->user); + + $set = mcpOpsCall($token, 'manage_storage', ['action' => 'set_backup', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid, 'data' => ['frequency' => 'daily', 'save_s3' => true, 's3_storage_uuid' => $this->other['s3']->uuid]]); + expect(mcpOpsIsError($set))->toBeTrue() + ->and(ScheduledVolumeBackup::query()->exists())->toBeFalse(); + + $create = mcpOpsCall($token, 'manage_storage', ['action' => 'create', 'resource' => 'service', 'uuid' => $this->own['service']->uuid, 'data' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil', 'resource_uuid' => $this->other['serviceApplication']->uuid]]); + expect(mcpOpsIsError($create))->toBeTrue() + ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); +}); + +test('manage_storage cannot reach storages of another team', function (string $action, bool $ownParent) { + $foreignVolume = $this->other['volume']; + $foreignVolume->scheduledBackups()->create(['team_id' => $this->otherTeam->id, 'frequency' => 'daily']); + $parent = $ownParent ? $this->own['application'] : $this->other['application']; + + $response = mcpOpsCall(mcpOpsToken($this->user, ['read', 'write', 'deploy']), 'manage_storage', [ + 'action' => $action, 'resource' => 'application', 'uuid' => $parent->uuid, 'storage_uuid' => $foreignVolume->uuid, 'confirm' => true, + 'data' => match ($action) { + 'create' => ['type' => 'persistent', 'name' => 'evil', 'mount_path' => '/evil'], + 'update' => ['type' => 'persistent', 'uuid' => $foreignVolume->uuid, 'mount_path' => '/hijacked'], + 'set_backup' => ['frequency' => 'hourly'], + default => [], + }, + ]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignVolume->fresh()?->mount_path)->toBe('/data') + ->and($foreignVolume->scheduledBackups()->sole()->frequency)->toBe('daily') + ->and(LocalPersistentVolume::where('mount_path', '/evil')->exists())->toBeFalse(); + Queue::assertNotPushed(VolumeBackupJob::class); +})->with([ + 'create on foreign app' => ['create', false], + 'update via own app' => ['update', true], + 'update via foreign app' => ['update', false], + 'delete via own app' => ['delete', true], + 'set backup via own app' => ['set_backup', true], + 'run backup via own app' => ['run_backup', true], + 'run backup via foreign app' => ['run_backup', false], + 'remove backup via own app' => ['remove_backup', true], +]); + +test('manage_storage needs confirm for delete, remove_backup, and run_backup and rejects read-only tokens', function () { + $this->own['volume']->scheduledBackups()->create(['team_id' => $this->team->id, 'frequency' => 'daily']); + $args = ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'storage_uuid' => $this->own['volume']->uuid]; + $token = mcpOpsToken($this->user, ['read', 'write', 'deploy']); + + foreach (['delete', 'remove_backup', 'run_backup'] as $action) { + $response = mcpOpsCall($token, 'manage_storage', [...$args, 'action' => $action]); + expect(mcpOpsIsError($response))->toBeTrue() + ->and(mcpOpsText($response))->toContain('confirm=true'); + } + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_storage', [...$args, 'action' => 'delete', 'confirm' => true]); + expect(mcpOpsIsError($readOnly))->toBeTrue() + ->and($this->own['volume']->fresh())->not->toBeNull() + ->and(ScheduledVolumeBackup::count())->toBe(1); + Queue::assertNotPushed(VolumeBackupJob::class); +}); + +// ---------------------------------------------------------------- manage_tags + +test('manage_tags creates, renames, attaches, detaches, and deletes tags of the token team', function () { + $token = mcpOpsToken($this->user); + + $created = mcpOpsCall($token, 'manage_tags', ['action' => 'create', 'data' => ['name' => 'Web']]); + expect(mcpOpsIsError($created))->toBeFalse(); + $tag = Tag::where('team_id', $this->team->id)->where('name', 'web')->sole(); + + $renamed = mcpOpsCall($token, 'manage_tags', ['action' => 'update', 'tag_uuid' => $tag->uuid, 'data' => ['name' => 'frontend']]); + expect(mcpOpsIsError($renamed))->toBeFalse() + ->and($tag->fresh()->name)->toBe('frontend'); + + foreach (['application', 'database', 'service'] as $resource) { + $attached = mcpOpsCall($token, 'manage_tags', ['action' => 'attach', 'resource' => $resource, 'uuid' => $this->own[$resource]->uuid, 'data' => ['tag_name' => 'frontend']]); + expect(mcpOpsIsError($attached))->toBeFalse() + ->and($this->own[$resource]->tags()->whereKey($tag->id)->exists())->toBeTrue(); + } + + $detached = mcpOpsCall($token, 'manage_tags', ['action' => 'detach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $tag->uuid]); + expect(mcpOpsIsError($detached))->toBeFalse() + ->and($this->own['application']->tags()->whereKey($tag->id)->exists())->toBeFalse(); + + $deleted = mcpOpsCall($token, 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid, 'confirm' => true]); + expect(mcpOpsIsError($deleted))->toBeFalse() + ->and($tag->fresh())->toBeNull(); +}); + +test('manage_tags attach never links a tag of another team', function () { + $foreignTag = Tag::create(['name' => 'shared', 'team_id' => $this->otherTeam->id]); + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'attach', 'resource' => 'application', 'uuid' => $this->own['application']->uuid, 'data' => ['tag_name' => 'shared']]); + + expect(mcpOpsIsError($response))->toBeFalse() + ->and(mcpOpsText($response))->not->toContain($foreignTag->uuid) + ->and($this->own['application']->tags()->whereKey($foreignTag->id)->exists())->toBeFalse() + ->and($this->own['application']->tags()->sole()->team_id)->toBe($this->team->id); +}); + +test('manage_tags cannot change tags or resources of another team', function (string $action) { + $foreignTag = Tag::create(['name' => 'theirs', 'team_id' => $this->otherTeam->id]); + $this->other['application']->tags()->attach($foreignTag->id); + $ownTag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); + $this->own['application']->tags()->attach($ownTag->id); + + $args = match ($action) { + 'update' => ['tag_uuid' => $foreignTag->uuid, 'data' => ['name' => 'hijacked']], + 'delete' => ['tag_uuid' => $foreignTag->uuid], + 'attach to foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'data' => ['tag_name' => 'evil']], + 'detach foreign tag from own app' => ['resource' => 'application', 'uuid' => $this->own['application']->uuid, 'tag_uuid' => $foreignTag->uuid], + 'detach from foreign app' => ['resource' => 'application', 'uuid' => $this->other['application']->uuid, 'tag_uuid' => $foreignTag->uuid], + }; + $toolAction = str_starts_with($action, 'attach') ? 'attach' : (str_starts_with($action, 'detach') ? 'detach' : $action); + + $response = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => $toolAction, 'confirm' => true, ...$args]); + + expect(mcpOpsIsError($response))->toBeTrue() + ->and($foreignTag->fresh()?->name)->toBe('theirs') + ->and($this->other['application']->tags()->whereKey($foreignTag->id)->exists())->toBeTrue() + ->and(Tag::where('name', 'evil')->exists())->toBeFalse(); +})->with([ + 'update', + 'delete', + 'attach to foreign app', + 'detach foreign tag from own app', + 'detach from foreign app', +]); + +test('manage_tags delete needs confirm and write actions reject read-only and member tokens', function () { + $tag = Tag::create(['name' => 'mine', 'team_id' => $this->team->id]); + + $noConfirm = mcpOpsCall(mcpOpsToken($this->user), 'manage_tags', ['action' => 'delete', 'tag_uuid' => $tag->uuid]); + expect(mcpOpsIsError($noConfirm))->toBeTrue() + ->and(mcpOpsText($noConfirm))->toContain('confirm=true'); + + $readOnly = mcpOpsCall(mcpOpsToken($this->user, ['read']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); + expect(mcpOpsIsError($readOnly))->toBeTrue(); + + $member = mcpOpsCall(mcpOpsToken(mcpOpsMember($this->team), ['read', 'write']), 'manage_tags', ['action' => 'create', 'data' => ['name' => 'nope']]); + expect(mcpOpsIsError($member))->toBeTrue(); + + expect($tag->fresh())->not->toBeNull() + ->and(Tag::where('name', 'nope')->exists())->toBeFalse(); +}); diff --git a/tests/Feature/Mcp/McpReadToolsTest.php b/tests/Feature/Mcp/McpReadToolsTest.php index 618a0b9cb6..155941ffd7 100644 --- a/tests/Feature/Mcp/McpReadToolsTest.php +++ b/tests/Feature/Mcp/McpReadToolsTest.php @@ -2,6 +2,8 @@ use App\Jobs\ApplicationDeploymentJob; use App\Mcp\Concerns\BuildsResponse; +use App\Mcp\Servers\CoolifyServer; +use App\Mcp\Tools\CoolifyHelp; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; @@ -10,6 +12,7 @@ use App\Models\EnvironmentVariable; use App\Models\GithubApp; use App\Models\InstanceSettings; use App\Models\Project; +use App\Models\S3Storage; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledDatabaseBackupExecution; use App\Models\ScheduledTask; @@ -144,7 +147,6 @@ test('tools/list includes new read tools and lifecycle tools', function () { 'list_resources', 'list_deployments', 'get_deployment', - 'get_logs', 'list_env_keys', 'list_storages', 'list_destinations', @@ -163,10 +165,8 @@ test('tools/list includes new read tools and lifecycle tools', function () { 'list_application_previews', 'list_shared_env_keys', 'coolify_help', - 'control', - 'deploy', - 'cancel_deployment', ); + expect($names)->not->toContain('control', 'deploy', 'cancel_deployment', 'get_logs'); }); test('database backup tools scope schedules by database type and id', function () { @@ -217,6 +217,36 @@ test('database backup tools scope schedules by database type and id', function ( expect($response->json('result.isError'))->toBeTrue(); }); +test('list_database_backups returns every S3 destination primary first', function () { + $postgres = StandalonePostgresql::create([ + 'name' => 'multi-s3-db', + 'postgres_password' => 'password', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + ]); + [$first, $second] = collect(['first', 'second'])->map(fn (string $name) => S3Storage::create([ + 'name' => $name, 'region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', + 'bucket' => $name, 'endpoint' => 'https://s3.example.com', 'team_id' => $this->team->id, + ]))->all(); + $backup = ScheduledDatabaseBackup::create([ + 'team_id' => $this->team->id, + 'frequency' => '0 0 * * *', + 'save_s3' => true, + 's3_storage_id' => $second->id, + 'database_id' => $postgres->id, + 'database_type' => $postgres->getMorphClass(), + ]); + $backup->syncS3Storages([$first->id, $second->id]); + + $response = mcpReadCall('list_database_backups', ['uuid' => $postgres->uuid]); + $response->assertOk(); + + $listed = mcpReadJson($response)['data']['backups'][0]; + expect($listed['s3_storage_uuid'])->toBe($second->uuid) + ->and($listed['s3_storage_uuids'])->toBe([$second->uuid, $first->uuid]); +}); + test('list_backup_executions omits messages without sensitive read and redacts when included', function () { $postgres = StandalonePostgresql::create([ 'name' => 'backup-msg-db', @@ -1590,9 +1620,7 @@ test('get_logs requires sensitive read ability', function () { 'uuid' => $this->application->uuid, ]); - $response->assertOk(); - expect($response->json('result.isError'))->toBeTrue() - ->and($response->json('result.content.0.text'))->toContain('read:sensitive'); + expect($response->json('error.message'))->toBe('Tool [get_logs] not found.'); }); test('team members cannot retrieve logs with sensitive read ability', function () { @@ -1665,11 +1693,48 @@ test('get_logs returns structured choices when service has multiple containers', ->and(json_encode($body['data']['message'] ?? ''))->not->toContain('"uuid"'); }); -test('coolify_help returns catalog intents', function () { - $response = mcpReadCall('coolify_help', ['intent' => 'essentials']); +test('coolify_help lists only the tools this token can see', function () { + $response = mcpReadCall('coolify_help'); $response->assertOk(); $body = mcpReadJson($response); - expect($body['data']['catalog']['essentials']['tools'])->toContain('search_resources', 'control'); + + expect($body['data']['read_tools'])->toHaveKeys(['search_resources', 'list_applications']) + ->and($body['data']['read_tools'])->not->toHaveKey('get_logs') + ->and($body['data']['change_tools'])->not->toHaveKeys(['control', 'manage_project', 'create_application']) + ->and($body['data']['recipes'])->toHaveKey('deploy_app_from_git'); +}); + +test('coolify_help topic returns recipe steps and marks tools the token cannot use', function () { + $body = mcpReadJson(mcpReadCall('coolify_help', ['topic' => 'deploy_app_from_git'])); + $steps = collect($body['data']['steps'])->keyBy('tool'); + + expect($steps['list_projects']['available'])->toBeTrue() + ->and($steps['create_application']['available'])->toBeFalse() + ->and($steps['create_application']['action'])->toBe('public'); +}); + +test('coolify_help search filters tools and rejects unknown topics', function () { + $body = mcpReadJson(mcpReadCall('coolify_help', ['search' => 'deployment'])); + expect($body['data']['read_tools'])->toHaveKeys(['list_deployments', 'get_deployment']) + ->and($body['data']['read_tools'])->not->toHaveKey('list_servers'); + + app('auth')->forgetGuards(); + $unknown = mcpReadCall('coolify_help', ['topic' => 'nope']); + expect($unknown->json('result.isError'))->toBeTrue(); +}); + +test('coolify_help recipes only name registered tools and actions', function () { + $tools = collect(CoolifyServer::TOOLS)->mapWithKeys(fn (string $class) => [app($class)->name() => app($class)]); + + foreach (CoolifyHelp::RECIPES as $recipe) { + foreach ($recipe['steps'] as $step) { + expect($tools)->toHaveKey($step['tool']); + if (isset($step['action'])) { + $actions = (new ReflectionMethod($tools[$step['tool']], 'actions'))->invoke($tools[$step['tool']]); + expect($actions)->toHaveKey($step['action']); + } + } + } }); test('list_unhealthy_resources sample_only returns summary and samples', function () { @@ -1689,12 +1754,11 @@ test('control and deploy require deploy ability', function () { 'action' => 'start', 'uuid' => $this->application->uuid, ]); - $denied->assertOk(); - expect($denied->json('result.isError'))->toBeTrue(); - expect($denied->json('result.content.0.text'))->toContain('Missing required permissions'); + expect($denied->json('error.message'))->toBe('Tool [control] not found.'); + app('auth')->forgetGuards(); $deployDenied = mcpReadCall('deploy', ['uuid' => $this->application->uuid]); - expect($deployDenied->json('result.isError'))->toBeTrue(); + expect($deployDenied->json('error.message'))->toBe('Tool [deploy] not found.'); }); test('control stop requires confirm', function () { @@ -2485,8 +2549,8 @@ test('redactLogText redacts JSON secret fields in log lines', function () { ->and($redacted)->toContain('ok') ->and($redacted)->not->toContain('redactme01') ->and($redacted)->not->toContain('redactme02') - ->and($redacted)->toContain('token=') - ->and($redacted)->toContain('API_KEY=') + ->and($redacted)->toContain('token') + ->and($redacted)->toContain('API_KEY') ->and($redacted)->toContain(REDACTED); // Shell-style still works alongside JSON diff --git a/tests/Feature/Mcp/McpServerToolsTest.php b/tests/Feature/Mcp/McpServerToolsTest.php new file mode 100644 index 0000000000..36dafa8f07 --- /dev/null +++ b/tests/Feature/Mcp/McpServerToolsTest.php @@ -0,0 +1,883 @@ + true, 'cpu' => 42.5, 'memUsed' => 1024, 'memTotal' => 4096, + 'diskUsed' => 10, 'diskTotal' => 100, 'diskPercent' => 10.0, + 'load1' => 0.5, 'netRx' => 5.0, 'netTx' => 1.0, + ]; + } + + public function history(string $metric, string $range): array + { + self::$calls++; + if ($metric === 'disk') { + throw new RuntimeException('Disk endpoint unavailable.'); + } + + return [ + [now()->subHours(3)->getTimestampMs(), 1.0], + [now()->subMinutes(10)->getTimestampMs(), $metric === 'cpu' ? 42.5 : 1024.0], + ]; + } + + public function networkHistory(string $range): array + { + self::$calls++; + + return ['rx' => [[now()->subMinutes(5)->getTimestampMs(), 5.0]], 'tx' => [[now()->subMinutes(5)->getTimestampMs(), 1.0]]]; + } +} + +beforeEach(function () { + Server::flushIdentityMap(); + Queue::fake(); + Process::fake(); + Storage::fake('ssh-keys'); + Storage::fake('ssh-mux'); + Http::preventStrayRequests(); + config()->set('app.maintenance.store', 'array'); + config()->set('cache.default', 'array'); + config()->set('constants.ssh.mux_enabled', false); + + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + $this->key = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->otherKey = PrivateKey::factory()->create(['team_id' => $this->otherTeam->id]); + + $this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->key->id]); + $this->otherServer = Server::factory()->create(['team_id' => $this->otherTeam->id, 'private_key_id' => $this->otherKey->id, 'name' => 'Theirs']); + + session(['currentTeam' => $this->team]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function mcpSrvToken(User $user, array $abilities = ['read', 'write']): string +{ + return $user->createToken('mcp-srv', $abilities)->plainTextToken; +} + +function mcpSrvCall(string $token, string $tool, array $arguments = []) +{ + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpSrvText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpSrvIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +function mcpSrvData($response): mixed +{ + return json_decode(mcpSrvText($response), true)['data'] ?? null; +} + +function mcpSrvMakeFunctional(Server $server): void +{ + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + Server::flushIdentityMap(); +} + +function mcpSrvGithubApp(Team $team, PrivateKey $key, array $overrides = []): GithubApp +{ + return GithubApp::create(array_merge([ + 'name' => 'gh-'.$team->id, + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234, + 'installation_id' => 5678, + 'client_id' => 'client-id', + 'client_secret' => 'GH_CLIENT_SECRET_VALUE', + 'webhook_secret' => 'GH_WEBHOOK_SECRET_VALUE', + 'private_key_id' => $key->id, + 'team_id' => $team->id, + 'is_system_wide' => false, + ], $overrides)); +} + +function mcpSrvS3(Team $team, array $overrides = []): S3Storage +{ + return S3Storage::create(array_merge([ + 'team_id' => $team->id, + 'name' => 'S3 '.$team->id, + 'region' => 'us-east-1', + 'key' => 'S3_ACCESS_KEY_VALUE', + 'secret' => 'S3_SECRET_KEY_VALUE', + 'bucket' => 'test-bucket', + 'endpoint' => 'https://s3.amazonaws.com', + 'is_usable' => false, + ], $overrides)); +} + +// ---------------------------------------------------------------- shared checks + +test('server group write actions reject read-only tokens', function (string $tool, array $arguments) { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), $tool, $arguments + ['confirm' => true]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toMatch('/Missing required permissions: (write|deploy)|Tool \[\w+\] not found/'); +})->with([ + 'manage_server create' => ['manage_server', ['action' => 'create', 'data' => ['ip' => '203.0.113.50']]], + 'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']], + 'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']], + 'manage_destination create' => ['manage_destination', ['action' => 'create', 'server_uuid' => 'x']], + 'manage_registry create' => ['manage_registry', ['action' => 'create', 'uuid' => 'x']], + 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], + 'manage_s3_storage create' => ['manage_s3_storage', ['action' => 'create']], + 'manage_git_source create' => ['manage_git_source', ['action' => 'create', 'resource' => 'github']], + 'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']], + 'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'discord']], +]); + +test('server group write actions reject member tokens', function (string $tool, array $arguments) { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $response = mcpSrvCall(mcpSrvToken($member, ['read', 'write']), $tool, $arguments + ['confirm' => true]); + + expect($response->json('result'))->toBeNull() + ->and($response->json('error.message'))->toBe('Missing required team role.'); +})->with([ + 'manage_server update' => ['manage_server', ['action' => 'update', 'data' => ['name' => 'x']]], + 'manage_private_key create' => ['manage_private_key', ['action' => 'create']], + 'manage_registry list' => ['manage_registry', ['action' => 'list']], + 'cloud_provider create_token' => ['cloud_provider', ['action' => 'create_token']], + 'manage_notifications update' => ['manage_notifications', ['action' => 'update', 'resource' => 'email']], +]); + +test('server group destructive actions need confirm=true', function (string $tool, array $arguments) { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), $tool, $arguments); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('requires confirm=true'); + Queue::assertNothingPushed(); +})->with([ + 'manage_server delete' => ['manage_server', ['action' => 'delete', 'uuid' => 'x']], + 'manage_proxy update' => ['manage_proxy', ['action' => 'update', 'uuid' => 'x']], + 'manage_proxy set_configuration' => ['manage_proxy', ['action' => 'set_configuration', 'uuid' => 'x']], + 'manage_proxy restart' => ['manage_proxy', ['action' => 'restart', 'uuid' => 'x']], + 'docker_cleanup run' => ['docker_cleanup', ['action' => 'run', 'uuid' => 'x']], + 'manage_destination delete' => ['manage_destination', ['action' => 'delete', 'uuid' => 'x']], + 'manage_registry delete' => ['manage_registry', ['action' => 'delete', 'uuid' => 'x', 'registry' => 'ghcr.io']], + 'manage_private_key delete' => ['manage_private_key', ['action' => 'delete', 'uuid' => 'x']], + 'manage_s3_storage delete' => ['manage_s3_storage', ['action' => 'delete', 'uuid' => 'x']], + 'manage_git_source delete' => ['manage_git_source', ['action' => 'delete', 'resource' => 'github', 'source_id' => '1']], + 'cloud_provider delete_token' => ['cloud_provider', ['action' => 'delete_token', 'uuid' => 'x']], + 'cloud_provider provision_server' => ['cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner']], +]); + +// ---------------------------------------------------------------- manage_server + +test('manage_server creates a server in the token team with a team private key', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [ + 'name' => 'mcp-server', 'ip' => '203.0.113.50', 'private_key_uuid' => $this->key->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)); + $server = Server::where('name', 'mcp-server')->sole(); + expect($server->team_id)->toBe($this->team->id) + ->and($server->private_key_id)->toBe($this->key->id); +}); + +test('manage_server rejects a private key of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'create', 'data' => [ + 'name' => 'stolen-key', 'ip' => '203.0.113.51', 'private_key_uuid' => $this->otherKey->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::where('name', 'stolen-key')->exists())->toBeFalse(); + + $update = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]); + expect(mcpSrvIsError($update))->toBeTrue() + ->and($this->server->fresh()->private_key_id)->toBe($this->key->id); +}); + +test('manage_server cannot change, validate, or delete a server of another team', function (string $action, array $data) { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_server', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue(); + $fresh = Server::find($this->otherServer->id); + expect($fresh)->not->toBeNull()->and($fresh->name)->toBe('Theirs'); + Queue::assertNothingPushed(); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'validate' => ['validate', []], + 'delete' => ['delete', []], +]); + +test('manage_server updates, validates, and deletes its own server', function () { + $token = mcpSrvToken($this->user); + + $update = mcpSrvCall($token, 'manage_server', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['name' => 'renamed']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and($this->server->fresh()->name)->toBe('renamed'); + + $validate = mcpSrvCall($token, 'manage_server', ['action' => 'validate', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($validate))->toBeFalse(mcpSrvText($validate)); + + $delete = mcpSrvCall($token, 'manage_server', ['action' => 'delete', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and(Server::find($this->server->id))->toBeNull(); +}); + +// ---------------------------------------------------------------- manage_proxy + +test('manage_proxy reads and updates the proxy of its own server and queues a restart', function () { + $token = mcpSrvToken($this->user, ['read', 'write', 'deploy']); + + $get = mcpSrvCall($token, 'manage_proxy', ['action' => 'get', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvData($get))->toHaveKey('proxy_type'); + + $update = mcpSrvCall($token, 'manage_proxy', ['action' => 'update', 'uuid' => $this->server->uuid, 'confirm' => true, 'data' => ['generate_exact_labels' => true]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and((bool) $this->server->settings()->first()->generate_exact_labels)->toBeTrue(); + + $restart = mcpSrvCall($token, 'manage_proxy', ['action' => 'restart', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($restart))->toBeFalse(mcpSrvText($restart)); + Queue::assertPushed(RestartProxyJob::class); +}); + +test('manage_proxy cannot read or change the proxy of another team', function (string $action, array $data) { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'manage_proxy', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and((bool) $this->otherServer->settings()->first()->generate_exact_labels)->toBeFalse(); + Queue::assertNothingPushed(); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['generate_exact_labels' => true]], + 'set_configuration' => ['set_configuration', ['configuration' => "services:\n traefik:\n image: evil\n"]], + 'restart' => ['restart', []], +]); + +// ---------------------------------------------------------------- docker_cleanup + +test('docker_cleanup reads, updates, lists, and runs cleanup on its own server', function () { + $token = mcpSrvToken($this->user, ['read', 'write', 'deploy']); + + $get = mcpSrvCall($token, 'docker_cleanup', ['action' => 'get', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvData($get))->toHaveKey('docker_cleanup_threshold'); + + $update = mcpSrvCall($token, 'docker_cleanup', ['action' => 'update', 'uuid' => $this->server->uuid, 'data' => ['docker_cleanup_threshold' => 55]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and((int) $this->server->settings()->first()->docker_cleanup_threshold)->toBe(55); + + $executions = mcpSrvCall($token, 'docker_cleanup', ['action' => 'executions', 'uuid' => $this->server->uuid]); + expect(mcpSrvIsError($executions))->toBeFalse(mcpSrvText($executions)); + + $run = mcpSrvCall($token, 'docker_cleanup', ['action' => 'run', 'uuid' => $this->server->uuid, 'confirm' => true]); + expect(mcpSrvIsError($run))->toBeFalse(mcpSrvText($run)); + Queue::assertPushed(DockerCleanupJob::class); +}); + +test('docker_cleanup cannot touch a server of another team', function (string $action, array $data) { + $before = (int) $this->otherServer->settings()->first()->docker_cleanup_threshold; + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'docker_cleanup', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and((int) $this->otherServer->settings()->first()->docker_cleanup_threshold)->toBe($before); + Queue::assertNothingPushed(); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['docker_cleanup_threshold' => 12]], + 'executions' => ['executions', []], + 'run' => ['run', []], +]); + +// ---------------------------------------------------------------- manage_destination + +test('manage_destination creates, renames, and deletes a destination on its own server', function () { + $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class); + $cleanup->shouldReceive('handle')->once(); + $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup); + $token = mcpSrvToken($this->user); + + $create = mcpSrvCall($token, 'manage_destination', ['action' => 'create', 'server_uuid' => $this->server->uuid, 'data' => ['network' => 'mcp-net', 'name' => 'mcp-dest']]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)); + $destination = StandaloneDocker::where('network', 'mcp-net')->sole(); + expect($destination->server_id)->toBe($this->server->id); + + $update = mcpSrvCall($token, 'manage_destination', ['action' => 'update', 'uuid' => $destination->uuid, 'data' => ['name' => 'renamed-dest']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and($destination->fresh()->name)->toBe('renamed-dest'); + + $delete = mcpSrvCall($token, 'manage_destination', ['action' => 'delete', 'uuid' => $destination->uuid, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and($destination->fresh())->toBeNull(); +}); + +test('manage_destination cannot create a destination on a server of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => 'create', 'server_uuid' => $this->otherServer->uuid, 'data' => ['network' => 'evil-net']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(StandaloneDocker::where('network', 'evil-net')->exists())->toBeFalse(); +}); + +test('manage_destination cannot rename or delete a destination of another team', function (string $action, array $data) { + $cleanup = Mockery::mock(RemoveStandaloneDockerNetwork::class); + $cleanup->shouldNotReceive('handle'); + $this->app->instance(RemoveStandaloneDockerNetwork::class, $cleanup); + $foreign = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail(); + $name = $foreign->name; + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_destination', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe($name); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'delete' => ['delete', []], +]); + +// ---------------------------------------------------------------- manage_registry + +test('manage_registry lists registry logins without passwords', function () { + mcpSrvMakeFunctional($this->server); + Process::fake(fn () => Process::result(output: json_encode([ + 'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:REGISTRY_PASSWORD_VALUE')]], + ]))); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('ghcr.io') + ->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE') + ->and(mcpSrvText($response))->not->toContain(base64_encode('octocat:REGISTRY_PASSWORD_VALUE')); +}); + +test('manage_registry list needs read:sensitive', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write']), 'manage_registry', ['action' => 'list', 'uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('read:sensitive'); +}); + +test('manage_registry logs in on its own server without echoing the password', function () { + mcpSrvMakeFunctional($this->server); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', 'write', 'deploy']), 'manage_registry', ['action' => 'create', 'uuid' => $this->server->uuid, 'data' => [ + 'registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'REGISTRY_PASSWORD_VALUE', + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->not->toContain('REGISTRY_PASSWORD_VALUE'); + Process::assertRan(fn ($process) => str_contains(is_array($process->command) ? implode(' ', $process->command) : $process->command, 'docker')); +}); + +test('manage_registry cannot touch registries on a server of another team', function (string $action, string $ability, array $extra) { + mcpSrvMakeFunctional($this->otherServer); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read', $ability]), 'manage_registry', ['action' => $action, 'uuid' => $this->otherServer->uuid, 'confirm' => true] + $extra); + + expect(mcpSrvIsError($response))->toBeTrue(); + Process::assertNothingRan(); +})->with([ + 'list' => ['list', 'read:sensitive', []], + 'create' => ['create', 'write', ['data' => ['registry' => 'ghcr.io', 'username' => 'evil', 'password' => 'x']]], + 'check' => ['check', 'write', ['registry' => 'ghcr.io']], + 'delete' => ['delete', 'write', ['registry' => 'ghcr.io']], +]); + +// ---------------------------------------------------------------- manage_private_key + +test('manage_private_key never returns key material, even to root and read:sensitive tokens', function (array $abilities) { + $token = mcpSrvToken($this->user, $abilities); + $keyBody = 'QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi'; + + $list = mcpSrvCall($token, 'manage_private_key', ['action' => 'list']); + expect(mcpSrvIsError($list))->toBeFalse(mcpSrvText($list)) + ->and(mcpSrvText($list))->toContain($this->key->uuid) + ->and(mcpSrvText($list))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($list))->not->toContain($keyBody) + ->and(mcpSrvText($list))->not->toContain($this->otherKey->uuid); + + $get = mcpSrvCall($token, 'manage_private_key', ['action' => 'get', 'uuid' => $this->key->uuid]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($get))->not->toContain($keyBody); +})->with([ + 'root' => [['root']], + 'read:sensitive' => [['read', 'read:sensitive']], +]); + +test('manage_private_key create does not return or audit the key material', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => 'create', 'data' => [ + 'name' => 'mcp-key', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY, + ]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->not->toContain('PRIVATE KEY') + ->and(mcpSrvText($response))->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2'); + expect(PrivateKey::where('name', 'mcp-key')->sole()->team_id)->toBe($this->team->id); + + $audit = AuditEvent::query()->get()->toJson(); + expect($audit)->not->toContain('PRIVATE KEY') + ->and($audit)->not->toContain('AAAECKtKd242pj0qdKWaOpXAQFI2ZGLQOg9vCP2'); +}); + +test('manage_private_key cannot read, change, or delete a key of another team', function (string $action, array $data) { + $original = $this->otherKey->fresh()->name; + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_private_key', ['action' => $action, 'uuid' => $this->otherKey->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(PrivateKey::find($this->otherKey->id))->not->toBeNull() + ->and(PrivateKey::find($this->otherKey->id)->name)->toBe($original); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['name' => 'Hijacked', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]], + 'delete' => ['delete', []], +]); + +test('manage_private_key deletes an unused key of its own team', function () { + $unused = PrivateKey::create(['team_id' => $this->team->id, 'name' => 'unused', 'private_key' => MCP_SRV_NEW_PRIVATE_KEY]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_private_key', ['action' => 'delete', 'uuid' => $unused->uuid, 'confirm' => true]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(PrivateKey::find($unused->id))->toBeNull(); +}); + +// ---------------------------------------------------------------- manage_s3_storage + +test('manage_s3_storage creates a storage and never returns the secret', function () { + $token = mcpSrvToken($this->user, ['root']); + + $create = mcpSrvCall($token, 'manage_s3_storage', ['action' => 'create', 'data' => [ + 'name' => 'mcp-s3', 'endpoint' => 'https://s3.amazonaws.com', 'bucket' => 'mcp-bucket', 'region' => 'us-east-1', + 'key' => 'S3_ACCESS_KEY_VALUE', 'secret' => 'S3_SECRET_KEY_VALUE', + ]]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('S3_SECRET_KEY_VALUE'); + $storage = S3Storage::where('name', 'mcp-s3')->sole(); + expect($storage->team_id)->toBe($this->team->id); + + foreach ([['action' => 'list'], ['action' => 'get', 'uuid' => $storage->uuid]] as $arguments) { + $read = mcpSrvCall($token, 'manage_s3_storage', $arguments); + expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read)) + ->and(mcpSrvText($read))->toContain($storage->uuid) + ->and(mcpSrvText($read))->not->toContain('S3_SECRET_KEY_VALUE'); + } +}); + +test('manage_s3_storage cannot read, change, test, or delete a storage of another team', function (string $action, array $data) { + $foreign = mcpSrvS3($this->otherTeam, ['name' => 'Theirs']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_s3_storage', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('S3_SECRET_KEY_VALUE') + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'get' => ['get', []], + 'update' => ['update', ['name' => 'Hijacked']], + 'validate' => ['validate', []], + 'delete' => ['delete', []], +]); + +test('manage_s3_storage list does not include storages of another team', function () { + $foreign = mcpSrvS3($this->otherTeam); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_s3_storage', ['action' => 'list']); + + expect(mcpSrvIsError($response))->toBeFalse() + ->and(mcpSrvText($response))->not->toContain($foreign->uuid); +}); + +// ---------------------------------------------------------------- manage_git_source + +test('manage_git_source lists apps without client or webhook secrets', function () { + $own = mcpSrvGithubApp($this->team, $this->key); + $shared = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'shared', 'is_system_wide' => true]); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'manage_git_source', ['action' => 'list', 'resource' => 'github']); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain($own->uuid) + ->and(mcpSrvText($response))->not->toContain('GH_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($response))->not->toContain('GH_WEBHOOK_SECRET_VALUE') + ->and(mcpSrvText($response))->not->toContain('PRIVATE KEY'); +}); + +test('manage_git_source rejects a GitHub app with a private key of another team', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'create', 'resource' => 'github', 'data' => [ + 'name' => 'stolen', 'html_url' => 'https://github.com', 'app_id' => 1, 'installation_id' => 2, + 'client_id' => 'id', 'client_secret' => 'secret', 'webhook_secret' => 'hook', 'private_key_uuid' => $this->otherKey->uuid, + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(GithubApp::where('name', 'stolen')->exists())->toBeFalse(); + + $own = mcpSrvGithubApp($this->team, $this->key); + $update = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'update', 'resource' => 'github', 'source_id' => (string) $own->id, 'data' => ['private_key_uuid' => $this->otherKey->uuid]]); + expect(mcpSrvIsError($update))->toBeTrue() + ->and($own->fresh()->private_key_id)->toBe($this->key->id); +}); + +test('manage_git_source cannot change or delete GitHub apps of another team, including system-wide ones', function (bool $systemWide, string $action) { + $foreign = mcpSrvGithubApp($this->otherTeam, $this->otherKey, ['name' => 'Theirs', 'is_system_wide' => $systemWide]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'github', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'team app' => false, + 'system-wide app' => true, +])->with(['update', 'delete']); + +test('manage_git_source creates, updates, and deletes a GitLab app without returning secrets', function () { + $token = mcpSrvToken($this->user, ['read', 'write']); + + $create = mcpSrvCall($token, 'manage_git_source', ['action' => 'create', 'resource' => 'gitlab', 'data' => [ + 'name' => 'mcp-gitlab', 'html_url' => 'https://gitlab.com', 'client_secret' => 'GL_CLIENT_SECRET_VALUE', 'webhook_token' => 'GL_WEBHOOK_TOKEN_VALUE', + ]]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($create))->not->toContain('GL_WEBHOOK_TOKEN_VALUE'); + $app = GitlabApp::where('name', 'mcp-gitlab')->sole(); + expect($app->team_id)->toBe($this->team->id); + + $list = mcpSrvCall($token, 'manage_git_source', ['action' => 'list', 'resource' => 'gitlab']); + expect(mcpSrvIsError($list))->toBeFalse() + ->and(mcpSrvText($list))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($list))->not->toContain('GL_WEBHOOK_TOKEN_VALUE'); + $sourceId = collect(mcpSrvData($list))->firstWhere('name', 'mcp-gitlab')['id']; + expect($sourceId)->not->toBeNull(); + + $update = mcpSrvCall($token, 'manage_git_source', ['action' => 'update', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'data' => ['name' => 'renamed-gitlab']]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain('GL_CLIENT_SECRET_VALUE') + ->and(mcpSrvText($update))->not->toContain('GL_WEBHOOK_TOKEN_VALUE') + ->and($app->fresh()->name)->toBe('renamed-gitlab'); + + $delete = mcpSrvCall($token, 'manage_git_source', ['action' => 'delete', 'resource' => 'gitlab', 'source_id' => (string) $sourceId, 'confirm' => true]); + expect(mcpSrvIsError($delete))->toBeFalse(mcpSrvText($delete)) + ->and(GitlabApp::find($app->id))->toBeNull(); +}); + +test('manage_git_source cannot change or delete a GitLab app of another team', function (string $action) { + $foreign = GitlabApp::create(['name' => 'Theirs', 'html_url' => 'https://gitlab.com', 'api_url' => 'https://gitlab.com/api/v4', 'team_id' => $this->otherTeam->id]); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => $action, 'resource' => 'gitlab', 'source_id' => (string) $foreign->id, 'confirm' => true, 'data' => ['name' => 'Hijacked']]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with(['update', 'delete']); + +test('manage_git_source requires a known resource', function () { + $response = mcpSrvCall(mcpSrvToken($this->user), 'manage_git_source', ['action' => 'list', 'resource' => 'bitbucket']); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('resource must be one of: github, gitlab'); +}); + +// ---------------------------------------------------------------- cloud_provider + +test('cloud_provider adds and lists tokens without returning the token value', function () { + Http::fake(['https://api.hetzner.cloud/v1/servers' => Http::response([], 200)]); + $token = mcpSrvToken($this->user, ['root']); + + $create = mcpSrvCall($token, 'cloud_provider', ['action' => 'create_token', 'data' => ['provider' => 'hetzner', 'token' => 'HETZNER_TOKEN_VALUE', 'name' => 'mcp-hetzner']]); + expect(mcpSrvIsError($create))->toBeFalse(mcpSrvText($create)) + ->and(mcpSrvText($create))->not->toContain('HETZNER_TOKEN_VALUE'); + $stored = CloudProviderToken::where('name', 'mcp-hetzner')->sole(); + expect($stored->team_id)->toBe($this->team->id); + + foreach ([['action' => 'list_tokens'], ['action' => 'get_token', 'uuid' => $stored->uuid]] as $arguments) { + $read = mcpSrvCall($token, 'cloud_provider', $arguments); + expect(mcpSrvIsError($read))->toBeFalse(mcpSrvText($read)) + ->and(mcpSrvText($read))->toContain($stored->uuid) + ->and(mcpSrvText($read))->not->toContain('HETZNER_TOKEN_VALUE'); + } +}); + +test('cloud_provider cannot read, change, validate, or delete a token of another team', function (string $action, array $data) { + Http::fake(); + $foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['root']), 'cloud_provider', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('FOREIGN_TOKEN_VALUE') + ->and($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); + Http::assertNothingSent(); +})->with([ + 'get_token' => ['get_token', []], + 'update_token' => ['update_token', ['name' => 'Hijacked']], + 'validate_token' => ['validate_token', []], + 'delete_token' => ['delete_token', []], +]); + +test('cloud_provider reads a catalog with a token of its own team', function () { + Http::fake(['https://api.hetzner.cloud/v1/locations*' => Http::response([ + 'locations' => [['id' => 1, 'name' => 'nbg1', 'description' => 'Nuremberg', 'country' => 'DE', 'city' => 'Nuremberg']], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200)]); + $cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'catalog', 'resource' => 'hetzner_locations', 'data' => ['cloud_provider_token_uuid' => $cloudToken->uuid]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('nbg1') + ->and(mcpSrvText($response))->not->toContain('OWN_TOKEN_VALUE'); +}); + +test('cloud_provider cannot use a token of another team for catalogs or provisioning', function (string $action, string $resource, array $data) { + Http::fake(); + $foreign = CloudProviderToken::create(['team_id' => $this->otherTeam->id, 'provider' => 'hetzner', 'token' => 'FOREIGN_TOKEN_VALUE', 'name' => 'Theirs']); + $serverCount = Server::count(); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => $action, 'resource' => $resource, 'confirm' => true, 'data' => ['cloud_provider_token_uuid' => $foreign->uuid] + $data]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::count())->toBe($serverCount); + Http::assertNothingSent(); +})->with([ + 'catalog' => ['catalog', 'hetzner_server_types', []], + 'provision' => ['provision_server', 'hetzner', ['location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil']], +]); + +test('cloud_provider provisioning rejects a private key of another team', function () { + Http::fake(); + $cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hetzner', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']); + $serverCount = Server::count(); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'provision_server', 'resource' => 'hetzner', 'confirm' => true, 'data' => [ + 'cloud_provider_token_uuid' => $cloudToken->uuid, 'private_key_uuid' => $this->otherKey->uuid, + 'location' => 'nbg1', 'server_type' => 'cx22', 'image' => 1, 'name' => 'evil', + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(Server::count())->toBe($serverCount); + Http::assertNothingSent(); +}); + +test('cloud_provider provisioning a Hostinger VPS with a post-install script or extra SSH keys needs deploy', function (array $extra) { + Http::fake(); + $cloudToken = CloudProviderToken::create(['team_id' => $this->team->id, 'provider' => 'hostinger', 'token' => 'OWN_TOKEN_VALUE', 'name' => 'Mine']); + $serverCount = Server::count(); + + $response = mcpSrvCall(mcpSrvToken($this->user), 'cloud_provider', ['action' => 'provision_server', 'resource' => 'hostinger', 'confirm' => true, 'data' => [ + 'cloud_provider_token_uuid' => $cloudToken->uuid, 'private_key_uuid' => $this->key->uuid, + 'item_id' => 'hostingercom-vps-kvm1-usd-1m', 'data_center_id' => 19, 'template_id' => 1130, + ...$extra, + ]]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('Missing required permissions: deploy') + ->and(Server::count())->toBe($serverCount); + Http::assertNothingSent(); +})->with([ + 'post-install script' => [['post_install_script_id' => 73]], + 'extra SSH keys' => [['public_key_ids' => [42]]], +]); + +// ---------------------------------------------------------------- manage_notifications + +test('manage_notifications updates a channel of the token team and never returns webhook URLs or tokens', function () { + $token = mcpSrvToken($this->user, ['root']); + $otherSettings = DiscordNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]); + + $update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'discord', 'data' => [ + 'discord_enabled' => true, 'discord_webhook_url' => 'https://discord.com/api/webhooks/123/DISCORD_WEBHOOK_SECRET', + ]]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain('DISCORD_WEBHOOK_SECRET'); + + expect(DiscordNotificationSettings::where('team_id', $this->team->id)->sole()->discord_webhook_url)->toContain('DISCORD_WEBHOOK_SECRET') + ->and($otherSettings->fresh()->discord_webhook_url)->toBeNull(); + + $get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => 'discord']); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->toContain('discord_enabled') + ->and(mcpSrvText($get))->not->toContain('DISCORD_WEBHOOK_SECRET'); +}); + +test('manage_notifications never returns channel secrets', function (string $channel, array $data, string $secret) { + $token = mcpSrvToken($this->user, ['root']); + + $update = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => $channel, 'data' => $data]); + expect(mcpSrvIsError($update))->toBeFalse(mcpSrvText($update)) + ->and(mcpSrvText($update))->not->toContain($secret); + + $get = mcpSrvCall($token, 'manage_notifications', ['action' => 'get', 'resource' => $channel]); + expect(mcpSrvIsError($get))->toBeFalse(mcpSrvText($get)) + ->and(mcpSrvText($get))->not->toContain($secret); +})->with([ + 'email smtp password' => ['email', ['smtp_password' => 'SMTP_PASSWORD_VALUE', 'resend_api_key' => 'RESEND_KEY_VALUE'], 'SMTP_PASSWORD_VALUE'], + 'email resend key' => ['email', ['resend_api_key' => 'RESEND_KEY_VALUE'], 'RESEND_KEY_VALUE'], + 'slack webhook' => ['slack', ['slack_webhook_url' => 'https://hooks.slack.com/services/SLACK_SECRET_PATH'], 'SLACK_SECRET_PATH'], + 'telegram token' => ['telegram', ['telegram_token' => 'TELEGRAM_TOKEN_VALUE', 'telegram_chat_id' => '123'], 'TELEGRAM_TOKEN_VALUE'], + 'pushover keys' => ['pushover', ['pushover_user_key' => 'PUSHOVER_USER_VALUE', 'pushover_api_token' => 'PUSHOVER_TOKEN_VALUE'], 'PUSHOVER_'], + 'webhook url' => ['webhook', ['webhook_url' => 'https://example.com/hook/WEBHOOK_SECRET_PATH'], 'WEBHOOK_SECRET_PATH'], +]); + +test('manage_notifications only changes the token team', function () { + TelegramNotificationSettings::query()->firstOrCreate(['team_id' => $this->otherTeam->id]); + $sessionSwitch = Team::factory()->create(); + $sessionSwitch->members()->attach($this->user->id, ['role' => 'owner']); + $token = mcpSrvToken($this->user); + session(['currentTeam' => $sessionSwitch]); + + $response = mcpSrvCall($token, 'manage_notifications', ['action' => 'update', 'resource' => 'telegram', 'data' => ['telegram_chat_id' => '999']]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(TelegramNotificationSettings::where('team_id', $this->team->id)->sole()->telegram_chat_id)->toBe('999') + ->and(TelegramNotificationSettings::where('team_id', $this->otherTeam->id)->sole()->telegram_chat_id)->toBeNull() + ->and(TelegramNotificationSettings::where('team_id', $sessionSwitch->id)->first()?->telegram_chat_id)->toBeNull(); +}); + +// ---------------------------------------------------------------- list_audit_events + +test('list_audit_events returns only events of the token team', function () { + AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']); + AuditEvent::factory()->create(['team_id' => $this->otherTeam->id, 'description' => 'other-team-event']); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'list_audit_events', ['action' => 'list', 'data' => ['per_page' => 50]]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)) + ->and(mcpSrvText($response))->toContain('own-team-event') + ->and(mcpSrvText($response))->not->toContain('other-team-event'); +}); + +test('list_audit_events is denied for team members', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + AuditEvent::factory()->create(['team_id' => $this->team->id, 'description' => 'own-team-event']); + + $response = mcpSrvCall(mcpSrvToken($member, ['read']), 'list_audit_events', ['action' => 'list']); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->not->toContain('own-team-event'); +}); + +// ---------------------------------------------------------------- get_server_metrics + +test('get_server_metrics returns recent metrics for a server of the token team', function () { + McpSrvStubMetricsClient::$calls = 0; + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->server->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 60]); + + expect(mcpSrvIsError($response))->toBeFalse(mcpSrvText($response)); + $data = mcpSrvData($response); + expect($data['online'])->toBeTrue() + ->and($data['current']['cpu_percent'])->toBe(42.5) + ->and($data['series']['cpu_percent'])->toHaveCount(1) + ->and($data['series']['disk_used_percent'])->toBe([]) + ->and($data['series']['network_rx_bytes_per_sec'])->toHaveCount(1) + ->and(mcpSrvText($response))->not->toContain('sentinel_token'); +}); + +test('get_server_metrics caps minutes and rejects invalid values', function () { + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->server->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + $token = mcpSrvToken($this->user, ['read']); + + $capped = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 999999]); + expect(mcpSrvData($capped)['minutes'])->toBe(43200); + + $invalid = mcpSrvCall($token, 'get_server_metrics', ['server_uuid' => $this->server->uuid, 'minutes' => 0]); + expect(mcpSrvIsError($invalid))->toBeTrue(); +}); + +test('get_server_metrics does not read metrics of a server of another team', function () { + McpSrvStubMetricsClient::$calls = 0; + $this->app->bind(SentinelMetricsClient::class, fn ($app, array $parameters) => new McpSrvStubMetricsClient($parameters['server'])); + $this->otherServer->settings->update(['is_metrics_enabled' => true]); + Server::flushIdentityMap(); + + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->otherServer->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('not found') + ->and(McpSrvStubMetricsClient::$calls)->toBe(0); +}); + +test('get_server_metrics explains when metrics are disabled', function () { + $response = mcpSrvCall(mcpSrvToken($this->user, ['read']), 'get_server_metrics', ['server_uuid' => $this->server->uuid]); + + expect(mcpSrvIsError($response))->toBeTrue() + ->and(mcpSrvText($response))->toContain('Metrics are not enabled'); +}); diff --git a/tests/Feature/Mcp/McpToggleApiTest.php b/tests/Feature/Mcp/McpToggleApiTest.php index 9759668262..109c5bbbd9 100644 --- a/tests/Feature/Mcp/McpToggleApiTest.php +++ b/tests/Feature/Mcp/McpToggleApiTest.php @@ -5,6 +5,7 @@ use App\Models\Team; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Once; uses(RefreshDatabase::class); @@ -95,6 +96,7 @@ test('non-root token cannot disable MCP server', function () { test('root token can enable MCP server when the REST API is disabled', function () { InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false]); + Once::flush(); $token = makeRootMcpToken($this->user); test()->withHeaders(['Authorization' => 'Bearer '.$token]) @@ -104,16 +106,23 @@ test('root token can enable MCP server when the REST API is disabled', function expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeTrue(); }); -test('root token cannot enable MCP server from an IP outside the allow-list', function () { +test('MCP switch routes enforce the allowed API IPs', function (string $path) { InstanceSettings::query()->where('id', 0)->update(['is_api_enabled' => false, 'allowed_ips' => '192.0.2.10']); + Once::flush(); $token = makeRootMcpToken($this->user); test()->withHeaders(['Authorization' => 'Bearer '.$token]) - ->postJson('/api/v1/mcp/enable') - ->assertForbidden(); + ->postJson($path) + ->assertForbidden() + ->assertJson(['message' => 'You are not allowed to access the API.']); expect(InstanceSettings::find(0)->is_mcp_server_enabled)->toBeFalse(); -}); + + test()->withServerVariables(['REMOTE_ADDR' => '192.0.2.10']) + ->withHeaders(['Authorization' => 'Bearer '.$token]) + ->postJson($path) + ->assertOk(); +})->with(['/api/v1/mcp/enable', '/api/v1/mcp/disable']); test('unauthenticated request to /api/v1/mcp/enable returns 401', function () { $response = test()->postJson('/api/v1/mcp/enable'); diff --git a/tests/Feature/Mcp/McpWriteToolsTest.php b/tests/Feature/Mcp/McpWriteToolsTest.php new file mode 100644 index 0000000000..3db6c4ff08 --- /dev/null +++ b/tests/Feature/Mcp/McpWriteToolsTest.php @@ -0,0 +1,150 @@ +set('app.maintenance.store', 'array'); + InstanceSettings::query()->delete(); + $settings = new InstanceSettings(['is_mcp_server_enabled' => true, 'is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->otherTeam = Team::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->otherTeam->members()->attach($this->otherUser->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); +}); + +function mcpWriteToken(User $user, array $abilities = ['write']): string +{ + return $user->createToken('mcp-write', $abilities)->plainTextToken; +} + +function mcpWriteCall(string $token, string $tool, array $arguments = []) +{ + // Each MCP call is a new HTTP request; do not reuse the user and token of an earlier call. + app('auth')->forgetGuards(); + + return test()->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json, text/event-stream', + ])->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'tools/call', + 'params' => ['name' => $tool, 'arguments' => (object) $arguments], + ]); +} + +function mcpWriteText($response): string +{ + return (string) ($response->json('result.content.0.text') ?? $response->json('error.message')); +} + +function mcpWriteIsError($response): bool +{ + return $response->json('result.isError') === true || $response->json('error') !== null; +} + +test('manage_project creates a project in the token team, not the session team', function () { + $sessionTeam = Team::factory()->create(); + $sessionTeam->members()->attach($this->user->id, ['role' => 'owner']); + $token = mcpWriteToken($this->user); + session(['currentTeam' => $sessionTeam]); + + $response = mcpWriteCall($token, 'manage_project', ['action' => 'create', 'data' => ['name' => 'From MCP']]); + + $response->assertOk(); + expect(mcpWriteIsError($response))->toBeFalse(); + expect(Project::where('name', 'From MCP')->sole()->team_id)->toBe($this->team->id); +}); + +test('manage_project cannot change or delete a project of another team', function (string $action, array $data) { + $foreign = Project::create(['name' => 'Theirs', 'team_id' => $this->otherTeam->id]); + $token = mcpWriteToken($this->user); + + $response = mcpWriteCall($token, 'manage_project', ['action' => $action, 'uuid' => $foreign->uuid, 'confirm' => true, 'data' => $data]); + + expect(mcpWriteIsError($response))->toBeTrue(); + expect($foreign->fresh())->not->toBeNull() + ->and($foreign->fresh()->name)->toBe('Theirs'); +})->with([ + 'update' => ['update', ['name' => 'Hijacked']], + 'delete' => ['delete', []], + 'create environment' => ['create_environment', ['name' => 'evil']], +]); + +test('manage_project rejects read-only tokens and member tokens', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + + $readResponse = mcpWriteCall(mcpWriteToken($this->user, ['read']), 'manage_project', ['action' => 'create', 'data' => ['name' => 'Nope']]); + expect(mcpWriteIsError($readResponse))->toBeTrue() + ->and(mcpWriteText($readResponse))->toContain('Tool [manage_project] not found'); + + foreach ([['read'], ['write']] as $abilities) { + $memberToken = $member->createToken('member', $abilities)->plainTextToken; + $memberResponse = mcpWriteCall($memberToken, 'manage_project', ['action' => 'create', 'data' => ['name' => 'Nope']]); + expect(mcpWriteIsError($memberResponse))->toBeTrue(); + } + + expect(Project::where('name', 'Nope')->exists())->toBeFalse(); +}); + +test('manage_project delete needs confirm=true', function () { + $project = Project::create(['name' => 'Mine', 'team_id' => $this->team->id]); + $token = mcpWriteToken($this->user); + + $denied = mcpWriteCall($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid]); + expect(mcpWriteIsError($denied))->toBeTrue() + ->and(mcpWriteText($denied))->toContain('confirm=true') + ->and($project->fresh())->not->toBeNull(); + + $deleted = mcpWriteCall($token, 'manage_project', ['action' => 'delete', 'uuid' => $project->uuid, 'confirm' => true]); + expect(mcpWriteIsError($deleted))->toBeFalse() + ->and($project->fresh())->toBeNull(); +}); + +test('manage_project returns REST validation errors', function () { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'create', 'data' => ['name' => '']]); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('name'); +}); + +test('write tools reject path parameters that try to reach other routes', function (string $uuid) { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'update', 'uuid' => $uuid, 'data' => ['name' => 'x']]); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('invalid characters'); +})->with(['../enable', 'a/b', '..', '%2e%2e', 'x?y=1']); + +test('write tools reject an unknown action', function () { + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'drop_everything']); + + expect(mcpWriteIsError($response))->toBeTrue() + ->and(mcpWriteText($response))->toContain('action must be one of'); +}); + +test('write tools work when the REST API is disabled', function () { + InstanceSettings::query()->whereKey(0)->update(['is_api_enabled' => false]); + Once::flush(); + + $response = mcpWriteCall(mcpWriteToken($this->user), 'manage_project', ['action' => 'create', 'data' => ['name' => 'Api Off']]); + + expect(mcpWriteIsError($response))->toBeFalse() + ->and(Project::where('name', 'Api Off')->exists())->toBeTrue(); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index 54870d3f8c..a71e1ea041 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -87,6 +87,8 @@ it('creates Server with all fillable attributes', function () { 'hetzner_server_status' => 'running', 'digitalocean_droplet_id' => 987654, 'digitalocean_droplet_status' => 'active', + 'hostinger_virtual_machine_id' => 17923, + 'hostinger_virtual_machine_status' => 'running', 'is_validating' => false, 'detected_traefik_version' => 'v2.10.0', 'traefik_outdated_info' => 'Up to date', @@ -106,6 +108,8 @@ it('creates Server with all fillable attributes', function () { expect($server->hetzner_server_status)->toBe('running'); expect($server->digitalocean_droplet_id)->toBe(987654); expect($server->digitalocean_droplet_status)->toBe('active'); + expect($server->hostinger_virtual_machine_id)->toBe(17923); + expect($server->hostinger_virtual_machine_status)->toBe('running'); expect($server->ip_previous)->toBe('10.0.0.1'); }); diff --git a/tests/Feature/MultiServerApplicationTest.php b/tests/Feature/MultiServerApplicationTest.php index 2f586e07b8..8f47010ac3 100644 --- a/tests/Feature/MultiServerApplicationTest.php +++ b/tests/Feature/MultiServerApplicationTest.php @@ -320,6 +320,25 @@ describe('combined deployment notification', function () { Notification::assertNothingSent(); }); + test('takes commit details for the summary from the main deployment', function () { + $parent = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => new_public_id(), + 'server_id' => $this->mainServer->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', + 'commit_message' => 'Add checkout page', + ]); + Cache::put("multi-server-deployment-queued:{$parent->deployment_uuid}", true); + $child = createMultiServerDeployment($this->application, $this->extraServer, ApplicationDeploymentStatus::FINISHED->value, $parent->deployment_uuid); + $job = makeMultiServerDeploymentJob(['application' => $this->application, 'application_deployment_queue' => $child]); + + callMultiServerDeploymentJob($job, 'sendMultiServerDeploymentNotification', $parent->deployment_uuid); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === $parent->commit + && $notification->commit_message === 'Add checkout page'); + }); + test('sends a failed notification that links to the failed server deployment', function () { $parentUuid = new_public_id(); Cache::put("multi-server-deployment-queued:{$parentUuid}", true); diff --git a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php index 5563e87769..47b215d5f0 100644 --- a/tests/Feature/MutableLivewireComponentsAuthorizationTest.php +++ b/tests/Feature/MutableLivewireComponentsAuthorizationTest.php @@ -182,7 +182,7 @@ it('authorizes every volume backup form control', function (string $path, array [ '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Enable S3\s*<\/x-forms\.button>/s', '/]*wire:click="toggleS3")(?=[^>]*canGate="update")(?=[^>]*:canResource="\$resource")[^>]*>\s*Disable S3\s*<\/x-forms\.button>/s', - '/ 0]); + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); +}); + +function renderNavbarAt(string $path, bool $mobile = false): string +{ + app()->instance('request', Request::create($path)); + + return (string) test()->blade($mobile ? '' : ''); +} + +function loginNavbarUser(string $role): void +{ + test()->team->members()->attach(test()->user->id, ['role' => $role]); + test()->actingAs(test()->user); + session(['currentTeam' => test()->team]); +} + +test('a submenu page opens its submenu with the current page active', function () { + loginNavbarUser('owner'); + + $html = renderNavbarAt('/metrics'); + + expect($html) + ->toContain('Back to the main menu') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Metrics#s'); +}); + +test('a page outside every submenu starts on the main menu', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->not->toContain('data-active-submenu="'); +}); + +test('a submenu entry links to its first visible page', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/servers'))->toContain('href="'.route('analytics').'"'); +}); + +test('the registries page is hidden from members in the integrations submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/sources')) + ->toContain('Destinations') + ->not->toContain('href="'.route('registries.index').'"'); +}); + +test('the team submenu lists the team pages by role', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/team/members')) + ->toContain('href="'.route('team.member.index').'"') + ->toContain('href="'.route('team.audit-log').'"') + ->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('members do not see the audit log or danger zone in the team submenu', function () { + loginNavbarUser('member'); + + expect(renderNavbarAt('/team')) + ->toContain('href="'.route('team.member.index').'"') + ->not->toContain('href="'.route('team.audit-log').'"') + ->not->toContain('href="'.route('team.danger-zone').'"'); +}); + +test('notifications and keys & tokens open their own submenus', function () { + loginNavbarUser('owner'); + + expect(renderNavbarAt('/notifications/discord')) + ->toContain('href="'.route('notifications.slack').'"') + ->toMatch('#aria-current="page"[^>]*>.*?discord\.svg#s'); + + expect(renderNavbarAt('/security/api-tokens')) + ->toContain('href="'.route('security.private-key.index').'"') + ->toContain('Back to the main menu'); +}); + +test('the admin link belongs to the main menu, not to submenus', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/team')) + ->toMatch('#]*>\s*toContain('Back to the main menu') + ->toContain('href="'.route('shared-variables.team.index').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Servers#s'); +}); + +test('instance settings open their own submenu for instance admins', function () { + config()->set('app.env', 'local'); + $root = User::factory()->create(['id' => 0]); + test()->team->members()->attach($root->id, ['role' => 'owner']); + test()->actingAs($root); + session(['currentTeam' => test()->team]); + + expect(renderNavbarAt('/settings/email')) + ->toContain('Back to the main menu') + ->toContain('href="'.route('settings.oauth').'"') + ->toMatch('#aria-current="page"[^>]*>\s*]*>.*?\s*]*>Email#s'); +}); + +test('a parent entry opens its submenu in place on mobile and desktop', function () { + loginNavbarUser('owner'); + + foreach ([renderNavbarAt('/servers', mobile: true), renderNavbarAt('/servers')] as $html) { + expect($html) + ->toContain('x-show="submenu === \'Monitoring\'"') + ->toMatch('#