mirror of
https://github.com/graycoreio/github-actions-magento2.git
synced 2026-06-13 13:14:53 +00:00
feat(supported-version): add service_preferences and support for php-fpm and nginx (#255)
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user