mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
chore: merge main into release/v4.5 (#12115)
This commit is contained in:
@@ -1,65 +0,0 @@
|
||||
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 next
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: next
|
||||
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
|
||||
|
||||
if git merge --no-edit origin/main; then
|
||||
git push origin HEAD:next
|
||||
exit 0
|
||||
fi
|
||||
|
||||
conflicts=$(git diff --name-only --diff-filter=U)
|
||||
git merge --abort
|
||||
|
||||
if [ -z "$conflicts" ]; then
|
||||
echo '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
|
||||
@@ -0,0 +1,81 @@
|
||||
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"
|
||||
@@ -235,8 +235,8 @@ The persistent-storage implementation is the reference pattern: `Project\Service
|
||||
## Git Workflow
|
||||
|
||||
- Production branch: `main`
|
||||
- Development branch: `next`
|
||||
- Fix PRs should target the current production branch; feature PRs should target `next`
|
||||
- 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
|
||||
|
||||
<laravel-boost-guidelines>
|
||||
=== foundation rules ===
|
||||
|
||||
+4
-3
@@ -193,11 +193,12 @@ Choose the branch based on the type of change:
|
||||
| --- | --- | --- |
|
||||
| Fixes and small improvements | `main` | `main` |
|
||||
| Security fixes | `main` | `main` |
|
||||
| New features and larger changes | `next` | `next` |
|
||||
| New features and larger changes | `release/v4.x` | `release/v4.x` |
|
||||
|
||||
- For a fix, branch from `main` and target `main`.
|
||||
- For a feature, branch from `next` and target `next`.
|
||||
- If a fix is discovered while developing a feature, submit it separately to `main`. Maintainers will merge `main` into `next` so the fix is included there too.
|
||||
- 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.
|
||||
- Pull requests targeting the wrong branch may be closed or asked to retarget.
|
||||
|
||||
|
||||
|
||||
+27
-23
@@ -5,52 +5,54 @@
|
||||
| Branch | Purpose |
|
||||
| --- | --- |
|
||||
| `main` | Latest production source |
|
||||
| `next` | Feature integration and RC releases |
|
||||
| `feature/*` | New features based on and merged into `next` |
|
||||
| `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 |
|
||||
| `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.
|
||||
|
||||
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 `next`.
|
||||
- Merge `main` into `next` regularly so every production fix is included in the next release.
|
||||
- Do not merge `next` into `main` until an RC is approved for a stable release.
|
||||
- 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.
|
||||
|
||||
## Feature and RC flow
|
||||
|
||||
```text
|
||||
feature/* → next → RC
|
||||
feature/* → release/v4.x → RC
|
||||
```
|
||||
|
||||
1. Merge feature branches into `next`.
|
||||
2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`.
|
||||
3. Regular `next` builds publish `sha-<short-sha>`, `4.4-rc.1.<short-sha>`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag.
|
||||
4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease.
|
||||
5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`.
|
||||
6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease.
|
||||
1. Merge feature branches into `release/v4.x`.
|
||||
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.<short-sha>` and the moving `4.5-dev` tag. It never publishes an RC or stable 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`.
|
||||
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
|
||||
next → main → stable release
|
||||
release/v4.x → main → stable release
|
||||
```
|
||||
|
||||
1. Temporarily stop merging features into `next`.
|
||||
2. Change the version on `next` from the approved RC to the stable version, such as `4.4.0`.
|
||||
3. Merge `next` into `main`.
|
||||
4. Create a reviewed draft GitHub Release named `v4.4.0`.
|
||||
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`.
|
||||
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.4.0` and `latest`, then publishes the draft.
|
||||
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. Advance `next` to the next development version.
|
||||
8. Create the next release branch from `main`, such as `release/v4.6`, and set its development version. Delete the old release branch.
|
||||
|
||||
## Hotfix flow
|
||||
|
||||
```text
|
||||
main → hotfix/X.Y.Z → main → next
|
||||
main → hotfix/X.Y.Z → main → release/v4.x
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -59,7 +61,7 @@ main → hotfix/X.Y.Z → main → next
|
||||
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 `next`, resolve the version in favor of the next intended RC, and delete the hotfix branch if one was used.
|
||||
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.
|
||||
8. Update the CDN only after the release is approved.
|
||||
|
||||
## Image tags
|
||||
@@ -67,9 +69,11 @@ main → hotfix/X.Y.Z → main → next
|
||||
| Tag | Meaning |
|
||||
| --- | --- |
|
||||
| `latest` | Latest stable release |
|
||||
| `next` | Latest successful `next` build |
|
||||
| `next` | Latest successful `next` build (legacy branch) |
|
||||
| `X.Y-dev` | Latest successful `release/vX.Y` build |
|
||||
| `X.Y-dev.<short-sha>` | Exact `release/vX.Y` commit build |
|
||||
| `X.Y.Z` | Exact stable release |
|
||||
| `X.Y-rc.N` | Exact RC release |
|
||||
| `sha-<commit>` | Exact commit build |
|
||||
|
||||
Git tags use the `v` prefix, such as `v4.4.0`. Docker image tags do not.
|
||||
Git tags use the `v` prefix, such as `v4.5.0`. Docker image tags do not.
|
||||
|
||||
@@ -4964,8 +4964,8 @@ class ApplicationsController extends Controller
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$customLabels = base64_decode($request->custom_labels);
|
||||
if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) {
|
||||
$customLabels = decodeBase64EncodedLabels($request->custom_labels);
|
||||
if ($customLabels === null) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => [
|
||||
@@ -4973,6 +4973,7 @@ class ApplicationsController extends Controller
|
||||
],
|
||||
], 422);
|
||||
}
|
||||
$request->offsetSet('custom_labels', base64_encode($customLabels));
|
||||
}
|
||||
if ($request->has('domains') && $server->isProxyShouldRun()) {
|
||||
$uuid = $request->uuid;
|
||||
|
||||
@@ -84,7 +84,7 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x';
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->query = request()->query();
|
||||
|
||||
@@ -89,7 +89,7 @@ class PublicGitRepository extends Component
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/v4.x';
|
||||
$this->repository_url = 'https://github.com/coollabsio/coolify-examples/tree/main';
|
||||
$this->port = 3000;
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
|
||||
@@ -31,6 +31,13 @@ class ExecuteContainerCommand extends Component
|
||||
|
||||
public bool $containersLoaded = false;
|
||||
|
||||
/**
|
||||
* Container picker entries. Alpine reads them through entangle, because containers load after the first render.
|
||||
*
|
||||
* @var list<array{value: string, label: string}>
|
||||
*/
|
||||
public array $containerOptions = [];
|
||||
|
||||
protected $rules = [
|
||||
'server' => 'required',
|
||||
'container' => 'required',
|
||||
@@ -149,6 +156,10 @@ class ExecuteContainerCommand extends Component
|
||||
$this->containers = $this->containers->sortBy(function ($container) {
|
||||
return data_get($container, 'container.Names');
|
||||
});
|
||||
$this->containerOptions = $this->containers->map(fn (array $container) => [
|
||||
'value' => $this->containerTarget($container),
|
||||
'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'),
|
||||
])->values()->all();
|
||||
|
||||
if ($this->containers->count() === 1) {
|
||||
$this->selected_container = $this->containerTarget($this->containers->first());
|
||||
|
||||
@@ -2441,18 +2441,18 @@ class Application extends BaseModel
|
||||
|
||||
public function parseContainerLabels(?ApplicationPreview $preview = null)
|
||||
{
|
||||
$customLabels = data_get($this, 'custom_labels');
|
||||
if (! $customLabels) {
|
||||
$storedLabels = data_get($this, 'custom_labels');
|
||||
if (! $storedLabels) {
|
||||
return;
|
||||
}
|
||||
if (base64_encode(base64_decode($customLabels, true)) !== $customLabels) {
|
||||
$this->custom_labels = str($customLabels)->replace(',', "\n");
|
||||
$this->custom_labels = base64_encode($customLabels);
|
||||
}
|
||||
$customLabels = base64_decode($this->custom_labels);
|
||||
if (mb_detect_encoding($customLabels, 'UTF-8', true) === false) {
|
||||
|
||||
$customLabels = decodeBase64EncodedLabels($storedLabels);
|
||||
if ($customLabels === null && ! isBase64Encoded($storedLabels)) {
|
||||
$customLabels = $storedLabels;
|
||||
} elseif ($customLabels === null) {
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($this, $preview)))->replace('|coolify|', "\n");
|
||||
}
|
||||
|
||||
$this->custom_labels = base64_encode($customLabels);
|
||||
$this->save();
|
||||
|
||||
|
||||
@@ -2723,6 +2723,42 @@ function isBase64Encoded($strValue)
|
||||
{
|
||||
return base64_encode(base64_decode($strValue, true)) === $strValue;
|
||||
}
|
||||
|
||||
function decodeBase64EncodedLabels(string $value): ?string
|
||||
{
|
||||
if (! isBase64Encoded($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
if ($decoded === false) {
|
||||
return null;
|
||||
}
|
||||
$labels = $decoded;
|
||||
|
||||
while ($decoded !== '' && isBase64Encoded($decoded)) {
|
||||
$next = base64_decode($decoded, true);
|
||||
if ($next === false) {
|
||||
break;
|
||||
}
|
||||
$decoded = $next;
|
||||
if (mb_detect_encoding($decoded, 'UTF-8', true) !== false) {
|
||||
$lines = preg_split('/\r\n|\n|\r/', $decoded);
|
||||
if ($lines === false) {
|
||||
break;
|
||||
}
|
||||
$containsOnlyLabels = collect($lines)
|
||||
->filter(fn (string $line) => $line !== '')
|
||||
->every(fn (string $line) => str_contains($line, '=') && ! str_starts_with($line, '='));
|
||||
|
||||
if ($containsOnlyLabels) {
|
||||
$labels = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mb_detect_encoding($labels, 'UTF-8', true) === false ? null : $labels;
|
||||
}
|
||||
function customApiValidator(Collection|array $item, array $rules, array $messages = [])
|
||||
{
|
||||
if (is_array($item)) {
|
||||
|
||||
@@ -5,7 +5,7 @@ $sshCommandTimeout = (int) env('SSH_COMMAND_TIMEOUT', 3600);
|
||||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.4.2',
|
||||
'version' => env('COOLIFY_VERSION') ?: '4.4.3',
|
||||
'helper_version' => '1.0.18',
|
||||
'railpack_version' => '0.40.1',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.4.2"
|
||||
"version": "4.4.3"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.5-rc.1"
|
||||
|
||||
@@ -2317,7 +2317,7 @@ html[data-theme="custom"] textarea:disabled {
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 2.25rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.625rem 0 0.75rem;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
class="menu-item justify-between !bg-neutral-100 dark:!bg-white/[0.04] hover:!bg-neutral-200 dark:hover:!bg-white/[0.07] !text-fg-faint">
|
||||
<span class="flex items-center gap-2.5 min-w-0">
|
||||
<x-reicon name="search" class="menu-item-icon" />
|
||||
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search<span
|
||||
x-show="hasPageSearch" x-cloak class="text-[11px] opacity-70"> page & global</span></span>
|
||||
<span class="menu-item-label truncate" :class="collapsed && 'lg:hidden'">Search</span>
|
||||
</span>
|
||||
<kbd class="px-1.5 py-0.5 text-[11px] font-medium text-fg-faint bg-neutral-200 dark:bg-white/[0.06] rounded-md border border-transparent dark:border-white/5"
|
||||
:class="collapsed && 'lg:hidden'" x-text="modKeyLabel + 'K'"></kbd>
|
||||
|
||||
@@ -33,10 +33,6 @@
|
||||
$consoleThemeKeys = collect($consoleThemes)->pluck('key')->values();
|
||||
$consoleThemeNames = collect($consoleThemes)->pluck('name', 'key');
|
||||
$consoleThemeAccents = collect($consoleThemes)->pluck('accent', 'key');
|
||||
$containerOptions = $containers->map(fn ($container) => [
|
||||
'value' => data_get($container, 'server.uuid').':'.data_get($container, 'container.Names'),
|
||||
'label' => data_get($container, 'container.Names').' · '.data_get($container, 'server.name'),
|
||||
])->values();
|
||||
@endphp
|
||||
|
||||
@if (in_array($type, ['application', 'database', 'service', 'server'], true))
|
||||
@@ -78,7 +74,7 @@
|
||||
containerOpen: false,
|
||||
targetChosen: @js($selected_container !== 'default'),
|
||||
selectedContainer: @entangle('selected_container').live,
|
||||
containerOptions: @js($containerOptions),
|
||||
containerOptions: @entangle('containerOptions'),
|
||||
init() {
|
||||
const savedTheme = localStorage.getItem('coolify-console-theme');
|
||||
this.consoleTheme = this.themeKeys.includes(savedTheme) ? savedTheme : 'system';
|
||||
|
||||
@@ -267,6 +267,34 @@ test('http basic auth updates preserve user-managed labels', function () {
|
||||
expect(base64_decode($this->application->fresh()->custom_labels))->toBe('sentinel-label=true');
|
||||
});
|
||||
|
||||
test('nested base64 custom labels are stored in canonical form', function () {
|
||||
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
|
||||
|
||||
$this->withHeaders(applicationSettingsApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}", [
|
||||
'custom_labels' => base64_encode(base64_encode($labels)),
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
expect($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
|
||||
});
|
||||
|
||||
test('deployment parsing repairs historically nested custom labels', function () {
|
||||
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
|
||||
$this->application->update(['custom_labels' => base64_encode(base64_encode($labels))]);
|
||||
|
||||
expect($this->application->parseContainerLabels())->toBe($labels)
|
||||
->and($this->application->fresh()->custom_labels)->toBe(base64_encode($labels));
|
||||
});
|
||||
|
||||
test('deployment parsing keeps commas in raw custom label values', function () {
|
||||
$labels = "traefik.enable=true\ntraefik.http.routers.web.middlewares=gzip,redirect-to-https";
|
||||
$this->application->update(['custom_labels' => $labels]);
|
||||
|
||||
expect($this->application->parseContainerLabels())->toBe($labels)
|
||||
->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',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
it('decodes nested base64 container labels to label text', function () {
|
||||
$labels = "traefik.enable=true\ntraefik.http.routers.web.rule=Host(`example.com`)";
|
||||
|
||||
expect(decodeBase64EncodedLabels(base64_encode(base64_encode($labels))))->toBe($labels);
|
||||
});
|
||||
|
||||
it('decodes single encoded container labels without changing them', function () {
|
||||
$labels = "traefik.enable=true\ncom.example.version=1";
|
||||
|
||||
expect(decodeBase64EncodedLabels(base64_encode($labels)))->toBe($labels);
|
||||
});
|
||||
|
||||
it('rejects values that are not valid base64', function () {
|
||||
expect(decodeBase64EncodedLabels('traefik.enable=true'))->toBeNull();
|
||||
});
|
||||
|
||||
it('handles an empty encoded value', function () {
|
||||
expect(decodeBase64EncodedLabels(''))->toBe('');
|
||||
});
|
||||
|
||||
it('does not decode label text that happens to be valid base64', function () {
|
||||
expect(decodeBase64EncodedLabels(base64_encode('foo=')))->toBe('foo=');
|
||||
});
|
||||
|
||||
it('decodes labels through an intermediate layer that is not label text', function () {
|
||||
$labels = 'traefik.enable=true';
|
||||
|
||||
expect(decodeBase64EncodedLabels(base64_encode(base64_encode(base64_encode($labels)))))->toBe($labels);
|
||||
});
|
||||
|
||||
it('preserves label text when a further decoded layer is not UTF-8', function () {
|
||||
expect(decodeBase64EncodedLabels(base64_encode(base64_encode('foo='))))->toBe('foo=');
|
||||
});
|
||||
|
||||
it('rejects encoded non UTF-8 text', function () {
|
||||
expect(decodeBase64EncodedLabels(base64_encode("\xff")))->toBeNull();
|
||||
});
|
||||
@@ -21,7 +21,7 @@ it('shows the settings of the open resource in the command palette and switches
|
||||
$page = visit(applicationConfigurationUrl($this->stack['project'], $this->stack['environment'], $this->application));
|
||||
$page->assertSee('Palette App')
|
||||
->assertMissing('[aria-label="Filter settings"]')
|
||||
->assertVisible('button:visible:has-text("Search page & global")');
|
||||
->assertVisible('button:visible[title^="Search this page"]');
|
||||
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
@@ -49,7 +49,7 @@ it('does not show the scope switch on pages without a settings sidebar', functio
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/dashboard');
|
||||
$page->assertDontSee('page & global');
|
||||
$page->assertMissing('button[title^="Search this page"]');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-input')
|
||||
@@ -65,7 +65,7 @@ it('focuses the search input when the palette is opened from the mobile menu', f
|
||||
|
||||
$page->assertSee('Palette App')
|
||||
->click('button:visible:has-text("Open sidebar")')
|
||||
->assertSee('Search page & global')
|
||||
->assertVisible('button:visible[title^="Search this page"]')
|
||||
->screenshot(filename: 'command-palette-mobile-menu')
|
||||
->click('button:visible:has-text("Search")')
|
||||
->assertVisible('.command-palette-input')
|
||||
@@ -112,7 +112,7 @@ it('searches the sidebar pages of other settings pages', function (string $pageT
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit($url);
|
||||
$page->assertVisible('button:visible:has-text("Search page & global")');
|
||||
$page->assertVisible('button:visible[title^="Search this page"]');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-scope')
|
||||
@@ -135,7 +135,7 @@ it('searches the sections of a page without a settings sidebar and scrolls to th
|
||||
loginAndSkipBoarding();
|
||||
|
||||
$page = visit('/profile');
|
||||
$page->assertVisible('button:visible:has-text("Search page & global")');
|
||||
$page->assertVisible('button:visible[title^="Search this page"]');
|
||||
$page->script("window.dispatchEvent(new Event('open-global-search'))");
|
||||
|
||||
$page->assertVisible('.command-palette-scope')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Symfony\Component\Process\Process as SymfonyProcess;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config()->set('app.maintenance.store', 'array');
|
||||
config()->set('constants.ssh.mux_enabled', false);
|
||||
|
||||
$stack = seedBrowserResourceStack();
|
||||
$stack['server']->settings->update(['is_terminal_enabled' => true]);
|
||||
$this->application = createBrowserApplication($stack, ['uuid' => 'app-terminal-picker']);
|
||||
$this->terminalPath = "/project/{$stack['project']->uuid}/environment/{$stack['environment']->uuid}/application/{$this->application->uuid}/terminal";
|
||||
Cache::flush();
|
||||
|
||||
// An open WebSocket that accepts the connection and waits (it only reacts to a client frame).
|
||||
$this->terminalServer = new SymfonyProcess(['node', base_path('tests/Fixtures/terminal-websocket-rejection-server.mjs'), 'reject-token']);
|
||||
$this->terminalServer->start();
|
||||
$this->terminalServer->waitUntil(fn (string $type, string $output) => str_contains($output, "\n"));
|
||||
config()->set('constants.terminal.protocol', 'ws');
|
||||
config()->set('constants.terminal.host', '127.0.0.1');
|
||||
config()->set('constants.terminal.port', trim($this->terminalServer->getOutput()));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
$this->terminalServer?->stop(0);
|
||||
});
|
||||
|
||||
it('lists the lazily loaded containers when the user must choose one', function () {
|
||||
Process::fake(function ($process) {
|
||||
$command = (string) $process->command;
|
||||
|
||||
if (str_contains($command, 'docker ps -a')) {
|
||||
return Process::result(output: collect(['app-terminal-picker-web', 'app-terminal-picker-worker'])
|
||||
->map(fn (string $name) => json_encode(['ID' => md5($name), 'Names' => $name, 'State' => 'running', 'Labels' => '']))
|
||||
->implode("\n"));
|
||||
}
|
||||
|
||||
// The chosen container stops before the session starts, so no WebSocket session is needed.
|
||||
if (str_contains($command, 'docker inspect')) {
|
||||
return Process::result(output: json_encode(['State' => ['Status' => 'exited']]));
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$page = visit('/login')
|
||||
->fill('email', 'test@example.com')
|
||||
->fill('password', 'password')
|
||||
->click('Login')
|
||||
->assertSee('Dashboard')
|
||||
->navigate($this->terminalPath)
|
||||
->assertSee('Start a terminal session')
|
||||
->assertSee('app-terminal-picker-web')
|
||||
->assertSee('app-terminal-picker-worker')
|
||||
->screenshot(filename: 'terminal-container-picker-list')
|
||||
->click('[data-terminal-target-picker="launcher"] button:nth-of-type(2)')
|
||||
->assertSee('The container is not running.')
|
||||
->assertDontSee('Start a terminal session')
|
||||
->assertSee('app-terminal-picker-worker · localhost');
|
||||
|
||||
$page->screenshot(filename: 'terminal-container-picker-selected');
|
||||
});
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.4.2"
|
||||
"version": "4.4.3"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.5-rc.1"
|
||||
|
||||
Reference in New Issue
Block a user