Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .buildkite/pipeline-utils/affected-packages/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,17 @@ const filteredFiles = filterFilesByPackages(
## PR Jest selective testing

On pull request builds, Jest unit and integration test groups are narrowed to configs under affected packages (see `pick_test_group_run_order` in CI stats). Add the GitHub label `ci:prevent-selective-testing` to run the full Jest suite instead. Touching files listed in `CRITICAL_FILES_JEST_*` in `const.ts` also skips filtering for the relevant test type.

## PR FTR solution-selective testing (opt-in)

FTR configs are **not** narrowed by affected packages by default (every PR runs the full enabled manifest set). Two opt-in PR labels change how FTR behaves when a PR's diff is confined to one or more **solutions** (`observability`, `security`, `search`, `workplaceai`, `vectordb`):

- `ci:skip-unaffected-ftr-configs` — drop the FTR configs of solutions the PR does not touch (only the touched solutions + `platform`/`base` manifests run).
- `ci:soft-fail-unaffected-ftr-configs` — keep running the untouched solutions' configs, but make their failures **non-blocking** (reported and annotated, but they don't fail the PR).

If both labels are present, skip wins. The implementation lives in `pick_test_group_run_order/selective_ftr.ts` and uses the affected-packages utilities here:

- A change is attributed to a solution by the module `group` from `kibana.jsonc` (via `getModuleGroup`) or, for files outside any module, by the `x-pack/solutions/<solution>/` path.
- The diff must be **fully** confined to solutions. The full FTR suite still runs (blocking) when any changed file maps to `platform`/shared or to no solution, when a downstream dependent lives in `platform`/an unknown group, or when a file in `CRITICAL_FILES_FTR` (`const.ts`) changes. This is safe because solutions are `visibility: private` and cannot depend on one another.

The non-blocking behaviour is enforced inside `.buildkite/scripts/steps/test/ftr_configs.sh`, which reads the `ftr_soft_fail_configs.json` artifact produced by `pick_test_group_run_order` and swallows (only) those configs' failures.
1 change: 1 addition & 0 deletions .buildkite/pipeline-utils/affected-packages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getAffectedProjectsMoon } from './strategy_moon';
export * from './const';
export * from './utils';
export { listChangedFiles } from './strategy_git';
export { findModuleForPath, getModuleGroup } from './module_lookup';

