feat(deployments): support Docker image tags for preview deployments

Add end-to-end support for `docker_registry_image_tag` in preview and deployment queue flows.

- Extend deploy API to accept `pull_request_id` alias and `docker_tag` for preview deploys
- Persist preview-specific Docker tags on `application_previews` and `application_deployment_queues`
- Pass tag through `queue_application_deployment()` and de-duplicate queued jobs by tag
- Update deployment job logic to resolve and use preview Docker tags for dockerimage build packs
- Update Livewire previews UI/state to manage per-preview tags and manual preview/tag inputs
- Add migration for new tag columns and model fillable/casts updates
- Add feature and unit tests covering API behavior and tag resolution
This commit is contained in:
Andras Bacsai 2026-03-30 13:35:35 +02:00
parent 3fddc795f6
commit 61f47cc7ee
13 changed files with 468 additions and 38 deletions

View file

@ -4,12 +4,15 @@
use App\Actions\Database\StartDatabase;
use App\Actions\Service\StartService;
use App\Enums\ApplicationDeploymentStatus;
use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\Tag;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Visus\Cuid2\Cuid2;
@ -228,8 +231,8 @@ public function cancel_deployment(Request $request)
// Check if deployment can be cancelled (must be queued or in_progress)
$cancellableStatuses = [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
ApplicationDeploymentStatus::QUEUED->value,
ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
@ -246,7 +249,7 @@ public function cancel_deployment(Request $request)
// Mark deployment as cancelled
$deployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Get the server
@ -304,6 +307,8 @@ public function cancel_deployment(Request $request)
new OA\Parameter(name: 'uuid', in: 'query', description: 'Resource UUID(s). Comma separated list is also accepted.', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'force', in: 'query', description: 'Force rebuild (without cache)', schema: new OA\Schema(type: 'boolean')),
new OA\Parameter(name: 'pr', in: 'query', description: 'Pull Request Id for deploying specific PR builds. Cannot be used with tag parameter.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'pull_request_id', in: 'query', description: 'Preview deployment identifier. Alias of pr.', schema: new OA\Schema(type: 'integer')),
new OA\Parameter(name: 'docker_tag', in: 'query', description: 'Docker image tag for Docker Image preview deployments. Requires pull_request_id.', schema: new OA\Schema(type: 'string')),
],
responses: [
@ -354,7 +359,9 @@ public function deploy(Request $request)
$uuids = $request->input('uuid');
$tags = $request->input('tag');
$force = $request->input('force') ?? false;
$pr = $request->input('pr') ? max((int) $request->input('pr'), 0) : 0;
$pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
if ($uuids && $tags) {
return response()->json(['message' => 'You can only use uuid or tag, not both.'], 400);
@ -362,16 +369,22 @@ public function deploy(Request $request)
if ($tags && $pr) {
return response()->json(['message' => 'You can only use tag or pr, not both.'], 400);
}
if ($dockerTag && $pr === 0) {
return response()->json(['message' => 'docker_tag requires pull_request_id.'], 400);
}
if ($dockerTag && $tags) {
return response()->json(['message' => 'You can only use tag or docker_tag, not both.'], 400);
}
if ($tags) {
return $this->by_tags($tags, $teamId, $force);
} elseif ($uuids) {
return $this->by_uuids($uuids, $teamId, $force, $pr);
return $this->by_uuids($uuids, $teamId, $force, $pr, $dockerTag);
}
return response()->json(['message' => 'You must provide uuid or tag.'], 400);
}
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0)
private function by_uuids(string $uuid, int $teamId, bool $force = false, int $pr = 0, ?string $dockerTag = null)
{
$uuids = explode(',', $uuid);
$uuids = collect(array_filter($uuids));
@ -384,15 +397,22 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
foreach ($uuids as $uuid) {
$resource = getResourceByUuid($uuid, $teamId);
if ($resource) {
$dockerTagForResource = $dockerTag;
if ($pr !== 0) {
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
$preview = null;
if ($resource instanceof Application && $resource->build_pack === 'dockerimage') {
$preview = $this->upsertDockerImagePreview($resource, $pr, $dockerTag);
$dockerTagForResource = $preview?->docker_registry_image_tag;
} else {
$preview = $resource->previews()->where('pull_request_id', $pr)->first();
}
if (! $preview) {
$deployments->push(['message' => "Pull request {$pr} not found for this resource.", 'resource_uuid' => $uuid]);
continue;
}
}
$result = $this->deploy_resource($resource, $force, $pr);
$result = $this->deploy_resource($resource, $force, $pr, $dockerTagForResource);
if (isset($result['status']) && $result['status'] === 429) {
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
}
@ -465,7 +485,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
return response()->json(['message' => 'No resources found with this tag.'], 404);
}
public function deploy_resource($resource, bool $force = false, int $pr = 0): array
public function deploy_resource($resource, bool $force = false, int $pr = 0, ?string $dockerTag = null): array
{
$message = null;
$deployment_uuid = null;
@ -477,9 +497,12 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
// Check authorization for application deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this application.', 'deployment_uuid' => null];
}
if ($dockerTag !== null && $resource->build_pack !== 'dockerimage') {
return ['message' => 'docker_tag can only be used with Docker Image applications.', 'deployment_uuid' => null];
}
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $resource,
@ -487,6 +510,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
force_rebuild: $force,
pull_request_id: $pr,
is_api: true,
docker_registry_image_tag: $dockerTag,
);
if ($result['status'] === 'queue_full') {
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
@ -500,7 +524,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
// Check authorization for service deployment
try {
$this->authorize('deploy', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to deploy this service.', 'deployment_uuid' => null];
}
StartService::run($resource);
@ -510,7 +534,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
// Database resource - check authorization
try {
$this->authorize('manage', $resource);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
} catch (AuthorizationException $e) {
return ['message' => 'Unauthorized to start this database.', 'deployment_uuid' => null];
}
StartDatabase::dispatch($resource);
@ -525,6 +549,34 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
return ['message' => $message, 'deployment_uuid' => $deployment_uuid];
}
private function upsertDockerImagePreview(Application $application, int $pullRequestId, ?string $dockerTag): ?ApplicationPreview
{
$preview = $application->previews()->where('pull_request_id', $pullRequestId)->first();
if (! $preview && $dockerTag === null) {
return null;
}
if (! $preview) {
$preview = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => '',
'docker_registry_image_tag' => $dockerTag,
]);
$preview->generate_preview_fqdn();
return $preview;
}
if ($dockerTag !== null && $preview->docker_registry_image_tag !== $dockerTag) {
$preview->docker_registry_image_tag = $dockerTag;
$preview->save();
}
return $preview;
}
#[OA\Get(
summary: 'List application deployments',
description: 'List application deployments by using the app uuid',

View file

@ -76,6 +76,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private ?string $dockerImageTag = null;
private ?string $dockerImagePreviewTag = null;
private GithubApp|GitlabApp|string $source = 'other';
private StandaloneDocker|SwarmDocker $destination;
@ -208,6 +210,7 @@ public function __construct(public int $application_deployment_queue_id)
$this->restart_only = $this->application_deployment_queue->restart_only;
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server;
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
@ -246,6 +249,9 @@ public function __construct(public int $application_deployment_queue_id)
// Set preview fqdn
if ($this->pull_request_id !== 0) {
$this->preview = ApplicationPreview::findPreviewByApplicationAndPullId($this->application->id, $this->pull_request_id);
if ($this->application->build_pack === 'dockerimage' && str($this->dockerImagePreviewTag)->isEmpty()) {
$this->dockerImagePreviewTag = $this->preview?->docker_registry_image_tag;
}
if ($this->preview) {
if ($this->application->build_pack === 'dockercompose') {
$this->preview->generate_preview_fqdn_compose();
@ -466,14 +472,14 @@ private function decide_what_to_do()
$this->just_restart();
return;
} elseif ($this->application->build_pack === 'dockerimage') {
$this->deploy_dockerimage_buildpack();
} elseif ($this->pull_request_id !== 0) {
$this->deploy_pull_request();
} elseif ($this->application->dockerfile) {
$this->deploy_simple_dockerfile();
} elseif ($this->application->build_pack === 'dockercompose') {
$this->deploy_docker_compose_buildpack();
} elseif ($this->application->build_pack === 'dockerimage') {
$this->deploy_dockerimage_buildpack();
} elseif ($this->application->build_pack === 'dockerfile') {
$this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') {
@ -554,11 +560,7 @@ private function deploy_simple_dockerfile()
private function deploy_dockerimage_buildpack()
{
$this->dockerImage = $this->application->docker_registry_image_name;
if (str($this->application->docker_registry_image_tag)->isEmpty()) {
$this->dockerImageTag = 'latest';
} else {
$this->dockerImageTag = $this->application->docker_registry_image_tag;
}
$this->dockerImageTag = $this->resolveDockerImageTag();
// Check if this is an image hash deployment
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
@ -575,6 +577,19 @@ private function deploy_dockerimage_buildpack()
$this->rolling_update();
}
private function resolveDockerImageTag(): string
{
if ($this->pull_request_id !== 0 && str($this->dockerImagePreviewTag)->isNotEmpty()) {
return $this->dockerImagePreviewTag;
}
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
return $this->application->docker_registry_image_tag;
}
return 'latest';
}
private function deploy_docker_compose_buildpack()
{
if (data_get($this->application, 'docker_compose_location')) {
@ -1934,6 +1949,11 @@ private function query_logs()
private function deploy_pull_request()
{
if ($this->application->build_pack === 'dockerimage') {
$this->deploy_dockerimage_buildpack();
return;
}
if ($this->application->build_pack === 'dockercompose') {
$this->deploy_docker_compose_buildpack();

View file

@ -35,8 +35,17 @@ class Previews extends Component
public array $previewFqdns = [];
public array $previewDockerTags = [];
public ?int $manualPullRequestId = null;
public ?string $manualDockerTag = null;
protected $rules = [
'previewFqdns.*' => 'string|nullable',
'previewDockerTags.*' => 'string|nullable',
'manualPullRequestId' => 'integer|min:1|nullable',
'manualDockerTag' => 'string|nullable',
];
public function mount()
@ -53,12 +62,17 @@ private function syncData(bool $toModel = false): void
$preview = $this->application->previews->get($key);
if ($preview) {
$preview->fqdn = $fqdn;
if ($this->application->build_pack === 'dockerimage') {
$preview->docker_registry_image_tag = $this->previewDockerTags[$key] ?? null;
}
}
}
} else {
$this->previewFqdns = [];
$this->previewDockerTags = [];
foreach ($this->application->previews as $key => $preview) {
$this->previewFqdns[$key] = $preview->fqdn;
$this->previewDockerTags[$key] = $preview->docker_registry_image_tag;
}
}
}
@ -174,7 +188,7 @@ public function generate_preview($preview_id)
}
}
public function add(int $pull_request_id, ?string $pull_request_html_url = null)
public function add(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
try {
$this->authorize('update', $this->application);
@ -195,13 +209,18 @@ public function add(int $pull_request_id, ?string $pull_request_html_url = null)
} else {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && ! is_null($pull_request_html_url)) {
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
$found = ApplicationPreview::forceCreate([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_html_url' => $pull_request_html_url ?? '',
'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
$found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save();
}
$found->generate_preview_fqdn();
$this->application->refresh();
$this->syncData(false);
@ -217,37 +236,50 @@ public function force_deploy_without_cache(int $pull_request_id, ?string $pull_r
{
$this->authorize('deploy', $this->application);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true);
$dockerRegistryImageTag = null;
if ($this->application->build_pack === 'dockerimage') {
$dockerRegistryImageTag = $this->application->previews()
->where('pull_request_id', $pull_request_id)
->value('docker_registry_image_tag');
}
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: true, docker_registry_image_tag: $dockerRegistryImageTag);
}
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null)
public function add_and_deploy(int $pull_request_id, ?string $pull_request_html_url = null, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
$this->add($pull_request_id, $pull_request_html_url);
$this->deploy($pull_request_id, $pull_request_html_url);
$this->add($pull_request_id, $pull_request_html_url, $docker_registry_image_tag);
$this->deploy($pull_request_id, $pull_request_html_url, force_rebuild: false, docker_registry_image_tag: $docker_registry_image_tag);
}
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false)
public function deploy(int $pull_request_id, ?string $pull_request_html_url = null, bool $force_rebuild = false, ?string $docker_registry_image_tag = null)
{
$this->authorize('deploy', $this->application);
try {
$this->setDeploymentUuid();
$found = ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found && ! is_null($pull_request_html_url)) {
ApplicationPreview::forceCreate([
if (! $found && (! is_null($pull_request_html_url) || ($this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()))) {
$found = ApplicationPreview::forceCreate([
'application_id' => $this->application->id,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'pull_request_html_url' => $pull_request_html_url ?? '',
'docker_registry_image_tag' => $docker_registry_image_tag,
]);
}
if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) {
$found->docker_registry_image_tag = $docker_registry_image_tag;
$found->save();
}
$result = queue_application_deployment(
application: $this->application,
deployment_uuid: $this->deployment_uuid,
force_rebuild: $force_rebuild,
pull_request_id: $pull_request_id,
git_type: $found->git_type ?? null,
docker_registry_image_tag: $docker_registry_image_tag,
);
if ($result['status'] === 'queue_full') {
$this->dispatch('error', 'Deployment queue full', $result['message']);
@ -277,6 +309,32 @@ protected function setDeploymentUuid()
$this->parameters['deployment_uuid'] = $this->deployment_uuid;
}
public function addDockerImagePreview()
{
$this->authorize('deploy', $this->application);
$this->validateOnly('manualPullRequestId');
$this->validateOnly('manualDockerTag');
if ($this->application->build_pack !== 'dockerimage') {
$this->dispatch('error', 'Manual Docker Image previews are only available for Docker Image applications.');
return;
}
if ($this->manualPullRequestId === null || str($this->manualDockerTag)->isEmpty()) {
$this->dispatch('error', 'Both pull request id and docker tag are required.');
return;
}
$dockerTag = str($this->manualDockerTag)->trim()->value();
$this->add_and_deploy($this->manualPullRequestId, null, $dockerTag);
$this->manualPullRequestId = null;
$this->manualDockerTag = null;
}
private function stopContainers(array $containers, $server)
{
$containersToStop = collect($containers)->pluck('Names')->toArray();

View file

@ -16,6 +16,7 @@
'application_id' => ['type' => 'string'],
'deployment_uuid' => ['type' => 'string'],
'pull_request_id' => ['type' => 'integer'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
'force_rebuild' => ['type' => 'boolean'],
'commit' => ['type' => 'string'],
'status' => ['type' => 'string'],
@ -67,6 +68,7 @@ class ApplicationDeploymentQueue extends Model
];
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
];

View file

@ -11,6 +11,7 @@ class ApplicationPreview extends BaseModel
use SoftDeletes;
protected $fillable = [
'application_id',
'pull_request_id',
'pull_request_html_url',
'pull_request_issue_comment_id',
@ -18,9 +19,14 @@ class ApplicationPreview extends BaseModel
'status',
'git_type',
'docker_compose_domains',
'docker_registry_image_tag',
'last_online_at',
];
protected $casts = [
'pull_request_id' => 'integer',
];
protected static function booted()
{
static::forceDeleting(function ($preview) {

View file

@ -7,6 +7,7 @@
class DockerCleanupExecution extends BaseModel
{
protected $fillable = [
'server_id',
'status',
'message',
'cleanup_log',

View file

@ -12,7 +12,7 @@
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false)
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
@ -47,6 +47,7 @@ function queue_application_deployment(Application $application, string $deployme
$existing_deployment = ApplicationDeploymentQueue::where('application_id', $application_id)
->where('commit', $commit)
->where('pull_request_id', $pull_request_id)
->where('docker_registry_image_tag', $docker_registry_image_tag)
->whereIn('status', [ApplicationDeploymentStatus::IN_PROGRESS->value, ApplicationDeploymentStatus::QUEUED->value])
->first();
@ -72,6 +73,7 @@ function queue_application_deployment(Application $application, string $deployme
'deployment_uuid' => $deployment_uuid,
'deployment_url' => $deployment_url,
'pull_request_id' => $pull_request_id,
'docker_registry_image_tag' => $docker_registry_image_tag,
'force_rebuild' => $force_rebuild,
'is_webhook' => $is_webhook,
'is_api' => $is_api,

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('application_previews', function (Blueprint $table) {
$table->string('docker_registry_image_tag')->nullable()->after('docker_compose_domains');
});
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->string('docker_registry_image_tag')->nullable()->after('pull_request_id');
});
}
public function down(): void
{
Schema::table('application_previews', function (Blueprint $table) {
$table->dropColumn('docker_registry_image_tag');
});
Schema::table('application_deployment_queues', function (Blueprint $table) {
$table->dropColumn('docker_registry_image_tag');
});
}
};

View file

@ -46,7 +46,7 @@
href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Scheduled Tasks</span></a>
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Webhooks</span></a>
@if ($application->git_based())
@if ($application->git_based() || $application->build_pack === 'dockerimage')
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Preview Deployments</span></a>
@endif

View file

@ -68,6 +68,20 @@ class="dark:text-warning">{{ $application->destination->server->name }}</span>.<
@endif
</div>
</div>
@if ($application->build_pack === 'dockerimage')
<div class="flex flex-col gap-2 pt-4">
<h3>Manual Preview Deployment</h3>
<form wire:submit.prevent="addDockerImagePreview" class="flex flex-col gap-2 xl:flex-row xl:items-end">
<x-forms.input id="manualPullRequestId" label="Pull Request Id"
helper="Used as the preview identifier for naming, domains, logs, and cleanup." />
<x-forms.input id="manualDockerTag" label="Docker Tag"
helper="The image tag to deploy for this preview, for example pr_1234." />
@can('deploy', $application)
<x-forms.button type="submit">Deploy Preview</x-forms.button>
@endcan
</form>
</div>
@endif
@if ($application->previews->count() > 0)
<h3 class="py-4">Deployments</h3>
<div class="flex flex-wrap w-full gap-4">
@ -87,11 +101,13 @@ class="dark:text-warning">{{ $application->destination->server->name }}</span>.<
<x-external-link />
</a>
@endif
|
<a target="_blank" href="{{ data_get($preview, 'pull_request_html_url') }}">Open
PR on Git
<x-external-link />
</a>
@if (filled(data_get($preview, 'pull_request_html_url')))
|
<a target="_blank" href="{{ data_get($preview, 'pull_request_html_url') }}">Open
PR on Git
<x-external-link />
</a>
@endif
@if (count($parameters) > 0)
|
<a {{ wireNavigate() }}
@ -131,6 +147,10 @@ class="flex items-end gap-2 pt-4">
<form wire:submit="save_preview('{{ $preview->id }}')" class="flex items-end gap-2 pt-4">
<x-forms.input label="Domain" helper="One domain per preview."
id="previewFqdns.{{ $previewName }}" canGate="update" :canResource="$application"></x-forms.input>
@if ($application->build_pack === 'dockerimage')
<x-forms.input label="Docker Tag" helper="The image tag used for this preview deployment."
id="previewDockerTags.{{ $previewName }}" canGate="update" :canResource="$application"></x-forms.input>
@endif
@can('update', $application)
<x-forms.button type="submit">Save</x-forms.button>
<x-forms.button wire:click="generate_preview('{{ $preview->id }}')">Generate
@ -157,7 +177,8 @@ class="flex items-end gap-2 pt-4">
Force deploy (without
cache)
</x-forms.button>
<x-forms.button wire:click="deploy({{ data_get($preview, 'pull_request_id') }})">
<x-forms.button
wire:click="deploy({{ data_get($preview, 'pull_request_id') }}, null, false, '{{ data_get($preview, 'docker_registry_image_tag') }}')">
@if (data_get($preview, 'status') === 'exited')
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 dark:text-warning"
viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none"

View file

@ -8,6 +8,22 @@
uses(RefreshDatabase::class);
it('persists the server id when creating an execution record', function () {
$user = User::factory()->create();
$team = $user->teams()->first();
$server = Server::factory()->create(['team_id' => $team->id]);
$execution = DockerCleanupExecution::create([
'server_id' => $server->id,
]);
expect($execution->server_id)->toBe($server->id);
$this->assertDatabaseHas('docker_cleanup_executions', [
'id' => $execution->id,
'server_id' => $server->id,
]);
});
it('creates a failed execution record when server is not functional', function () {
$user = User::factory()->create();
$team = $user->teams()->first();

View file

@ -0,0 +1,146 @@
<?php
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'coolify-'.Str::lower(Str::random(8)),
]);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createDockerImageApplication(Environment $environment, StandaloneDocker $destination): Application
{
return Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'ghcr.io/coollabsio/example',
'docker_registry_image_tag' => 'latest',
]);
}
test('it queues a docker image preview deployment and stores the preview tag', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 1234,
'docker_tag' => 'pr_1234',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.resource_uuid', $application->uuid);
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 1234)
->first();
expect($preview)->not()->toBeNull();
expect($preview->docker_registry_image_tag)->toBe('pr_1234');
$deployment = $application->deployment_queue()->latest('id')->first();
expect($deployment)->not()->toBeNull();
expect($deployment->pull_request_id)->toBe(1234);
expect($deployment->docker_registry_image_tag)->toBe('pr_1234');
});
test('it updates an existing docker image preview tag when redeploying through the api', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => 99,
'pull_request_html_url' => '',
'docker_registry_image_tag' => 'pr_99_old',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 99,
'docker_tag' => 'pr_99_new',
'force' => true,
]);
$response->assertSuccessful();
$preview = ApplicationPreview::query()
->where('application_id', $application->id)
->where('pull_request_id', 99)
->first();
expect($preview->docker_registry_image_tag)->toBe('pr_99_new');
});
test('it rejects docker_tag without pull_request_id', function () {
$application = createDockerImageApplication($this->environment, $this->destination);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'docker_tag' => 'pr_1234',
]);
$response->assertStatus(400);
$response->assertJson(['message' => 'docker_tag requires pull_request_id.']);
});
test('it rejects docker_tag for non docker image applications', function () {
$application = Application::factory()->create([
'uuid' => (string) Str::uuid(),
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'nixpacks',
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/deploy', [
'uuid' => $application->uuid,
'pull_request_id' => 7,
'docker_tag' => 'pr_7',
]);
$response->assertSuccessful();
$response->assertJsonPath('deployments.0.message', 'docker_tag can only be used with Docker Image applications.');
});

View file

@ -0,0 +1,76 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
it('prefers the preview specific docker image tag for preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 42);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'latest',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('pr_42');
});
it('falls back to the application docker image tag for non preview deployments', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 0);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => 'stable',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, 'pr_42');
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('stable');
});
it('falls back to latest when neither preview nor application tags are set', function () {
$reflection = new ReflectionClass(ApplicationDeploymentJob::class);
$job = $reflection->newInstanceWithoutConstructor();
$pullRequestProperty = $reflection->getProperty('pull_request_id');
$pullRequestProperty->setAccessible(true);
$pullRequestProperty->setValue($job, 7);
$applicationProperty = $reflection->getProperty('application');
$applicationProperty->setAccessible(true);
$applicationProperty->setValue($job, new Application([
'docker_registry_image_tag' => '',
]));
$previewTagProperty = $reflection->getProperty('dockerImagePreviewTag');
$previewTagProperty->setAccessible(true);
$previewTagProperty->setValue($job, null);
$method = $reflection->getMethod('resolveDockerImageTag');
$method->setAccessible(true);
expect($method->invoke($job))->toBe('latest');
});