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: ""
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:
command:
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>;
ports?: string[];
options?: string;
volumes?: string[];
}
export interface Services {
@@ -12,6 +13,7 @@ export interface Services {
rabbitmq?: ServiceConfig;
redis?: ServiceConfig;
valkey?: ServiceConfig;
'php-fpm'?: ServiceConfig;
}
const BASE_ARGS = [
@@ -24,21 +26,29 @@ const BASE_ARGS = [
'--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[] => {
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;'];
};
export const buildInstallArgs = (services: Services | null): string[] => {
export const buildInstallArgs = (services: Services | null, containerMode = false): string[] => {
const args = [...BASE_ARGS];
if (!services) return args;
const portIdx: 0 | 1 = containerMode ? 1 : 0;
const hostFor = (alias: string): string => containerMode ? alias : 'localhost';
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(
`--db-host=127.0.0.1:${dbPort}`,
`--db-host=${dbHost}`,
`--db-name=${services.mysql.env?.MYSQL_DATABASE ?? 'magento'}`,
`--db-user=${services.mysql.env?.MYSQL_USER ?? 'magento'}`,
`--db-password=${services.mysql.env?.MYSQL_PASSWORD ?? 'magento'}`,
@@ -46,37 +56,44 @@ export const buildInstallArgs = (services: Services | null): string[] => {
}
if (services.opensearch) {
const port = parsePort(services.opensearch, portIdx, '9200');
args.push(
'--search-engine=opensearch',
'--opensearch-host=localhost',
'--opensearch-port=9200',
`--opensearch-host=${hostFor('opensearch')}`,
`--opensearch-port=${port}`,
);
} else if (services.elasticsearch) {
const majorVersion = services.elasticsearch.image.split(':')[1]?.split('.')[0];
const port = parsePort(services.elasticsearch, portIdx, '9200');
args.push(
`--search-engine=elasticsearch${majorVersion}`,
'--elasticsearch-host=localhost',
'--elasticsearch-port=9200',
`--elasticsearch-host=${hostFor('elasticsearch')}`,
`--elasticsearch-port=${port}`,
);
}
if (services.rabbitmq) {
const port = parsePort(services.rabbitmq, portIdx, '5672');
args.push(
'--amqp-host=localhost',
'--amqp-port=5672',
`--amqp-host=${hostFor('rabbitmq')}`,
`--amqp-port=${port}`,
'--amqp-user=guest',
'--amqp-password=guest',
);
}
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(
'--session-save=redis',
'--session-save-redis-host=localhost',
'--session-save-redis-port=6379',
`--session-save-redis-host=${host}`,
`--session-save-redis-port=${port}`,
'--cache-backend=redis',
'--cache-backend-redis-server=localhost',
'--cache-backend-redis-port=6379',
`--cache-backend-redis-server=${host}`,
`--cache-backend-redis-port=${port}`,
);
}
+39 -4
View File
@@ -1,12 +1,25 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as nodePath from 'path';
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> {
try {
const servicesInput = core.getInput('services');
const path = core.getInput('path') || '.';
const extraArgs = core.getInput('extra_args').trim();
const containerId = core.getInput('container_id').trim();
const containerMode = containerId !== '';
let services: Services | null = 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
// when binary logging is enabled.
// when binary logging is enabled. The prep always runs runner-side against the published port.
if (services?.mysql) {
await exec.exec('mysql', buildMysqlPrepArgs(services.mysql));
}
const args = buildInstallArgs(services);
const args = buildInstallArgs(services, containerMode);
if (extraArgs) {
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) {
core.setFailed((error as Error).message);
}