export interface AffectedPackagesConfig {
strategy?: 'git' | 'moon';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.mock('../utils', () => ({
import {
getModuleLookup,
findModuleForPath,
getModuleGroup,
getModuleDependencies,
buildModuleDownstreamGraph,
resetModuleLookupCache,
Expand Down Expand Up @@ -261,6 +262,37 @@ describe('module_lookup', () => {
});
});

describe('getModuleGroup', () => {
it('captures the `group` field from kibana.jsonc for every module', () => {
const { groupById } = getModuleLookup();
for (const spec of MODULES) {
// createModule() writes `group: 'platform'` for all test modules
expect(groupById.get(spec.id)).toBe('platform');
}
});

it('returns the group for a known module via getModuleGroup', () => {
expect(getModuleGroup('@kbn/core')).toBe('platform');
});

it('returns undefined for an unknown module', () => {
expect(getModuleGroup('@kbn/does-not-exist')).toBeUndefined();
});

it('omits modules whose kibana.jsonc has no group', () => {
const dir = path.join(tmpDir, 'packages', 'no-group');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'kibana.jsonc'),
JSON.stringify({ type: 'shared-common', id: '@kbn/no-group' })
);
commitAll(tmpDir, 'add module without group');

resetModuleLookupCache();
expect(getModuleGroup('@kbn/no-group')).toBeUndefined();
});
});

describe('findModuleForPath', () => {
it('maps a deep file path to its containing module', () => {
expect(findModuleForPath('packages/core/src/index.ts')).toBe('@kbn/core');
Expand Down
21 changes: 20 additions & 1 deletion .buildkite/pipeline-utils/affected-packages/module_lookup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ export interface ModuleLookup {
* `"@kbn/core-http-server-internal"` → `"src/core/packages/http/server-internal"`
*/
byId: Map<string, string>;
/**
* `"@kbn/core-http-server-internal"` → `"platform"` (the `group` field from
* `kibana.jsonc`: `platform` or a solution name such as `observability`).
* Modules whose manifest omits `group` are absent from this map.
*/
groupById: Map<string, string>;
}

let cachedModuleLookup: ModuleLookup | null = null;
Expand All @@ -41,6 +47,7 @@ export function getModuleLookup(): ModuleLookup {

const byDir = new Map<string, string>();
const byId = new Map<string, string>();
const groupById = new Map<string, string>();

for (const file of files) {
if (file.includes('__fixtures__')) {
Expand All @@ -52,13 +59,25 @@ export function getModuleLookup(): ModuleLookup {
if (config.id && typeof config.id === 'string') {
byDir.set(dir, config.id);
byId.set(config.id, dir);
if (config.group && typeof config.group === 'string') {
groupById.set(config.id, config.group);
}
}
}

cachedModuleLookup = { byDir, byId };
cachedModuleLookup = { byDir, byId, groupById };
return cachedModuleLookup;
}

/**
* Returns the `group` declared in a module's `kibana.jsonc` (e.g. `platform` or
* a solution name like `observability`), or `undefined` when the module is
* unknown or declares no group.
*/
export function getModuleGroup(moduleId: string): string | undefined {
return getModuleLookup().groupById.get(moduleId);
}

export function findModuleForPath(filePath: string): string | undefined {
const lookup = getModuleLookup();
const normalizedFilePath = filePath.replace(/\\/g, '/');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,66 @@ export const STEP_KEYS = {

/** PR label that prevents selective testing. */
export const PREVENT_SELECTIVE_TESTS_LABEL = 'ci:prevent-selective-testing';

/**
* PR label that drops FTR configs belonging to solutions the PR does not touch.
* Only takes effect when the diff is confined to one or more solutions (no
* platform/shared/CI/test-infra changes); otherwise the full suite still runs.
*/
export const FTR_SKIP_UNAFFECTED_LABEL = 'ci:skip-unaffected-ftr-configs';

/**
* PR label that keeps running FTR configs of untouched solutions but makes their
* failures non-blocking (they no longer fail the PR). Same confinement gate as
* the skip label. If both labels are present, skip wins.
*/
export const FTR_SOFT_FAIL_UNAFFECTED_LABEL = 'ci:soft-fail-unaffected-ftr-configs';

/** The base `group` shared across solutions (from `kibana.jsonc`). */
export const PLATFORM_GROUP = 'platform';

/** Solution `group` values (from `kibana.jsonc`), matching `VALID_SOLUTIONS`. */
export const SOLUTION_GROUPS = [
'observability',
'security',
'search',
'workplaceai',
'vectordb',
] as const;

/**
* Maps a solution `group` to the infix used in its FTR manifest filenames
* (`.buildkite/ftr-manifests/ftr_<infix>_*.yml`). Most match 1:1, but a few
* historical names differ (`observability`→`oblt`, `workplaceai`→`workplace_ai`).
*/
export const SOLUTION_MANIFEST_INFIX: Record<string, string> = {
observability: 'oblt',
security: 'security',
search: 'search',
workplaceai: 'workplace_ai',
vectordb: 'vectordb',
};

/**
* Touching any of these forces the full FTR suite to run (blocking), even when
* the rest of the diff looks solution-scoped. Kept narrow: shared test harness,
* FTR base services, CI selection logic, and root toolchain files. Most of these
* already resolve to the `platform` group or `[uncategorized]` and would bail
* anyway — listing them makes the intent explicit and guards path edge-cases.
*/
export const CRITICAL_FILES_FTR = [
'package.json',
'yarn.lock',
'tsconfig.base.json',
'tsconfig.json',
'.node-version',
'.nvmrc',
'src/setup_node_env/**/*',
'src/platform/packages/shared/kbn-test/**/*',
'src/platform/packages/shared/kbn-ftr-common-functional-services/**/*',
'src/platform/packages/shared/kbn-ftr-common-functional-ui-services/**/*',
'.buildkite/ftr-manifests/**/*',
'.buildkite/pipeline-utils/affected-packages/**/*.{ts,js,sh}',
'.buildkite/pipeline-utils/ci-stats/**/*.{ts,js}',
'.buildkite/scripts/steps/test/**/*',
];
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ jest.mock('#pipeline-utils', () => ({
collectEnvFromLabels: () => ({}),
}));

import { MAX_MINUTES, PREVENT_SELECTIVE_TESTS_LABEL, RETRIES } from './const';
import {
FTR_SKIP_UNAFFECTED_LABEL,
FTR_SOFT_FAIL_UNAFFECTED_LABEL,
MAX_MINUTES,
PREVENT_SELECTIVE_TESTS_LABEL,
RETRIES,
} from './const';
import { loadRunOrderConfig } from './env_config';

const TYPE_ENV = {
Expand Down Expand Up @@ -55,6 +61,8 @@ describe('loadRunOrderConfig', () => {
expect(cfg.ftrConfigsDeps).toEqual(['build']);
expect(cfg.jestConfigsDeps).toEqual([]);
expect(cfg.useSelectiveTesting).toBe(false);
expect(cfg.ftrSkipUnaffectedSolutions).toBe(false);
expect(cfg.ftrSoftFailUnaffectedSolutions).toBe(false);
});

it('parses CSV envs and trims whitespace', () => {
Expand Down Expand Up @@ -143,6 +151,27 @@ describe('loadRunOrderConfig', () => {
expect(cfg.useSelectiveTesting).toBe(false);
});

it('enables FTR skip-unaffected when its label is present', () => {
process.env.GITHUB_PR_LABELS = `foo,${FTR_SKIP_UNAFFECTED_LABEL},bar`;
const cfg = loadRunOrderConfig();
expect(cfg.ftrSkipUnaffectedSolutions).toBe(true);
expect(cfg.ftrSoftFailUnaffectedSolutions).toBe(false);
});

it('enables FTR soft-fail-unaffected when its label is present', () => {
process.env.GITHUB_PR_LABELS = `${FTR_SOFT_FAIL_UNAFFECTED_LABEL}`;
const cfg = loadRunOrderConfig();
expect(cfg.ftrSoftFailUnaffectedSolutions).toBe(true);
expect(cfg.ftrSkipUnaffectedSolutions).toBe(false);
});

it('leaves both FTR selective flags off when no related label is present', () => {
process.env.GITHUB_PR_LABELS = 'some-other-label';
const cfg = loadRunOrderConfig();
expect(cfg.ftrSkipUnaffectedSolutions).toBe(false);
expect(cfg.ftrSoftFailUnaffectedSolutions).toBe(false);
});

it('uses TEST_GROUP_TYPE_* overrides when provided', () => {
process.env.TEST_GROUP_TYPE_UNIT = 'unit-type';
process.env.TEST_GROUP_TYPE_INTEGRATION = 'integration-type';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { MAX_MINUTES, RETRIES, PREVENT_SELECTIVE_TESTS_LABEL } from './const';
import {
MAX_MINUTES,
RETRIES,
PREVENT_SELECTIVE_TESTS_LABEL,
FTR_SKIP_UNAFFECTED_LABEL,
FTR_SOFT_FAIL_UNAFFECTED_LABEL,
} from './const';
import { collectEnvFromLabels, getRequiredEnv } from '#pipeline-utils';

const VALID_SOLUTIONS = ['observability', 'search', 'security', 'workplaceai', 'vectordb'];
Expand Down Expand Up @@ -78,11 +84,23 @@ export function loadRunOrderConfig() {
useSelectiveTesting:
Boolean(process.env.GITHUB_PR_NUMBER) &&
!(parseCsvEnv('GITHUB_PR_LABELS') ?? []).includes(PREVENT_SELECTIVE_TESTS_LABEL),

// Opt-in FTR solution-selective behaviour, driven by PR labels. These are
// independent of `useSelectiveTesting` (which only governs Jest) but still
// require a merge base to diff against.
ftrSkipUnaffectedSolutions: hasLabel(FTR_SKIP_UNAFFECTED_LABEL),
ftrSoftFailUnaffectedSolutions: hasLabel(FTR_SOFT_FAIL_UNAFFECTED_LABEL),

prMergeBase: process.env.GITHUB_PR_MERGE_BASE || undefined,
prNumber: process.env.GITHUB_PR_NUMBER || undefined,
} as const;
}

/** True when the given label is present in `GITHUB_PR_LABELS`. */
function hasLabel(label: string): boolean {
return (parseCsvEnv('GITHUB_PR_LABELS') ?? []).includes(label);
}

export type RunOrderConfig = ReturnType<typeof loadRunOrderConfig>;

function parseFloatEnv(name: string, defaultValue: number): number {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import * as path from 'path';

import { SOLUTION_MANIFEST_INFIX } from './const';
import { getEnabledFtrConfigs } from './ftr_manifests';

// repo root, relative to .buildkite/pipeline-utils/ci-stats/pick_test_group_run_order
const REPO_ROOT = path.resolve(__dirname, '../../../..');

describe('SOLUTION_MANIFEST_INFIX', () => {
it('maps each solution group to its manifest filename infix', () => {
expect(SOLUTION_MANIFEST_INFIX).toEqual({
observability: 'oblt',
security: 'security',
search: 'search',
workplaceai: 'workplace_ai',
vectordb: 'vectordb',
});
});
});

describe('getEnabledFtrConfigs – solution filtering', () => {
const originalCwd = process.cwd();

beforeAll(() => {
// manifest paths in the JSON index are relative to the repo root
process.chdir(REPO_ROOT);
});

afterAll(() => {
process.chdir(originalCwd);
});

const flatten = (byQueue: Map<string, string[]>) => Array.from(byQueue.values()).flat();

it('picks up workplace_ai manifest configs when filtering by "workplaceai"', () => {
// Regression guard: the old mapping only remapped observability→oblt, so
// `workplaceai` silently matched no manifest (ftr_workplaceai_ vs ftr_workplace_ai_).
const { ftrConfigsByQueue } = getEnabledFtrConfigs(undefined, ['workplaceai']);
const configs = flatten(ftrConfigsByQueue);
expect(configs).toContain(
'x-pack/solutions/workplaceai/test/serverless/functional/configs/config.ts'
);
});

it('returns a strict subset of the full enabled set (other solution manifests dropped)', () => {
const all = flatten(getEnabledFtrConfigs(undefined, undefined).ftrConfigsByQueue);
const filtered = flatten(getEnabledFtrConfigs(undefined, ['workplaceai']).ftrConfigsByQueue);

expect(filtered.length).toBeGreaterThan(0);
// filtering actually drops the other solutions' manifests…
expect(filtered.length).toBeLessThan(all.length);
// …and every retained config is part of the full enabled set
const allSet = new Set(all);
expect(filtered.every((c) => allSet.has(c))).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import minimatch from 'minimatch';
import { parse as loadYaml } from 'yaml';

import { serverless, stateful } from '../../../ftr-manifests/ftr_configs_manifests.json';
import { SOLUTION_MANIFEST_INFIX } from './const';
import type { FtrConfigsManifest } from './types';

const ALL_FTR_MANIFEST_REL_PATHS = serverless.concat(stateful);
Expand All @@ -32,7 +33,7 @@ export function getEnabledFtrConfigs(
} = { enabled: [], defaultQueue: undefined };
const uniqueQueues = new Set<string>();

const mappedSolutions = solutions?.map((s) => (s === 'observability' ? 'oblt' : s));
const mappedSolutions = solutions?.map((s) => SOLUTION_MANIFEST_INFIX[s] ?? s);
for (const manifestRelPath of ALL_FTR_MANIFEST_REL_PATHS) {
if (
mappedSolutions &&
Expand Down
Loading
Loading