mirror of
https://github.com/graycoreio/github-actions-magento2.git
synced 2026-06-08 19:46:41 +00:00
feat(supported-version): add service_preferences and support for php-fpm and nginx (#255)
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Configure Service Nginx
|
||||
|
||||
A GitHub Action that pushes a Magento-aware nginx configuration into an **already-running nginx service container**, reloads it, and waits for the container's healthcheck to pass.
|
||||
|
||||
The action does **not** start nginx. It assumes the calling workflow declared nginx as a `services:` container (typically alongside `php-fpm`, and other mandatory Magento services).
|
||||
|
||||
The shipped `default.conf` is a thin outer wrapper that defines a `fastcgi_backend` upstream pointing at `php-fpm:9000`, sets `$MAGE_ROOT`, and includes Magento's own `nginx.conf.sample` from your own Magento install. All real routing rules come from Magento's bundled file.
|
||||
|
||||
## When to use this
|
||||
|
||||
Use this action when you have a workflow that:
|
||||
|
||||
1. Boots nginx as `services:` containers with the workspace bind-mounted at `/var/www/html`, and
|
||||
2. Wants those containers to actually serve a Magento store you've already installed into the workspace (e.g. for end-to-end smoke tests, integration tests, or any HTTP-driven check).
|
||||
|
||||
You do **not** need this action if:
|
||||
|
||||
- You're not running nginx at all (unit tests, coding standards, static analysis).
|
||||
- nginx is started by something other than a GitHub Actions `services:` block
|
||||
- You've already configured nginx some other way and don't need a Magento-ready outer config.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An nginx service container is running on the same Docker host as the runner, with an image matching the `image` input.
|
||||
- A `php-fpm` container, the included `default.conf` will set up a fast-cgi backend to `php-fpm:9000`.
|
||||
- The runner's workspace (`$GITHUB_WORKSPACE`) is bind-mounted into the nginx container at `/var/www/html`.
|
||||
- A Magento install exists at `${{ inputs.magento_path }}` relative to the workspace, with `nginx.conf.sample` present (it ships with Magento by default after `composer install`).
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Required | Default | Description |
|
||||
| ------------------------ | -------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `container_id` | Yes | — | The ID of the running nginx service container. Pass `${{ job.services.nginx.id }}` (replace `nginx` with whatever you named the service). |
|
||||
| `magento_path` | No | `.` | Path to the Magento store, relative to the GitHub workspace. Combined with the `/var/www/html` mount prefix to compute the in-container `MAGE_ROOT`. |
|
||||
| `health_timeout_seconds` | No | `10` | How long to wait for nginx to report `healthy` after the config is pushed and the container restarts. |
|
||||
|
||||
## Usage
|
||||
|
||||
```yml
|
||||
jobs:
|
||||
smoke-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
## There are other service requirements for Magento, but this is just for the explanation of this service
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
ports: ["80:80"]
|
||||
volumes:
|
||||
- ${{ github.workspace }}:/var/www/html
|
||||
options: --health-cmd "nginx -t" --health-interval=10s --health-retries=3
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: graycoreio/github-actions-magento2/setup-magento@main
|
||||
id: setup-magento
|
||||
with:
|
||||
mode: store
|
||||
- run: composer install
|
||||
working-directory: ${{ steps.setup-magento.outputs.path }}
|
||||
- uses: graycoreio/github-actions-magento2/setup-install@main
|
||||
with:
|
||||
services: ${{ toJSON(matrix.services) }}
|
||||
path: ${{ steps.setup-magento.outputs.path }}
|
||||
container_id: ${{ job.services['php-fpm'].id }}
|
||||
- uses: graycoreio/github-actions-magento2/configure-service-nginx@main
|
||||
with:
|
||||
container_id: ${{ job.services.nginx.id }}
|
||||
magento_path: ${{ inputs.path }}
|
||||
- uses: graycoreio/github-actions-magento2/smoke-test@main
|
||||
with:
|
||||
kind: page
|
||||
```
|
||||
@@ -18,6 +18,7 @@ See the [action.yml](./action.yml)
|
||||
| custom_versions | The versions you want to support, as a comma-separated string, i.e. 'magento/project-community-edition:2.3.7-p3, magento/project-community-edition:2.4.2-p2' | false | '' |
|
||||
| recent_time_frame | The time frame (from today) used when `kind` is `recent`. Combination of years (y), months (m), and days (d), e.g. `2y 2m 2d`. | false | '2y' |
|
||||
| include_services | Whether to include a `services` key in each matrix entry with GitHub Actions service container configurations for MySQL, search engine, RabbitMQ, and cache. | false | 'true' |
|
||||
| service_preferences | Comma-separated list of service implementations to prefer (e.g. `elasticsearch,valkey`). See [Service preferences](#service-preferences). | false | '' |
|
||||
|
||||
## Kinds
|
||||
- `currently-supported` - The currently supported Magento Open Source versions by Adobe.
|
||||
@@ -32,6 +33,47 @@ See the [action.yml](./action.yml)
|
||||
- `mage-os`
|
||||
- `magento-open-source` (default)
|
||||
|
||||
## Service preferences
|
||||
|
||||
When `include_services: true` (the default), each matrix entry is enriched with a `services` map. Some tiers of services (for example, search) have more than one valid implementation across the supported Magento versions:
|
||||
|
||||
- **search**: `opensearch` or `elasticsearch`
|
||||
- **cache**: `valkey` or `redis`
|
||||
|
||||
By default the action picks `opensearch` over `elasticsearch` and `valkey` over `redis` wherever both are available for the matrix entry's Magento version. `service_preferences` lets the caller override that default pick by naming the implementation they want.
|
||||
|
||||
Tiers without a preference fall back to the per-version default pick. Your preferences are **selective**, not **exclusive**.
|
||||
|
||||
### Format
|
||||
|
||||
A comma-separated list of service implementation names. Whitespace around names is tolerated.
|
||||
|
||||
```yml
|
||||
with:
|
||||
service_preferences: elasticsearch,valkey
|
||||
```
|
||||
|
||||
### Accepted names
|
||||
|
||||
| Name | Tier |
|
||||
|------------------|--------|
|
||||
| `mysql` | db |
|
||||
| `elasticsearch` | search |
|
||||
| `opensearch` | search |
|
||||
| `rabbitmq` | queue |
|
||||
| `redis` | cache |
|
||||
| `valkey` | cache |
|
||||
|
||||
### Example
|
||||
|
||||
```yml
|
||||
- uses: graycoreio/github-actions-magento2/supported-version@v8.0.0 # x-release-please-version
|
||||
id: supported-version
|
||||
with:
|
||||
kind: currently-supported
|
||||
service_preferences: opensearch,valkey
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```yml
|
||||
|
||||
@@ -27,6 +27,11 @@ inputs:
|
||||
default: "true"
|
||||
description: "Whether to include a `services` key in each matrix entry with GitHub Actions service configurations."
|
||||
|
||||
service_preferences:
|
||||
required: false
|
||||
default: ""
|
||||
description: "Comma-separated list of service implementations to prefer (e.g. `elasticsearch,valkey`). Each name overrides the per-tier default implementation pick. Errors on unknown names, tier collisions (two names sharing a tier), or implementations not supported by every matrix entry for your selected kind."
|
||||
|
||||
outputs:
|
||||
matrix:
|
||||
description: "The Github Actions matrix of software technologies required to run Magento."
|
||||
|
||||
Vendored
+29
-25
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { validateKind } from './kind/validate-kinds';
|
||||
import { getMatrixForKind } from './matrix/get-matrix-for-kind';
|
||||
import { validateProject } from "./project/validate-projects";
|
||||
import { buildServicesForEntry } from "./services/build-services";
|
||||
import { parseServicePreferences, validatePreferencesAgainstMatrix } from "./services/preferences";
|
||||
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
@@ -12,19 +13,33 @@ export async function run(): Promise<void> {
|
||||
const project = core.getInput("project");
|
||||
const recent_time_frame = core.getInput("recent_time_frame");
|
||||
const include_services = core.getInput("include_services") === "true";
|
||||
const service_preferences_raw = core.getInput("service_preferences");
|
||||
|
||||
validateProject(<any>project)
|
||||
|
||||
validateKind(<any>kind, customVersions ? customVersions.split(',') : undefined);
|
||||
|
||||
const preferences = parseServicePreferences(service_preferences_raw);
|
||||
const hasPreferences = Object.keys(preferences).length > 0;
|
||||
|
||||
if (!include_services && hasPreferences) {
|
||||
throw new Error(
|
||||
'service_preferences cannot be combined with include_services: false. Set include_services: true or clear service_preferences.'
|
||||
);
|
||||
}
|
||||
|
||||
let matrix = getMatrixForKind(kind, project, customVersions, recent_time_frame);
|
||||
|
||||
if (include_services) {
|
||||
if (hasPreferences) {
|
||||
validatePreferencesAgainstMatrix(preferences, matrix.include);
|
||||
}
|
||||
const workspace = process.env.GITHUB_WORKSPACE || '';
|
||||
matrix = {
|
||||
magento: matrix.magento,
|
||||
include: matrix.include.map((entry) => ({
|
||||
...entry,
|
||||
services: buildServicesForEntry(entry)
|
||||
services: buildServicesForEntry(entry, preferences, workspace)
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -36,4 +51,4 @@ export async function run(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
run()
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface ServiceConfig {
|
||||
env?: Record<string, string>;
|
||||
ports?: string[];
|
||||
options?: string;
|
||||
volumes?: string[];
|
||||
}
|
||||
|
||||
export interface Services {
|
||||
|
||||
@@ -223,13 +223,17 @@ describe('buildServicesForEntry', () => {
|
||||
describe('complete service output', () => {
|
||||
it('should build all services when all are available', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry);
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(Object.keys(services)).toHaveLength(4);
|
||||
expect(Object.keys(services).sort()).toEqual(
|
||||
['mysql', 'nginx', 'opensearch', 'php-fpm', 'rabbitmq', 'valkey']
|
||||
);
|
||||
expect(services.mysql).toBeDefined();
|
||||
expect(services.opensearch).toBeDefined();
|
||||
expect(services.rabbitmq).toBeDefined();
|
||||
expect(services.valkey).toBeDefined();
|
||||
expect(services.nginx).toBeDefined();
|
||||
expect(services['php-fpm']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle entry with minimal services', () => {
|
||||
@@ -239,11 +243,123 @@ describe('buildServicesForEntry', () => {
|
||||
opensearch: '',
|
||||
rabbitmq: '',
|
||||
redis: '',
|
||||
valkey: ''
|
||||
valkey: '',
|
||||
nginx: '',
|
||||
php: ''
|
||||
});
|
||||
const services = buildServicesForEntry(entry);
|
||||
|
||||
expect(Object.keys(services)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('web tier (nginx + php-fpm)', () => {
|
||||
it('emits both nginx and php-fpm when both data points are present', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services.nginx).toBeDefined();
|
||||
expect(services['php-fpm']).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses the entry.nginx image for nginx', () => {
|
||||
const entry = createTestEntry({ nginx: 'nginx:1.27-alpine' });
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services.nginx.image).toBe('nginx:1.27-alpine');
|
||||
});
|
||||
|
||||
it('composes the php-fpm image from entry.php using mappia', () => {
|
||||
const entry = createTestEntry({ php: '8.2' });
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services['php-fpm'].image).toBe('mappia/magento-php:fpm-alpine8.2');
|
||||
});
|
||||
|
||||
it('mounts the runner workspace at /var/www/html on both services', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, {}, '/home/runner/work/foo/foo');
|
||||
|
||||
expect(services.nginx.volumes).toEqual(['/home/runner/work/foo/foo:/var/www/html']);
|
||||
expect(services['php-fpm'].volumes).toEqual(['/home/runner/work/foo/foo:/var/www/html']);
|
||||
});
|
||||
|
||||
it('exposes port 80 on nginx with the nginx -t healthcheck', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services.nginx.ports).toEqual(['80:80']);
|
||||
expect(services.nginx.options).toContain('nginx -t');
|
||||
});
|
||||
|
||||
it('skips both when entry.nginx is empty (they emit together or not at all)', () => {
|
||||
const entry = createTestEntry({ nginx: '' });
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services.nginx).toBeUndefined();
|
||||
expect(services['php-fpm']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips both when entry.php is empty (they emit together or not at all)', () => {
|
||||
const entry = createTestEntry({ php: '' });
|
||||
const services = buildServicesForEntry(entry, {}, '/runner/ws');
|
||||
|
||||
expect(services.nginx).toBeUndefined();
|
||||
expect(services['php-fpm']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with service preferences', () => {
|
||||
it('uses elasticsearch when search preference is elasticsearch, even if opensearch is available', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, { search: 'elasticsearch' });
|
||||
|
||||
expect(services.elasticsearch).toBeDefined();
|
||||
expect(services.elasticsearch.image).toBe('elasticsearch:8.11.4');
|
||||
expect(services.opensearch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses opensearch when search preference is opensearch (matches default)', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, { search: 'opensearch' });
|
||||
|
||||
expect(services.opensearch).toBeDefined();
|
||||
expect(services.elasticsearch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses redis when cache preference is redis, even if valkey is available', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, { cache: 'redis' });
|
||||
|
||||
expect(services.redis).toBeDefined();
|
||||
expect(services.redis.image).toBe('redis:7.2');
|
||||
expect(services.valkey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies preferences across multiple tiers independently', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, { search: 'elasticsearch', cache: 'redis' });
|
||||
|
||||
expect(services.elasticsearch).toBeDefined();
|
||||
expect(services.redis).toBeDefined();
|
||||
expect(services.opensearch).toBeUndefined();
|
||||
expect(services.valkey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to default-pick for tiers without a preference', () => {
|
||||
const entry = createTestEntry();
|
||||
const services = buildServicesForEntry(entry, { search: 'elasticsearch' });
|
||||
|
||||
expect(services.elasticsearch).toBeDefined();
|
||||
expect(services.valkey).toBeDefined();
|
||||
});
|
||||
|
||||
it('treats single-implementation-tier preferences as no-ops', () => {
|
||||
const entry = createTestEntry();
|
||||
const withPref = buildServicesForEntry(entry, { db: 'mysql', queue: 'rabbitmq' });
|
||||
const withoutPref = buildServicesForEntry(entry);
|
||||
|
||||
expect(withPref).toEqual(withoutPref);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
opensearchConfig,
|
||||
rabbitmqConfig,
|
||||
redisConfig,
|
||||
valkeyConfig
|
||||
valkeyConfig,
|
||||
buildNginxConfig,
|
||||
buildPhpFpmConfig
|
||||
} from './service-config';
|
||||
import { ServicePreferences } from './preferences';
|
||||
|
||||
interface SearchEngineChoice {
|
||||
type: 'opensearch' | 'elasticsearch';
|
||||
@@ -19,10 +22,22 @@ interface CacheChoice {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which search engine to use for a matrix entry.
|
||||
* Prefers opensearch over elasticsearch.
|
||||
* Picks the search engine for a matrix entry. Honors caller's `service_preferences`
|
||||
* when set; otherwise prefers opensearch over elasticsearch.
|
||||
*/
|
||||
function getSearchEngineChoice(entry: PackageMatrixVersion): SearchEngineChoice | null {
|
||||
const getSearchEngineChoice = (entry: PackageMatrixVersion, preference?: string): SearchEngineChoice | null => {
|
||||
if (preference === 'opensearch') {
|
||||
if (entry.opensearch && entry.opensearch.trim() !== '') {
|
||||
return { type: 'opensearch', image: entry.opensearch };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (preference === 'elasticsearch') {
|
||||
if (entry.elasticsearch && entry.elasticsearch.trim() !== '') {
|
||||
return { type: 'elasticsearch', image: entry.elasticsearch };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (entry.opensearch && entry.opensearch.trim() !== '') {
|
||||
return { type: 'opensearch', image: entry.opensearch };
|
||||
}
|
||||
@@ -33,10 +48,22 @@ function getSearchEngineChoice(entry: PackageMatrixVersion): SearchEngineChoice
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines which cache to use for a matrix entry.
|
||||
* Prefers valkey over redis.
|
||||
* Picks the cache for a matrix entry. Honors caller's `service_preferences`
|
||||
* when set; otherwise prefers valkey over redis.
|
||||
*/
|
||||
function getCacheChoice(entry: PackageMatrixVersion): CacheChoice | null {
|
||||
const getCacheChoice = (entry: PackageMatrixVersion, preference?: string): CacheChoice | null => {
|
||||
if (preference === 'valkey') {
|
||||
if (entry.valkey && entry.valkey.trim() !== '') {
|
||||
return { type: 'valkey', image: entry.valkey };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (preference === 'redis') {
|
||||
if (entry.redis && entry.redis.trim() !== '') {
|
||||
return { type: 'redis', image: entry.redis };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (entry.valkey && entry.valkey.trim() !== '') {
|
||||
return { type: 'valkey', image: entry.valkey };
|
||||
}
|
||||
@@ -47,18 +74,25 @@ function getCacheChoice(entry: PackageMatrixVersion): CacheChoice | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the services object for a single matrix entry.
|
||||
* Builds the services object for a single matrix entry. Emits every
|
||||
* tier the entry has data for: mysql, search (opensearch/elasticsearch),
|
||||
* queue (rabbitmq), cache (valkey/redis), and web (nginx + php-fpm
|
||||
* together). The web tier requires `workspace` so the volume mount
|
||||
* has a real path; if the entry lacks either nginx or php data the
|
||||
* web tier is skipped entirely (they're coupled — emit both or neither).
|
||||
*/
|
||||
export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
|
||||
export const buildServicesForEntry = (
|
||||
entry: PackageMatrixVersion,
|
||||
preferences: ServicePreferences = {},
|
||||
workspace: string = ''
|
||||
): Services => {
|
||||
const services: Services = {};
|
||||
|
||||
// MySQL is always included if present
|
||||
if (entry.mysql && entry.mysql.trim() !== '') {
|
||||
services.mysql = mysqlConfig.getConfig(entry.mysql);
|
||||
}
|
||||
|
||||
// Search engine: prefer opensearch over elasticsearch
|
||||
const searchEngine = getSearchEngineChoice(entry);
|
||||
const searchEngine = getSearchEngineChoice(entry, preferences.search);
|
||||
if (searchEngine) {
|
||||
if (searchEngine.type === 'opensearch') {
|
||||
services.opensearch = opensearchConfig.getConfig(searchEngine.image);
|
||||
@@ -67,13 +101,11 @@ export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
|
||||
}
|
||||
}
|
||||
|
||||
// RabbitMQ
|
||||
if (entry.rabbitmq && entry.rabbitmq.trim() !== '') {
|
||||
services.rabbitmq = rabbitmqConfig.getConfig(entry.rabbitmq);
|
||||
}
|
||||
|
||||
// Cache: prefer valkey over redis
|
||||
const cache = getCacheChoice(entry);
|
||||
const cache = getCacheChoice(entry, preferences.cache);
|
||||
if (cache) {
|
||||
if (cache.type === 'valkey') {
|
||||
services.valkey = valkeyConfig.getConfig(cache.image);
|
||||
@@ -82,5 +114,12 @@ export function buildServicesForEntry(entry: PackageMatrixVersion): Services {
|
||||
}
|
||||
}
|
||||
|
||||
const nginxImage = (entry.nginx || '').trim();
|
||||
const phpVersion = String(entry.php ?? '').trim();
|
||||
if (nginxImage !== '' && phpVersion !== '') {
|
||||
services.nginx = buildNginxConfig(nginxImage, workspace);
|
||||
services['php-fpm'] = buildPhpFpmConfig(phpVersion, workspace);
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { Tier, ServicePreferences, KNOWN_SERVICE_NAMES, tierFor } from './tier-map';
|
||||
export { parseServicePreferences } from './parse-service-preferences';
|
||||
export { validatePreferencesAgainstMatrix } from './validate-preferences-against-matrix';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { parseServicePreferences } from './parse-service-preferences';
|
||||
|
||||
describe('parseServicePreferences', () => {
|
||||
it('returns an empty map when input is empty', () => {
|
||||
expect(parseServicePreferences('')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns an empty map for whitespace-only input', () => {
|
||||
expect(parseServicePreferences(' ')).toEqual({});
|
||||
});
|
||||
|
||||
it('maps a single name to its tier', () => {
|
||||
expect(parseServicePreferences('opensearch')).toEqual({ search: 'opensearch' });
|
||||
});
|
||||
|
||||
it('maps two names in different tiers', () => {
|
||||
expect(parseServicePreferences('elasticsearch,valkey')).toEqual({
|
||||
search: 'elasticsearch',
|
||||
cache: 'valkey',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts single-implementation-tier names as no-op-like preferences', () => {
|
||||
expect(parseServicePreferences('mysql,rabbitmq')).toEqual({
|
||||
db: 'mysql',
|
||||
queue: 'rabbitmq',
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates whitespace around names', () => {
|
||||
expect(parseServicePreferences(' opensearch , valkey ')).toEqual({
|
||||
search: 'opensearch',
|
||||
cache: 'valkey',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws on unknown service name', () => {
|
||||
expect(() => parseServicePreferences('foobar')).toThrowError(/unknown service "foobar"/);
|
||||
});
|
||||
|
||||
it('throws on a collision in the search tier', () => {
|
||||
expect(() => parseServicePreferences('elasticsearch,opensearch')).toThrowError(
|
||||
/collision in tier "search"/
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on a collision in the cache tier', () => {
|
||||
expect(() => parseServicePreferences('redis,valkey')).toThrowError(/collision in tier "cache"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ServicePreferences, SERVICE_TIER_MAP, KNOWN_SERVICE_NAMES } from './tier-map';
|
||||
|
||||
/**
|
||||
* Parses the comma-separated `service_preferences` input into a
|
||||
* tier-to-implementation map. Throws on unknown service names or when
|
||||
* two names collide in the same tier.
|
||||
*/
|
||||
export const parseServicePreferences = (raw: string): ServicePreferences => {
|
||||
const trimmed = (raw || '').trim();
|
||||
if (trimmed === '') return {};
|
||||
|
||||
const names = trimmed.split(',').map(s => s.trim()).filter(s => s !== '');
|
||||
const preferences: ServicePreferences = {};
|
||||
|
||||
for (const name of names) {
|
||||
const tier = SERVICE_TIER_MAP[name];
|
||||
if (!tier) {
|
||||
throw new Error(
|
||||
`service_preferences: unknown service "${name}". Known services: ${KNOWN_SERVICE_NAMES.join(', ')}`
|
||||
);
|
||||
}
|
||||
const existing = preferences[tier];
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`service_preferences: collision in tier "${tier}" — both "${existing}" and "${name}" specified`
|
||||
);
|
||||
}
|
||||
preferences[tier] = name;
|
||||
}
|
||||
|
||||
return preferences;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { tierFor } from './tier-map';
|
||||
|
||||
describe('tierFor', () => {
|
||||
it.each([
|
||||
['mysql', 'db'],
|
||||
['elasticsearch', 'search'],
|
||||
['opensearch', 'search'],
|
||||
['rabbitmq', 'queue'],
|
||||
['redis', 'cache'],
|
||||
['valkey', 'cache'],
|
||||
])('maps %s to %s', (name, tier) => {
|
||||
expect(tierFor(name)).toBe(tier);
|
||||
});
|
||||
|
||||
it('returns undefined for unknown names', () => {
|
||||
expect(tierFor('foobar')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* A category of services that Magento can choose between. Each tier
|
||||
* has one or more concrete implementations (e.g. the `search` tier
|
||||
* resolves to either `opensearch` or `elasticsearch`).
|
||||
*/
|
||||
export type Tier = 'db' | 'search' | 'queue' | 'cache';
|
||||
|
||||
/**
|
||||
* Caller-expressed preference of "which implementation to use for which tier."
|
||||
* Produced by `parseServicePreferences` from the `service_preferences`
|
||||
* action input, then threaded into `buildServicesForEntry` to override
|
||||
* the per-tier default pick. Partial because callers only need to name
|
||||
* the tiers they care about; unset tiers fall back to the version's
|
||||
* default implementation.
|
||||
*/
|
||||
export type ServicePreferences = Partial<Record<Tier, string>>;
|
||||
|
||||
/**
|
||||
* Reverse lookup: which tier does each known service implementation
|
||||
* belong to. The keys of this map define the closed set of legal
|
||||
* service names in `service_preferences`; anything not listed here is
|
||||
* rejected as an unknown service. nginx and php-fpm are intentionally
|
||||
* absent — they're complementary (the `web` tier emits both together),
|
||||
* not alternatives, so picking one via preferences would be meaningless.
|
||||
*/
|
||||
export const SERVICE_TIER_MAP: Record<string, Tier> = {
|
||||
mysql: 'db',
|
||||
elasticsearch: 'search',
|
||||
opensearch: 'search',
|
||||
rabbitmq: 'queue',
|
||||
redis: 'cache',
|
||||
valkey: 'cache',
|
||||
};
|
||||
|
||||
/**
|
||||
* Forward lookup: the implementations available within each tier,
|
||||
* ordered by default preference (first entry wins when no caller
|
||||
* preference is supplied). Used by the validator to render
|
||||
* "supported: a, b" lists in compatibility errors and by
|
||||
* `buildServicesForEntry` to know what to fall back to.
|
||||
*/
|
||||
export const TIER_IMPLEMENTATIONS: Record<Tier, string[]> = {
|
||||
db: ['mysql'],
|
||||
search: ['opensearch', 'elasticsearch'],
|
||||
queue: ['rabbitmq'],
|
||||
cache: ['valkey', 'redis'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Flat list of every legal service name in `service_preferences`.
|
||||
* Surfaced in unknown-service error messages so the caller sees
|
||||
* exactly what's accepted without having to read source.
|
||||
*/
|
||||
export const KNOWN_SERVICE_NAMES = Object.keys(SERVICE_TIER_MAP);
|
||||
|
||||
/**
|
||||
* Returns the tier a service name belongs to, or `undefined` if the
|
||||
* name isn't recognized. Callers use the undefined return as the
|
||||
* signal to reject the input as unknown.
|
||||
*/
|
||||
export const tierFor = (serviceName: string): Tier | undefined => {
|
||||
return SERVICE_TIER_MAP[serviceName];
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { validatePreferencesAgainstMatrix } from './validate-preferences-against-matrix';
|
||||
import { PackageMatrixVersion } from '../../matrix/matrix-type';
|
||||
|
||||
const baseEntry = (overrides: Partial<PackageMatrixVersion> = {}): PackageMatrixVersion => ({
|
||||
magento: 'magento/project-community-edition:2.4.7',
|
||||
version: '2.4.7',
|
||||
php: '8.3',
|
||||
composer: '2.7.4',
|
||||
mysql: 'mysql:8.4',
|
||||
elasticsearch: 'elasticsearch:8.11.4',
|
||||
opensearch: 'opensearchproject/opensearch:2.19.1',
|
||||
rabbitmq: 'rabbitmq:4.0-management',
|
||||
redis: 'redis:7.2',
|
||||
varnish: 'varnish:7.5',
|
||||
valkey: 'valkey:8.0',
|
||||
nginx: 'nginx:1.26',
|
||||
os: 'ubuntu-latest',
|
||||
release: '2024-04-09T00:00:00+0000',
|
||||
eol: '2027-04-09T00:00:00+0000',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('validatePreferencesAgainstMatrix', () => {
|
||||
it('does not throw when all entries support the preference', () => {
|
||||
const entries = [baseEntry({ version: '2.4.7' }), baseEntry({ version: '2.4.6' })];
|
||||
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws when an entry lacks the preferenced implementation, listing the offender', () => {
|
||||
const entries = [
|
||||
baseEntry({ version: '2.4.7' }),
|
||||
baseEntry({ version: '2.4.5', opensearch: '' }),
|
||||
];
|
||||
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
|
||||
/not satisfied for:\n\s+- magento 2\.4\.5 \(supported: elasticsearch\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('lists all offenders, not just the first', () => {
|
||||
const entries = [
|
||||
baseEntry({ version: '2.4.5', opensearch: '' }),
|
||||
baseEntry({ version: '2.4.4', opensearch: '' }),
|
||||
];
|
||||
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
|
||||
/magento 2\.4\.5[\s\S]*magento 2\.4\.4/
|
||||
);
|
||||
});
|
||||
|
||||
it('reports "<none>" when the entry supports nothing in the tier', () => {
|
||||
const entries = [baseEntry({ version: '2.4.0', opensearch: '', elasticsearch: '' })];
|
||||
expect(() => validatePreferencesAgainstMatrix({ search: 'opensearch' }, entries)).toThrowError(
|
||||
/magento 2\.4\.0 \(supported: <none>\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('reports violations across multiple tiers', () => {
|
||||
const entries = [baseEntry({ version: '2.4.5', opensearch: '', valkey: '' })];
|
||||
let captured: Error | null = null;
|
||||
try {
|
||||
validatePreferencesAgainstMatrix({ search: 'opensearch', cache: 'valkey' }, entries);
|
||||
} catch (e) {
|
||||
captured = e as Error;
|
||||
}
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.message).toMatch(/opensearch/);
|
||||
expect(captured!.message).toMatch(/valkey/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { PackageMatrixVersion } from '../../matrix/matrix-type';
|
||||
import { ServicePreferences, Tier, TIER_IMPLEMENTATIONS } from './tier-map';
|
||||
|
||||
/**
|
||||
* Verifies that every preferenced implementation is supported by every
|
||||
* matrix entry. Collects all violations and throws a single
|
||||
* consolidated error.
|
||||
*/
|
||||
export const validatePreferencesAgainstMatrix = (
|
||||
preferences: ServicePreferences,
|
||||
entries: PackageMatrixVersion[]
|
||||
): void => {
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const tierKey of Object.keys(preferences) as Tier[]) {
|
||||
const implementation = preferences[tierKey]!;
|
||||
const tierImplementations = TIER_IMPLEMENTATIONS[tierKey];
|
||||
|
||||
const offenders: { version: string; supported: string[] }[] = [];
|
||||
for (const entry of entries) {
|
||||
const value = entry[implementation as keyof PackageMatrixVersion];
|
||||
const isSupported = typeof value === 'string' && value.trim() !== '';
|
||||
if (!isSupported) {
|
||||
const supported = tierImplementations.filter(name => {
|
||||
const v = entry[name as keyof PackageMatrixVersion];
|
||||
return typeof v === 'string' && v.trim() !== '';
|
||||
});
|
||||
offenders.push({ version: entry.version, supported });
|
||||
}
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const list = offenders
|
||||
.map(o => ` - magento ${o.version} (supported: ${o.supported.length > 0 ? o.supported.join(', ') : '<none>'})`)
|
||||
.join('\n');
|
||||
errors.push(`service_preferences "${implementation}" is not satisfied for:\n${list}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(errors.join('\n\n'));
|
||||
}
|
||||
}
|
||||
@@ -80,4 +80,29 @@ export const valkeyConfig: ServiceTemplate = {
|
||||
ports: ['6379:6379']
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the nginx service config for the web tier. Takes the image
|
||||
* (sourced from the matrix entry's `nginx` field) and the runner
|
||||
* workspace path so the volume mount lands the Magento install at
|
||||
* `/var/www/html` inside the container. Pairs with `buildPhpFpmConfig`
|
||||
* — they're emitted together as the web tier.
|
||||
*/
|
||||
export const buildNginxConfig = (image: string, workspace: string): ServiceConfig => ({
|
||||
image,
|
||||
ports: ['80:80'],
|
||||
volumes: [`${workspace}:/var/www/html`],
|
||||
options: '--health-cmd "nginx -t" --health-interval=10s --health-retries=3 --health-timeout=5s --health-start-period=5s'
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds the php-fpm service config for the web tier. Composes the
|
||||
* image from the matrix entry's `php` version (the mappia magento-php
|
||||
* image stream is the only widely-used Magento-aware php-fpm image).
|
||||
* Pairs with `buildNginxConfig`.
|
||||
*/
|
||||
export const buildPhpFpmConfig = (phpVersion: string, workspace: string): ServiceConfig => ({
|
||||
image: `mappia/magento-php:fpm-alpine${phpVersion}`,
|
||||
volumes: [`${workspace}:/var/www/html`]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user