feat(setup-install): add a container_id input to run setup:install against a specific container (#255)

This commit is contained in:
Damien Retzinger
2026-05-17 17:07:41 -04:00
parent b790da1859
commit 6d4ca8d669
5 changed files with 201 additions and 48 deletions
+5
View File
@@ -18,6 +18,11 @@ inputs:
default: "" default: ""
description: "Additional raw flags to append to the setup:install command." description: "Additional raw flags to append to the setup:install command."
container_id:
required: false
default: ""
description: "When set, runs setup:install via `docker exec` inside the container with this `container_id` (typically the value of `job.services['php-fpm'].id`) and uses service-network aliases (mysql, redis, etc.) instead of runner-side localhost when running setup:install."
outputs: outputs:
command: command:
description: "The full bin/magento setup:install command that was run." description: "The full bin/magento setup:install command that was run."
+28 -28
View File
File diff suppressed because one or more lines are too long
+96
View File
@@ -173,4 +173,100 @@ describe('buildInstallArgs', () => {
]); ]);
}); });
}); });
describe('container mode', () => {
it('uses the mysql network alias and the internal port from ports[0]', () => {
const services: Services = {
mysql: { ...MYSQL_SERVICE, ports: ['33060:3306'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--db-host=mysql:3306',
'--db-name=magento_integration_tests',
'--db-user=user',
'--db-password=password',
]);
});
it('uses the opensearch alias and parses the internal port', () => {
const services: Services = {
opensearch: { image: 'opensearchproject/opensearch:2.19.1', ports: ['19200:9200'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--search-engine=opensearch',
'--opensearch-host=opensearch',
'--opensearch-port=9200',
]);
});
it('uses the elasticsearch alias and parses the internal port', () => {
const services: Services = {
elasticsearch: { image: 'elasticsearch:8.11.4', ports: ['19200:9200'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--search-engine=elasticsearch8',
'--elasticsearch-host=elasticsearch',
'--elasticsearch-port=9200',
]);
});
it('uses the rabbitmq alias and parses the internal port', () => {
const services: Services = {
rabbitmq: { image: 'rabbitmq:4.0-management', ports: ['15672:5672'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--amqp-host=rabbitmq',
'--amqp-port=5672',
'--amqp-user=guest',
'--amqp-password=guest',
]);
});
it('uses the valkey alias when valkey is the cache service', () => {
const services: Services = {
valkey: { image: 'valkey:8.0', ports: ['16379:6379'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--session-save=redis',
'--session-save-redis-host=valkey',
'--session-save-redis-port=6379',
'--cache-backend=redis',
'--cache-backend-redis-server=valkey',
'--cache-backend-redis-port=6379',
]);
});
it('uses the redis alias when redis is the cache service', () => {
const services: Services = {
redis: { image: 'redis:7.2', ports: ['16379:6379'] },
};
expect(buildInstallArgs(services, true)).toEqual([
...BASE_ARGS,
'--session-save=redis',
'--session-save-redis-host=redis',
'--session-save-redis-port=6379',
'--cache-backend=redis',
'--cache-backend-redis-server=redis',
'--cache-backend-redis-port=6379',
]);
});
it('falls back to default internal ports when ports are absent', () => {
const services: Services = {
mysql: { ...MYSQL_SERVICE, ports: undefined },
opensearch: { image: 'opensearchproject/opensearch:2.19.1' },
rabbitmq: { image: 'rabbitmq:4.0-management' },
valkey: { image: 'valkey:8.0' },
};
const args = buildInstallArgs(services, true);
expect(args).toContain('--db-host=mysql:3306');
expect(args).toContain('--opensearch-port=9200');
expect(args).toContain('--amqp-port=5672');
expect(args).toContain('--session-save-redis-port=6379');
});
});
}); });
+31 -14
View File
@@ -3,6 +3,7 @@ export interface ServiceConfig {
env?: Record<string, string>; env?: Record<string, string>;
ports?: string[]; ports?: string[];
options?: string; options?: string;
volumes?: string[];
} }
export interface Services { export interface Services {
@@ -12,6 +13,7 @@ export interface Services {
rabbitmq?: ServiceConfig; rabbitmq?: ServiceConfig;
redis?: ServiceConfig; redis?: ServiceConfig;
valkey?: ServiceConfig; valkey?: ServiceConfig;
'php-fpm'?: ServiceConfig;
} }
const BASE_ARGS = [ const BASE_ARGS = [
@@ -24,21 +26,29 @@ const BASE_ARGS = [
'--backend-frontname=admin', '--backend-frontname=admin',
]; ];
const parsePort = (svc: ServiceConfig | undefined, index: 0 | 1, fallback: string): string => {
return svc?.ports?.[0]?.split(':')[index] ?? fallback;
};
export const buildMysqlPrepArgs = (mysql: ServiceConfig): string[] => { export const buildMysqlPrepArgs = (mysql: ServiceConfig): string[] => {
const rootPassword = mysql.env?.MYSQL_ROOT_PASSWORD ?? 'rootpassword'; const rootPassword = mysql.env?.MYSQL_ROOT_PASSWORD ?? 'rootpassword';
const port = mysql.ports?.[0]?.split(':')[0] ?? '3306'; const port = parsePort(mysql, 0, '3306');
return ['-h127.0.0.1', `--port=${port}`, '-uroot', `-p${rootPassword}`, '-e', 'SET GLOBAL log_bin_trust_function_creators = 1;']; return ['-h127.0.0.1', `--port=${port}`, '-uroot', `-p${rootPassword}`, '-e', 'SET GLOBAL log_bin_trust_function_creators = 1;'];
}; };
export const buildInstallArgs = (services: Services | null): string[] => { export const buildInstallArgs = (services: Services | null, containerMode = false): string[] => {
const args = [...BASE_ARGS]; const args = [...BASE_ARGS];
if (!services) return args; if (!services) return args;
const portIdx: 0 | 1 = containerMode ? 1 : 0;
const hostFor = (alias: string): string => containerMode ? alias : 'localhost';
if (services.mysql) { if (services.mysql) {
const dbPort = services.mysql.ports?.[0]?.split(':')[0] ?? '3306'; const dbPort = parsePort(services.mysql, portIdx, '3306');
const dbHost = containerMode ? `mysql:${dbPort}` : `127.0.0.1:${dbPort}`;
args.push( args.push(
`--db-host=127.0.0.1:${dbPort}`, `--db-host=${dbHost}`,
`--db-name=${services.mysql.env?.MYSQL_DATABASE ?? 'magento'}`, `--db-name=${services.mysql.env?.MYSQL_DATABASE ?? 'magento'}`,
`--db-user=${services.mysql.env?.MYSQL_USER ?? 'magento'}`, `--db-user=${services.mysql.env?.MYSQL_USER ?? 'magento'}`,
`--db-password=${services.mysql.env?.MYSQL_PASSWORD ?? 'magento'}`, `--db-password=${services.mysql.env?.MYSQL_PASSWORD ?? 'magento'}`,
@@ -46,37 +56,44 @@ export const buildInstallArgs = (services: Services | null): string[] => {
} }
if (services.opensearch) { if (services.opensearch) {
const port = parsePort(services.opensearch, portIdx, '9200');
args.push( args.push(
'--search-engine=opensearch', '--search-engine=opensearch',
'--opensearch-host=localhost', `--opensearch-host=${hostFor('opensearch')}`,
'--opensearch-port=9200', `--opensearch-port=${port}`,
); );
} else if (services.elasticsearch) { } else if (services.elasticsearch) {
const majorVersion = services.elasticsearch.image.split(':')[1]?.split('.')[0]; const majorVersion = services.elasticsearch.image.split(':')[1]?.split('.')[0];
const port = parsePort(services.elasticsearch, portIdx, '9200');
args.push( args.push(
`--search-engine=elasticsearch${majorVersion}`, `--search-engine=elasticsearch${majorVersion}`,
'--elasticsearch-host=localhost', `--elasticsearch-host=${hostFor('elasticsearch')}`,
'--elasticsearch-port=9200', `--elasticsearch-port=${port}`,
); );
} }
if (services.rabbitmq) { if (services.rabbitmq) {
const port = parsePort(services.rabbitmq, portIdx, '5672');
args.push( args.push(
'--amqp-host=localhost', `--amqp-host=${hostFor('rabbitmq')}`,
'--amqp-port=5672', `--amqp-port=${port}`,
'--amqp-user=guest', '--amqp-user=guest',
'--amqp-password=guest', '--amqp-password=guest',
); );
} }
if (services.valkey || services.redis) { if (services.valkey || services.redis) {
const cacheKey: 'valkey' | 'redis' = services.valkey ? 'valkey' : 'redis';
const cache = services[cacheKey]!;
const port = parsePort(cache, portIdx, '6379');
const host = hostFor(cacheKey);
args.push( args.push(
'--session-save=redis', '--session-save=redis',
'--session-save-redis-host=localhost', `--session-save-redis-host=${host}`,
'--session-save-redis-port=6379', `--session-save-redis-port=${port}`,
'--cache-backend=redis', '--cache-backend=redis',
'--cache-backend-redis-server=localhost', `--cache-backend-redis-server=${host}`,
'--cache-backend-redis-port=6379', `--cache-backend-redis-port=${port}`,
); );
} }
+39 -4
View File
@@ -1,12 +1,25 @@
import * as core from '@actions/core'; import * as core from '@actions/core';
import * as exec from '@actions/exec'; import * as exec from '@actions/exec';
import * as nodePath from 'path';
import { buildInstallArgs, buildMysqlPrepArgs, Services } from './build-command'; import { buildInstallArgs, buildMysqlPrepArgs, Services } from './build-command';
const resolveContainerPath = (runnerPath: string): string => {
const workspace = process.env.GITHUB_WORKSPACE || '';
const absolute = nodePath.resolve(workspace, runnerPath);
const relative = nodePath.relative(workspace, absolute);
if (relative.startsWith('..')) {
throw new Error(`container_id: path ${runnerPath} resolves outside GITHUB_WORKSPACE (${workspace})`);
}
return relative ? `/var/www/html/${relative}` : '/var/www/html';
};
export async function run(): Promise<void> { export async function run(): Promise<void> {
try { try {
const servicesInput = core.getInput('services'); const servicesInput = core.getInput('services');
const path = core.getInput('path') || '.'; const path = core.getInput('path') || '.';
const extraArgs = core.getInput('extra_args').trim(); const extraArgs = core.getInput('extra_args').trim();
const containerId = core.getInput('container_id').trim();
const containerMode = containerId !== '';
let services: Services | null = null; let services: Services | null = null;
if (servicesInput && servicesInput !== 'null') { if (servicesInput && servicesInput !== 'null') {
@@ -14,20 +27,42 @@ export async function run(): Promise<void> {
} }
// setup:install creates MySQL triggers, which requires log_bin_trust_function_creators=1 // setup:install creates MySQL triggers, which requires log_bin_trust_function_creators=1
// when binary logging is enabled. // when binary logging is enabled. The prep always runs runner-side against the published port.
if (services?.mysql) { if (services?.mysql) {
await exec.exec('mysql', buildMysqlPrepArgs(services.mysql)); await exec.exec('mysql', buildMysqlPrepArgs(services.mysql));
} }
const args = buildInstallArgs(services); const args = buildInstallArgs(services, containerMode);
if (extraArgs) { if (extraArgs) {
args.push(...extraArgs.split(/\s+/)); args.push(...extraArgs.split(/\s+/));
} }
core.setOutput('command', `php bin/magento setup:install ${args.join(' ')}`); if (containerMode) {
const containerPath = resolveContainerPath(path);
await exec.exec('php', ['bin/magento', 'setup:install', ...args], { cwd: path }); const command = `docker exec -w ${containerPath} ${containerId} php bin/magento setup:install ${args.join(' ')}`;
core.setOutput('command', command);
await exec.exec('docker', [
'exec',
'-w', containerPath,
containerId,
'php', 'bin/magento', 'setup:install',
...args,
]);
// setup:install runs as root inside the container, but php-fpm workers
// serve requests as `www-data`. Hand ownership of the Magento writable
// dirs to www-data so request-time cache/log writes succeed.
await exec.exec('docker', [
'exec', containerId, 'sh', '-c',
`for d in var generated pub/static pub/media; do [ -d "${containerPath}/$d" ] && chown -R www-data:www-data "${containerPath}/$d"; done`,
]);
} else {
core.setOutput('command', `php bin/magento setup:install ${args.join(' ')}`);
await exec.exec('php', ['bin/magento', 'setup:install', ...args], { cwd: path });
}
} catch (error) { } catch (error) {
core.setFailed((error as Error).message); core.setFailed((error as Error).message);
} }