Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e9854fe
Create edit ILM flow
SoniaSanzV Feb 17, 2026
7e048d1
Changes from node scripts/eslint_all_files --no-cache --fix
kibanamachine Feb 17, 2026
86da194
fix type
SoniaSanzV Feb 17, 2026
8d1fe22
Add missing prop in tests
SoniaSanzV Feb 17, 2026
64aa842
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 17, 2026
ab3e4e7
Fix left padding
SoniaSanzV Feb 18, 2026
90390ca
fix text jump
SoniaSanzV Feb 18, 2026
9076f68
Fix on save validation
SoniaSanzV Feb 18, 2026
8c50e6f
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 18, 2026
a72141d
Fix as any lint error
SoniaSanzV Feb 18, 2026
f38fd55
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 18, 2026
974e31d
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 19, 2026
6dbde72
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 19, 2026
68ea503
Fix problem on save
SoniaSanzV Feb 20, 2026
e5c6f2c
not display donwsampling for non metrics streams
SoniaSanzV Feb 20, 2026
7675f16
Fix type check
SoniaSanzV Feb 20, 2026
1f9f187
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 20, 2026
924c5c8
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 20, 2026
ac82385
Fix stream type in test
SoniaSanzV Feb 20, 2026
8f559b3
Fix stream type in test
SoniaSanzV Feb 20, 2026
aa7cd64
Update callout copy
SoniaSanzV Feb 20, 2026
fc5c22c
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 23, 2026
7d3984a
normalizeIlmPhases
SoniaSanzV Feb 23, 2026
17a2191
Merge branch 'main' into downsampling/edit_ilm_action
SoniaSanzV Feb 24, 2026
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
Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Comment thread
damian-polewski marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In some instances, I was receiving a validation errors when one shouldn't exist. For example, in the attached screenshot, 3d is larger than 0ms, but it's shown as an error. Not entirely sure, but it feels like it might have something to do with the speed at which the user is entering a value in the number input.

