From 2ba38967b33acb70764273bc799333a0650aa3c5 Mon Sep 17 00:00:00 2001 From: Louis Lambeau Date: Fri, 17 Apr 2026 18:04:26 +0200 Subject: [PATCH] Add support for flavor inheritance. --- src/config/schema.json | 8 + src/docker/resources/DockerImageResource.ts | 5 +- src/monorepo/component.ts | 12 +- src/monorepo/config.ts | 10 +- src/monorepo/flavor.ts | 112 +++++++++++ src/monorepo/index.ts | 1 + src/monorepo/monorepo.ts | 4 + .../resources/PublishResourcesOperation.ts | 13 +- .../resources/DockerImageResource.spec.ts | 3 + tests/unit/monorepo/component.spec.ts | 28 +++ tests/unit/monorepo/flavor.spec.ts | 189 ++++++++++++++++++ tests/unit/monorepo/monorepo.spec.ts | 46 +++++ .../PublishResourcesOperation.spec.ts | 1 + website/src/content/docs/advanced/flavors.md | 29 +++ 14 files changed, 446 insertions(+), 15 deletions(-) create mode 100644 src/monorepo/flavor.ts create mode 100644 tests/unit/monorepo/flavor.spec.ts diff --git a/src/config/schema.json b/src/config/schema.json index 47a6c477..6118869e 100644 --- a/src/config/schema.json +++ b/src/config/schema.json @@ -313,6 +313,10 @@ "additionalProperties": false, "required": [], "properties": { + "extends": { + "$ref": "#/definitions/Identifier", + "description": "Name of another flavor to inherit patches and defaults from. The parent's patches are applied before this flavor's own patches." + }, "patches": { "type": "array", "items": { @@ -447,6 +451,10 @@ "additionalProperties": false, "required": [], "properties": { + "extends": { + "$ref": "#/definitions/Identifier", + "description": "Name of another flavor (within the same component) to inherit patches from. The parent's patches are applied before this flavor's own patches." + }, "patches": { "type": "array", "items": { diff --git a/src/docker/resources/DockerImageResource.ts b/src/docker/resources/DockerImageResource.ts index b309f593..afb703ba 100644 --- a/src/docker/resources/DockerImageResource.ts +++ b/src/docker/resources/DockerImageResource.ts @@ -186,7 +186,10 @@ class DockerImageResourceBuilder extends SentinelFileBasedBuilder< async _mustBuild( resource: ResourceInfo, ): Promise { - const flavor = this.monorepo.flavors[this.monorepo.currentFlavor]; + const flavorName = this.monorepo.currentFlavor; + const flavor = this.monorepo.flavors[flavorName] + ? this.monorepo.flavor(flavorName) + : undefined; const trigger = resolveRebuildTrigger({ resource: resource.rebuildTrigger, flavor: flavor?.defaults?.rebuildPolicy?.['docker/image'], diff --git a/src/monorepo/component.ts b/src/monorepo/component.ts index 19d1e675..d58b5c4c 100644 --- a/src/monorepo/component.ts +++ b/src/monorepo/component.ts @@ -11,6 +11,8 @@ import { toIdentifedHash, } from '@/monorepo'; +import { resolveComponentFlavor } from './flavor.js'; + export class Component implements ComponentConfig { public readonly _rootDir?: string; public readonly tasks: Tasks; @@ -37,13 +39,15 @@ export class Component implements ComponentConfig { } flavor(name: string, mustExist = true): ComponentFlavorConfig { - const flavor = this.flavors[name]; + if (!this.flavors[name]) { + if (mustExist) { + throw new Error(`Unknown flavor: ${name}`); + } - if (!flavor && mustExist) { - throw new Error(`Unknown flavor: ${name}`); + return undefined as unknown as ComponentFlavorConfig; } - return flavor; + return resolveComponentFlavor(this.flavors, name); } cloneWith(config: Partial): ComponentConfig { diff --git a/src/monorepo/config.ts b/src/monorepo/config.ts index 8cb7441d..f1cf56c6 100644 --- a/src/monorepo/config.ts +++ b/src/monorepo/config.ts @@ -10,6 +10,8 @@ import { ProjectFlavorConfig, } from '@/config'; +import { resolveProjectFlavor } from './flavor.js'; + export class MonorepoConfig implements EMBConfig { project: ProjectConfig; defaults: DefaultsConfig; @@ -42,13 +44,7 @@ export class MonorepoConfig implements EMBConfig { } flavor(name: string): ProjectFlavorConfig { - const flavor = this.flavors[name]; - - if (!flavor) { - throw new Error(`Unknown flavor: ${name}`); - } - - return flavor; + return resolveProjectFlavor(this.flavors, name); } toJSON(): Required { diff --git a/src/monorepo/flavor.ts b/src/monorepo/flavor.ts new file mode 100644 index 00000000..e001d66b --- /dev/null +++ b/src/monorepo/flavor.ts @@ -0,0 +1,112 @@ +import deepMerge from '@fastify/deepmerge'; + +import { + ComponentFlavorConfig, + JsonPatchOperation, + ProjectFlavorConfig, +} from '@/config/schema.js'; +import { CircularDependencyError, UnkownReferenceError } from '@/errors.js'; + +type FlavorLike = { + extends?: string; + patches?: JsonPatchOperation[]; +}; + +function buildChain( + flavors: Record, + name: string, + subject: string, +): string[] { + const chain: string[] = []; + const visited = new Set(); + let current: string | undefined = name; + + while (current !== undefined) { + if (visited.has(current)) { + const cycle = [...chain, current].join(' -> '); + throw new CircularDependencyError( + `Circular ${subject} inheritance detected: ${cycle}`, + [[...chain, current]], + ); + } + + const config: T | undefined = flavors[current]; + + if (!config) { + if (chain.length === 0) { + throw new UnkownReferenceError( + `Unknown ${subject}: ${current}`, + current, + ); + } + + const parent = chain.at(-1); + throw new UnkownReferenceError( + `Unknown parent ${subject}: '${current}' (extended from '${parent}')`, + current, + ); + } + + visited.add(current); + chain.push(current); + current = config.extends; + } + + return chain.reverse(); +} + +export function resolveProjectFlavor( + flavors: Record, + name: string, +): ProjectFlavorConfig { + const chain = buildChain(flavors, name, 'flavor'); + const patches: JsonPatchOperation[] = []; + let defaults: ProjectFlavorConfig['defaults'] | undefined; + const merge = deepMerge(); + + for (const flavorName of chain) { + const f = flavors[flavorName]; + if (f.patches) { + patches.push(...f.patches); + } + + if (f.defaults) { + defaults = defaults + ? (merge(defaults, f.defaults) as ProjectFlavorConfig['defaults']) + : f.defaults; + } + } + + const resolved: ProjectFlavorConfig = {}; + if (patches.length > 0) { + resolved.patches = patches; + } + + if (defaults) { + resolved.defaults = defaults; + } + + return resolved; +} + +export function resolveComponentFlavor( + flavors: Record, + name: string, +): ComponentFlavorConfig { + const chain = buildChain(flavors, name, 'component flavor'); + const patches: JsonPatchOperation[] = []; + + for (const flavorName of chain) { + const f = flavors[flavorName]; + if (f.patches) { + patches.push(...f.patches); + } + } + + const resolved: ComponentFlavorConfig = {}; + if (patches.length > 0) { + resolved.patches = patches; + } + + return resolved; +} diff --git a/src/monorepo/index.ts b/src/monorepo/index.ts index 5040bc08..01327570 100644 --- a/src/monorepo/index.ts +++ b/src/monorepo/index.ts @@ -1,5 +1,6 @@ export * from './component.js'; export * from './config.js'; +export * from './flavor.js'; export * from './monorepo.js'; export * from './operations/index.js'; export * from './plugins/index.js'; diff --git a/src/monorepo/monorepo.ts b/src/monorepo/monorepo.ts index 96b874bf..6f7ab462 100644 --- a/src/monorepo/monorepo.ts +++ b/src/monorepo/monorepo.ts @@ -39,6 +39,10 @@ export class Monorepo { return this._config.flavors; } + flavor(name: string) { + return this._config.flavor(name); + } + get name() { return this._config.project.name; } diff --git a/src/monorepo/operations/resources/PublishResourcesOperation.ts b/src/monorepo/operations/resources/PublishResourcesOperation.ts index 8dc971d2..548a9958 100644 --- a/src/monorepo/operations/resources/PublishResourcesOperation.ts +++ b/src/monorepo/operations/resources/PublishResourcesOperation.ts @@ -70,8 +70,13 @@ export class PublishResourcesOperation extends AbstractOperation< let targetResources: ResourceInfo[]; if (input.resources && input.resources.length > 0) { // Resolve order using full collection, then filter to only publishable - const orderedResources = findRunOrder(input.resources, allResourcesCollection); - targetResources = orderedResources.filter((r) => publishableIds.has(r.id)); + const orderedResources = findRunOrder( + input.resources, + allResourcesCollection, + ); + targetResources = orderedResources.filter((r) => + publishableIds.has(r.id), + ); } else { // All publishable resources - resolve order using full collection const orderedResources = findRunOrder( @@ -79,7 +84,9 @@ export class PublishResourcesOperation extends AbstractOperation< allResourcesCollection, ); // Filter to only publishable (dependencies are resolved but not published) - targetResources = orderedResources.filter((r) => publishableIds.has(r.id)); + targetResources = orderedResources.filter((r) => + publishableIds.has(r.id), + ); } // Verify each resource's builder supports publish diff --git a/tests/unit/docker/resources/DockerImageResource.spec.ts b/tests/unit/docker/resources/DockerImageResource.spec.ts index 9f03603f..faa4d13a 100644 --- a/tests/unit/docker/resources/DockerImageResource.spec.ts +++ b/tests/unit/docker/resources/DockerImageResource.spec.ts @@ -91,6 +91,9 @@ describe('Docker / DockerImageResource', () => { }, }, flavors: {}, + flavor(name: string) { + return (this as unknown as Monorepo).flavors[name]; + }, } as unknown as Monorepo; mockComponent = { diff --git a/tests/unit/monorepo/component.spec.ts b/tests/unit/monorepo/component.spec.ts index 283b663d..7f469b15 100644 --- a/tests/unit/monorepo/component.spec.ts +++ b/tests/unit/monorepo/component.spec.ts @@ -225,6 +225,34 @@ describe('Monorepo / Component', () => { expect(() => component.withFlavor('broken')).toThrow(); }); + + test('it applies patches from an extended flavor before the child patches', () => { + const component = new Component( + 'api', + { + rootDir: 'api', + description: 'base', + flavors: { + production: { + patches: [ + { op: 'replace', path: '/rootDir', value: 'api-prod' }, + { op: 'replace', path: '/description', value: 'production' }, + ], + }, + test: { + extends: 'production', + patches: [{ op: 'replace', path: '/description', value: 'test' }], + }, + }, + }, + monorepo, + ); + + const flavored = component.withFlavor('test'); + + expect(flavored.rootDir).toBe('api-prod'); + expect(flavored.config.description).toBe('test'); + }); }); describe('#join()', () => { diff --git a/tests/unit/monorepo/flavor.spec.ts b/tests/unit/monorepo/flavor.spec.ts new file mode 100644 index 00000000..d3807d22 --- /dev/null +++ b/tests/unit/monorepo/flavor.spec.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'vitest'; + +import { ComponentFlavorConfig, ProjectFlavorConfig } from '@/config/schema.js'; +import { resolveComponentFlavor, resolveProjectFlavor } from '@/monorepo'; + +describe('Monorepo / flavor resolver', () => { + describe('resolveProjectFlavor', () => { + test('returns the flavor as-is when it has no parent', () => { + const flavors: Record = { + production: { + patches: [{ op: 'replace', path: '/env/NODE_ENV', value: 'prod' }], + }, + }; + + const resolved = resolveProjectFlavor(flavors, 'production'); + + expect(resolved.patches).toHaveLength(1); + expect(resolved.patches?.[0]).toMatchObject({ + op: 'replace', + path: '/env/NODE_ENV', + value: 'prod', + }); + }); + + test('concatenates parent patches before child patches', () => { + const flavors: Record = { + production: { + patches: [ + { op: 'replace', path: '/env/NODE_ENV', value: 'production' }, + { op: 'replace', path: '/env/LOG_LEVEL', value: 'warn' }, + ], + }, + test: { + extends: 'production', + patches: [{ op: 'replace', path: '/env/NODE_ENV', value: 'test' }], + }, + }; + + const resolved = resolveProjectFlavor(flavors, 'test'); + + expect(resolved.patches).toHaveLength(3); + expect(resolved.patches?.[0]).toMatchObject({ + path: '/env/NODE_ENV', + value: 'production', + }); + expect(resolved.patches?.[1]).toMatchObject({ + path: '/env/LOG_LEVEL', + value: 'warn', + }); + expect(resolved.patches?.[2]).toMatchObject({ + path: '/env/NODE_ENV', + value: 'test', + }); + }); + + test('supports multi-level inheritance chains', () => { + const flavors: Record = { + base: { + patches: [{ op: 'replace', path: '/a', value: 1 }], + }, + middle: { + extends: 'base', + patches: [{ op: 'replace', path: '/b', value: 2 }], + }, + leaf: { + extends: 'middle', + patches: [{ op: 'replace', path: '/c', value: 3 }], + }, + }; + + const resolved = resolveProjectFlavor(flavors, 'leaf'); + + expect( + resolved.patches?.map((p) => ('value' in p ? p.value : null)), + ).toEqual([1, 2, 3]); + }); + + test('deep-merges defaults with child winning', () => { + const flavors: Record = { + production: { + defaults: { + rebuildPolicy: { + 'docker/image': { strategy: 'auto' }, + }, + }, + }, + test: { + extends: 'production', + defaults: { + rebuildPolicy: { + 'docker/image': { + strategy: 'watch-paths', + paths: ['Dockerfile'], + }, + }, + }, + }, + }; + + const resolved = resolveProjectFlavor(flavors, 'test'); + + expect(resolved.defaults?.rebuildPolicy?.['docker/image']).toMatchObject({ + strategy: 'watch-paths', + paths: ['Dockerfile'], + }); + }); + + test('inherits defaults when child does not set them', () => { + const flavors: Record = { + production: { + defaults: { + rebuildPolicy: { + 'docker/image': { strategy: 'always' }, + }, + }, + }, + test: { extends: 'production' }, + }; + + const resolved = resolveProjectFlavor(flavors, 'test'); + + expect(resolved.defaults?.rebuildPolicy?.['docker/image']).toMatchObject({ + strategy: 'always', + }); + }); + + test('throws when the flavor is unknown', () => { + expect(() => resolveProjectFlavor({}, 'ghost')).toThrow(/Unknown flavor/); + }); + + test('throws when a parent flavor is unknown', () => { + const flavors: Record = { + test: { extends: 'ghost', patches: [] }, + }; + + expect(() => resolveProjectFlavor(flavors, 'test')).toThrow( + /Unknown parent flavor/, + ); + }); + + test('throws on a direct cycle', () => { + const flavors: Record = { + a: { extends: 'a' }, + }; + + expect(() => resolveProjectFlavor(flavors, 'a')).toThrow(/Circular/); + }); + + test('throws on an indirect cycle', () => { + const flavors: Record = { + a: { extends: 'b' }, + b: { extends: 'c' }, + c: { extends: 'a' }, + }; + + expect(() => resolveProjectFlavor(flavors, 'a')).toThrow(/Circular/); + }); + }); + + describe('resolveComponentFlavor', () => { + test('concatenates parent patches before child patches', () => { + const flavors: Record = { + production: { + patches: [{ op: 'replace', path: '/rootDir', value: 'prod' }], + }, + test: { + extends: 'production', + patches: [{ op: 'replace', path: '/description', value: 'test' }], + }, + }; + + const resolved = resolveComponentFlavor(flavors, 'test'); + + expect(resolved.patches).toHaveLength(2); + expect(resolved.patches?.[0]).toMatchObject({ path: '/rootDir' }); + expect(resolved.patches?.[1]).toMatchObject({ path: '/description' }); + }); + + test('throws when a parent component flavor is unknown', () => { + const flavors: Record = { + test: { extends: 'ghost' }, + }; + + expect(() => resolveComponentFlavor(flavors, 'test')).toThrow( + /Unknown parent component flavor/, + ); + }); + }); +}); diff --git a/tests/unit/monorepo/monorepo.spec.ts b/tests/unit/monorepo/monorepo.spec.ts index ffd91c42..f4dce862 100644 --- a/tests/unit/monorepo/monorepo.spec.ts +++ b/tests/unit/monorepo/monorepo.spec.ts @@ -107,5 +107,51 @@ describe('Config - MonorepoConfig', () => { ?.params as { target: string }; expect(newBuild.target).to.equal('production'); }); + + test('applies patches from an extended flavor before the child patches', async () => { + const inheritedConfig = new MonorepoConfig({ + project: { name: 'test' }, + components: { + api: { + resources: { + image: { + type: 'docker/image', + params: { target: 'development', context: 'api' }, + }, + }, + }, + }, + flavors: { + production: { + patches: [ + { + op: 'replace', + path: '/components/api/resources/image/params/target', + value: 'production', + }, + ], + }, + test: { + extends: 'production', + patches: [ + { + op: 'replace', + path: '/components/api/resources/image/params/target', + value: 'test', + }, + ], + }, + }, + }); + const inheritedRepo = new Monorepo(inheritedConfig, '/tmp'); + await inheritedRepo.init(); + + const test = await inheritedRepo.withFlavor('test'); + const params = test.component('api').resources?.image?.params as { + target: string; + }; + + expect(params.target).to.equal('test'); + }); }); }); diff --git a/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts b/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts index f7d07af3..002eb617 100644 --- a/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts +++ b/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts @@ -246,6 +246,7 @@ describe('Monorepo / Operations / Resources / PublishResourcesOperation', () => publishCalls.push(context.config.id); }); } + return builder; }, ); diff --git a/website/src/content/docs/advanced/flavors.md b/website/src/content/docs/advanced/flavors.md index 8d33bfd4..742bb7d1 100644 --- a/website/src/content/docs/advanced/flavors.md +++ b/website/src/content/docs/advanced/flavors.md @@ -154,6 +154,35 @@ This allows you to: - Set global environment changes at project level - Set component-specific build changes at component level +## Extending Another Flavor + +A flavor can inherit from another flavor of the same level using the `extends` field. The parent's patches are applied first, then the child's patches on top — so a child can override a parent value by emitting its own `replace`, or undo a parent `add` with its own `remove`. + +```yaml +flavors: + production: + patches: + - op: replace + path: /env/NODE_ENV + value: production + - op: replace + path: /env/LOG_LEVEL + value: warn + + test: + extends: production + patches: + # keep everything production does, but flip NODE_ENV + - op: replace + path: /env/NODE_ENV + value: test + # and drop the LOG_LEVEL the parent set + - op: remove + path: /env/LOG_LEVEL +``` + +Inheritance also works at the component level (a component flavor can extend another component flavor of the same component) and across multiple levels (`a → b → c`). Project-level `defaults` (such as `rebuildPolicy`) are deep-merged with child values winning. Cycles and references to unknown parents are rejected at load time. + ## Viewing Flavor Configuration To see what configuration a flavor produces: