Files
coolify/tests/Unit/DockerfileArgInsertionTest.php
T
Andras BacsaiandClaude Opus 5.5 a3843730f8 fix(deployments): rebuild when a build secret value changes
Docker ignores secret values for the build cache, so the
COOLIFY_BUILD_SECRETS_HASH secret mount never invalidated it. Pass the
hash as a build arg and declare it in each stage instead.

Parse Dockerfile instructions so ARG lines and secret mounts are not
added inside heredoc bodies or continuation lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:56:31 +01:00

267 lines
7.5 KiB
PHP

<?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('ignores FROM text inside heredoc bodies', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'FROM python:3.13 AS build',
'RUN <<EOF',
'from app import create_app',
'EOF',
"COPY <<-'SQL' /init.sql",
"\tSELECT *",
"\tFROM users;",
"\tSQL",
'RUN cat <<<"from here-string"',
'FROM nginx:alpine',
]);
expect($job->findFromInstructionLines($dockerfile))->toBe([0, 9]);
});
it('returns the last line of a FROM instruction that continues on the next line', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'FROM --platform=linux/amd64 \\',
' # base image',
' node:22',
'RUN echo \\',
' FROM not-an-instruction',
'FROM nginx:alpine',
]);
expect($job->findFromInstructionLines($dockerfile))->toBe([2, 5]);
});
it('uses the escape parser directive for continuation lines', function () {
$job = Mockery::mock(ApplicationDeploymentJob::class)->makePartial();
$dockerfile = collect([
'# escape=`',
'FROM mcr.microsoft.com/windows/servercore:ltsc2022',
'RUN dir `',
' FROM c:\\',
'FROM nginx:alpine',
]);
expect($job->findFromInstructionLines($dockerfile))->toBe([1, 4]);
});
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');
});