Skip to content

Commit 3db1ddd

Browse files
ersin-erdalclaudecursoragent
authored
[9.4] [ResponseOps] Skip unchanged alerts-as-data index templates on install (#280154) (#291097)
# Backport This will backport the following commits from `main` to `9.4`: - [[ResponseOps] Skip unchanged alerts-as-data index templates on install (#280154)](#280154) <!--- Backport version: 12.0.4 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Ersin Erdal","email":"92688503+ersin-erdal@users.noreply.github.com"},"sourceCommit":{"committedDate":"2026-09-10T15:06:27Z","message":"[ResponseOps] Skip unchanged alerts-as-data index templates on install (#280154)\n\n## Summary\n\nExtends the skip-unchanged (`_meta` content-hash) installation work from\n#278126 to **index templates**, completing that behavior across the\nresource types the alerts service installs per context.\n\n#278126 is merged. Previously `createOrUpdateIndexTemplate` always PUT\nthe template on every install. Now it stamps a content hash into the\ntemplate's `_meta`, GET-checks the currently-installed template, and\n**skips the write when the hash matches**.\n\n## Why\n\nThis is the remaining slice of Phase 2 of #246016. On its own, #278126\nmakes a repeated context install a no-op for the ILM policy and\ncomponent templates, but the **index template still re-PUT** every time.\n\nElasticsearch already recognises an identical PUT and leaves the cluster\nstate alone, so these writes were not publishing new cluster states —\nbut the body still crosses the wire from every node, and the master\nstill queues and runs a cluster-state update task that parses the\ntemplate before concluding nothing changed. That matters most in\ncombination with the per-node install lock (#280126): when one node\ninstalls and the others follow, a follower's install should be cheap\nGETs, not redundant cluster-state writes. With this change, a follower's\nrepeated install is a no-op across component *and* index templates.\n\n## What changed\n\n- `create_or_update_index_template.ts`:\n- a single `getExistingIndexTemplate` GET now returns both the existing\n`total_fields.limit` (for the Phase-1 preservation) and the existing\n`_meta.content_hash`.\n- the content hash is computed **after** the Phase-1 limit preservation,\nso the stamped hash matches the body actually installed.\n- skip the `putIndexTemplate` (and the preceding `simulateTemplate`)\nwhen the installed hash matches.\n\n### Fail-safe\n\nSkipping happens only on a positive hash match. A missing stamp\n(templates installed before this change), a 404, or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOn that fall-through path we also cannot preserve a higher existing\n`total_fields.limit` (same as a 404). The skip still keys off the stamp,\nnot the live body: a hand-edited template with an intact `content_hash`\nstays un-repaired, where every install used to rewrite it.\n\n### The `total_fields.limit` is tracked apart from the hash\n\nThe limit is deliberately left out of the content hash. Kibana raises it\noutside this path while a mapping install crawls the limit up\n(`updateIndexTemplateFieldsLimit`), and operators raise it by hand\nthrough DevTools or the fields-limit API — both leaving `_meta` intact.\nHashing it would make every such raise read as a changed template and\nrewrite byte-identical content on the next startup, which is exactly the\nwrite this PR exists to remove.\n\nIt is compared as a number instead, and skipping requires *both* an\nunchanged hash and an installed limit that already satisfies the\nconfigured one, so a genuinely raised configured limit still installs\nand a template missing `ignore_dynamic_beyond_limit` still gets\nrepaired:\n\n| change | before | now |\n| --- | --- | --- |\n| limit raised out of band (crawl / DevTools) | redundant install |\nskipped |\n| configured limit raised | install | install |\n| configured limit lowered | install (preserving the higher limit) |\nskipped |\n| anything else in the template changed | install | install |\n\n### Logging\n\nThe install is logged at `info` with the reason it went ahead (`not\ninstalled`, `installed template carries no content hash`, `content\nchanged (<old> -> <new>)`, or an unsatisfied limit). Installs are the\nrare event after this change, so a resurgence of template writes is\nvisible without enabling debug logging. The skip stays at `debug`, since\nat `info` it would add a line per template per (context x space) on\nevery startup.\n\n## Scope / not included\n\n- **Concrete write index / mapping** hash-skip and the\n**monotonic-version rule** are intentionally *not* here — they're\nseparate, more involved changes (the mapping path interacts with the\nauto-increase retry, and version-monotonicity needs semver comparison).\nThis PR is the index-template slice.\n- No change to *when* install runs or to auto-increase behavior.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — 333\npass, incl. index-template tests (stamped PUT / skip-on-match /\nPUT-on-stale-hash / PUT-when-unreadable) and updated integration\nassertions.\n- `node scripts/jest .../rule_registry/server/rule_data_plugin_service`\n— pass.\n- `node scripts/jest_integration --config\nx-pack/platform/plugins/shared/alerting/jest.integration.config.js\nx-pack/platform/plugins/shared/alerting/server/integration_tests/skip_unchanged_index_templates.test.ts`\n— 5 pass against a real Elasticsearch: the stamp/skip round trip, the\nre-PUT on change, the unstamped upgrade path, and both limit directions\n(the out-of-band raise driven through the real\n`updateIndexTemplateFieldsLimit`).\n- scoped `type_check` and `eslint` clean.\n\nContributes to #246016\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"49e091ed8e8e898933b8a65467b28fdb72d218bd","branchLabelMapping":{"^v9.6.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:Alerting","release_note:skip","backport:skip","Team:ResponseOps","v9.6.0"],"title":"[ResponseOps] Skip unchanged alerts-as-data index templates on install","number":280154,"url":"https://github.com/elastic/kibana/pull/280154","mergeCommit":{"message":"[ResponseOps] Skip unchanged alerts-as-data index templates on install (#280154)\n\n## Summary\n\nExtends the skip-unchanged (`_meta` content-hash) installation work from\n#278126 to **index templates**, completing that behavior across the\nresource types the alerts service installs per context.\n\n#278126 is merged. Previously `createOrUpdateIndexTemplate` always PUT\nthe template on every install. Now it stamps a content hash into the\ntemplate's `_meta`, GET-checks the currently-installed template, and\n**skips the write when the hash matches**.\n\n## Why\n\nThis is the remaining slice of Phase 2 of #246016. On its own, #278126\nmakes a repeated context install a no-op for the ILM policy and\ncomponent templates, but the **index template still re-PUT** every time.\n\nElasticsearch already recognises an identical PUT and leaves the cluster\nstate alone, so these writes were not publishing new cluster states —\nbut the body still crosses the wire from every node, and the master\nstill queues and runs a cluster-state update task that parses the\ntemplate before concluding nothing changed. That matters most in\ncombination with the per-node install lock (#280126): when one node\ninstalls and the others follow, a follower's install should be cheap\nGETs, not redundant cluster-state writes. With this change, a follower's\nrepeated install is a no-op across component *and* index templates.\n\n## What changed\n\n- `create_or_update_index_template.ts`:\n- a single `getExistingIndexTemplate` GET now returns both the existing\n`total_fields.limit` (for the Phase-1 preservation) and the existing\n`_meta.content_hash`.\n- the content hash is computed **after** the Phase-1 limit preservation,\nso the stamped hash matches the body actually installed.\n- skip the `putIndexTemplate` (and the preceding `simulateTemplate`)\nwhen the installed hash matches.\n\n### Fail-safe\n\nSkipping happens only on a positive hash match. A missing stamp\n(templates installed before this change), a 404, or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOn that fall-through path we also cannot preserve a higher existing\n`total_fields.limit` (same as a 404). The skip still keys off the stamp,\nnot the live body: a hand-edited template with an intact `content_hash`\nstays un-repaired, where every install used to rewrite it.\n\n### The `total_fields.limit` is tracked apart from the hash\n\nThe limit is deliberately left out of the content hash. Kibana raises it\noutside this path while a mapping install crawls the limit up\n(`updateIndexTemplateFieldsLimit`), and operators raise it by hand\nthrough DevTools or the fields-limit API — both leaving `_meta` intact.\nHashing it would make every such raise read as a changed template and\nrewrite byte-identical content on the next startup, which is exactly the\nwrite this PR exists to remove.\n\nIt is compared as a number instead, and skipping requires *both* an\nunchanged hash and an installed limit that already satisfies the\nconfigured one, so a genuinely raised configured limit still installs\nand a template missing `ignore_dynamic_beyond_limit` still gets\nrepaired:\n\n| change | before | now |\n| --- | --- | --- |\n| limit raised out of band (crawl / DevTools) | redundant install |\nskipped |\n| configured limit raised | install | install |\n| configured limit lowered | install (preserving the higher limit) |\nskipped |\n| anything else in the template changed | install | install |\n\n### Logging\n\nThe install is logged at `info` with the reason it went ahead (`not\ninstalled`, `installed template carries no content hash`, `content\nchanged (<old> -> <new>)`, or an unsatisfied limit). Installs are the\nrare event after this change, so a resurgence of template writes is\nvisible without enabling debug logging. The skip stays at `debug`, since\nat `info` it would add a line per template per (context x space) on\nevery startup.\n\n## Scope / not included\n\n- **Concrete write index / mapping** hash-skip and the\n**monotonic-version rule** are intentionally *not* here — they're\nseparate, more involved changes (the mapping path interacts with the\nauto-increase retry, and version-monotonicity needs semver comparison).\nThis PR is the index-template slice.\n- No change to *when* install runs or to auto-increase behavior.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — 333\npass, incl. index-template tests (stamped PUT / skip-on-match /\nPUT-on-stale-hash / PUT-when-unreadable) and updated integration\nassertions.\n- `node scripts/jest .../rule_registry/server/rule_data_plugin_service`\n— pass.\n- `node scripts/jest_integration --config\nx-pack/platform/plugins/shared/alerting/jest.integration.config.js\nx-pack/platform/plugins/shared/alerting/server/integration_tests/skip_unchanged_index_templates.test.ts`\n— 5 pass against a real Elasticsearch: the stamp/skip round trip, the\nre-PUT on change, the unstamped upgrade path, and both limit directions\n(the out-of-band raise driven through the real\n`updateIndexTemplateFieldsLimit`).\n- scoped `type_check` and `eslint` clean.\n\nContributes to #246016\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"49e091ed8e8e898933b8a65467b28fdb72d218bd"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.6.0","branchLabelMappingKey":"^v9.6.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/280154","number":280154,"mergeCommit":{"message":"[ResponseOps] Skip unchanged alerts-as-data index templates on install (#280154)\n\n## Summary\n\nExtends the skip-unchanged (`_meta` content-hash) installation work from\n#278126 to **index templates**, completing that behavior across the\nresource types the alerts service installs per context.\n\n#278126 is merged. Previously `createOrUpdateIndexTemplate` always PUT\nthe template on every install. Now it stamps a content hash into the\ntemplate's `_meta`, GET-checks the currently-installed template, and\n**skips the write when the hash matches**.\n\n## Why\n\nThis is the remaining slice of Phase 2 of #246016. On its own, #278126\nmakes a repeated context install a no-op for the ILM policy and\ncomponent templates, but the **index template still re-PUT** every time.\n\nElasticsearch already recognises an identical PUT and leaves the cluster\nstate alone, so these writes were not publishing new cluster states —\nbut the body still crosses the wire from every node, and the master\nstill queues and runs a cluster-state update task that parses the\ntemplate before concluding nothing changed. That matters most in\ncombination with the per-node install lock (#280126): when one node\ninstalls and the others follow, a follower's install should be cheap\nGETs, not redundant cluster-state writes. With this change, a follower's\nrepeated install is a no-op across component *and* index templates.\n\n## What changed\n\n- `create_or_update_index_template.ts`:\n- a single `getExistingIndexTemplate` GET now returns both the existing\n`total_fields.limit` (for the Phase-1 preservation) and the existing\n`_meta.content_hash`.\n- the content hash is computed **after** the Phase-1 limit preservation,\nso the stamped hash matches the body actually installed.\n- skip the `putIndexTemplate` (and the preceding `simulateTemplate`)\nwhen the installed hash matches.\n\n### Fail-safe\n\nSkipping happens only on a positive hash match. A missing stamp\n(templates installed before this change), a 404, or any GET failure —\npermissions, an exhausted retry — leaves the installed content unknown\nand falls through to the normal PUT, so the check can never block an\ninstall that would otherwise have succeeded. The worst case is today's\nbehavior: a redundant write.\n\nOn that fall-through path we also cannot preserve a higher existing\n`total_fields.limit` (same as a 404). The skip still keys off the stamp,\nnot the live body: a hand-edited template with an intact `content_hash`\nstays un-repaired, where every install used to rewrite it.\n\n### The `total_fields.limit` is tracked apart from the hash\n\nThe limit is deliberately left out of the content hash. Kibana raises it\noutside this path while a mapping install crawls the limit up\n(`updateIndexTemplateFieldsLimit`), and operators raise it by hand\nthrough DevTools or the fields-limit API — both leaving `_meta` intact.\nHashing it would make every such raise read as a changed template and\nrewrite byte-identical content on the next startup, which is exactly the\nwrite this PR exists to remove.\n\nIt is compared as a number instead, and skipping requires *both* an\nunchanged hash and an installed limit that already satisfies the\nconfigured one, so a genuinely raised configured limit still installs\nand a template missing `ignore_dynamic_beyond_limit` still gets\nrepaired:\n\n| change | before | now |\n| --- | --- | --- |\n| limit raised out of band (crawl / DevTools) | redundant install |\nskipped |\n| configured limit raised | install | install |\n| configured limit lowered | install (preserving the higher limit) |\nskipped |\n| anything else in the template changed | install | install |\n\n### Logging\n\nThe install is logged at `info` with the reason it went ahead (`not\ninstalled`, `installed template carries no content hash`, `content\nchanged (<old> -> <new>)`, or an unsatisfied limit). Installs are the\nrare event after this change, so a resurgence of template writes is\nvisible without enabling debug logging. The skip stays at `debug`, since\nat `info` it would add a line per template per (context x space) on\nevery startup.\n\n## Scope / not included\n\n- **Concrete write index / mapping** hash-skip and the\n**monotonic-version rule** are intentionally *not* here — they're\nseparate, more involved changes (the mapping path interacts with the\nauto-increase retry, and version-monotonicity needs semver comparison).\nThis PR is the index-template slice.\n- No change to *when* install runs or to auto-increase behavior.\n\n## Testing\n\n- `node scripts/jest\nx-pack/platform/plugins/shared/alerting/server/alerts_service` — 333\npass, incl. index-template tests (stamped PUT / skip-on-match /\nPUT-on-stale-hash / PUT-when-unreadable) and updated integration\nassertions.\n- `node scripts/jest .../rule_registry/server/rule_data_plugin_service`\n— pass.\n- `node scripts/jest_integration --config\nx-pack/platform/plugins/shared/alerting/jest.integration.config.js\nx-pack/platform/plugins/shared/alerting/server/integration_tests/skip_unchanged_index_templates.test.ts`\n— 5 pass against a real Elasticsearch: the stamp/skip round trip, the\nre-PUT on change, the unstamped upgrade path, and both limit directions\n(the out-of-band raise driven through the real\n`updateIndexTemplateFieldsLimit`).\n- scoped `type_check` and `eslint` clean.\n\nContributes to #246016\n\n---------\n\nCo-authored-by: Claude Opus 4.8 <noreply@anthropic.com>\nCo-authored-by: Cursor <cursoragent@cursor.com>","sha":"49e091ed8e8e898933b8a65467b28fdb72d218bd"}}]}] BACKPORT--> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 554922f commit 3db1ddd

4 files changed

Lines changed: 574 additions & 50 deletions

File tree

x-pack/platform/plugins/shared/alerting/server/alerts_service/alerts_service.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ const getIndexTemplatePutBody = (opts?: GetIndexTemplatePutBodyOpts) => {
197197
kibana: { version: '8.8.0' },
198198
managed: true,
199199
namespace,
200+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
200201
},
201202
};
202203
};
@@ -889,6 +890,7 @@ describe('Alerts Service', () => {
889890
kibana: { version: '8.8.0' },
890891
managed: true,
891892
namespace: 'default',
893+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
892894
},
893895
};
894896

