diff --git a/.github/workflows/coolify-release-branch-build.yml b/.github/workflows/coolify-release-branch-build.yml deleted file mode 100644 index 8b9e4bb4e6..0000000000 --- a/.github/workflows/coolify-release-branch-build.yml +++ /dev/null @@ -1,144 +0,0 @@ -name: Build Coolify Release Branch - -on: - push: - branches: ["release/v*"] - paths-ignore: - - .github/workflows/coolify-helper.yml - - .github/workflows/coolify-helper-next.yml - - .github/workflows/pr-quality.yaml - - docker/coolify-helper/Dockerfile - - docker/testing-host/Dockerfile - - templates/** - - CHANGELOG.md - -permissions: - contents: read - packages: write - -concurrency: - group: coolify-release-branch-build-${{ github.ref_name }} - cancel-in-progress: false - -env: - GITHUB_REGISTRY: ghcr.io - DOCKER_REGISTRY: docker.io - IMAGE_NAME: coollabsio/coolify - -jobs: - prepare: - runs-on: ubuntu-24.04 - outputs: - dev_version: ${{ steps.version.outputs.dev_version }} - short_sha: ${{ steps.version.outputs.short_sha }} - version: ${{ steps.version.outputs.version }} - steps: - - name: Resolve version from branch name - id: version - env: - BRANCH: ${{ github.ref_name }} - run: | - if [[ ! "${BRANCH}" =~ ^release/v([0-9]+)\.([0-9]+)$ ]]; then - echo "Invalid release branch: ${BRANCH}. Expected release/vX.Y, such as release/v4.5." - exit 1 - fi - - DEV_VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}-dev" - SHORT_SHA="${GITHUB_SHA::7}" - echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT" - echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "version=${DEV_VERSION}.${SHORT_SHA}" >> "$GITHUB_OUTPUT" - - build: - needs: prepare - 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: - persist-credentials: false - - - 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: Build and push release branch 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.prepare.outputs.version }} - tags: | - ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} - ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.prepare.outputs.version }}-${{ matrix.arch }} - - publish: - needs: [prepare, build] - runs-on: ubuntu-24.04 - strategy: - matrix: - registry: [ghcr.io, docker.io] - steps: - - uses: docker/setup-buildx-action@v3 - - - name: Login to ${{ env.GITHUB_REGISTRY }} - if: matrix.registry == 'ghcr.io' - uses: docker/login-action@v3 - with: - registry: ${{ env.GITHUB_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Login to ${{ env.DOCKER_REGISTRY }} - if: matrix.registry == 'docker.io' - uses: docker/login-action@v3 - with: - registry: ${{ env.DOCKER_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Publish release branch manifest on ${{ matrix.registry }} - env: - REGISTRY: ${{ matrix.registry }} - DEV_VERSION: ${{ needs.prepare.outputs.dev_version }} - VERSION: ${{ needs.prepare.outputs.version }} - run: | - IMAGE="${REGISTRY}/${IMAGE_NAME}" - docker buildx imagetools create \ - "${IMAGE}:${VERSION}-amd64" \ - "${IMAGE}:${VERSION}-aarch64" \ - --tag "${IMAGE}:${VERSION}" \ - --tag "${IMAGE}:${DEV_VERSION}" - - notify: - needs: [prepare, build, publish] - if: always() - runs-on: ubuntu-24.04 - steps: - - uses: sarisia/actions-status-discord@v1 - with: - webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }} - status: ${{ contains(needs.*.result, 'failure') && 'failure' || (contains(needs.*.result, 'cancelled') && 'cancelled' || 'success') }} diff --git a/.github/workflows/sync-main-to-next.yml b/.github/workflows/sync-main-to-next.yml new file mode 100644 index 0000000000..6e520633ae --- /dev/null +++ b/.github/workflows/sync-main-to-next.yml @@ -0,0 +1,65 @@ +name: Sync main to next + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-main-to-next + cancel-in-progress: false + +jobs: + sync: + name: Merge main into next + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Merge main into next + env: + GH_TOKEN: ${{ github.token }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git fetch origin main next + git checkout --quiet -B next origin/next + + if git merge --no-edit origin/main; then + git push origin HEAD:refs/heads/next + exit 0 + fi + + conflicts=$(git diff --name-only --diff-filter=U) + git merge --abort + + if [ -z "$conflicts" ]; then + echo '::error title=main to next sync failed::The merge failed without conflicts, so no pull request was created.' + exit 1 + fi + + sync_branch='automation/sync-main-to-next' + existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url') + if [ -n "$existing_pr" ]; then + # The conflict was already reported when this pull request was opened. + # Stay green until it is merged instead of failing every scheduled run. + echo "::notice title=main to next sync blocked::main still conflicts with next; resolve the open pull request: $existing_pr" + exit 0 + fi + + git push --force origin origin/main:"refs/heads/$sync_branch" + gh pr create \ + --base next \ + --head "$sync_branch" \ + --title 'chore: merge main into next' \ + --body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.' + + echo '::error title=main to next sync blocked::main could not be merged into next without conflicts; a pull request was opened to resolve them.' + exit 1 diff --git a/.github/workflows/sync-main-to-release.yml b/.github/workflows/sync-main-to-release.yml deleted file mode 100644 index 6ea7ea0768..0000000000 --- a/.github/workflows/sync-main-to-release.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Sync main to release branches - -on: - schedule: - - cron: '*/10 * * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-main-to-release - cancel-in-progress: false - -jobs: - sync: - name: Merge main into release branches - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Merge main into every release/v* branch - env: - GH_TOKEN: ${{ github.token }} - run: | - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git fetch origin main '+refs/heads/release/v*:refs/remotes/origin/release/v*' - - branches=$(git for-each-ref --format='%(refname:lstrip=3)' 'refs/remotes/origin/release/v*') - if [ -z "$branches" ]; then - echo 'No release/v* branch exists, so there is nothing to sync.' - exit 0 - fi - - failed=0 - for branch in $branches; do - echo "::group::Sync main into $branch" - git checkout --quiet -B "$branch" "origin/$branch" - - if git merge --no-edit origin/main; then - git push origin "HEAD:refs/heads/$branch" - echo '::endgroup::' - continue - fi - - conflicts=$(git diff --name-only --diff-filter=U) - git merge --abort - echo '::endgroup::' - - if [ -z "$conflicts" ]; then - echo "::error title=main to $branch sync failed::The merge failed without conflicts, so no pull request was created." - failed=1 - continue - fi - - sync_branch="automation/sync-main-to-${branch//\//-}" - existing_pr=$(gh pr list --base "$branch" --head "$sync_branch" --state open --json url --jq '.[0].url') - if [ -n "$existing_pr" ]; then - # The conflict was already reported when this pull request was opened. - # Stay green until it is merged instead of failing every scheduled run. - echo "::notice title=main to $branch sync blocked::main still conflicts with $branch; resolve the open pull request: $existing_pr" - continue - fi - - git push --force origin origin/main:"refs/heads/$sync_branch" - gh pr create \ - --base "$branch" \ - --head "$sync_branch" \ - --title "chore: merge main into $branch" \ - --body "This pull request was created automatically because main could not be merged into $branch without conflicts. Resolve conflicts on this temporary branch; never update main with $branch." - - echo "::error title=main to $branch sync blocked::main could not be merged into $branch without conflicts; a pull request was opened to resolve them." - failed=1 - done - - exit "$failed" diff --git a/AGENTS.md b/AGENTS.md index 3016656c89..661a920c56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,8 +267,8 @@ The persistent-storage implementation is the reference pattern: `Project\Service ## Git Workflow - Production branch: `main` -- Development branch: `release/v4.x`, where `x` is the next minor release (for example, `release/v4.5` while production is `4.4.x`). Do not use `next`. -- Fix PRs should target the current production branch; feature PRs should target the current `release/v4.x` branch +- Development branch: `next`. Do not create `release/v*` branches. +- Fix PRs should target the current production branch; feature PRs should target `next` === foundation rules === diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 977b90919a..d93150c6bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,12 +193,11 @@ Choose the branch based on the type of change: | --- | --- | --- | | Fixes and small improvements | `main` | `main` | | Security fixes | `main` | `main` | -| New features and larger changes | `release/v4.x` | `release/v4.x` | +| New features and larger changes | `next` | `next` | - For a fix, branch from `main` and target `main`. -- For a feature, branch from `release/v4.x` and target `release/v4.x`. -- `x` is the number of the next minor release. For example, when the production version is `4.4.x`, use `release/v4.5`. There is no `next` branch. -- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `release/v4.x` so the fix is included there too. +- For a feature, branch from `next` and target `next`. +- If a fix is discovered while developing a feature, submit it separately to `main`. `main` is merged into `next` automatically, so the fix is included there too. - Pull requests targeting the wrong branch may be closed or asked to retarget. diff --git a/RELEASE.md b/RELEASE.md index 15cc64527d..e0847d2cd8 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,54 +5,54 @@ | Branch | Purpose | | --- | --- | | `main` | Latest production source | -| `release/v4.x` | Feature integration and RC releases for the next minor release, such as `release/v4.5` | -| `feature/*` | New features based on and merged into the current `release/v4.x` branch | +| `next` | Feature integration and RC releases for the next version | +| `feature/*` | New features based on and merged into `next` | | `hotfix/X.Y.Z` | Production fixes based on `main` | -`x` is the number of the next minor release. When `4.4.x` is the production version, the release branch is `release/v4.5`. Create a new `release/v4.x` branch from `main` after each stable minor release. Do not use a long-lived `next` branch. +`next` is the only long-lived development branch. Do not create `release/v*` branches. Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`. ## Where changes go - Fixes, security updates, and small improvements target `main`. -- New features and larger changes target the current `release/v4.x` branch. -- **Sync main to release branches** merges `main` into every `release/v*` branch every 10 minutes, so every production fix is included in the next release. If a merge conflicts, it opens a `chore: merge main into release/vX.Y` pull request to resolve. -- Do not merge `release/v4.x` into `main` until an RC is approved for a stable release. +- New features and larger changes target `next`. +- **Sync main to next** merges `main` into `next` every 10 minutes, so every production fix is included in the next release. If the merge conflicts, it opens a `chore: merge main into next` pull request to resolve. +- Do not merge `next` into `main` until an RC is approved for a stable release. ## Feature and RC flow ```text -feature/* → release/v4.x → RC +feature/* → next → RC ``` -1. Merge feature branches into `release/v4.x`. +1. Merge feature branches into `next`. 2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.5-rc.1`. -3. Every push to `release/v4.x` runs **Build Coolify Release Branch**. It calculates the version from the branch name and publishes `4.5-dev.` and the moving `4.5-dev` tag. It never publishes an RC or stable tag. +3. Every push to `next` runs **Build Coolify Next**. It publishes `sha-`, `4.5-rc.1.`, and the moving `next` tag. It never publishes the exact `4.5-rc.1` tag. 4. Create a reviewed draft GitHub Release named `v4.5-rc.1` and mark it as a prerelease. -5. Run **Release Coolify RC** manually from `release/v4.x` and enter `v4.5-rc.1`. +5. Run **Release Coolify RC** manually from `next` and enter `v4.5-rc.1`. 6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.5-rc.1`, updates the `next` image tag, and publishes the draft prerelease. 7. Advance `coolify.nightly.version` to the next intended RC version. ## Stable release flow ```text -release/v4.x → main → stable release +next → main → stable release ``` -1. Temporarily stop merging features into `release/v4.x`. -2. Change the version on `release/v4.x` from the approved RC to the stable version, such as `4.5.0`. -3. Merge `release/v4.x` into `main`. +1. Temporarily stop merging features into `next`. +2. Change the version on `next` from the approved RC to the stable version, such as `4.5.0`. +3. Merge `next` into `main`. 4. Create a reviewed draft GitHub Release named `v4.5.0`. 5. Run the stable release workflow from `main`. 6. The workflow rebuilds the exact stable version, publishes `4.5.0` and `latest`, then publishes the draft. 7. Update the CDN only after the release is approved. -8. Create the next release branch from `main`, such as `release/v4.6`, and set its development version. Delete the old release branch. +8. Advance `next` to the next development version, such as `4.6-rc.1`. ## Hotfix flow ```text -main → hotfix/X.Y.Z → main → release/v4.x +main → hotfix/X.Y.Z → main → next ``` 1. Create `hotfix/X.Y.Z` from `main` when a patch needs an integration branch. A single fix may use a normal branch from `main` instead. @@ -61,7 +61,7 @@ main → hotfix/X.Y.Z → main → release/v4.x 4. Merge the fix into `main`. 5. Create a reviewed draft GitHub Release named `vX.Y.Z`. 6. Run the stable release workflow from `main`. -7. Merge `main` into `release/v4.x`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used. +7. **Sync main to next** merges `main` into `next`. If the version conflicts, resolve it in favor of the next intended RC in the sync pull request. Delete the hotfix branch if one was used. 8. Update the CDN only after the release is approved. ## Image tags @@ -69,9 +69,8 @@ main → hotfix/X.Y.Z → main → release/v4.x | Tag | Meaning | | --- | --- | | `latest` | Latest stable release | -| `next` | Latest successful `next` build (legacy branch) | -| `X.Y-dev` | Latest successful `release/vX.Y` build | -| `X.Y-dev.` | Exact `release/vX.Y` commit build | +| `next` | Latest successful `next` build or RC release | +| `X.Y-rc.N.` | Exact `next` commit build | | `X.Y.Z` | Exact stable release | | `X.Y-rc.N` | Exact RC release | | `sha-` | Exact commit build | diff --git a/app/Http/Controllers/Api/IntegrationTokensController.php b/app/Http/Controllers/Api/IntegrationTokensController.php index 13a225a107..afd31bc699 100644 --- a/app/Http/Controllers/Api/IntegrationTokensController.php +++ b/app/Http/Controllers/Api/IntegrationTokensController.php @@ -64,9 +64,11 @@ class IntegrationTokensController extends Controller } elseif (($body['provider'] ?? null) === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } elseif (($body['provider'] ?? null) === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } $validator = customApiValidator($body, $rules); diff --git a/app/Livewire/Security/IntegrationTokenEditor.php b/app/Livewire/Security/IntegrationTokenEditor.php index 6a4c7ce2e6..811b7f7712 100644 --- a/app/Livewire/Security/IntegrationTokenEditor.php +++ b/app/Livewire/Security/IntegrationTokenEditor.php @@ -76,11 +76,13 @@ class IntegrationTokenEditor extends Component if ($this->integrationToken->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->integrationToken->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Livewire/Security/IntegrationTokenForm.php b/app/Livewire/Security/IntegrationTokenForm.php index 54ce0b704e..f05a5879f6 100644 --- a/app/Livewire/Security/IntegrationTokenForm.php +++ b/app/Livewire/Security/IntegrationTokenForm.php @@ -78,6 +78,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'infisical') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.client_id'] = ['required', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } if ($this->provider === 'doppler') { @@ -87,6 +88,7 @@ class IntegrationTokenForm extends Component if ($this->provider === 'vault') { $rules['metadata.base_url'] = ['required', 'url:http,https']; $rules['metadata.namespace'] = ['nullable', 'string']; + $rules['metadata.timeout'] = ['nullable', 'integer', 'min:1', 'max:300']; } return $rules; diff --git a/app/Livewire/Server/PrivateKey/Show.php b/app/Livewire/Server/PrivateKey/Show.php index 7bc02aa03d..97eb760d2c 100644 --- a/app/Livewire/Server/PrivateKey/Show.php +++ b/app/Livewire/Server/PrivateKey/Show.php @@ -30,7 +30,10 @@ class Show extends Component private function loadPrivateKeys(): void { - $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get()->where('is_git_related', false); + $this->privateKeys = PrivateKey::where('team_id', $this->server->team_id)->get() + ->where('is_git_related', false) + ->sortBy(fn (PrivateKey $privateKey) => $privateKey->id !== $this->server->private_key_id) + ->values(); } public function setPrivateKey($privateKeyId) @@ -51,6 +54,7 @@ class Show extends Component throw new \Exception($error); } }); + $this->loadPrivateKeys(); $this->dispatch('success', 'Private key updated successfully.'); $this->dispatch('refreshServerShow'); } catch (\Exception $e) { diff --git a/app/Models/IntegrationToken.php b/app/Models/IntegrationToken.php index 05f8174c70..10860b360b 100644 --- a/app/Models/IntegrationToken.php +++ b/app/Models/IntegrationToken.php @@ -121,6 +121,16 @@ class IntegrationToken extends BaseModel return $this->provider === 'cloudflare' && data_get($this->metadata, 'proxied_dns') === true; } + /** + * Optional request timeout in seconds for self-hosted secret managers. + * + * @param array $metadata + */ + public static function requestTimeout(array $metadata): ?int + { + return filled($metadata['timeout'] ?? null) ? (int) $metadata['timeout'] : null; + } + public function dopplerTokenType(): ?string { if ($this->provider !== 'doppler') { diff --git a/app/Models/SecretManagerLink.php b/app/Models/SecretManagerLink.php index 0e47cf63ed..20a8ff55d6 100644 --- a/app/Models/SecretManagerLink.php +++ b/app/Models/SecretManagerLink.php @@ -63,6 +63,7 @@ class SecretManagerLink extends BaseModel data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token->token, + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'project_id'), (string) data_get($settings, 'environment'), @@ -72,6 +73,7 @@ class SecretManagerLink extends BaseModel (string) data_get($metadata, 'base_url'), $token->token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->fetchSecrets( (string) data_get($settings, 'mount', 'secret'), (string) data_get($settings, 'path'), diff --git a/app/Services/InfisicalService.php b/app/Services/InfisicalService.php index 1d3e87e48c..3be8de7728 100644 --- a/app/Services/InfisicalService.php +++ b/app/Services/InfisicalService.php @@ -14,7 +14,7 @@ class InfisicalService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $clientId, private string $clientSecret) + public function __construct(string $baseUrl, private string $clientId, private string $clientSecret, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -87,7 +87,7 @@ class InfisicalService { return Http::acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); } } diff --git a/app/Services/IntegrationTokenValidator.php b/app/Services/IntegrationTokenValidator.php index 6033ce98f7..2872a83a91 100644 --- a/app/Services/IntegrationTokenValidator.php +++ b/app/Services/IntegrationTokenValidator.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Models\IntegrationToken; + /** * Validates an integration token against its provider API before it is saved. */ @@ -16,11 +18,13 @@ class IntegrationTokenValidator (string) data_get($metadata, 'base_url', 'https://app.infisical.com'), (string) data_get($metadata, 'client_id'), $token, + IntegrationToken::requestTimeout($metadata), ))->validate(), 'vault' => (new VaultService( (string) data_get($metadata, 'base_url'), $token, data_get($metadata, 'namespace'), + IntegrationToken::requestTimeout($metadata), ))->validate(), default => false, }; diff --git a/app/Services/VaultService.php b/app/Services/VaultService.php index e41652cd54..852b749303 100644 --- a/app/Services/VaultService.php +++ b/app/Services/VaultService.php @@ -14,7 +14,7 @@ class VaultService /** @var array */ private array $httpClientOptions; - public function __construct(string $baseUrl, private string $token, private ?string $namespace = null) + public function __construct(string $baseUrl, private string $token, private ?string $namespace = null, private ?int $timeout = null) { $this->baseUrl = rtrim($baseUrl, '/'); Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate(); @@ -56,8 +56,8 @@ class VaultService $client = Http::withHeaders(['X-Vault-Token' => $this->token]) ->acceptJson() ->withOptions($this->httpClientOptions) - ->connectTimeout(5) - ->timeout(10); + ->connectTimeout($this->timeout ?? 5) + ->timeout($this->timeout ?? 10); if (filled($this->namespace)) { $client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]); diff --git a/other/nightly/versions.json b/other/nightly/versions.json index 50f381b7b6..39fa524e2b 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,20 +1,20 @@ { "coolify": { "v4": { - "version": "4.5.0" + "version": "4.4.7" }, "nightly": { - "version": "4.6-rc.1" + "version": "4.5-rc.1" }, "helper": { - "version": "1.0.18" + "version": "1.0.17" }, "sentinel": { "version": "1.1.2" }, "sentinel-host": { - "version": "1.1.1", - "digest": "sha256:b8b686616eb09fc014c616eeb6ff6c2c96fd62429d2fafa06706695fdbe72f1b" + "version": "1.1.2", + "digest": "sha256:149d2097943716d30af9659eeeb11259601335442242ecb0b0331982a82a3bd2" } }, "traefik": { diff --git a/resources/views/livewire/security/integration-token-editor.blade.php b/resources/views/livewire/security/integration-token-editor.blade.php index 46882ba513..310d32de76 100644 --- a/resources/views/livewire/security/integration-token-editor.blade.php +++ b/resources/views/livewire/security/integration-token-editor.blade.php @@ -20,12 +20,16 @@ + @elseif ($integrationToken->provider === 'vault')
+
@endif diff --git a/resources/views/livewire/security/integration-token-form.blade.php b/resources/views/livewire/security/integration-token-form.blade.php index 004c642f03..4f029f6318 100644 --- a/resources/views/livewire/security/integration-token-form.blade.php +++ b/resources/views/livewire/security/integration-token-form.blade.php @@ -17,6 +17,8 @@ + @else
@@ -34,6 +36,8 @@ placeholder="https://vault.example.com:8200" /> +
@endif diff --git a/scripts/restore-backup-vm.sh b/scripts/restore-backup-vm.sh index 7de0291c13..856e8fca1c 100755 --- a/scripts/restore-backup-vm.sh +++ b/scripts/restore-backup-vm.sh @@ -9,6 +9,9 @@ # Backup file pg_dump custom-format backup (.dmp), optionally gzipped (.gz) # APP_KEY APP_KEY of the instance that created the backup (base64:...), not shown # Version Coolify version or image tag, e.g. 4.3.23 or sha-99c8409 (empty: latest stable) +# Cloud y for a Coolify Cloud backup: starts Coolify with SELF_HOSTED=false, as Cloud runs. +# Self-hosted mode on a Cloud backup adds the localhost server and makes app:init +# try SSH to every server, so Reverb does not start and the container stays unhealthy. # # Flow: # 1. Base image (once per version, until destroy): a separate builder VM with internet access installs @@ -52,7 +55,7 @@ die() { } usage() { - sed -n '2,11p' "$0" | sed 's/^# \{0,1\}//' + sed -n '2,14p' "$0" | sed 's/^# \{0,1\}//' exit 1 } @@ -204,6 +207,7 @@ BACKUP_FILE=${BACKUP_FILE/#\~/$HOME} read -r -s -p "APP_KEY of the backed-up instance (input is hidden): " APP_KEY echo read -r -p "Coolify version or image tag (empty for latest stable): " VERSION +read -r -p "Is this a Coolify Cloud backup? [y/N]: " CLOUD_BACKUP [ -s "$BACKUP_FILE" ] || die "Backup file '$BACKUP_FILE' does not exist or is empty." [[ "$APP_KEY" == base64:* ]] || die "APP_KEY must start with 'base64:'." @@ -216,6 +220,7 @@ if [ -z "$VERSION" ]; then VERSION=$(curl -fsSL https://cdn.coollabs.io/coolify/versions.json | jq -r '.coolify.v4.version') echo "Latest stable version: $VERSION" fi +[[ "${CLOUD_BACKUP,,}" =~ ^(y|yes)$ ]] && SELF_HOSTED=false || SELF_HOSTED=true [[ "$VERSION" =~ ^[A-Za-z0-9._-]+$ ]] || die "Version '$VERSION' contains characters that are not allowed." BASE_IMAGE="$IMAGE_DIR/coolify-base-$VERSION.qcow2" @@ -272,6 +277,7 @@ log "Restoring the backup and starting Coolify (migrations run on start)" # Send APP_KEY through stdin, so it does not show in the process list of this host or the VM. { printf 'APP_KEY=%q\n' "$APP_KEY" + printf 'SELF_HOSTED=%q\n' "$SELF_HOSTED" cat <<'REMOTE' set -euo pipefail cd /data/coolify/source @@ -295,6 +301,8 @@ for flag in HORIZON_ENABLED SCHEDULER_ENABLED; do sed -i "/^${flag}=/d" .env echo "${flag}=false" >>.env done +sed -i '/^SELF_HOSTED=/d' .env +echo "SELF_HOSTED=${SELF_HOSTED}" >>.env # Recreate the database: tables from a newer fresh install must not remain. docker exec coolify-db psql -q -U coolify -d postgres -v ON_ERROR_STOP=1 \ diff --git a/tests/Feature/SecretManagers/SecretManagerLinkTest.php b/tests/Feature/SecretManagers/SecretManagerLinkTest.php index acaa33f7a7..c7074ceb8d 100644 --- a/tests/Feature/SecretManagers/SecretManagerLinkTest.php +++ b/tests/Feature/SecretManagers/SecretManagerLinkTest.php @@ -187,6 +187,25 @@ test('a vault link uses the base url and namespace from the token metadata', fun Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a')); }); +test('an infisical link uses the request timeout from the token metadata', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return str_contains($request->url(), '/auth/universal-auth/login') + ? Http::response(['accessToken' => 'short-lived-token']) + : Http::response(['secrets' => [['secretKey' => 'KEY', 'secretValue' => 'value']]]); + }); + + $link = createSecretManagerLink('infisical', + ['project_id' => 'project-1', 'environment' => 'prod'], + ['base_url' => 'https://example.com', 'client_id' => 'client-id', 'timeout' => '30'], + ); + + expect($link->fetchSecrets())->toBe(['KEY' => 'value']) + ->and($timeouts)->toBe([[30, 30], [30, 30]]); +}); + test('services resolve environment variables from their configured secret manager', function () { Http::fake([ 'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([ diff --git a/tests/Feature/SecretManagers/SecretManagerServicesTest.php b/tests/Feature/SecretManagers/SecretManagerServicesTest.php index f6a2c34ac3..7618c6cb96 100644 --- a/tests/Feature/SecretManagers/SecretManagerServicesTest.php +++ b/tests/Feature/SecretManagers/SecretManagerServicesTest.php @@ -125,6 +125,21 @@ describe('InfisicalService', function () { && $request['expandSecretReferences'] === 'true'); }); + test('uses a 5 second connect timeout and a 10 second request timeout by default', function (?int $timeout, array $expected) { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = [$options['connect_timeout'], $options['timeout']]; + + return Http::response(['accessToken' => 'short-lived-token']); + }); + + expect((new InfisicalService('https://example.com', 'client-id', 'client-secret', $timeout))->validate())->toBeTrue() + ->and($timeouts)->toBe([$expected]); + })->with([ + 'default' => [null, [5, 10]], + 'custom' => [60, [60, 60]], + ]); + test('throws when the login fails', function () { Http::fake([ 'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([ diff --git a/tests/Feature/Security/IntegrationTokenFormTest.php b/tests/Feature/Security/IntegrationTokenFormTest.php index 2344563ca4..4d90de6173 100644 --- a/tests/Feature/Security/IntegrationTokenFormTest.php +++ b/tests/Feature/Security/IntegrationTokenFormTest.php @@ -470,6 +470,39 @@ test('saving a vault token with unchanged connection settings and a blank token ->and($savedToken->metadata)->toBe(['base_url' => 'https://example.com:8200', 'namespace' => 'team-a']); }); +test('changing the vault request timeout keeps the stored token and validates with the new timeout', function () { + $timeouts = []; + Http::fake(function ($request, array $options) use (&$timeouts) { + $timeouts[] = $options['timeout']; + + return Http::response(['data' => []]); + }); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', '45') + ->call('save') + ->assertHasNoErrors() + ->assertDispatched('success'); + + $savedToken->refresh(); + expect($timeouts)->toBe([45]) + ->and($savedToken->token)->toBe('hvs.stored-secret') + ->and(IntegrationToken::requestTimeout($savedToken->metadata))->toBe(45); +}); + +test('a request timeout outside 1 to 300 seconds is rejected', function (string $timeout) { + Http::fake(); + $savedToken = vaultIntegrationToken($this->team); + + Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $savedToken->uuid]) + ->set('metadata.timeout', $timeout) + ->call('save') + ->assertHasErrors(['metadata.timeout']); + + Http::assertNothingSent(); +})->with(['0', '301', 'abc']); + test('the token list shows its empty state after the editor deletes the last token', function () { $token = IntegrationToken::query()->create([ 'team_id' => $this->team->id, diff --git a/tests/Feature/ServerPrivateKeyDropdownTest.php b/tests/Feature/ServerPrivateKeyDropdownTest.php index 8951491a97..a8d9aaeced 100644 --- a/tests/Feature/ServerPrivateKeyDropdownTest.php +++ b/tests/Feature/ServerPrivateKeyDropdownTest.php @@ -135,4 +135,13 @@ describe('after the user switches the session team', function () { expect($this->server->fresh()->private_key_id)->toBe($this->sameTeamKey->id); }); + + test('lists the active key first', function () { + expect($this->component->get('privateKeys')->first()->id)->toBe($this->currentPrivateKey->id); + + $keys = $this->component->call('setPrivateKey', $this->sameTeamKey->id)->get('privateKeys'); + + expect($keys->first()->id)->toBe($this->sameTeamKey->id) + ->and($keys->pluck('id'))->toContain($this->currentPrivateKey->id); + }); }); diff --git a/versions.json b/versions.json index 50f381b7b6..39fa524e2b 100644 --- a/versions.json +++ b/versions.json @@ -1,20 +1,20 @@ { "coolify": { "v4": { - "version": "4.5.0" + "version": "4.4.7" }, "nightly": { - "version": "4.6-rc.1" + "version": "4.5-rc.1" }, "helper": { - "version": "1.0.18" + "version": "1.0.17" }, "sentinel": { "version": "1.1.2" }, "sentinel-host": { - "version": "1.1.1", - "digest": "sha256:b8b686616eb09fc014c616eeb6ff6c2c96fd62429d2fafa06706695fdbe72f1b" + "version": "1.1.2", + "digest": "sha256:149d2097943716d30af9659eeeb11259601335442242ecb0b0331982a82a3bd2" } }, "traefik": {