Image

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @MichaelMarcialis, could you let me know what was the configuration of this policy?

Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
SoniaSanzV marked this conversation as resolved.
Comment thread
SoniaSanzV marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ describe('Converter Helpers', () => {
read_failure_store: true,
manage_failure_store: true,
view_index_metadata: true,
create_snapshot_repository: true,
},
effective_failure_store: {
disabled: {},
Expand Down Expand Up @@ -142,6 +143,7 @@ describe('Converter Helpers', () => {
read_failure_store: true,
manage_failure_store: true,
view_index_metadata: true,
create_snapshot_repository: true,
},
effective_lifecycle: {
dsl: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ interface IngestStreamPrivileges {
read_failure_store: boolean;
// User can manage failure store information
manage_failure_store: boolean;
// User can create snapshot repositories (needed for frozen phase searchable snapshots)
create_snapshot_repository: boolean;
}

const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object({
Expand All @@ -48,6 +50,7 @@ const ingestStreamPrivilegesSchema: z.Schema<IngestStreamPrivileges> = z.object(
text_structure: z.boolean(),
read_failure_store: z.boolean(),
manage_failure_store: z.boolean(),
create_snapshot_repository: z.boolean(),
});

export interface IngestBase {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe('ClassicStream', () => {
text_structure: true,
read_failure_store: true,
manage_failure_store: true,
create_snapshot_repository: true,
view_index_metadata: true,
},
data_stream_exists: true,
Expand Down Expand Up @@ -157,6 +158,7 @@ describe('ClassicStream', () => {
text_structure: true,
failure_store: true,
view_index_metadata: true,
create_snapshot_repository: true,
},
data_stream_exists: true,
dashboards: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe('WiredStream', () => {
read_failure_store: true,
manage_failure_store: true,
view_index_metadata: true,
create_snapshot_repository: true,
},
effective_lifecycle: {
dsl: {},
Expand Down Expand Up @@ -165,6 +166,7 @@ describe('WiredStream', () => {
text_structure: true,
failure_store: true,
view_index_metadata: true,
create_snapshot_repository: true,
},
dashboards: [],
queries: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ export class StreamsClient {
REQUIRED_MANAGE_PRIVILEGES.push('monitor_text_structure');
}

const CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE = 'cluster:admin/repository/put';

const REQUIRED_INDEX_PRIVILEGES = [
'read',
'write',
Expand All @@ -563,7 +565,7 @@ export class StreamsClient {

const privileges =
await this.dependencies.scopedClusterClient.asCurrentUser.security.hasPrivileges({
cluster: REQUIRED_MANAGE_PRIVILEGES,
cluster: [...REQUIRED_MANAGE_PRIVILEGES, CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE],
index: [
{
names,
Expand Down Expand Up @@ -594,6 +596,8 @@ export class StreamsClient {
text_structure: isServerless ? true : privileges.cluster.monitor_text_structure,
read_failure_store: names.every((name) => privileges.index[name].read_failure_store),
manage_failure_store: names.every((name) => privileges.index[name].manage_failure_store),
create_snapshot_repository:
privileges.cluster[CREATE_SNAPSHOT_REPOSITORY_CLUSTER_PRIVILEGE] === true,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@

import { buildPolicyUsage, normalizeIlmPhases } from './ilm_policies';

const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';

const getActions = (phase: unknown): Record<string, unknown> | undefined => {
if (!isRecord(phase)) return undefined;
const actions = phase.actions;
return isRecord(actions) ? actions : undefined;
};

describe('lifecycle helpers', () => {
describe('buildPolicyUsage', () => {
it('derives data streams from backing indices', () => {
Expand Down Expand Up @@ -67,17 +76,40 @@ describe('lifecycle helpers', () => {
expect(normalizeIlmPhases(undefined)).toEqual({});
});

it('drops undefined phases', () => {
it('normalizes ES ILM phases into Streams ILM phases', () => {
const normalized = normalizeIlmPhases({
hot: { actions: { rollover: { max_age: '1d' } } },
warm: { min_age: '2d' },
hot: {
actions: {
rollover: { max_age: '1d' },
set_priority: { priority: 100 },
},
},
warm: {
min_age: '2d',
actions: { readonly: {} },
},
frozen: undefined,
} as unknown as Parameters<typeof normalizeIlmPhases>[0]);

expect(normalized).toEqual({
hot: { actions: { rollover: { max_age: '1d' } } },
warm: { min_age: '2d' },
expect(normalized.hot).toMatchObject({
name: 'hot',
size_in_bytes: 0,
rollover: { max_age: '1d' },
});
expect(getActions(normalized.hot)).toEqual({
rollover: { max_age: '1d' },
set_priority: { priority: 100 },
});

expect(normalized.warm).toMatchObject({
name: 'warm',
size_in_bytes: 0,
min_age: '2d',
readonly: true,
});
expect(getActions(normalized.warm)).toEqual({ readonly: {} });

expect(normalized.frozen).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@
* 2.0.
*/

import type {
IlmPhases as EsIlmPhases,
IlmPhase as EsIlmPhase,
} from '@elastic/elasticsearch/lib/api/types';
import type {
IlmPolicy,
IlmPolicyDeletePhase,
IlmPolicyHotPhase,
IlmPolicyPhase,
IlmPolicyPhases,
IlmPolicyUsage,
PhaseName,
} from '@kbn/streams-schema';

interface IlmPolicyEntry {
policy?: {
phases?: IlmPolicyPhases;
phases?: EsIlmPhases;
_meta?: Record<string, unknown>;
deprecated?: boolean;
};
Expand All @@ -30,6 +36,141 @@ export interface IlmPoliciesResponse {
[policyName: string]: IlmPolicyEntry;
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object';

const asRecord = (value: unknown): Record<string, unknown> => (isRecord(value) ? value : {});

const durationToString = (value: unknown): string | undefined => {
if (typeof value === 'string') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (value == null) return undefined;
return String(value);
};

type PhaseActions = Record<string, unknown>;
type IlmPolicyPhaseWithActions = IlmPolicyPhase & { actions: PhaseActions };
type IlmPolicyHotPhaseWithActions = IlmPolicyHotPhase & { actions: PhaseActions };
type IlmPolicyDeletePhaseWithActions = IlmPolicyDeletePhase & { actions: PhaseActions };

const toStreamsDownsample = (
phaseName: Exclude<PhaseName, 'delete'>,
phaseMinAge: string | undefined,
actions: PhaseActions
): IlmPolicyPhase['downsample'] | undefined => {
const downsampleAction = asRecord(actions.downsample);
const fixedInterval = downsampleAction.fixed_interval;
if (typeof fixedInterval !== 'string' || fixedInterval.trim() === '') {
return undefined;
}

const after = phaseName === 'hot' ? '0ms' : phaseMinAge ?? '0ms';
return { after, fixed_interval: fixedInterval };
};

const toStreamsSearchableSnapshot = (actions: PhaseActions): string | undefined => {
const searchableSnapshotAction = asRecord(actions.searchable_snapshot);
const repo = searchableSnapshotAction.snapshot_repository;
return typeof repo === 'string' && repo.trim() !== '' ? repo : undefined;
};

const toStreamsHotRollover = (actions: PhaseActions): IlmPolicyHotPhase['rollover'] => {
const rolloverAction = asRecord(actions.rollover);

// ES can send `-1` to indicate "unset" for max_age.
const maxAgeRaw = rolloverAction.max_age;
const maxAge =
typeof maxAgeRaw === 'number' && maxAgeRaw === -1 ? undefined : durationToString(maxAgeRaw);

const rollover: IlmPolicyHotPhase['rollover'] = {};

const maxSize = rolloverAction.max_size;
if (typeof maxSize === 'string' || typeof maxSize === 'number') {
rollover.max_size = maxSize;
}

const maxPrimaryShardSize = rolloverAction.max_primary_shard_size;
if (typeof maxPrimaryShardSize === 'string' || typeof maxPrimaryShardSize === 'number') {
rollover.max_primary_shard_size = maxPrimaryShardSize;
}

if (maxAge != null) rollover.max_age = maxAge;

const maxDocs = rolloverAction.max_docs;
if (typeof maxDocs === 'number') {
rollover.max_docs = maxDocs;
}

const maxPrimaryShardDocs = rolloverAction.max_primary_shard_docs;
if (typeof maxPrimaryShardDocs === 'number') {
rollover.max_primary_shard_docs = maxPrimaryShardDocs;
}

return rollover;
};

function toStreamsNonDeletePhase(
phaseName: 'hot',
phase: EsIlmPhase | undefined
): IlmPolicyHotPhaseWithActions | undefined;
function toStreamsNonDeletePhase(
phaseName: Exclude<PhaseName, 'delete' | 'hot'>,
phase: EsIlmPhase | undefined
): IlmPolicyPhaseWithActions | undefined;
function toStreamsNonDeletePhase(
phaseName: Exclude<PhaseName, 'delete'>,
phase: EsIlmPhase | undefined
): IlmPolicyHotPhaseWithActions | IlmPolicyPhaseWithActions | undefined {
if (!phase) return undefined;

const actions = { ...asRecord(phase.actions) };
const minAge = durationToString(phase.min_age);

const readonlyEnabled = Object.prototype.hasOwnProperty.call(actions, 'readonly');
const searchableSnapshot = toStreamsSearchableSnapshot(actions);
const downsample = toStreamsDownsample(phaseName, minAge, actions);

const base = {
name: phaseName,
size_in_bytes: 0,
...(minAge ? { min_age: minAge } : {}),
...(downsample ? { downsample } : {}),
...(readonlyEnabled ? { readonly: true } : {}),
...(searchableSnapshot ? { searchable_snapshot: searchableSnapshot } : {}),
actions,
};

if (phaseName === 'hot') {
return {
...base,
name: 'hot',
rollover: toStreamsHotRollover(actions),
};
}

return base;
}

const toStreamsDeletePhase = (
phase: EsIlmPhase | undefined
): IlmPolicyDeletePhaseWithActions | undefined => {
if (!phase) return undefined;

const actions = { ...asRecord(phase.actions) };
const minAge = durationToString(phase.min_age) ?? '';
const deleteAction = asRecord(actions.delete);
const deleteSearchableSnapshot = deleteAction.delete_searchable_snapshot;

return {
name: 'delete',
min_age: minAge,
...(typeof deleteSearchableSnapshot === 'boolean'
? { delete_searchable_snapshot: deleteSearchableSnapshot }
: {}),
actions,
};
};

export const buildPolicyUsage = (
policyEntry: IlmPolicyEntry,
dataStreamByBackingIndices: Record<string, string> = {}
Expand All @@ -45,13 +186,24 @@ export const buildPolicyUsage = (
return { in_use_by: { data_streams: dataStreams, indices } };
};

export const normalizeIlmPhases = (phases?: IlmPolicyPhases): IlmPolicy['phases'] => {
export const normalizeIlmPhases = (phases?: EsIlmPhases): IlmPolicy['phases'] => {
if (!phases) {
return {};
}

const entries = Object.entries(phases) as Array<[string, IlmPolicyPhase]>;
return Object.fromEntries(
entries.filter(([, phase]) => phase !== undefined)
) as IlmPolicy['phases'];
const hotPhase = toStreamsNonDeletePhase('hot', phases.hot);
const warmPhase = toStreamsNonDeletePhase('warm', phases.warm);
const coldPhase = toStreamsNonDeletePhase('cold', phases.cold);
const frozenPhase = toStreamsNonDeletePhase('frozen', phases.frozen);
const deletePhase = toStreamsDeletePhase(phases.delete);

// Keep output stable: omit missing phases entirely.
const normalized: IlmPolicy['phases'] = {};
if (hotPhase) normalized.hot = hotPhase;
if (warmPhase) normalized.warm = warmPhase;
if (coldPhase) normalized.cold = coldPhase;
if (frozenPhase) normalized.frozen = frozenPhase;
if (deletePhase) normalized.delete = deletePhase;

return normalized;
};
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,39 @@ const lifecycleIlmPoliciesUpdateRoute = createServerRoute({
},
});

const lifecycleSnapshotRepositoriesRoute = createServerRoute({
endpoint: 'GET /internal/streams/lifecycle/_snapshot_repositories',
options: {
access: 'internal',
},
security: {
authz: {
requiredPrivileges: [STREAMS_API_PRIVILEGES.read],
},
},
params: z.object({}),
handler: async ({
request,
getScopedClients,
}): Promise<{ repositories: Array<{ name: string; type: string }> }> => {
const { scopedClusterClient } = await getScopedClients({ request });
const repositoriesByName = await scopedClusterClient.asCurrentUser.snapshot.getRepository({
name: '*',
});

const repositories = Object.entries(repositoriesByName).map(([name, { type }]) => ({
name,
type: type ?? '',
}));

return { repositories };
},
});

export const internalLifecycleRoutes = {
...lifecycleStatsRoute,
...lifecycleIlmExplainRoute,
...lifecycleIlmPoliciesRoute,
...lifecycleIlmPoliciesUpdateRoute,
...lifecycleSnapshotRepositoriesRoute,
};
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const privileges = {
text_structure: true,
read_failure_store: true,
manage_failure_store: true,
create_snapshot_repository: true,
};

const buildWiredDefinition = (): Streams.WiredStream.GetResponse => ({
Expand Down
Loading
Loading