fix: envs added to the right place in dockerfiles (#7123)
This commit is contained in:
commit
395d225f90
8 changed files with 500 additions and 244 deletions
|
|
@ -3226,6 +3226,20 @@ private function generate_secrets_hash($variables)
|
|||
return hash_hmac('sha256', $secrets_string, $this->secrets_hash_key);
|
||||
}
|
||||
|
||||
protected function findFromInstructionLines($dockerfile): array
|
||||
{
|
||||
$fromLines = [];
|
||||
foreach ($dockerfile as $index => $line) {
|
||||
$trimmedLine = trim($line);
|
||||
// Check if line starts with FROM (case-insensitive)
|
||||
if (preg_match('/^FROM\s+/i', $trimmedLine)) {
|
||||
$fromLines[] = $index;
|
||||
}
|
||||
}
|
||||
|
||||
return $fromLines;
|
||||
}
|
||||
|
||||
private function add_build_env_variables_to_dockerfile()
|
||||
{
|
||||
if ($this->dockerBuildkitSupported) {
|
||||
|
|
@ -3238,6 +3252,18 @@ private function add_build_env_variables_to_dockerfile()
|
|||
'ignore_errors' => true,
|
||||
]);
|
||||
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
||||
|
||||
// Find all FROM instruction positions
|
||||
$fromLines = $this->findFromInstructionLines($dockerfile);
|
||||
|
||||
// If no FROM instructions found, skip ARG insertion
|
||||
if (empty($fromLines)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all ARG statements to insert
|
||||
$argsToInsert = collect();
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
// Only add environment variables that are available during build
|
||||
$envs = $this->application->environment_variables()
|
||||
|
|
@ -3246,9 +3272,9 @@ private function add_build_env_variables_to_dockerfile()
|
|||
->get();
|
||||
foreach ($envs as $env) {
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
|
|
@ -3258,9 +3284,7 @@ private function add_build_env_variables_to_dockerfile()
|
|||
->map(function ($var) {
|
||||
return "ARG {$var}";
|
||||
});
|
||||
foreach ($coolify_vars as $arg) {
|
||||
$dockerfile->splice(1, 0, [$arg]);
|
||||
}
|
||||
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
||||
}
|
||||
} else {
|
||||
// Only add preview environment variables that are available during build
|
||||
|
|
@ -3270,9 +3294,9 @@ private function add_build_env_variables_to_dockerfile()
|
|||
->get();
|
||||
foreach ($envs as $env) {
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}"]);
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$dockerfile->splice(1, 0, ["ARG {$env->key}={$env->real_value}"]);
|
||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
|
|
@ -3282,18 +3306,23 @@ private function add_build_env_variables_to_dockerfile()
|
|||
->map(function ($var) {
|
||||
return "ARG {$var}";
|
||||
});
|
||||
foreach ($coolify_vars as $arg) {
|
||||
$dockerfile->splice(1, 0, [$arg]);
|
||||
}
|
||||
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
||||
}
|
||||
}
|
||||
|
||||
if ($envs->isNotEmpty()) {
|
||||
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
|
||||
if ($argsToInsert->isNotEmpty()) {
|
||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||
// Insert all ARGs after this FROM instruction
|
||||
foreach ($argsToInsert->reverse() as $arg) {
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
||||
return [$env->key => $env->real_value];
|
||||
});
|
||||
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
||||
$dockerfile->splice(1, 0, ["ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"]);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||
}
|
||||
|
||||
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ @keyframes lds-heart {
|
|||
*/
|
||||
html,
|
||||
body {
|
||||
@apply w-full min-h-full bg-neutral-50 dark:bg-base dark:text-neutral-400;
|
||||
@apply w-full min-h-full bg-gray-50 dark:bg-base dark:text-neutral-400;
|
||||
}
|
||||
|
||||
body {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class="text-sm dark:text-neutral-400 hover:text-coollabs dark:hover:text-warning
|
|||
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400 ">
|
||||
Don't have an account?
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -82,7 +82,7 @@ class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 d
|
|||
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">or
|
||||
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">or
|
||||
continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ function getOldOrLocal($key, $localValue)
|
|||
</svg>
|
||||
<div>
|
||||
<p class="font-bold text-warning">Root User Setup</p>
|
||||
<p class="text-sm dark:text-white text-black">This user will be the root user with full admin access.</p>
|
||||
<p class="text-sm dark:text-white text-black">This user will be the root user with full
|
||||
admin access.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -58,13 +59,16 @@ function getOldOrLocal($key, $localValue)
|
|||
<x-forms.input id="password_confirmation" required type="password" name="password_confirmation"
|
||||
label="{{ __('input.password.again') }}" />
|
||||
|
||||
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
|
||||
<div
|
||||
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
|
||||
<p class="text-xs dark:text-neutral-400">
|
||||
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
|
||||
Your password should be min 8 characters long and contain at least one uppercase letter,
|
||||
one lowercase letter, one number, and one symbol.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
|
||||
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
|
||||
isHighlighted>
|
||||
Create Account
|
||||
</x-forms.button>
|
||||
</form>
|
||||
|
|
@ -74,17 +78,18 @@ function getOldOrLocal($key, $localValue)
|
|||
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
Already have an account?
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
|
||||
<a href="/login"
|
||||
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
|
||||
{{ __('auth.already_registered') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</x-layout-simple>
|
||||
</x-layout-simple>
|
||||
|
|
@ -47,16 +47,19 @@
|
|||
label="{{ __('input.email') }}" />
|
||||
<x-forms.input required type="password" id="password" name="password"
|
||||
label="{{ __('input.password') }}" />
|
||||
<x-forms.input required type="password" id="password_confirmation"
|
||||
name="password_confirmation" label="{{ __('input.password.again') }}" />
|
||||
<x-forms.input required type="password" id="password_confirmation" name="password_confirmation"
|
||||
label="{{ __('input.password.again') }}" />
|
||||
|
||||
<div class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
|
||||
<div
|
||||
class="p-4 bg-neutral-50 dark:bg-coolgray-200 rounded-lg border border-neutral-200 dark:border-coolgray-400">
|
||||
<p class="text-xs dark:text-neutral-400">
|
||||
Your password should be min 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one symbol.
|
||||
Your password should be min 8 characters long and contain at least one uppercase letter,
|
||||
one lowercase letter, one number, and one symbol.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit" isHighlighted>
|
||||
<x-forms.button class="w-full justify-center py-3 box-boarding mt-2" type="submit"
|
||||
isHighlighted>
|
||||
{{ __('auth.reset_password') }}
|
||||
</x-forms.button>
|
||||
</form>
|
||||
|
|
@ -66,17 +69,18 @@
|
|||
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
Remember your password?
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/login" class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
|
||||
<a href="/login"
|
||||
class="block w-full text-center py-3 px-4 rounded-lg border border-neutral-300 dark:border-coolgray-400 font-medium hover:border-coollabs dark:hover:border-warning transition-colors">
|
||||
Back to Login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</x-layout-simple>
|
||||
</x-layout-simple>
|
||||
|
|
@ -120,7 +120,7 @@ class="mt-2 text-sm dark:text-neutral-400 hover:text-black dark:hover:text-white
|
|||
<div class="w-full border-t border-neutral-300 dark:border-coolgray-400"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
<span class="px-2 bg-gray-50 dark:bg-base text-neutral-500 dark:text-neutral-400">
|
||||
Need help?
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<html data-theme="dark" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<script>
|
||||
// Immediate theme application - runs before any rendering
|
||||
(function() {
|
||||
(function () {
|
||||
const t = localStorage.theme || 'dark';
|
||||
const d = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
document.documentElement.classList[d ? 'add' : 'remove']('dark');
|
||||
|
|
@ -75,102 +75,102 @@
|
|||
</head>
|
||||
@section('body')
|
||||
|
||||
<body>
|
||||
<x-toast />
|
||||
<script data-navigate-once>
|
||||
// Global HTML sanitization function using DOMPurify
|
||||
window.sanitizeHTML = function(html) {
|
||||
if (!html) return '';
|
||||
const URL_RE = /^(https?:|mailto:)/i;
|
||||
const config = {
|
||||
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong',
|
||||
'u'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button', 'select',
|
||||
'textarea', 'details', 'summary', 'dialog', 'style'
|
||||
],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
|
||||
'onsubmit', 'ontoggle', 'style'
|
||||
],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
SANITIZE_DOM: true,
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
SAFE_FOR_TEMPLATES: true,
|
||||
ALLOWED_URI_REGEXP: URL_RE
|
||||
};
|
||||
|
||||
// One-time hook registration (idempotent pattern)
|
||||
if (!window.__dpLinkHook) {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||
// Remove Alpine.js directives to prevent XSS
|
||||
if (node.hasAttributes && node.hasAttributes()) {
|
||||
const attrs = Array.from(node.attributes);
|
||||
attrs.forEach(attr => {
|
||||
// Remove x-* attributes (Alpine directives)
|
||||
if (attr.name.startsWith('x-')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Remove @* attributes (Alpine event shorthand)
|
||||
if (attr.name.startsWith('@')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Remove :* attributes (Alpine binding shorthand)
|
||||
if (attr.name.startsWith(':')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Existing link sanitization
|
||||
if (node.nodeName === 'A' && node.hasAttribute('href')) {
|
||||
const href = node.getAttribute('href') || '';
|
||||
if (!URL_RE.test(href)) node.removeAttribute('href');
|
||||
if (node.getAttribute('target') === '_blank') {
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
});
|
||||
window.__dpLinkHook = true;
|
||||
}
|
||||
return DOMPurify.sanitize(html, config);
|
||||
<body class="dark:text-inherit text-black">
|
||||
<x-toast />
|
||||
<script data-navigate-once>
|
||||
// Global HTML sanitization function using DOMPurify
|
||||
window.sanitizeHTML = function (html) {
|
||||
if (!html) return '';
|
||||
const URL_RE = /^(https?:|mailto:)/i;
|
||||
const config = {
|
||||
ALLOWED_TAGS: ['a', 'b', 'br', 'code', 'del', 'div', 'em', 'i', 'p', 'pre', 's', 'span', 'strong',
|
||||
'u'
|
||||
],
|
||||
ALLOWED_ATTR: ['class', 'href', 'target', 'title', 'rel'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'applet', 'iframe', 'form', 'input', 'button', 'select',
|
||||
'textarea', 'details', 'summary', 'dialog', 'style'
|
||||
],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange',
|
||||
'onsubmit', 'ontoggle', 'style'
|
||||
],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM: false,
|
||||
RETURN_DOM_FRAGMENT: false,
|
||||
SANITIZE_DOM: true,
|
||||
SANITIZE_NAMED_PROPS: true,
|
||||
SAFE_FOR_TEMPLATES: true,
|
||||
ALLOWED_URI_REGEXP: URL_RE
|
||||
};
|
||||
|
||||
// Initialize theme if not set
|
||||
if (!('theme' in localStorage)) {
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
// One-time hook registration (idempotent pattern)
|
||||
if (!window.__dpLinkHook) {
|
||||
DOMPurify.addHook('afterSanitizeAttributes', node => {
|
||||
// Remove Alpine.js directives to prevent XSS
|
||||
if (node.hasAttributes && node.hasAttributes()) {
|
||||
const attrs = Array.from(node.attributes);
|
||||
attrs.forEach(attr => {
|
||||
// Remove x-* attributes (Alpine directives)
|
||||
if (attr.name.startsWith('x-')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Remove @* attributes (Alpine event shorthand)
|
||||
if (attr.name.startsWith('@')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
// Remove :* attributes (Alpine binding shorthand)
|
||||
if (attr.name.startsWith(':')) {
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let theme = localStorage.theme
|
||||
let cpuColor = '#1e90ff'
|
||||
let ramColor = '#00ced1'
|
||||
let textColor = '#ffffff'
|
||||
let editorBackground = '#181818'
|
||||
let editorTheme = 'blackboard'
|
||||
|
||||
function checkTheme() {
|
||||
theme = localStorage.theme
|
||||
if (theme == 'system') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
if (theme == 'dark') {
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#ffffff'
|
||||
editorBackground = '#181818'
|
||||
editorTheme = 'blackboard'
|
||||
} else {
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#000000'
|
||||
editorBackground = '#ffffff'
|
||||
editorTheme = null
|
||||
}
|
||||
// Existing link sanitization
|
||||
if (node.nodeName === 'A' && node.hasAttribute('href')) {
|
||||
const href = node.getAttribute('href') || '';
|
||||
if (!URL_RE.test(href)) node.removeAttribute('href');
|
||||
if (node.getAttribute('target') === '_blank') {
|
||||
node.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
});
|
||||
window.__dpLinkHook = true;
|
||||
}
|
||||
@auth
|
||||
return DOMPurify.sanitize(html, config);
|
||||
};
|
||||
|
||||
// Initialize theme if not set
|
||||
if (!('theme' in localStorage)) {
|
||||
localStorage.theme = 'dark';
|
||||
}
|
||||
|
||||
let theme = localStorage.theme
|
||||
let cpuColor = '#1e90ff'
|
||||
let ramColor = '#00ced1'
|
||||
let textColor = '#ffffff'
|
||||
let editorBackground = '#181818'
|
||||
let editorTheme = 'blackboard'
|
||||
|
||||
function checkTheme() {
|
||||
theme = localStorage.theme
|
||||
if (theme == 'system') {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
if (theme == 'dark') {
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#ffffff'
|
||||
editorBackground = '#181818'
|
||||
editorTheme = 'blackboard'
|
||||
} else {
|
||||
cpuColor = '#1e90ff'
|
||||
ramColor = '#00ced1'
|
||||
textColor = '#000000'
|
||||
editorBackground = '#ffffff'
|
||||
editorTheme = null
|
||||
}
|
||||
}
|
||||
@auth
|
||||
window.Pusher = Pusher;
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'pusher',
|
||||
|
|
@ -199,131 +199,131 @@ function checkTheme() {
|
|||
// Maximum number of reconnection attempts
|
||||
maxAttempts: 15
|
||||
});
|
||||
@endauth
|
||||
let checkHealthInterval = null;
|
||||
let checkIfIamDeadInterval = null;
|
||||
@endauth
|
||||
let checkHealthInterval = null;
|
||||
let checkIfIamDeadInterval = null;
|
||||
|
||||
function changePasswordFieldType(event) {
|
||||
let element = event.target
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (element.className === "relative") {
|
||||
break;
|
||||
}
|
||||
element = element.parentElement;
|
||||
function changePasswordFieldType(event) {
|
||||
let element = event.target
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (element.className === "relative") {
|
||||
break;
|
||||
}
|
||||
element = element.children[1];
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
if (element.disabled) return;
|
||||
element.classList.add('truncate');
|
||||
this.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
if (element.disabled) return;
|
||||
element.classList.remove('truncate');
|
||||
this.type = 'password';
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
element = element.children[1];
|
||||
if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {
|
||||
if (element.type === 'password') {
|
||||
element.type = 'text';
|
||||
if (element.disabled) return;
|
||||
element.classList.add('truncate');
|
||||
this.type = 'text';
|
||||
} else {
|
||||
element.type = 'password';
|
||||
if (element.disabled) return;
|
||||
element.classList.remove('truncate');
|
||||
this.type = 'password';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
|
||||
}
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.Livewire.on('reloadWindow', (timeout) => {
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, timeout);
|
||||
return;
|
||||
} else {
|
||||
function copyToClipboard(text) {
|
||||
navigator?.clipboard?.writeText(text) && window.Livewire.dispatch('success', 'Copied to clipboard.');
|
||||
}
|
||||
document.addEventListener('livewire:init', () => {
|
||||
window.Livewire.on('reloadWindow', (timeout) => {
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
window.Livewire.on('info', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Info', {
|
||||
type: 'info',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Info', {
|
||||
type: 'info',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'info',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('error', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Error', {
|
||||
type: 'danger',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Error', {
|
||||
type: 'danger',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'danger',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('warning', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Warning', {
|
||||
type: 'warning',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Warning', {
|
||||
type: 'warning',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'warning',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('success', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Success', {
|
||||
type: 'success',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Success', {
|
||||
type: 'success',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'success',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
}, timeout);
|
||||
return;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
})
|
||||
window.Livewire.on('info', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Info', {
|
||||
type: 'info',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Info', {
|
||||
type: 'info',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'info',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('error', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Error', {
|
||||
type: 'danger',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Error', {
|
||||
type: 'danger',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'danger',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('warning', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Warning', {
|
||||
type: 'warning',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Warning', {
|
||||
type: 'warning',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'warning',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
window.Livewire.on('success', (message) => {
|
||||
if (typeof message === 'string') {
|
||||
window.toast('Success', {
|
||||
type: 'success',
|
||||
description: message,
|
||||
})
|
||||
return;
|
||||
}
|
||||
if (message.length == 1) {
|
||||
window.toast('Success', {
|
||||
type: 'success',
|
||||
description: message[0],
|
||||
})
|
||||
} else if (message.length == 2) {
|
||||
window.toast(message[0], {
|
||||
type: 'success',
|
||||
description: message[1],
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
@show
|
||||
|
||||
</html>
|
||||
</html>
|
||||
218
tests/Unit/DockerfileArgInsertionTest.php
Normal file
218
tests/Unit/DockerfileArgInsertionTest.php
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
|
||||
/**
|
||||
* Test the Dockerfile ARG insertion logic
|
||||
* This tests the fix for GitHub issue #7118
|
||||
*/
|
||||
it('finds FROM instructions in simple dockerfile', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'FROM node:16',
|
||||
'WORKDIR /app',
|
||||
'COPY . .',
|
||||
]);
|
||||
|
||||
$result = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($result)->toBe([0]);
|
||||
});
|
||||
|
||||
it('finds FROM instructions with comments before', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'# Build stage',
|
||||
'# Another comment',
|
||||
'FROM node:16',
|
||||
'WORKDIR /app',
|
||||
]);
|
||||
|
||||
$result = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($result)->toBe([2]);
|
||||
});
|
||||
|
||||
it('finds multiple FROM instructions in multi-stage dockerfile', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'FROM node:16 AS builder',
|
||||
'WORKDIR /app',
|
||||
'RUN npm install',
|
||||
'',
|
||||
'FROM nginx:alpine',
|
||||
'COPY --from=builder /app/dist /usr/share/nginx/html',
|
||||
]);
|
||||
|
||||
$result = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($result)->toBe([0, 4]);
|
||||
});
|
||||
|
||||
it('handles FROM with different cases', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'from node:16',
|
||||
'From nginx:alpine',
|
||||
'FROM alpine:latest',
|
||||
]);
|
||||
|
||||
$result = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($result)->toBe([0, 1, 2]);
|
||||
});
|
||||
|
||||
it('returns empty array when no FROM instructions found', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'# Just comments',
|
||||
'WORKDIR /app',
|
||||
'RUN npm install',
|
||||
]);
|
||||
|
||||
$result = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('inserts ARGs after FROM in simple dockerfile', function () {
|
||||
$dockerfile = collect([
|
||||
'FROM node:16',
|
||||
'WORKDIR /app',
|
||||
'COPY . .',
|
||||
]);
|
||||
|
||||
$fromLines = [0];
|
||||
$argsToInsert = collect(['ARG MY_VAR=value', 'ARG ANOTHER_VAR']);
|
||||
|
||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||
foreach ($argsToInsert->reverse() as $arg) {
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
|
||||
expect($dockerfile[0])->toBe('FROM node:16');
|
||||
expect($dockerfile[1])->toBe('ARG MY_VAR=value');
|
||||
expect($dockerfile[2])->toBe('ARG ANOTHER_VAR');
|
||||
expect($dockerfile[3])->toBe('WORKDIR /app');
|
||||
});
|
||||
|
||||
it('inserts ARGs after each FROM in multi-stage dockerfile', function () {
|
||||
$dockerfile = collect([
|
||||
'FROM node:16 AS builder',
|
||||
'WORKDIR /app',
|
||||
'',
|
||||
'FROM nginx:alpine',
|
||||
'COPY --from=builder /app/dist /usr/share/nginx/html',
|
||||
]);
|
||||
|
||||
$fromLines = [0, 3];
|
||||
$argsToInsert = collect(['ARG MY_VAR=value']);
|
||||
|
||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||
foreach ($argsToInsert->reverse() as $arg) {
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
|
||||
// First stage
|
||||
expect($dockerfile[0])->toBe('FROM node:16 AS builder');
|
||||
expect($dockerfile[1])->toBe('ARG MY_VAR=value');
|
||||
expect($dockerfile[2])->toBe('WORKDIR /app');
|
||||
|
||||
// Second stage (index shifted by +1 due to inserted ARG)
|
||||
expect($dockerfile[4])->toBe('FROM nginx:alpine');
|
||||
expect($dockerfile[5])->toBe('ARG MY_VAR=value');
|
||||
});
|
||||
|
||||
it('inserts ARGs after FROM when comments precede FROM', function () {
|
||||
$dockerfile = collect([
|
||||
'# Build stage comment',
|
||||
'FROM node:16',
|
||||
'WORKDIR /app',
|
||||
]);
|
||||
|
||||
$fromLines = [1];
|
||||
$argsToInsert = collect(['ARG MY_VAR=value']);
|
||||
|
||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||
foreach ($argsToInsert->reverse() as $arg) {
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
|
||||
expect($dockerfile[0])->toBe('# Build stage comment');
|
||||
expect($dockerfile[1])->toBe('FROM node:16');
|
||||
expect($dockerfile[2])->toBe('ARG MY_VAR=value');
|
||||
expect($dockerfile[3])->toBe('WORKDIR /app');
|
||||
});
|
||||
|
||||
it('handles real-world nuxt multi-stage dockerfile with comments', function () {
|
||||
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
|
||||
|
||||
$dockerfile = collect([
|
||||
'# Build Stage 1',
|
||||
'',
|
||||
'FROM node:22-alpine AS build',
|
||||
'WORKDIR /app',
|
||||
'',
|
||||
'RUN corepack enable',
|
||||
'',
|
||||
'# Copy package.json and your lockfile, here we add pnpm-lock.yaml for illustration',
|
||||
'COPY package.json pnpm-lock.yaml .npmrc ./',
|
||||
'',
|
||||
'# Install dependencies',
|
||||
'RUN pnpm i',
|
||||
'',
|
||||
'# Copy the entire project',
|
||||
'COPY . ./',
|
||||
'',
|
||||
'# Build the project',
|
||||
'RUN pnpm run build',
|
||||
'',
|
||||
'# Build Stage 2',
|
||||
'',
|
||||
'FROM node:22-alpine',
|
||||
'WORKDIR /app',
|
||||
'',
|
||||
'# Only `.output` folder is needed from the build stage',
|
||||
'COPY --from=build /app/.output/ ./',
|
||||
'',
|
||||
'# Change the port and host',
|
||||
'ENV PORT=80',
|
||||
'ENV HOST=0.0.0.0',
|
||||
'',
|
||||
'EXPOSE 80',
|
||||
'',
|
||||
'CMD ["node", "/app/server/index.mjs"]',
|
||||
]);
|
||||
|
||||
// Find FROM instructions
|
||||
$fromLines = $job->findFromInstructionLines($dockerfile);
|
||||
|
||||
expect($fromLines)->toBe([2, 21]);
|
||||
|
||||
// Simulate ARG insertion
|
||||
$argsToInsert = collect(['ARG BUILD_VAR=production']);
|
||||
|
||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||
foreach ($argsToInsert->reverse() as $arg) {
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify first stage
|
||||
expect($dockerfile[2])->toBe('FROM node:22-alpine AS build');
|
||||
expect($dockerfile[3])->toBe('ARG BUILD_VAR=production');
|
||||
expect($dockerfile[4])->toBe('WORKDIR /app');
|
||||
|
||||
// Verify second stage (index shifted by +1 due to first ARG insertion)
|
||||
expect($dockerfile[22])->toBe('FROM node:22-alpine');
|
||||
expect($dockerfile[23])->toBe('ARG BUILD_VAR=production');
|
||||
expect($dockerfile[24])->toBe('WORKDIR /app');
|
||||
});
|
||||
Loading…
Reference in a new issue