x-pack/platform/plugins/shared/alerting/server/alerts_service/lib/create_or_update_index_template.test.ts

Lines changed: 218 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,223 @@ describe('getIndexTemplate', () => {
131131
});
132132

133133
describe('createOrUpdateIndexTemplate', () => {
134+
// The template is stamped with a content hash in `_meta` before it is installed.
135+
const stampedIndexTemplate = (namespace = 'default', useDataStream = false) => {
136+
const indexTemplate = IndexTemplate(namespace, useDataStream);
137+
return {
138+
...indexTemplate,
139+
_meta: {
140+
...indexTemplate._meta,
141+
content_hash: expect.stringMatching(/^[0-9a-f]{16}$/),
142+
},
143+
};
144+
};
145+
146+
const templateWithLimit = (limit: number) => {
147+
const indexTemplate = IndexTemplate();
148+
return {
149+
...indexTemplate,
150+
template: {
151+
...indexTemplate.template,
152+
settings: {
153+
...indexTemplate.template.settings,
154+
'index.mapping.total_fields.limit': limit,
155+
},
156+
},
157+
};
158+
};
159+
160+
// An already-installed template, as ES returns it: a stamp plus settings that satisfy
161+
// the configured limit, which is what the skip requires.
162+
const installedTemplate = ({
163+
contentHash,
164+
limit = 2500,
165+
}: {
166+
contentHash: string;
167+
limit?: number;
168+
}) =>
169+
({
170+
index_templates: [
171+
{
172+
name: '.alerts-test.alerts-default-index-template',
173+
index_template: {
174+
_meta: { content_hash: contentHash },
175+
template: {
176+
settings: {
177+
'index.mapping.total_fields.limit': limit,
178+
'index.mapping.total_fields.ignore_dynamic_beyond_limit': true,
179+
},
180+
},
181+
},
182+
},
183+
],
184+
} as unknown as Awaited<ReturnType<typeof clusterClient.indices.getIndexTemplate>>);
185+
186+
// Installs once against an empty cluster to capture the hash this template stamps.
187+
const captureInstalledHash = async (): Promise<string> => {
188+
clusterClient.indices.simulateTemplate.mockImplementation(async () => SimulateTemplateResponse);
189+
await createOrUpdateIndexTemplate({
190+
logger,
191+
esClient: clusterClient,
192+
template: IndexTemplate(),
193+
});
194+
const installedHash = (
195+
clusterClient.indices.putIndexTemplate.mock.calls[0][0] as unknown as {
196+
_meta: { content_hash: string };
197+
}
198+
)._meta.content_hash;
199+
clusterClient.indices.putIndexTemplate.mockClear();
200+
clusterClient.indices.simulateTemplate.mockClear();
201+
return installedHash;
202+
};
203+
134204
beforeEach(() => {
135205
jest.resetAllMocks();
136206
jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier);
137207
});
138208

139-
it(`should call esClient to put index template`, async () => {
209+
it(`should call esClient to put index template, stamped with a content hash`, async () => {
140210
clusterClient.indices.simulateTemplate.mockImplementation(async () => SimulateTemplateResponse);
141211
await createOrUpdateIndexTemplate({
142212
logger,
143213
esClient: clusterClient,
144214
template: IndexTemplate(),
145215
});
146216

147-
expect(clusterClient.indices.simulateTemplate).toHaveBeenCalledWith(IndexTemplate());
148-
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(IndexTemplate());
217+
expect(clusterClient.indices.simulateTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
218+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
219+
// Logged at info so a resurgence of template writes is visible without debug logging.
220+
expect(logger.info).toHaveBeenCalledWith(
221+
`Installing index template .alerts-test.alerts-default-index-template: not installed`
222+
);
223+
});
224+
225+
it(`should skip the PUT when the installed content hash matches`, async () => {
226+
const installedHash = await captureInstalledHash();
227+
clusterClient.indices.getIndexTemplate.mockResolvedValue(
228+
installedTemplate({ contentHash: installedHash })
229+
);
230+
231+
await createOrUpdateIndexTemplate({
232+
logger,
233+
esClient: clusterClient,
234+
template: IndexTemplate(),
235+
});
236+
237+
expect(clusterClient.indices.simulateTemplate).not.toHaveBeenCalled();
238+
expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled();
239+
});
240+
241+
it(`should skip when the total_fields.limit was raised out of band but the content is unchanged`, async () => {
242+
const installedHash = await captureInstalledHash();
243+
244+
// A field-limit crawl (or a DevTools edit) raises the limit while leaving `_meta`
245+
// alone. That is not a template change, so it must not trigger an install.
246+
clusterClient.indices.getIndexTemplate.mockResolvedValue(
247+
installedTemplate({ contentHash: installedHash, limit: 5000 })
248+
);
249+
250+
await createOrUpdateIndexTemplate({
251+
logger,
252+
esClient: clusterClient,
253+
template: IndexTemplate(),
254+
});
255+
256+
expect(clusterClient.indices.simulateTemplate).not.toHaveBeenCalled();
257+
expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled();
258+
});
259+
260+
it(`should PUT when the configured total_fields.limit is raised above the installed one`, async () => {
261+
const installedHash = await captureInstalledHash();
262+
clusterClient.indices.getIndexTemplate.mockResolvedValue(
263+
installedTemplate({ contentHash: installedHash, limit: 2500 })
264+
);
265+
266+
// Only the limit changed, which the content hash deliberately does not cover, so the
267+
// numeric check is the only thing that can catch it.
268+
await createOrUpdateIndexTemplate({
269+
logger,
270+
esClient: clusterClient,
271+
template: templateWithLimit(3000),
272+
});
273+
274+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(
275+
expect.objectContaining({
276+
template: expect.objectContaining({
277+
settings: expect.objectContaining({ 'index.mapping.total_fields.limit': 3000 }),
278+
}),
279+
})
280+
);
281+
expect(logger.info).toHaveBeenCalledWith(
282+
`Installing index template .alerts-test.alerts-default-index-template: installed total_fields.limit of 2500 does not satisfy the configured 3000`
283+
);
284+
});
285+
286+
it(`should PUT when the installed template has no ignore_dynamic_beyond_limit`, async () => {
287+
const installedHash = await captureInstalledHash();
288+
clusterClient.indices.getIndexTemplate.mockResolvedValue({
289+
index_templates: [
290+
{
291+
name: '.alerts-test.alerts-default-index-template',
292+
index_template: {
293+
_meta: { content_hash: installedHash },
294+
template: { settings: { 'index.mapping.total_fields.limit': 2500 } },
295+
},
296+
},
297+
],
298+
} as unknown as Awaited<ReturnType<typeof clusterClient.indices.getIndexTemplate>>);
299+
300+
await createOrUpdateIndexTemplate({
301+
logger,
302+
esClient: clusterClient,
303+
template: IndexTemplate(),
304+
});
305+
306+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
307+
});
308+
309+
it(`should PUT when the installed content hash differs`, async () => {
310+
clusterClient.indices.simulateTemplate.mockImplementation(async () => SimulateTemplateResponse);
311+
clusterClient.indices.getIndexTemplate.mockResolvedValue({
312+
index_templates: [
313+
{
314+
name: '.alerts-test.alerts-default-index-template',
315+
index_template: { _meta: { content_hash: 'stale-hash' } },
316+
},
317+
],
318+
} as unknown as Awaited<ReturnType<typeof clusterClient.indices.getIndexTemplate>>);
319+
320+
await createOrUpdateIndexTemplate({
321+
logger,
322+
esClient: clusterClient,
323+
template: IndexTemplate(),
324+
});
325+
326+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
327+
expect(logger.info).toHaveBeenCalledWith(
328+
expect.stringContaining(`content changed (stale-hash -> `)
329+
);
330+
});
331+
332+
it(`should PUT when the installed template carries no content hash`, async () => {
333+
clusterClient.indices.simulateTemplate.mockImplementation(async () => SimulateTemplateResponse);
334+
clusterClient.indices.getIndexTemplate.mockResolvedValue({
335+
index_templates: [
336+
{
337+
name: '.alerts-test.alerts-default-index-template',
338+
index_template: { _meta: { managed: true } },
339+
},
340+
],
341+
} as unknown as Awaited<ReturnType<typeof clusterClient.indices.getIndexTemplate>>);
342+
343+
await createOrUpdateIndexTemplate({
344+
logger,
345+
esClient: clusterClient,
346+
template: IndexTemplate(),
347+
});
348+
349+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(1);
350+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
149351
});
150352

151353
it(`should preserve a higher total_fields.limit from the existing template`, async () => {
@@ -199,7 +401,7 @@ describe('createOrUpdateIndexTemplate', () => {
199401
template: IndexTemplate(),
200402
});
201403

202-
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(IndexTemplate());
404+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
203405
});
204406

205407
it(`should install the configured total_fields.limit when the template does not exist`, async () => {
@@ -214,25 +416,23 @@ describe('createOrUpdateIndexTemplate', () => {
214416
template: IndexTemplate(),
215417
});
216418

217-
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(IndexTemplate());
419+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
218420
});
219421

220-
it(`should log and throw when fetching the existing template fails`, async () => {
221-
clusterClient.indices.getIndexTemplate.mockRejectedValue(new Error('fetch error'));
422+
it(`should PUT when the installed template cannot be read`, async () => {
423+
clusterClient.indices.getIndexTemplate.mockRejectedValue(new Error('security_exception'));
424+
clusterClient.indices.simulateTemplate.mockImplementation(async () => SimulateTemplateResponse);
222425

223-
await expect(() =>
224-
createOrUpdateIndexTemplate({
225-
logger,
226-
esClient: clusterClient,
227-
template: IndexTemplate(),
228-
})
229-
).rejects.toThrowErrorMatchingInlineSnapshot(`"fetch error"`);
426+
await createOrUpdateIndexTemplate({
427+
logger,
428+
esClient: clusterClient,
429+
template: IndexTemplate(),
430+
});
230431

231-
expect(logger.error).toHaveBeenCalledWith(
232-
`Error fetching existing index template .alerts-test.alerts-default-index-template - fetch error`,
233-
expect.any(Error)
432+
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith(stampedIndexTemplate());
433+
expect(logger.debug).toHaveBeenCalledWith(
434+
`Could not read installed index template .alerts-test.alerts-default-index-template; will install (security_exception)`
234435
);
235-
expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled();
236436
});
237437

238438
it(`should retry on transient ES errors`, async () => {

0 commit comments

Comments
 (0)