From e60cbd4582a49c8c20109f2d93fe206c79dad821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 15 Jun 2026 13:50:18 +0200 Subject: [PATCH 01/36] improvements --- environments/elk-ror/apm.docker-compose.yml | 4 ++-- environments/elk-ror/base.docker-compose.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/environments/elk-ror/apm.docker-compose.yml b/environments/elk-ror/apm.docker-compose.yml index 71ce6dff..037e98c8 100644 --- a/environments/elk-ror/apm.docker-compose.yml +++ b/environments/elk-ror/apm.docker-compose.yml @@ -15,8 +15,8 @@ services: condition: service_healthy networks: - es-ror-network - mem_limit: 1g - memswap_limit: 1g + mem_limit: 640m + memswap_limit: 640m healthcheck: test: [ "CMD", "curl", "-k", "--fail", "https://localhost:8200" ] interval: 10s diff --git a/environments/elk-ror/base.docker-compose.yml b/environments/elk-ror/base.docker-compose.yml index 060ce5c7..a411394f 100644 --- a/environments/elk-ror/base.docker-compose.yml +++ b/environments/elk-ror/base.docker-compose.yml @@ -11,8 +11,8 @@ services: ports: - "9200:9200" - "5005:5005" - mem_limit: 3g - memswap_limit: 3g + mem_limit: 2g + memswap_limit: 2g environment: - ES_VERSION=$ES_VERSION - cluster.name=elk-ror @@ -20,7 +20,7 @@ services: - discovery.type=single-node - bootstrap.memory_lock=true # for a sake of debugging and profiling - - "ES_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -Dcom.readonlyrest.settings.loading.attempts.count=0 -Dcom.readonlyrest.settings.loading.delay=0s -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9010 -Dcom.sun.management.jmxremote.local.only=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.rmi.port=9010 -Djava.rmi.server.hostname=127.0.0.1" + - "ES_JAVA_OPTS=-Xms1g -Xmx1g -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -Dcom.readonlyrest.settings.loading.attempts.count=0 -Dcom.readonlyrest.settings.loading.delay=0s -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9010 -Dcom.sun.management.jmxremote.local.only=false -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.rmi.port=9010 -Djava.rmi.server.hostname=127.0.0.1" # for a sake of ROR settings marked as "<-- related to ECK environment -->" - KIBANA_SERVICE_ACCOUNT_TOKEN=UNUSED - INTERNAL_PROBE_PASS=UNUSED From 9b0f843d0cea5379ceb4fdd1ae4325466d370dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 15 Jun 2026 14:41:07 +0200 Subject: [PATCH 02/36] improvements --- environments/elk-ror/base.docker-compose.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/environments/elk-ror/base.docker-compose.yml b/environments/elk-ror/base.docker-compose.yml index a411394f..fc672f03 100644 --- a/environments/elk-ror/base.docker-compose.yml +++ b/environments/elk-ror/base.docker-compose.yml @@ -64,7 +64,11 @@ services: interval: 10s timeout: 10s retries: 30 - start_period: 60s + # Two Kibana replicas + ES + APM boot concurrently. On CPU-small CI agents + # (e.g. the 2-vCPU Azure host) a replica needs >60s to reach /api/status, so + # `docker compose up --wait` was tearing the stack down before tests ran. + # start_period only extends the startup grace; it's free on a fast boot. + start_period: 180s networks: - es-ror-network ulimits: From 57bf92d4d7ce8e7fafbc7facb631500a9d160b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Tue, 16 Jun 2026 14:16:48 +0200 Subject: [PATCH 03/36] improvements --- environments/elk-ror/apm.docker-compose.yml | 4 ---- .../elk-ror/apm.limits.docker-compose.yml | 11 +++++++++++ environments/elk-ror/base.docker-compose.yml | 6 ------ .../elk-ror/base.limits.docker-compose.yml | 19 +++++++++++++++++++ environments/elk-ror/start.sh | 9 +++++++++ 5 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 environments/elk-ror/apm.limits.docker-compose.yml create mode 100644 environments/elk-ror/base.limits.docker-compose.yml diff --git a/environments/elk-ror/apm.docker-compose.yml b/environments/elk-ror/apm.docker-compose.yml index 037e98c8..2a0c7f98 100644 --- a/environments/elk-ror/apm.docker-compose.yml +++ b/environments/elk-ror/apm.docker-compose.yml @@ -15,8 +15,6 @@ services: condition: service_healthy networks: - es-ror-network - mem_limit: 640m - memswap_limit: 640m healthcheck: test: [ "CMD", "curl", "-k", "--fail", "https://localhost:8200" ] interval: 10s @@ -40,8 +38,6 @@ services: - ./certs:/example-app/certs:z networks: - es-ror-network - mem_limit: 128m - memswap_limit: 128m healthcheck: test: [ "CMD", "curl", "-k", "--http1.1", "--fail", "https://apm-server:8200" ] interval: 10s diff --git a/environments/elk-ror/apm.limits.docker-compose.yml b/environments/elk-ror/apm.limits.docker-compose.yml new file mode 100644 index 00000000..5a71a8e2 --- /dev/null +++ b/environments/elk-ror/apm.limits.docker-compose.yml @@ -0,0 +1,11 @@ +# Resource-limit overlay for the APM services. Applied on top of apm.docker-compose.yml by start.sh +# when APPLY_RESOURCE_LIMITS=true (default). See base.limits.docker-compose.yml for why it can be +# opted out of (Docker-in-Docker on cgroup v2 / threaded-mode memory controller). +services: + apm-server: + mem_limit: 640m + memswap_limit: 640m + + node-apm-app: + mem_limit: 128m + memswap_limit: 128m diff --git a/environments/elk-ror/base.docker-compose.yml b/environments/elk-ror/base.docker-compose.yml index fc672f03..6f2c87c0 100644 --- a/environments/elk-ror/base.docker-compose.yml +++ b/environments/elk-ror/base.docker-compose.yml @@ -11,8 +11,6 @@ services: ports: - "9200:9200" - "5005:5005" - mem_limit: 2g - memswap_limit: 2g environment: - ES_VERSION=$ES_VERSION - cluster.name=elk-ror @@ -51,8 +49,6 @@ services: depends_on: es-ror: condition: service_healthy - mem_limit: 1g - memswap_limit: 1g deploy: mode: replicated replicas: 2 @@ -85,8 +81,6 @@ services: condition: service_healthy ports: - "5601:443" - mem_limit: 256m - memswap_limit: 256m healthcheck: test: [ "CMD", "curl", "-k", "--fail", "-u", "kibana:kibana", "https://localhost:443/api/status" ] interval: 10s diff --git a/environments/elk-ror/base.limits.docker-compose.yml b/environments/elk-ror/base.limits.docker-compose.yml new file mode 100644 index 00000000..d0a80015 --- /dev/null +++ b/environments/elk-ror/base.limits.docker-compose.yml @@ -0,0 +1,19 @@ +# Resource-limit overlay for the base cluster, kept separate so it can be opted out of. +# Applied on top of base.docker-compose.yml by start.sh when APPLY_RESOURCE_LIMITS=true (default). +# Needed on small CI agents (e.g. the ~7.9 GB Azure host) where docker runs on the host directly. +# It is intentionally NOT applied when the e2e flow runs inside a Docker-in-Docker container on a +# cgroup v2 host: there BuildKit puts /sys/fs/cgroup/docker into "threaded" mode, and a threaded +# cgroup can't enable the memory (domain) controller, so a mem_limit makes the container fail to +# start. Heaps are pinned in base.docker-compose.yml, so dropping the limits there is safe. +services: + es-ror: + mem_limit: 2g + memswap_limit: 2g + + kbn-ror: + mem_limit: 1g + memswap_limit: 1g + + kbn-proxy: + mem_limit: 256m + memswap_limit: 256m diff --git a/environments/elk-ror/start.sh b/environments/elk-ror/start.sh index 60926142..5e7240e4 100755 --- a/environments/elk-ror/start.sh +++ b/environments/elk-ror/start.sh @@ -146,14 +146,23 @@ docker pull "$ROR_KBN_IMAGE" || { echo "Failed to pull Kibana image: $ROR_KBN_IM echo "Bootstrapping the docker-based environment ..." echo "Cluster type: $CLUSTER_TYPE" +# Resource limits live in separate *.limits.docker-compose.yml overlays so they can be opted out of. +# Applied by default; set APPLY_RESOURCE_LIMITS=false to skip them (e.g. when running inside a +# Docker-in-Docker container on a cgroup v2 host, where a threaded /sys/fs/cgroup/docker can't enable +# the memory controller and a mem_limit would prevent containers from starting). +APPLY_RESOURCE_LIMITS="${APPLY_RESOURCE_LIMITS:-true}" + # Set compose files based on cluster type if [[ "$CLUSTER_TYPE" == "base" ]]; then DOCKER_COMPOSE_FILES="-f base.docker-compose.yml" + [[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && DOCKER_COMPOSE_FILES="$DOCKER_COMPOSE_FILES -f base.limits.docker-compose.yml" echo "Starting base cluster (Elasticsearch + Kibana + ReadonlyREST)" elif [[ "$CLUSTER_TYPE" == "apm" ]]; then DOCKER_COMPOSE_FILES="-f base.docker-compose.yml -f apm.docker-compose.yml" + [[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && DOCKER_COMPOSE_FILES="$DOCKER_COMPOSE_FILES -f base.limits.docker-compose.yml -f apm.limits.docker-compose.yml" echo "Starting cluster with APM (Elasticsearch + Kibana + ReadonlyREST + APM Server + APM App)" fi +echo "Resource limits: $([[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && echo "applied" || echo "disabled (APPLY_RESOURCE_LIMITS=$APPLY_RESOURCE_LIMITS)")" if ! docker compose $DOCKER_COMPOSE_FILES config > /dev/null; then echo "Cannot validate docker compose configuration." From 408d19a5e262ca1ee29a5e8729b9f1c50555d329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Wed, 17 Jun 2026 14:26:23 +0200 Subject: [PATCH 04/36] improvements --- environments/elk-ror/start.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/environments/elk-ror/start.sh b/environments/elk-ror/start.sh index 5e7240e4..62281947 100755 --- a/environments/elk-ror/start.sh +++ b/environments/elk-ror/start.sh @@ -146,11 +146,12 @@ docker pull "$ROR_KBN_IMAGE" || { echo "Failed to pull Kibana image: $ROR_KBN_IM echo "Bootstrapping the docker-based environment ..." echo "Cluster type: $CLUSTER_TYPE" -# Resource limits live in separate *.limits.docker-compose.yml overlays so they can be opted out of. -# Applied by default; set APPLY_RESOURCE_LIMITS=false to skip them (e.g. when running inside a -# Docker-in-Docker container on a cgroup v2 host, where a threaded /sys/fs/cgroup/docker can't enable -# the memory controller and a mem_limit would prevent containers from starting). -APPLY_RESOURCE_LIMITS="${APPLY_RESOURCE_LIMITS:-true}" +# Resource limits live in separate *.limits.docker-compose.yml overlays so they're opt-in. +# Disabled by default β€” safe for Docker-in-Docker on a cgroup v2 host, where a threaded +# /sys/fs/cgroup/docker can't enable the memory controller and a mem_limit would prevent containers +# from starting. Set APPLY_RESOURCE_LIMITS=true to apply them; needed on small host-docker agents +# (e.g. the ~7.9 GB Azure host) to avoid OOM. +APPLY_RESOURCE_LIMITS="${APPLY_RESOURCE_LIMITS:-false}" # Set compose files based on cluster type if [[ "$CLUSTER_TYPE" == "base" ]]; then From 279c22f55ca7324da3be7ed9e5c81816ee54ef16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Thu, 18 Jun 2026 13:09:04 +0200 Subject: [PATCH 05/36] improvements --- .github/workflows/all-e2e-tests.yml | 7 +++++++ .github/workflows/targeted-e2e-tests.yml | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index adf8b459..37d35814 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -7,6 +7,13 @@ on: pull_request: types: [opened, synchronize, reopened] +env: + # These runs use docker directly on the GitHub-hosted runner (host docker, not nested), so the + # compose *.limits.docker-compose.yml overlays apply cleanly and protect the ~16 GB runner from OOM. + # start.sh leaves resource limits off by default (safe for the ROR KBN Docker-in-Docker flow); + # opt in here. (eck-env runs don't use docker-compose limits, so this is a no-op for them.) + APPLY_RESOURCE_LIMITS: "true" + jobs: # ========================================== # E2E TESTS - MASTER BRANCH diff --git a/.github/workflows/targeted-e2e-tests.yml b/.github/workflows/targeted-e2e-tests.yml index 2fb686bd..ed582930 100644 --- a/.github/workflows/targeted-e2e-tests.yml +++ b/.github/workflows/targeted-e2e-tests.yml @@ -25,6 +25,12 @@ on: default: 'prod' type: string +env: + # Runs use docker directly on the GitHub-hosted runner (host docker, not nested), so the compose + # *.limits.docker-compose.yml overlays apply cleanly and protect the runner from OOM. start.sh + # leaves resource limits off by default (safe for the ROR KBN Docker-in-Docker flow); opt in here. + APPLY_RESOURCE_LIMITS: "true" + jobs: e2e-tests: name: "πŸ”¬ E2E Tests" From d8fd984d9bc9263e013b8cc489f08247a9151ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 11:53:37 +0200 Subject: [PATCH 06/36] sync with ROR KBN repo --- e2e-tests/cypress/e2e/Allowed-api-paths.cy.ts | 113 ++++++++++++++++++ e2e-tests/cypress/e2e/Dev-tools.cy.ts | 6 +- e2e-tests/cypress/e2e/Discover.cy.ts | 2 +- e2e-tests/cypress/e2e/Hide_apps.cy.ts | 100 ++++++++++++---- e2e-tests/cypress/e2e/Index-session.cy.ts | 109 ----------------- e2e-tests/cypress/e2e/Jwt-auth.cy.ts | 2 - .../e2e/No-non-ok-responses-after-login.cy.ts | 40 +++++++ e2e-tests/cypress/e2e/Reporting.cy.ts | 4 + e2e-tests/cypress/e2e/Sanity-check.cy.ts | 2 - e2e-tests/cypress/e2e/Tenancy.cy.ts | 68 ++++++++++- e2e-tests/cypress/e2e/User-settings.cy.ts | 21 +++- .../fixtures/allowedApiPathsSettings.yaml | 60 ++++++++++ e2e-tests/cypress/fixtures/testSettings.yaml | 1 - e2e-tests/cypress/plugins/index.ts | 31 ++++- e2e-tests/cypress/support/commands.ts | 7 +- .../support/helpers/EsApiAdvancedClient.ts | 54 +++++++-- .../cypress/support/page-objects/Discover.ts | 10 +- .../support/page-objects/IndexManagement.ts | 17 ++- .../cypress/support/page-objects/Loader.ts | 2 +- .../cypress/support/page-objects/Login.ts | 1 - .../cypress/support/page-objects/Reporting.ts | 43 +++++-- .../RoAndRoStrictKibanaAccessAssertions.ts | 24 +++- .../cypress/support/page-objects/Spaces.ts | 24 +++- .../cypress/support/page-objects/Tenancy.ts | 2 +- 24 files changed, 549 insertions(+), 194 deletions(-) create mode 100644 e2e-tests/cypress/e2e/Allowed-api-paths.cy.ts delete mode 100644 e2e-tests/cypress/e2e/Index-session.cy.ts create mode 100644 e2e-tests/cypress/e2e/No-non-ok-responses-after-login.cy.ts create mode 100644 e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml diff --git a/e2e-tests/cypress/e2e/Allowed-api-paths.cy.ts b/e2e-tests/cypress/e2e/Allowed-api-paths.cy.ts new file mode 100644 index 00000000..e2633618 --- /dev/null +++ b/e2e-tests/cypress/e2e/Allowed-api-paths.cy.ts @@ -0,0 +1,113 @@ +/* Copyright (C) Beshu Limited t/a ReadonlyREST Security - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Beshu Limited in London, UK + */ + +import { Login } from '../support/page-objects/Login'; +import { rorApiClient } from '../support/helpers/RorApiClient'; + +// api_only users β€” allowed_api_paths enforcement is active +const apiOnlyExactUser = 'api_only_restricted_user:dev'; +const apiOnlyRegexpUser = 'api_only_restricted_regexp_user:dev'; +const apiOnlySpaceUser = 'api_only_space_restricted_user:dev'; +const apiOnlyInternalUser = 'api_only_internal_user:dev'; +const apiOnlyRorUser = 'api_only_ror_user:dev'; + +describe('allowed_api_paths enforcement for api_only users', () => { + before(() => rorApiClient.configureRorIndexMainSettings('allowedApiPathsSettings.yaml')); + after(() => rorApiClient.configureRorIndexMainSettings('defaultSettings.yaml')); + + describe('exact /api/ path', () => { + it('allows direct API calls to paths listed in allowed_api_paths', () => { + apiGet('api/spaces/space', apiOnlyExactUser).then(expectSpacesResponseIncludesDefault); + }); + + it('blocks direct API calls to paths not listed in allowed_api_paths', () => { + expectBlocked('api/saved_objects/_find?type=index-pattern', apiOnlyExactUser); + }); + }); + + describe('regexp /api/ path', () => { + it('allows API calls to any path matching the regexp in allowed_api_paths', () => { + apiGet('api/spaces/space', apiOnlyRegexpUser).then(expectSpacesResponseIncludesDefault); + }); + + it('blocks API calls to paths not matching the regexp in allowed_api_paths', () => { + expectBlocked('api/saved_objects/_find?type=index-pattern', apiOnlyRegexpUser); + }); + }); + + describe('space-aware /api/ path', () => { + it('allows API calls to the exact space-prefixed path listed in allowed_api_paths', () => { + apiGet('s/default/api/spaces/space', apiOnlySpaceUser).then(expectSpacesResponseIncludesDefault); + }); + + it('blocks API calls to the root /api/ form when only a space-prefixed pattern is configured', () => { + expectBlocked('api/spaces/space', apiOnlySpaceUser); + }); + + it('blocks API calls to a different space when only /s/default/ is listed in allowed_api_paths', () => { + expectBlocked('s/other/api/spaces/space', apiOnlySpaceUser); + }); + }); + + describe('Kibana internal /internal/ paths', () => { + it('allows calls to /internal/ paths matching the allowed_api_paths entry', () => { + expectAllowed('internal/spaces/get_all', apiOnlyInternalUser); + }); + + it('blocks calls to /internal/ paths not listed in allowed_api_paths', () => { + expectBlocked('internal/kibana/settings', apiOnlyInternalUser); + }); + + it('blocks calls to /api/ paths when only an /internal/ path is in allowed_api_paths', () => { + expectBlocked('api/spaces/space', apiOnlyInternalUser); + }); + }); + + describe('ReadonlyREST public API /api/ror/ paths', () => { + it('allows calls to /api/ror/ paths matching the allowed_api_paths entry', () => { + expectAllowed('api/ror/user/tenants', apiOnlyRorUser); + }); + + it('blocks calls to /api/ror/ paths not listed in allowed_api_paths', () => { + // apiOnlyExactUser only allows /api/spaces/space β€” /api/ror/ is not in the allowlist + expectBlocked('api/ror/user/tenants', apiOnlyExactUser); + }); + + it('blocks calls to /api/spaces/ paths when only /api/ror/ is in allowed_api_paths', () => { + expectBlocked('api/spaces/space', apiOnlyRorUser); + }); + }); +}); + +// --- Helpers --- + +function apiGet(endpoint: string, credentials: string) { + return cy.kbnGet({ endpoint, credentials, failOnStatusCode: false }); +} + +// ROR 403 is a specific body shape from guardKibanaApiPath; any other status means ROR let the request through +function assertRor403(response: unknown) { + expect(response).to.have.property('status_code', 403); + expect(response).to.have.property('status', 'forbidden'); +} + +function assertNotRor403(response: unknown) { + const body = response as Record; + expect(body.status).not.to.equal('forbidden'); +} + +function expectBlocked(endpoint: string, credentials: string) { + return apiGet(endpoint, credentials).then(assertRor403); +} + +function expectAllowed(endpoint: string, credentials: string) { + return apiGet(endpoint, credentials).then(assertNotRor403); +} + +function expectSpacesResponseIncludesDefault(response: unknown) { + const spaces = response as Array<{ id: string }>; + expect(spaces.map(s => s.id)).to.include('default'); +} diff --git a/e2e-tests/cypress/e2e/Dev-tools.cy.ts b/e2e-tests/cypress/e2e/Dev-tools.cy.ts index 9d423dc3..d69e9a00 100644 --- a/e2e-tests/cypress/e2e/Dev-tools.cy.ts +++ b/e2e-tests/cypress/e2e/Dev-tools.cy.ts @@ -10,10 +10,8 @@ describe('Dev tools', () => { }); it('should check dev tools', () => { - cy.log('should verify POST _bulk request forbidden with 403 status'); - DevTools.sendRequest( - 'POST /xx-enrich-iis/_bulk {enter} {{} "index" : {{} {}} {}} {enter} {{} "index" : {{} {}} {}}' - ); + cy.log('should verify POST _doc write request forbidden with 403 status'); + DevTools.sendRequest('POST /xx-enrich-iis/_doc {enter} {{} "field": "value" {}}'); DevTools.verifyIf403Status(); cy.log('should verify GET /_index_template successful with 403 status'); diff --git a/e2e-tests/cypress/e2e/Discover.cy.ts b/e2e-tests/cypress/e2e/Discover.cy.ts index d96697e8..b1e342f1 100644 --- a/e2e-tests/cypress/e2e/Discover.cy.ts +++ b/e2e-tests/cypress/e2e/Discover.cy.ts @@ -8,7 +8,7 @@ import { getKibanaVersion } from '../support/helpers'; import { esApiAdvancedClient } from '../support/helpers/EsApiAdvancedClient'; import { SearchSessions } from '../support/page-objects/SearchSessions'; -const userCredentials = 'user4:dev'; +const userCredentials = 'user2:dev'; const tenantIndex = '.kibana_admins_group'; const indexWithSearchSessions = semver.lt(getKibanaVersion(), '8.0.0') ? `${tenantIndex}_${getKibanaVersion()}_001` diff --git a/e2e-tests/cypress/e2e/Hide_apps.cy.ts b/e2e-tests/cypress/e2e/Hide_apps.cy.ts index a917a443..000e23f7 100644 --- a/e2e-tests/cypress/e2e/Hide_apps.cy.ts +++ b/e2e-tests/cypress/e2e/Hide_apps.cy.ts @@ -2,35 +2,95 @@ import semver from 'semver'; import { Settings } from '../support/page-objects/Settings'; import { Login } from '../support/page-objects/Login'; import { KibanaNavigation } from '../support/page-objects/KibanaNavigation'; +import { Spaces } from '../support/page-objects/Spaces'; import { RorMenu } from '../support/page-objects/RorMenu'; import { getKibanaVersion } from '../support/helpers'; import { PageNotFound } from '../support/page-objects/PageNotFound'; +import { SearchApps } from '../support/page-objects/SearchApps'; +import { Loader } from '../support/page-objects/Loader'; +import { Home } from '../support/page-objects/Home'; -describe('Hide apps', () => { +describe('hidden apps', () => { afterEach(() => { Settings.setSettingsData('defaultSettings.yaml'); }); - it('should hide all apps except of Stack Management', () => { - Settings.setSettingsData('hiddenSpaceManagementSettings.yaml'); - Login.initialization(); - RorMenu.openReportingPage(); - KibanaNavigation.checkStackManagementSectionElementsCount('ingest', 0); - KibanaNavigation.checkStackManagementSectionElementsCount('data', 0); - KibanaNavigation.checkStackManagementSectionElementsCount('insightsAndAlerting', 1); - KibanaNavigation.checkStackManagementSectionElementsCount('kibana', 2); - KibanaNavigation.checkIfStackManagementSubPageVisible('Reporting'); - if (semver.gte(getKibanaVersion(), '8.0.0')) { - KibanaNavigation.checkIfStackManagementSubPageVisible('Data Views'); - } else { - KibanaNavigation.checkIfStackManagementSubPageVisible('Index Patterns'); - } - KibanaNavigation.checkIfStackManagementSubPageVisible('Saved Objects'); + context('Stack Management navigation', () => { + // hiddenSpaceManagementSettings.yaml hides the "Space Management" sub-page + // and most other apps, leaving Stack Management with only the Reporting, + // Data Views (Index Patterns on 7.x), and Saved Objects sub-pages visible. + it('shows only the allowlisted Stack Management sub-pages when most apps are hidden', () => { + Settings.setSettingsData('hiddenSpaceManagementSettings.yaml'); + Login.initialization(); + RorMenu.openReportingPage(); + KibanaNavigation.checkStackManagementSectionElementsCount('ingest', 0); + KibanaNavigation.checkStackManagementSectionElementsCount('data', 0); + KibanaNavigation.checkStackManagementSectionElementsCount('insightsAndAlerting', 1); + KibanaNavigation.checkStackManagementSectionElementsCount('kibana', 2); + KibanaNavigation.checkIfStackManagementSubPageVisible('Reporting'); + if (semver.gte(getKibanaVersion(), '8.0.0')) { + KibanaNavigation.checkIfStackManagementSubPageVisible('Data Views'); + } else { + KibanaNavigation.checkIfStackManagementSubPageVisible('Index Patterns'); + } + KibanaNavigation.checkIfStackManagementSubPageVisible('Saved Objects'); + }); }); - it('should show "Page not found" on Login when navigation to the default route is prohibited', () => { - Settings.setSettingsData('hiddenHomePageSettings.yaml'); - Login.initialization({ finishUrl: '/app/page-not-found', spacePrefix: '' }); - PageNotFound.visible(); + context('default route hidden', () => { + it('shows "Page not found" on login when navigation to the default route is prohibited', () => { + Settings.setSettingsData('hiddenHomePageSettings.yaml'); + Login.initialization({ finishUrl: '/app/page-not-found', spacePrefix: '' }); + PageNotFound.visible(); + }); + }); + + context('Space solution view selector', () => { + // Security is always pre-populated in APP_IDS_TO_BE_HIDDEN regardless of config; no explicit settings call needed + it('does not show the Security option in the solution view dropdown when creating a space', () => { + if (semver.lt(getKibanaVersion(), '8.18.0')) { + cy.log('Solution view selector not available before Kibana 8.18.0 β€” skipping'); + return; + } + + Login.initialization(); + Spaces.navigateToCreateSpacePage(); + Spaces.openSolutionViewDropdown(); + + Spaces.verifySolutionViewSecurityOptionIsHidden(); + Spaces.verifySolutionViewOptionsAreVisible( + 'solutionViewEsOption', + 'solutionViewObltOption', + 'solutionViewClassicOption' + ); + }); + }); + + context('Kibana global search', () => { + beforeEach(() => { + Settings.setSettingsData('hiddenAllAppsSettings.yaml'); + Login.initialization(); + }); + + it('returns only Stack Management when all apps are hidden, and no results for hidden-app queries', () => { + Loader.waitForBreadcrumb('Home'); + SearchApps.openSearchAppsDropdown(); + // Only Stack Management should be visible because if we hide all apps, + // there is only a loading indicator visible in the Kibana UI. + SearchApps.verifyAppsInSearchResults(['Stack Management']); + Home.verifyIfCatalogueEmpty(); + + SearchApps.searchApp('aws'); + SearchApps.noResultsFound(); + + SearchApps.searchApp('type:integration'); + SearchApps.noResultsFound(); + + SearchApps.searchApp('readonly'); + SearchApps.noResultsFound(); + + SearchApps.searchApp('Index'); + SearchApps.noResultsFound(); + }); }); }); diff --git a/e2e-tests/cypress/e2e/Index-session.cy.ts b/e2e-tests/cypress/e2e/Index-session.cy.ts deleted file mode 100644 index 23ca7034..00000000 --- a/e2e-tests/cypress/e2e/Index-session.cy.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable no-use-before-define */ - -import semver from 'semver'; -import { esApiClient } from '../support/helpers/EsApiClient'; -import { Tenancy } from '../support/page-objects/Tenancy'; -import { KibanaNavigation } from '../support/page-objects/KibanaNavigation'; -import { Discover } from '../support/page-objects/Discover'; -import { kbnApiClient } from '../support/helpers/KbnApiClient'; -import { getKibanaVersion, userCredentials } from '../support/helpers'; -import { Login } from '../support/page-objects/Login'; -import { RorMenu } from '../support/page-objects/RorMenu'; - -describe('Index Session', () => { - beforeEach(() => { - esApiClient.deleteIndex(SESSION_INDEX); - }); - - afterEach(() => { - esApiClient.deleteIndex(SESSION_INDEX); - kbnApiClient.deleteSampleData('ecommerce', userCredentials); - kbnApiClient.deleteSampleData('ecommerce', userCredentials, 'template_group'); - }); - - it('should set correct tenancy when reading session without schema from legacy plugin UI', () => { - // Pre-load ecommerce sample data via API before visiting Discover so Kibana 9.x finds an - // existing data view instead of defaulting to the system "discover-observability-solution-all-logs" - // data view, which triggers aggressive background searches against non-existent indices and - // causes OOM crashes in the Electron renderer. - cy.on('uncaught:exception', () => false); - - esApiClient.addDocument( - SESSION_INDEX, - 'e47bcdeb-42ee-4bbf-abbd-0c8ef441873f', - LEGACY_SESSION_WITHOUT_SCHEMA_VERSION - ); - - kbnApiClient.loadSampleData('ecommerce', userCredentials, 'template_group'); - - Login.suppressPostLoginNotices(); - - Tenancy.disableTenancyOnUI().as('tenancyInjector'); - - Login.visitWithSessionCookie( - 'Fe26.2**050ab422c684a30b847701b834f067b3f11b559e531dd98baa5240f5abbfdc7d*a43QH_Cgag1pzdZk0CBGVg*LydOiS2PVo7N6fkoE4hlJsP3sT7b9PTXitqv1-118vl9TAPRTA02SVVXaiT-3p5L**b7b4db60afdf4242f7ba00bd67fab1a703be8eb5b140ab2dec790d81d16aab74*osQ2UuVjt3cV__N1lioDCAIO47upc6degwd-XLHErXk', - 'https://localhost:5601/s/default/app/discover' - ); - - cy.wait('@tenancyInjector'); - cy.get('[data-test-subj=globalLoadingIndicator-hidden]', { timeout: 30000 }).should('be.visible'); - - Tenancy.checkTenancyNameInBadge('template', 'rw'); - - KibanaNavigation.openPage('Discover'); - if (semver.gte(getKibanaVersion(), '9.0.0')) { - cy.intercept('POST', '/s/default/internal/search/ese**').as('dataViewSearch'); - Discover.selectDataView('Kibana Sample Data eCommerce'); - cy.wait('@dataViewSearch'); - } else if (semver.lt(getKibanaVersion(), '8.0.0')) { - // Kibana 7.x: explicitly select the ecommerce index pattern to avoid stale Discover state - cy.intercept('POST', '/s/default/internal/bsearch**').as('dataViewSearch'); - cy.get('[data-test-subj="indexPattern-switch-link"]').click(); - cy.findAllByText('kibana_sample_data_ecommerce').first().click(); - cy.wait('@dataViewSearch'); - } - Discover.verifyDocumentWithTodayRange(0, 'kibana_sample_data_ecommerce'); - }); - - it('should support legacy session format when creating a new session', () => { - Login.initialization(); - - esApiClient.documentsForIndex(SESSION_INDEX).then(result => { - const session = result.hits.hits[0]._source; - expect(session.currentGroup.id).to.eq('admins_group'); - expect(session.kibanaIndex).to.eq('.kibana_admins_group'); - }); - - RorMenu.changeTenancy('Infosec'); - - esApiClient.documentsForIndex(SESSION_INDEX).then(result => { - const session = result.hits.hits[0]._source; - expect(session.currentGroup.id).to.eq('infosec_group'); - expect(session.kibanaIndex).to.eq('.kibana_infosec_group'); - }); - }); -}); - -const SESSION_INDEX = '.readonlyrest_kbn_sessions'; - -const ONE_HOUR_MS = 60 * 60 * 1000; -const ONE_DAY_MS = 24 * ONE_HOUR_MS; - -const LEGACY_SESSION_WITHOUT_SCHEMA_VERSION = { - availableGroups: [ - { id: 'admins_group', name: 'administrators' }, - { id: 'infosec_group', name: 'infosec' }, - { id: 'template_group', name: 'template' } - ], - correlationId: 'f933769a-7419-465a-9db4-1420d29f7fb9', - currentGroup: { id: 'template_group', name: 'template' }, - customMetadata: { alert_message: 'Dear admin' }, - encryptedIdentitySessionHeaders: - 'U2FsdGVkX1/fqtxDmLhKvoekgdqi3rA2G3MWiwPanKpO068A+0ghMrb+/0xyokW5uMUMEo6YHxmPIBwdqYrXRZRUSRaJ0K1wLRzla25OSf1sRGBqpkedHzw0BwwCD2p95f+ZlKF3RVhEpsVIl886ed5we6pXWVBPPjCtBkGQOiewFtyuINWJMQnu3Ww2T8Z7y20al3tzYDTKMfkolWt6ibRT6V9HO1JbjFnRWEH0/hoor04NSV+urfNoLrDxfyGMKv5YrRFovMTGLW6QXh9ZEXKNBHXXMocU+l+yWuz245ohkQg34BebmC8j8L6TmbSAVQfUBW/j9+bKPXAoWtmZD9y7fgNCNBSzcQBc19EtzoGNuN+1MiMjr2g8YaAOYZCOu7VTCTyrsjjzfKSYLKZueJCMGV9seRUVeYojYfUHr1Od5byagQobxz4mhMK5AsQzxH1JLyEUzU2fqjL7I5inQX1xj28NPtwy9UwOKfGCniKf29tlCxVc/YBYjJ+1mmUHk/Zy6yJsBf1CIzCrgoumw1bzzaeuD/IRt4YqVqcyUB4O7ZjttZQHelmtO2b7iybrRBJMi/8ksPm8iH9RtlEXd+NzSibHaVGT6FZHQLVsutAT8j4mZAYgcWs3y6sUlTX+ZrZop717N33ZKlNG7wdtgt1ei+ZiTIZrl6ieNd4JYsDZ5gDXzTF+8HdzI+dSbFdPNpUTjj5OuUShFs1ZSR8VBEw9Ir00Fy5QZTax/WyDXjNrtrVExdI6R+QOQHt5CSgGkYOSolQz0jxKCPXVNH3jmU8JZzCBITQKJGVf6qUWsxWv+yPGMj26zfjB+fjplcG994YNL0xcIwGswuL1Qdj9E5e6dJdZaF6kUE2KCfxBXHSrhBrfnraHgTjWWx6ZvkF3zd642dd4aHvufWIChW2dq5stLrieRLhkXEbTLca3kw2ood846F1choK1KRYcOMdhgRezp7vNqen0zSXuIUgipRmPzonMrmyngCVEU+WYi8Ln7XRNwiHIc5nGx5S+cUbvWtmKAqz9RCuNtpDNHXojK+HcOVo2PKh0VsGN6ZJkx4BwysKe8lMXYkA7Hzor9mi9YWLxpRpp+qDpA4VGIiSOFA==', - expiresAt: Date.now() + ONE_HOUR_MS, - kibanaAccess: 'rw', - kibanaHiddenApps: ['Enterprise Search|Overview', 'Observability'], - kibanaIndex: '.kibana_template_group', - lastSessionActivityDate: Date.now() + ONE_DAY_MS, - username: 'admin' -}; diff --git a/e2e-tests/cypress/e2e/Jwt-auth.cy.ts b/e2e-tests/cypress/e2e/Jwt-auth.cy.ts index 2f191112..539f04b4 100644 --- a/e2e-tests/cypress/e2e/Jwt-auth.cy.ts +++ b/e2e-tests/cypress/e2e/Jwt-auth.cy.ts @@ -1,6 +1,5 @@ import { Tenancy } from '../support/page-objects/Tenancy'; import { getIframeBody } from '../support/helpers/iframe'; -import { Login } from '../support/page-objects/Login'; describe('JWT authentication', () => { const embeddedServerUrl = 'https://localhost:8080'; @@ -15,7 +14,6 @@ describe('JWT authentication', () => { }); it('should load Kibana via JWT auth, bypass login and show the correct tenancy', () => { - Login.suppressPostLoginNotices(); cy.visit(embeddedServerUrl); getIframeBody('iframe').then(iframeBody => { diff --git a/e2e-tests/cypress/e2e/No-non-ok-responses-after-login.cy.ts b/e2e-tests/cypress/e2e/No-non-ok-responses-after-login.cy.ts new file mode 100644 index 00000000..4f433b3b --- /dev/null +++ b/e2e-tests/cypress/e2e/No-non-ok-responses-after-login.cy.ts @@ -0,0 +1,40 @@ +/* Copyright (C) Beshu Limited t/a ReadonlyREST Security - All Rights Reserved + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + * Written by Beshu Limited in London, UK + */ + +import { Login } from '../support/page-objects/Login'; + +// Kibana endpoints that return non-ok responses in a ROR-managed installation because +// they belong to Kibana's native security or cloud layers, which ROR replaces or does not support. +const ENDPOINTS_IGNORED_BY_ROR = [ + '/internal/cloud_connect/cluster_details', // cloud cluster metadata β€” not available in non-Elastic-Cloud deployments + '/api/exception_lists/items/_find' // Elastic Endpoint Security (X-Pack) β€” not supported by ROR +]; + +describe('No unexpected non-ok responses after login', () => { + it('should not produce non-ok HTTP responses when loading Kibana after login', () => { + const nonOkResponses: Array<{ url: string; status: number }> = []; + + cy.intercept('**', req => { + req.on('response', res => { + const isLocalRequest = new URL(req.url).hostname === 'localhost'; + if (isLocalRequest && res.statusCode >= 400 && !ENDPOINTS_IGNORED_BY_ROR.some(endpoint => req.url.includes(endpoint))) { + nonOkResponses.push({ url: req.url, status: res.statusCode }); + } + }); + }); + + Login.initialization(); + + // req.on('response') fires outside Cypress's command queue β€” Kibana has long-running + // connections (SSE, websocket polling) so we cannot wait for pendingCount===0; + // a fixed wait is the only practical option here + cy.wait(3000); + + cy.wrap(null).then(() => { + expect(nonOkResponses, `unexpected non-ok responses: ${JSON.stringify(nonOkResponses, null, 2)}`).to.be.empty; + }); + }); +}); diff --git a/e2e-tests/cypress/e2e/Reporting.cy.ts b/e2e-tests/cypress/e2e/Reporting.cy.ts index de68fd67..584daaab 100644 --- a/e2e-tests/cypress/e2e/Reporting.cy.ts +++ b/e2e-tests/cypress/e2e/Reporting.cy.ts @@ -61,9 +61,13 @@ if (semver.gte(getKibanaVersion(), '8.15.0')) { Discover.openDataViewPage(); Discover.saveReport(newFormatReportingName); Discover.exportToCsv(); + // Let the first report land before rolling over (see waitForReportingSegmentsDocsCount). + esApiAdvancedClient.waitForReportingSegmentsDocsCount(index, 1); esApiClient.rolloverIndex(newFormatReportingIndex); Reporting.verifyAllDataStreamsSegmentsCount(index, 2); Discover.exportToCsv(); + // Let the second report land before asserting all three are listed. + esApiAdvancedClient.waitForReportingSegmentsDocsCount(index, 2); Reporting.openReportingPage('kibanaNavigation'); Reporting.verifySavedReport([newFormatReportingName, newFormatReportingName, oldFormatReportingName]); }); diff --git a/e2e-tests/cypress/e2e/Sanity-check.cy.ts b/e2e-tests/cypress/e2e/Sanity-check.cy.ts index 49662720..be5841fe 100644 --- a/e2e-tests/cypress/e2e/Sanity-check.cy.ts +++ b/e2e-tests/cypress/e2e/Sanity-check.cy.ts @@ -14,8 +14,6 @@ import { Tenancy } from '../support/page-objects/Tenancy'; describe('sanity check', () => { beforeEach(() => { - // Prune before the test in case a previous attempt's async CSV report landed after afterEach ran. - esApiAdvancedClient.pruneAllReportingIndices(); SampleData.createSampleData('sample_index', 1); Login.initialization(); }); diff --git a/e2e-tests/cypress/e2e/Tenancy.cy.ts b/e2e-tests/cypress/e2e/Tenancy.cy.ts index 62139f68..ce3f0c61 100644 --- a/e2e-tests/cypress/e2e/Tenancy.cy.ts +++ b/e2e-tests/cypress/e2e/Tenancy.cy.ts @@ -5,14 +5,14 @@ import { RorMenu } from '../support/page-objects/RorMenu'; import { KibanaNavigation } from '../support/page-objects/KibanaNavigation'; import { Loader } from '../support/page-objects/Loader'; import { Discover } from '../support/page-objects/Discover'; -import { Home } from '../support/page-objects/Home'; import { kbnApiClient } from '../support/helpers/KbnApiClient'; import { getKibanaVersion, userCredentials } from '../support/helpers'; import { Dashboard } from '../support/page-objects/Dashboard'; +import { IndexManagement } from '../support/page-objects/IndexManagement'; import { TENANCY_QUERY_STRING_KEY } from '../support/types'; import { Spaces } from '../support/page-objects/Spaces'; import { kbnApiAdvancedClient } from '../support/helpers/KbnApiAdvancedClient'; -import { IndexManagement } from '../support/page-objects/IndexManagement'; +import { UserSettings } from '../support/page-objects/UserSettings'; describe('Tenancy', () => { describe('should run tests', () => { @@ -25,6 +25,7 @@ describe('Tenancy', () => { endUrl = `/s/default/app/management/data/index_management/indices?${TENANCY_QUERY_STRING_KEY}=*` ) => { RorMenu.changeTenancy('administrators', endUrl, ''); + IndexManagement.waitUntilLoaded(); cy.go('back'); }; @@ -41,9 +42,61 @@ describe('Tenancy', () => { }); }; + beforeEach(() => { + cy.clearCookies(); + cy.clearLocalStorage(); + }); + // eslint-disable-next-line no-use-before-define runTests({ callbackBeforeLogin: openAnotherTabs }); }); + + it('should not apply stale remembered tenancy to a new user session after logout', () => { + const homeUrlWithInfosecTenancy = `/s/default/app/home?${TENANCY_QUERY_STRING_KEY}=${Tenancy.encryptedInfosecGroup}`; + + Login.initialization({ + visitedUrl: homeUrlWithInfosecTenancy, + finishUrl: `/s/default/app/home?${TENANCY_QUERY_STRING_KEY}=*`, + spacePrefix: '' + }); + + RorMenu.openRorMenu(); + UserSettings.openViaMenuIcon(); + UserSettings.changeUserSettingsValue('remember-group-after-logout-settings', 'enabled'); + RorMenu.openRorMenu(); + RorMenu.pressLogoutButton(); + + cy.url().should('include', `nextUrl=`); + cy.url().should('include', `${TENANCY_QUERY_STRING_KEY}%3D`); + + Login.fillLoginPageWith('kibana', 'kibana'); + Loader.loading(); + RorMenu.openRorMenu(); + RorMenu.verifyNoTenantAvailable(); + }); + + it('should redirect to page-not-found instead of carrying stale tenancy to a saved-object page after logout', () => { + const dashboardsUrlWithInfosecTenancy = `/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=${Tenancy.encryptedInfosecGroup}`; + + Login.initialization({ + visitedUrl: dashboardsUrlWithInfosecTenancy, + finishUrl: `/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=*`, + spacePrefix: '' + }); + + RorMenu.openRorMenu(); + UserSettings.openViaMenuIcon(); + UserSettings.changeUserSettingsValue('remember-group-after-logout-settings', 'enabled'); + RorMenu.openRorMenu(); + RorMenu.pressLogoutButton(); + + cy.url().should('include', `nextUrl=`); + cy.url().should('include', `${TENANCY_QUERY_STRING_KEY}%3D`); + + Login.fillLoginPageWith('kibana', 'kibana'); + Loader.loading(`/app/page-not-found?${TENANCY_QUERY_STRING_KEY}=*`, ''); + cy.url().should('include', '/app/page-not-found'); + }); }); function runTests({ @@ -92,8 +145,14 @@ function runTests({ }); callbackAfterLogin?.(); - Home.loadSampleData(); + kbnApiClient.loadSampleData('ecommerce', userCredentials, 'template_group'); + cy.waitForNetworkIdle('*', 500, { timeout: 10000 }); KibanaNavigation.openPage('Discover'); + if (semver.gte(getKibanaVersion(), '8.0.0')) { + cy.get('[data-test-subj="discover-dataView-switch-link"]', { timeout: 30000 }).should('exist'); + } else { + cy.get('[data-test-subj="indexPattern-switch-link"]', { timeout: 30000 }).should('exist'); + } Discover.openShareDiscover(); Discover.clickCopyLinkButton('admin'); if (semver.gte(getKibanaVersion(), '8.0.0')) { @@ -142,7 +201,7 @@ function runTests({ }); it('should redirect to page not found when tenancy is not available', () => { - const urlWithTenancyId = `/s/default/app/management/data/index_management/indices?${TENANCY_QUERY_STRING_KEY}=${Tenancy.encryptedTenancyWithNotAvailableTenancy}`; + const urlWithTenancyId = `/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=${Tenancy.encryptedTenancyWithNotAvailableTenancy}`; Login.initialization({ visitedUrl: urlWithTenancyId, finishUrl: `/app/page-not-found?${TENANCY_QUERY_STRING_KEY}=*`, @@ -161,7 +220,6 @@ function runTests({ spacePrefix: '' }); - IndexManagement.waitingForSectionLoadingFinish(); callbackAfterLogin?.(); Spaces.createNewSpace(newSpace); diff --git a/e2e-tests/cypress/e2e/User-settings.cy.ts b/e2e-tests/cypress/e2e/User-settings.cy.ts index 01df97c6..25355bb7 100644 --- a/e2e-tests/cypress/e2e/User-settings.cy.ts +++ b/e2e-tests/cypress/e2e/User-settings.cy.ts @@ -16,16 +16,33 @@ describe('User settings', () => { }); it('should verify user settings change', () => { + // Kibana 8.x lazily loads plugin chunks (securitySolution, observability, enterpriseSearch) + // during the reload below. When those chunks fail, Kibana's plugin lifecycle throws + // "executing a cancelled action" as an unhandled rejection. Neither failure is related to + // what this test verifies. + cy.on('uncaught:exception', err => { + if ( + err.message.includes('ChunkLoadError') || + err.message.includes('Loading chunk') || + err.message.includes('executing a cancelled action') + ) { + return false; + } + }); + cy.log('Change theme'); UserSettings.open(); - SecuritySettings.getIframeBody().find('[data-test-subj="dark"]').click({ force: true }); - SecuritySettings.getIframeBody().find('button').contains('Reload page').click({ force: true }); + + // Register the intercept before triggering any reload so we don't miss the CSS request if (semver.gte(getKibanaVersion(), '8.16.0')) { cy.intercept('**/*legacy_dark_theme.min.css').as('darkMode'); } else { cy.intercept('**/*dark.css').as('darkMode'); } + SecuritySettings.getIframeBody().find('[data-test-subj="dark"]').click({ force: true }); + SecuritySettings.getIframeBody().find('button').contains('Reload page').click({ force: true }); + cy.reload(); cy.wait('@darkMode'); diff --git a/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml b/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml new file mode 100644 index 00000000..96379e4a --- /dev/null +++ b/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml @@ -0,0 +1,60 @@ +readonlyrest: + ssl: + enable: true + keystore_file: '/etc/elasticsearch/ror-keystore.jks' + keystore_pass: readonlyrest + key_pass: readonlyrest + access_control_rules: + - name: 'Kibana service account - user/pass' + verbosity: error + auth_key: kibana:kibana + + # api_only user restricted to a single exact Kibana API path. + - name: API_ONLY_RESTRICTED + auth_key: api_only_restricted_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/spaces/space$' + + # api_only user restricted via regexp β€” allows all /api/spaces/* paths. + - name: API_ONLY_RESTRICTED_REGEXP + auth_key: api_only_restricted_regexp_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/spaces.*$' + + # api_only user restricted to a space-aware path β€” only /s/default/api/spaces/space. + - name: API_ONLY_SPACE_RESTRICTED + auth_key: api_only_space_restricted_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/s/default/api/spaces/space$' + + # api_only user restricted to Kibana internal API paths (/internal/*). + - name: API_ONLY_INTERNAL_RESTRICTED + auth_key: api_only_internal_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/internal/spaces/get_all.*$' + + # api_only user restricted to ReadonlyREST public API paths (/api/ror/*). + - name: API_ONLY_ROR_RESTRICTED + auth_key: api_only_ror_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/ror/user/tenants$' diff --git a/e2e-tests/cypress/fixtures/testSettings.yaml b/e2e-tests/cypress/fixtures/testSettings.yaml index 14503257..4e48c6e2 100644 --- a/e2e-tests/cypress/fixtures/testSettings.yaml +++ b/e2e-tests/cypress/fixtures/testSettings.yaml @@ -38,7 +38,6 @@ readonlyrest: ldap_authorization: name: "LDAP 1" groups: ["group3"] - kibana_access: admin - name: PERSONAL_GRP groups: [ personal_group ] diff --git a/e2e-tests/cypress/plugins/index.ts b/e2e-tests/cypress/plugins/index.ts index 3c685254..5b5a9881 100644 --- a/e2e-tests/cypress/plugins/index.ts +++ b/e2e-tests/cypress/plugins/index.ts @@ -158,11 +158,7 @@ module.exports = (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) return new Promise((resolve, reject) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any embeddedServer = (https.createServer as any)(sslOptions, (_req: any, res: any) => { - const jwt = generateJwt({ - sub: 'admin', - group: ['administrators', 'infosec', 'template'], - iat: Math.floor(Date.now() / 1000) - }); + const jwt = generateJwt({ sub: 'admin', group: ['administrators', 'infosec', 'template'], iat: Math.floor(Date.now() / 1000) }); const htmlWithJwt = html.toString().replace(/jwt=[^&"#\s]+/, `jwt=${jwt}`); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(htmlWithJwt); @@ -218,6 +214,31 @@ module.exports = (on: Cypress.PluginEvents, config: Cypress.PluginConfigOptions) return null; } }); + + // Discard the video for specs that finished with all tests passing. + // Combined with `videoCompression: false` in cypress.config.ts, this keeps + // failure-debug videos available while avoiding writing GBs of green-run + // videos to disk and uploading them as artifacts. + on('after:spec', async (_spec, results) => { + if (!results || !results.video) return; + // Keep the video if the spec had ANY failure. Prefer the stable + // `results.stats.failures` counter β€” in Cypress 14 the per-attempt + // `tests[].attempts[].state` field is no longer reliably populated, so the + // old `attempts[].state === 'failed'` check returned false even for failed + // specs and the failure video was wrongly deleted before upload. + const failures = + (results.stats && results.stats.failures > 0) || + (results.tests || []).some((t) => + t.state === 'failed' || + (t.attempts || []).some((a) => a.state === 'failed') + ); + if (failures) return; + try { + await fs.promises.unlink(results.video); + } catch { + // best-effort cleanup; don't fail the run if the file is already gone + } + }); }; interface HttpCallOptions { diff --git a/e2e-tests/cypress/support/commands.ts b/e2e-tests/cypress/support/commands.ts index 29f00d01..a4a25e5c 100644 --- a/e2e-tests/cypress/support/commands.ts +++ b/e2e-tests/cypress/support/commands.ts @@ -46,7 +46,7 @@ Cypress.Commands.add('esPut', ({ endpoint, credentials, payload }, ...args) => Cypress.Commands.add('kbnImport', ({ endpoint, credentials, fixtureFilename, currentGroupHeader }, ...args) => uploadFile(`${Cypress.config().baseUrl}/${endpoint}`, credentials, fixtureFilename, { 'kbn-xsrf': 'true', - ...(currentGroupHeader ? { 'x-ror-current-group': currentGroupHeader } : {}) + ...(currentGroupHeader ? { 'x-ror-tenancy-id': currentGroupHeader } : {}) }) ); @@ -96,7 +96,7 @@ Cypress.Commands.add( ({ method, endpoint, credentials, payload, currentGroupHeader, impersonating, failOnStatusCode, headers }) => { const customHeaders: { [key: string]: string } = { 'kbn-xsrf': 'true', ...headers }; if (currentGroupHeader) { - customHeaders['x-ror-current-group'] = currentGroupHeader; + customHeaders['x-ror-tenancy-id'] = currentGroupHeader; } if (impersonating) { @@ -211,7 +211,8 @@ Cypress.on('uncaught:exception', (err, runnable) => { err.message.includes('Markdown content is required in [readOnly] mode') || // kibana 8.13.0 throws this error on sample data canvas open err.message.includes('e.toSorted is not a function') || // kibana 8.15.0 throws this error on report generation err.message.includes('Not Found') || // kibana 9.0.0-beta1 throws: Uncaught (in promise) http_fetch_error_HttpFetchError: Not Found - err.message.includes('Loading chunk') // kibana 9.3.2 + err.message.includes("Cannot read properties of undefined (reading 'id')") || // kibana 9.x Discover throws when opening with no data views in the tenant + err.message.includes('endpoint is ignored by ReadonlyREST plugin') // unsupportedEndpointsFilter.ts intercepts Kibana security endpoints with 501; some callers lack try-catch ) { return false; } diff --git a/e2e-tests/cypress/support/helpers/EsApiAdvancedClient.ts b/e2e-tests/cypress/support/helpers/EsApiAdvancedClient.ts index 40b005ff..66d14fc0 100644 --- a/e2e-tests/cypress/support/helpers/EsApiAdvancedClient.ts +++ b/e2e-tests/cypress/support/helpers/EsApiAdvancedClient.ts @@ -7,15 +7,20 @@ export class EsApiAdvancedClient extends EsApiClient { public pruneAllReportingIndices(): void { cy.log('Pruning all reporting indices...'); - if (semver.satisfies(getKibanaVersion(), '>=8.19.0 <9.0.0 || >=9.1.0')) { - this.dataStreams().then(result => { - result.data_streams - .filter(dataStream => dataStream.name.startsWith('.kibana-reporting-')) - .forEach(reportingDataStream => { - this.deleteDataStream(reportingDataStream.name); - }); - }); - } else { + // Reporting has used data streams (.kibana-reporting-*, backed by hidden + // .ds-* indices) since 8.15; drop them in every version so stale docs from a + // prior run can't satisfy waitForReportingSegmentsDocsCount before the current + // export lands. No-op when none exist. + this.dataStreams().then(result => { + result.data_streams + .filter(dataStream => dataStream.name.startsWith('.kibana-reporting-')) + .forEach(reportingDataStream => { + this.deleteDataStream(reportingDataStream.name); + }); + }); + + // Pre-8.19 also has the legacy .reporting* indices; purge their docs too. + if (!semver.satisfies(getKibanaVersion(), '>=8.19.0 <9.0.0 || >=9.1.0')) { this.indices().then(result => { result .filter(index => index.index.startsWith('.reporting')) @@ -41,6 +46,37 @@ export class EsApiAdvancedClient extends EsApiClient { ); } + // exportToCsv returns when the report is QUEUED, not written. Poll the segments' + // combined docs.count (refresh-visible) until the report doc shows up, so a + // following rollover doesn't race the write and land the report in the wrong segment. + public waitForReportingSegmentsDocsCount( + indexName: string, + expectedDocs: number, + timeout = 30000, + interval = 1000 + ): Cypress.Chainable { + const startTime = Date.now(); + + const checkCount = (): Cypress.Chainable => + this.getAllReportingDataStreamSegments(indexName).then(segments => { + const total = segments.reduce((sum, seg) => sum + Number.parseInt(seg['docs.count'] ?? '0', 10), 0); + cy.log(`Reporting segments for ${indexName}: docs ${total}/${expectedDocs}`); + if (total >= expectedDocs) { + return; + } + if (Date.now() - startTime >= timeout) { + throw new Error( + `Timeout waiting for ${expectedDocs} report docs in ${indexName} segments (current: ${total}) after ${timeout / 1000}s` + ); + } + // Return the recursive chain so Cypress waits for the full poll to + // resolve before advancing (matches verifyAllDataStreamsSegmentsCount). + return cy.wait(interval).then(checkCount); + }); + + return cy.wrap(null).then(checkCount); + } + public waitForDocsCount( indexName: string, expectedCount = 0, diff --git a/e2e-tests/cypress/support/page-objects/Discover.ts b/e2e-tests/cypress/support/page-objects/Discover.ts index 2245c1a9..55f9586a 100644 --- a/e2e-tests/cypress/support/page-objects/Discover.ts +++ b/e2e-tests/cypress/support/page-objects/Discover.ts @@ -22,11 +22,13 @@ export class Discover { cy.get('[data-test-subj=discoverSaveButton]').click(); cy.get('[data-test-subj=savedObjectTitle]').type(reportName, { delay: 0 }); cy.get('[data-test-subj=confirmSaveSavedObjectButton]').click({ force: true }); - cy.contains('was saved', { timeout: 10000 }).should('exist'); + Discover.verifySearchSaved(); + } - cy.findByRole('navigation', { - name: /breadcrumb/i - }).findByText(reportName); + static verifySearchSaved() { + cy.log('verifySearchSaved'); + cy.contains('was saved', { timeout: 10000 }).should('exist'); + cy.url().should('include', '/view/'); } static exportToCsv() { diff --git a/e2e-tests/cypress/support/page-objects/IndexManagement.ts b/e2e-tests/cypress/support/page-objects/IndexManagement.ts index 45c67429..71fb6b25 100644 --- a/e2e-tests/cypress/support/page-objects/IndexManagement.ts +++ b/e2e-tests/cypress/support/page-objects/IndexManagement.ts @@ -2,6 +2,16 @@ import * as semver from 'semver'; import { getKibanaVersion } from '../helpers'; export class IndexManagement { + static waitUntilLoaded() { + if (semver.gte(getKibanaVersion(), '8.0.0')) { + cy.get('[data-test-subj="indicesSearch"]').should('be.visible'); + } else { + cy.get('input[aria-label="This is a search bar. As you type, the results lower in the page will automatically filter."]').should( + 'be.visible' + ); + } + } + static IncludeHiddenIndices() { cy.log('Include hidden indices'); @@ -97,16 +107,13 @@ export class IndexManagement { static openDataStreams() { cy.log('Open Data Streams'); + cy.get('[data-test-subj="data_streamsTab"]').click(); } static verifyDataStreamsEmptyPage() { cy.log('Verify data streams empty page'); + cy.contains('[data-test-subj="title"]', "You don't have any data streams yet"); } - - static waitingForSectionLoadingFinish() { - cy.log('Waiting for section loading'); - cy.get('[data-test-subj="sectionLoading"]', { timeout: 30000 }).should('not.exist'); - } } diff --git a/e2e-tests/cypress/support/page-objects/Loader.ts b/e2e-tests/cypress/support/page-objects/Loader.ts index b78c85b9..efcfb078 100644 --- a/e2e-tests/cypress/support/page-objects/Loader.ts +++ b/e2e-tests/cypress/support/page-objects/Loader.ts @@ -1,4 +1,4 @@ -import { TENANCY_QUERY_STRING_KEY } from '../types'; +import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; export class Loader { public static loading(finishUrl?: string, spacePrefix?: string) { diff --git a/e2e-tests/cypress/support/page-objects/Login.ts b/e2e-tests/cypress/support/page-objects/Login.ts index c70a3e3b..abee354f 100644 --- a/e2e-tests/cypress/support/page-objects/Login.ts +++ b/e2e-tests/cypress/support/page-objects/Login.ts @@ -7,7 +7,6 @@ export class Login { } static suppressPostLoginNotices() { - cy.setCookie('rorIgnoreActivationKeyInfo', 'true'); cy.on('url:changed', () => { sessionStorage.setItem('ror:ignoreKeyExpirationInfo', 'true'); localStorage.setItem('home:welcome:show', 'false'); diff --git a/e2e-tests/cypress/support/page-objects/Reporting.ts b/e2e-tests/cypress/support/page-objects/Reporting.ts index 80adfed9..c5791e97 100644 --- a/e2e-tests/cypress/support/page-objects/Reporting.ts +++ b/e2e-tests/cypress/support/page-objects/Reporting.ts @@ -77,18 +77,35 @@ export class Reporting { ); } - static verifyAllDataStreamsSegmentsCount(index: string, numberOfSegments: number) { - esApiAdvancedClient.getAllReportingDataStreamSegments(index).then(dataStreams => { - expect(dataStreams.length).to.equal(numberOfSegments); - const sortedStreams = [...dataStreams].sort((a, b) => a.index.localeCompare(b.index)); - - sortedStreams.forEach( - (dataStream, index) => - expect( - dataStream.index.endsWith(`00000${index + 1}`), - `Expected index "${dataStream.index}" to end with "00000${index + 1}"` - ).to.be.true - ); - }); + static verifyAllDataStreamsSegmentsCount(index: string, numberOfSegments: number, timeout = 30000) { + // Segment creation after a rollover is async, so poll instead of asserting on + // a single snapshot that could catch an intermediate state. + const startTime = Date.now(); + + const check = (): Cypress.Chainable => + esApiAdvancedClient.getAllReportingDataStreamSegments(index).then(dataStreams => { + if (dataStreams.length !== numberOfSegments && Date.now() - startTime < timeout) { + cy.log(`Reporting segments for ${index}: ${dataStreams.length}/${numberOfSegments}, waiting...`); + return cy.wait(1000).then(check); + } + + // Name the assertion so a timeout (rather than a genuine miscount) is obvious in CI. + const timedOut = Date.now() - startTime >= timeout; + const label = timedOut + ? `data stream segments for ${index} (timed out after ${timeout / 1000}s)` + : `data stream segments for ${index}`; + expect(dataStreams.length, label).to.equal(numberOfSegments); + const sortedStreams = [...dataStreams].sort((a, b) => a.index.localeCompare(b.index)); + + sortedStreams.forEach( + (dataStream, segmentIndex) => + expect( + dataStream.index.endsWith(`00000${segmentIndex + 1}`), + `Expected index "${dataStream.index}" to end with "00000${segmentIndex + 1}"` + ).to.be.true + ); + }); + + return cy.wrap(null).then(check); } } diff --git a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts index 09864a07..16106839 100644 --- a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts +++ b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts @@ -9,8 +9,8 @@ import { Discover } from './Discover'; import { Canvas } from './Canvas'; import { IndexPattern } from './IndexPattern'; import { getKibanaVersion } from '../helpers'; +import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; import { Tenancy } from './Tenancy'; -import { TENANCY_QUERY_STRING_KEY } from '../types'; import { kbnApiClient } from '../helpers/KbnApiClient'; import { Login } from './Login'; @@ -23,7 +23,21 @@ export class RoAndRoStrictKibanaAccessAssertions { Home.loadSampleDataButtonHidden(); cy.log('Verify Dashboard features'); - Dashboard.openDashboard(); + if (semver.gte(getKibanaVersion(), '9.4.0')) { + cy.intercept('GET', '/s/default/app/dashboards**').as('dashboardsApp'); + Tenancy.getTenancyFromUrl().then(tenancy => { + cy.visit(`/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=${tenancy}`); + }); + cy.wait('@dashboardsApp', { timeout: 30000 }).its('response.statusCode').should('eq', 200); + } else if (semver.gte(getKibanaVersion(), '8.7.0')) { + cy.intercept('POST', /\/content_management\/rpc\/search/).as('dashboardsSearch'); + Tenancy.getTenancyFromUrl().then(tenancy => { + cy.visit(`/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=${tenancy}`); + }); + cy.wait('@dashboardsSearch', { timeout: 30000 }).its('response.statusCode').should('eq', 200); + } else { + Dashboard.openDashboard(); + } Dashboard.openItem(0); SubHeader.breadcrumbsLastItem('[eCommerce] Revenue Dashboard'); Dashboard.editButtonNotExist(); @@ -32,6 +46,12 @@ export class RoAndRoStrictKibanaAccessAssertions { timeout: 30000 }); + cy.log('Verify Lens panel renders without error'); + cy.get('[data-test-subj="embeddableError"]').should('not.exist'); + if (semver.gte(getKibanaVersion(), '7.10.0')) { + cy.get('[data-test-subj="lnsVisualizationContainer"]').should('exist'); + } + cy.log('Verify Discover features'); KibanaNavigation.openPage('Discover'); SubHeader.readonlyDiscoverBadgeVisible(); diff --git a/e2e-tests/cypress/support/page-objects/Spaces.ts b/e2e-tests/cypress/support/page-objects/Spaces.ts index f6e10d08..ca7efd29 100644 --- a/e2e-tests/cypress/support/page-objects/Spaces.ts +++ b/e2e-tests/cypress/support/page-objects/Spaces.ts @@ -24,7 +24,7 @@ export class Spaces { static saveSpaceAndConfirm() { cy.get('[data-test-subj=save-space-button]').click(); - cy.get('body').then($body => { + cy.get('body').then(($body) => { if ($body.find('[data-test-subj=confirmModalConfirmButton]').length > 0) { cy.get('[data-test-subj=confirmModalConfirmButton]').click({ force: true }); } @@ -42,9 +42,7 @@ export class Spaces { static createNewSpace(spaceName: string) { cy.log('Create new space'); - cy.get('[data-test-subj=spacesNavSelector]').click(); - cy.get('[data-test-subj=manageSpaces]').click({ force: true }); - cy.get('[data-test-subj=createSpace]').click(); + Spaces.navigateToCreateSpacePage(); cy.get('[data-test-subj=addSpaceName]').type(spaceName); cy.get('#featureCategoryCheckbox_kibana').uncheck(); @@ -57,6 +55,24 @@ export class Spaces { cy.contains(`Space '${spaceName}' was saved.`); } + static navigateToCreateSpacePage() { + cy.get('[data-test-subj=spacesNavSelector]').click(); + cy.get('[data-test-subj=manageSpaces]').click({ force: true }); + cy.get('[data-test-subj=createSpace]').click(); + } + + static openSolutionViewDropdown() { + cy.get('[data-test-subj=solutionViewSelect]').click(); + } + + static verifySolutionViewSecurityOptionIsHidden() { + cy.get('[data-test-subj=solutionViewSecurityOption]').should('not.be.visible'); + } + + static verifySolutionViewOptionsAreVisible(...testSubjs: string[]) { + testSubjs.forEach(subj => cy.get(`[data-test-subj="${subj}"]`).should('be.visible')); + } + static openSpace(spaceName: string) { cy.log('Open space'); cy.getByDataTestSubj('spacesNavSelector').click(); diff --git a/e2e-tests/cypress/support/page-objects/Tenancy.ts b/e2e-tests/cypress/support/page-objects/Tenancy.ts index bea4480b..7e19045b 100644 --- a/e2e-tests/cypress/support/page-objects/Tenancy.ts +++ b/e2e-tests/cypress/support/page-objects/Tenancy.ts @@ -1,4 +1,4 @@ -import { TENANCY_QUERY_STRING_KEY } from '../types'; +import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; export class Tenancy { public static encryptedTenancyWithTemplateGroup = From c0741ead9be13f8805c368191ff463f0de0e42ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 12:28:40 +0200 Subject: [PATCH 07/36] wip --- e2e-tests/cypress/e2e/Discover.cy.ts | 2 +- e2e-tests/cypress/fixtures/defaultSettings.yaml | 3 ++- e2e-tests/cypress/support/page-objects/Loader.ts | 2 +- .../page-objects/RoAndRoStrictKibanaAccessAssertions.ts | 2 +- e2e-tests/cypress/support/page-objects/Tenancy.ts | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/e2e-tests/cypress/e2e/Discover.cy.ts b/e2e-tests/cypress/e2e/Discover.cy.ts index b1e342f1..d96697e8 100644 --- a/e2e-tests/cypress/e2e/Discover.cy.ts +++ b/e2e-tests/cypress/e2e/Discover.cy.ts @@ -8,7 +8,7 @@ import { getKibanaVersion } from '../support/helpers'; import { esApiAdvancedClient } from '../support/helpers/EsApiAdvancedClient'; import { SearchSessions } from '../support/page-objects/SearchSessions'; -const userCredentials = 'user2:dev'; +const userCredentials = 'user4:dev'; const tenantIndex = '.kibana_admins_group'; const indexWithSearchSessions = semver.lt(getKibanaVersion(), '8.0.0') ? `${tenantIndex}_${getKibanaVersion()}_001` diff --git a/e2e-tests/cypress/fixtures/defaultSettings.yaml b/e2e-tests/cypress/fixtures/defaultSettings.yaml index 95b78b3a..5a480325 100644 --- a/e2e-tests/cypress/fixtures/defaultSettings.yaml +++ b/e2e-tests/cypress/fixtures/defaultSettings.yaml @@ -3,8 +3,9 @@ helpers: verbosity: error ckr: &common-kibana-rules access: rw - hide_apps: ["Enterprise Search|Overview"] + hide_apps: ["Enterprise Search|Overview", "Observability"] index: ".kibana_@{acl:current_group}" + ag: &all-groups groups: - id: admins_group diff --git a/e2e-tests/cypress/support/page-objects/Loader.ts b/e2e-tests/cypress/support/page-objects/Loader.ts index efcfb078..b78c85b9 100644 --- a/e2e-tests/cypress/support/page-objects/Loader.ts +++ b/e2e-tests/cypress/support/page-objects/Loader.ts @@ -1,4 +1,4 @@ -import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; +import { TENANCY_QUERY_STRING_KEY } from '../types'; export class Loader { public static loading(finishUrl?: string, spacePrefix?: string) { diff --git a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts index 16106839..aa310047 100644 --- a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts +++ b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts @@ -9,7 +9,7 @@ import { Discover } from './Discover'; import { Canvas } from './Canvas'; import { IndexPattern } from './IndexPattern'; import { getKibanaVersion } from '../helpers'; -import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; +import { TENANCY_QUERY_STRING_KEY } from '../types'; import { Tenancy } from './Tenancy'; import { kbnApiClient } from '../helpers/KbnApiClient'; import { Login } from './Login'; diff --git a/e2e-tests/cypress/support/page-objects/Tenancy.ts b/e2e-tests/cypress/support/page-objects/Tenancy.ts index 7e19045b..bea4480b 100644 --- a/e2e-tests/cypress/support/page-objects/Tenancy.ts +++ b/e2e-tests/cypress/support/page-objects/Tenancy.ts @@ -1,4 +1,4 @@ -import { TENANCY_QUERY_STRING_KEY } from '../../../../shared/constants/queryStringKeys'; +import { TENANCY_QUERY_STRING_KEY } from '../types'; export class Tenancy { public static encryptedTenancyWithTemplateGroup = From a9767b3958158a35d7b04ca61d3e7bcbdea4c724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 13:27:16 +0200 Subject: [PATCH 08/36] wip --- e2e-tests/cypress/fixtures/defaultSettings.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/cypress/fixtures/defaultSettings.yaml b/e2e-tests/cypress/fixtures/defaultSettings.yaml index 5a480325..1b2265de 100644 --- a/e2e-tests/cypress/fixtures/defaultSettings.yaml +++ b/e2e-tests/cypress/fixtures/defaultSettings.yaml @@ -3,7 +3,7 @@ helpers: verbosity: error ckr: &common-kibana-rules access: rw - hide_apps: ["Enterprise Search|Overview", "Observability"] + hide_apps: ["Enterprise Search|Overview"] index: ".kibana_@{acl:current_group}" ag: &all-groups From f64e807798aefa62b7178c2043168f94bb63ba38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 16:26:02 +0200 Subject: [PATCH 09/36] wip --- .github/workflows/all-e2e-tests.yml | 72 ++++++- ci/prebuild-images-lib.sh | 289 ++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 ci/prebuild-images-lib.sh diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index c4c2a7d3..4b25a6ec 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -18,6 +18,9 @@ jobs: # ========================================== # E2E TESTS - MASTER BRANCH # ========================================== + # Runs against the RELEASED plugin images (`--mode prod`, ror-latest). The signal is "the shipped + # plugins still pass the suite", so a spec may only land on master once the plugin change it + # exercises has been released β€” specs synced from ROR KBN `develop` belong on `develop` until then. master-e2e-tests: name: "πŸ”¬ Master E2E Tests" if: > @@ -58,7 +61,7 @@ jobs: retry_wait_seconds: 120 retry_on: any command: | - ./runner.sh --run e2e --env ${{ matrix.env }} --elk ${{ matrix.version }} + ./runner.sh --run e2e --env ${{ matrix.env }} --elk ${{ matrix.version }} --mode prod env: ROR_ACTIVATION_KEY: ${{ secrets.ROR_KBN_LICENSE }} ELECTRON_EXTRA_LAUNCH_ARGS: '--disable-gpu' @@ -124,23 +127,74 @@ jobs: action: stop # ========================================== - # E2E TESTS - DEVELOP BRANCH + # DEV IMAGE PREPARATION - DEVELOP BRANCH # ========================================== - develop-e2e-tests: - name: "πŸ§ͺ Develop E2E Tests" + # Builds branch-matched dev images of BOTH plugins before the develop suite runs, the same way the + # ROR ES and ROR KBN repos do it for their own e2e jobs (ci/e2e-tests-lib.sh there). Without this + # the develop suite ran against the mutable `-ror-latest` dev tag, which is neither + # branch-matched nor stable for the duration of a run. + # + # One job, not one per matrix cell: both pre-build endpoints accept a version LIST, so the whole + # matrix costs two dispatches, and the image wait is paid once instead of twelve times. + # + # `target_branch` is passed verbatim; both pipelines fall back to `develop` when the branch does + # not exist on their side, so an e2e-only branch name is always safe. + prepare-dev-images: + name: "πŸ—οΈ Prepare dev images" if: > github.ref == 'refs/heads/develop' || (github.base_ref == 'develop' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) runs-on: ubuntu-latest + # Ceiling for the two polls in ci/prebuild-images-lib.sh (ES 45 min, KBN 30 min), which only run + # long when the pre-build pipelines actually rebuild; the unchanged-sources path is a cheap retag. + timeout-minutes: 90 + outputs: + run_tag: ${{ steps.prepare.outputs.run_tag }} + versions: ${{ steps.prepare.outputs.versions }} + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - name: Dispatch and await ROR plugin pre-builds + id: prepare + env: + # Single source of truth for the develop matrix: emitted as JSON for `fromJSON` below and + # passed as a space-separated list to the dispatchers, so the two can never drift. + ELK_VERSIONS: "9.4.4 9.3.8 8.19.19 7.17.29" + # PR runs must build from the PR head, not the merge ref. + TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} + ES_REPO_AZURE_PAT: ${{ secrets.ES_REPO_AZURE_PAT }} + KBN_REPO_GH_TOKEN: ${{ secrets.KBN_REPO_GH_TOKEN }} + run: | + set -euo pipefail + . ci/prebuild-images-lib.sh + + # Unique per attempt: a re-run must not silently reuse the previous attempt's images. + RUN_TAG="run-${{ github.run_id }}-${{ github.run_attempt }}" + { + echo "run_tag=$RUN_TAG" + echo "versions=$(printf '%s\n' $ELK_VERSIONS | jq -Rcn '[inputs]')" + } >> "$GITHUB_OUTPUT" + + dispatch_prebuild_images "$ELK_VERSIONS" "$TARGET_BRANCH" "$RUN_TAG" + wait_for_prebuild_images "$ELK_VERSIONS" "$RUN_TAG" + + # ========================================== + # E2E TESTS - DEVELOP BRANCH + # ========================================== + # Runs against the per-run dev images produced by prepare-dev-images. + develop-e2e-tests: + name: "πŸ§ͺ Develop E2E Tests" + needs: prepare-dev-images + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - version: ["9.4.4", "9.3.8", "8.19.19", "7.17.29"] + version: ${{ fromJSON(needs.prepare-dev-images.outputs.versions) }} env: [docker, eck-2.16.1, eck-3.4.1] env: - ROR_ES_VERSION: "latest" - ROR_KBN_VERSION: "latest" - MODE: 'dev' + ROR_IMAGE_TAG: ${{ needs.prepare-dev-images.outputs.run_tag }} steps: - name: Checkout code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 @@ -168,7 +222,7 @@ jobs: retry_wait_seconds: 120 retry_on: any command: | - ./runner.sh --run e2e --env ${{ matrix.env }} --elk ${{ matrix.version }} --ror-es ${{ env.ROR_ES_VERSION }} --ror-kbn ${{ env.ROR_KBN_VERSION }} --mode ${{ env.MODE }} + ./runner.sh --run e2e --env ${{ matrix.env }} --elk ${{ matrix.version }} --ror-es ${{ env.ROR_IMAGE_TAG }} --ror-kbn ${{ env.ROR_IMAGE_TAG }} --mode dev env: ROR_ACTIVATION_KEY: ${{ secrets.ROR_KBN_LICENSE }} diff --git a/ci/prebuild-images-lib.sh b/ci/prebuild-images-lib.sh new file mode 100644 index 00000000..fd36e82f --- /dev/null +++ b/ci/prebuild-images-lib.sh @@ -0,0 +1,289 @@ +# Shared helpers for dispatching and awaiting ROR plugin pre-build Docker images. +# +# Sourced β€” do not execute directly. +# +# This file is the single home of the cross-repo pre-build contract. Three repos consume it: +# +# readonlyrest-e2e-tests (this repo) dispatches BOTH plugins, waits for both, runs ./runner.sh +# elasticsearch-readonly… (ROR ES) dispatches KBN, builds ES itself, waits for KBN +# readonlyrest_kbn (ROR KBN) dispatches ES, builds KBN itself, waits for ES +# +# The plugin repos already clone this repo to run the Cypress suite, so they can source this file +# from that clone: +# +# . "$E2E_DIR/ci/prebuild-images-lib.sh" +# +# β€” provided the clone is hoisted to the start of their flow (today they clone at step 4, after the +# dispatch at step 1). Function names and argument orders below deliberately match the copies that +# currently live in those repos, so adopting this file is a delete-and-source with no call-site +# changes. +# +# What deliberately stays in the consuming repos: building their own plugin image +# (publish_ror_prebuild_plugin / publish_kbn_prebuild_image), cloning this repo, invoking +# ./runner.sh, and uploading Cypress artifacts. Those are repo-specific; only the dispatch/poll +# layer is genuinely shared, and it is where the subtle details live (JSON escaping of +# attacker-controlled branch names, poll timeouts, skip-optimization semantics). +# +# Required tooling is needed only at call time, not at source time: `gh` for the KBN dispatch, +# `curl` + `jq` for the ES dispatch, `docker` for the polls. A consumer that never calls a given +# function never needs its tools. +# +# Nothing here relies on the caller running under `set -e`: every step returns a non-zero status and +# composite helpers propagate it explicitly with `|| return $?`. + +# Guard against double-sourcing (a consumer may source this from more than one script). +if [ -n "${_ROR_PREBUILD_IMAGES_LIB_SOURCED:-}" ]; then + return 0 2>/dev/null || true +fi +_ROR_PREBUILD_IMAGES_LIB_SOURCED=1 + +# --- Coordinates ------------------------------------------------------------------------------- +# Overridable via the environment so a fork or a dry run can point elsewhere; the defaults are the +# real ones and are what every consumer uses. + +# ROR KBN pre-builds: a GitHub Actions workflow dispatched with the `gh` CLI. +ROR_KBN_GH_REPO="${ROR_KBN_GH_REPO:-sscarduzio/readonlyrest_kbn}" +ROR_KBN_PUBLISH_WORKFLOW="${ROR_KBN_PUBLISH_WORKFLOW:-publish-pre-builds.yml}" + +# ROR ES pre-builds: an Azure DevOps pipeline dispatched over the REST API (.azure/publish-pre-builds.yml +# in the ES repo, definitionId=7). Hardcoded by convention β€” only the auth token is a secret. The +# project name is URL-encoded because it contains spaces. +ROR_ES_AZURE_ORG="${ROR_ES_AZURE_ORG:-beshu-tech}" +ROR_ES_AZURE_PROJECT="${ROR_ES_AZURE_PROJECT:-ReadonlyREST%20for%20Elasticsearch}" +ROR_ES_AZURE_PIPELINE_ID="${ROR_ES_AZURE_PIPELINE_ID:-7}" + +ROR_ES_DEV_IMAGE_REPO="${ROR_ES_DEV_IMAGE_REPO:-beshultd/elasticsearch-readonlyrest-dev}" +ROR_KBN_DEV_IMAGE_REPO="${ROR_KBN_DEV_IMAGE_REPO:-beshultd/kibana-readonlyrest-dev}" + +# Default poll ceilings. ES gets the longer one: several dispatches can land on the same Azure +# pipeline definition at once and queue behind each other if the org has fewer free parallel jobs +# than legs β€” even on the cheap retag path, where each run still pays agent startup, checkout and +# Gradle cache restore. +ROR_ES_WAIT_TIMEOUT_SECONDS="${ROR_ES_WAIT_TIMEOUT_SECONDS:-$((45 * 60))}" +ROR_KBN_WAIT_TIMEOUT_SECONDS="${ROR_KBN_WAIT_TIMEOUT_SECONDS:-$((30 * 60))}" +ROR_PREBUILD_POLL_INTERVAL_SECONDS="${ROR_PREBUILD_POLL_INTERVAL_SECONDS:-30}" + +# --- Image reference helpers ------------------------------------------------------------------- + +# Fully-qualified dev image refs. Every consumer builds these strings today; centralising them means +# a registry move is a one-line change here instead of a hunt across three repos. +ror_es_dev_image() { echo "${ROR_ES_DEV_IMAGE_REPO}:${1}-ror-${2}"; } +ror_kbn_dev_image() { echo "${ROR_KBN_DEV_IMAGE_REPO}:${1}-ror-${2}"; } + +# Checks whether an image tag exists in the remote registry without pulling it. +docker_image_exists() { + docker manifest inspect "$1" >/dev/null 2>&1 +} + +# Normalise a space- or comma-separated version list into space-separated tokens, and reject +# anything that is not X.Y.Z[-qualifier]. Both dispatch endpoints accept either separator, so +# consumers may pass "9.4.4" or "9.4.4,9.3.8" or "9.4.4 9.3.8 8.19.19". +normalize_elk_versions() { + if [ "$#" -lt 1 ] || [ -z "${1// /}" ]; then + echo "ERROR: no ELK versions given" >&2 + return 1 + fi + + local VERSIONS + VERSIONS=$(echo "$1" | tr ',' ' ' | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//') + + local VERSION + for VERSION in $VERSIONS; do + if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then + echo "ERROR: invalid ELK version '$VERSION'. Expected format: X.Y.Z" >&2 + return 2 + fi + done + + echo "$VERSIONS" +} + +# --- Dispatch ---------------------------------------------------------------------------------- +# +# Both dispatchers are non-blocking and both are safe to call unconditionally, even when the +# canonical image already exists: the per-run alias tag is only guaranteed to exist because we +# dispatched, and the pipelines' skip optimization turns a "no source changes" dispatch into a cheap +# registry-side retag. +# +# `target_branch` may name a branch that does not exist in the target repo (e.g. a plugin-only or +# e2e-only feature branch). Both pipelines fall back to `develop` in that case, so passing the +# current branch verbatim is always safe. +# +# Both endpoints accept a space- or comma-separated version LIST. The plugin repos call these once +# per version because their pipelines are per-version; a matrix consumer should instead pass every +# version in one call and dispatch twice per run rather than 2Γ—N times. + +# Usage: dispatch_kbn_prebuild_image [force rebuild] +dispatch_kbn_prebuild_image() { + if [ "$#" -lt 3 ]; then + echo "Usage: dispatch_kbn_prebuild_image [force rebuild]" + return 1 + fi + + local KBN_VERSIONS TARGET_BRANCH RUN_TAG FORCE_REBUILD + KBN_VERSIONS=$(normalize_elk_versions "$1") || return $? + TARGET_BRANCH=$2 + RUN_TAG=$3 + FORCE_REBUILD=${4:-false} + + # The `$('*` check catches an Azure DevOps variable that was never resolved and arrived as the + # literal expression text, which otherwise fails much later with a confusing 401. + if [ -z "${KBN_REPO_GH_TOKEN:-}" ] || [[ "${KBN_REPO_GH_TOKEN}" == '$('* ]]; then + echo "ERROR: KBN_REPO_GH_TOKEN is not set or was not resolved (required to dispatch the ROR KBN pre-build workflow)" + return 2 + fi + + echo "" + echo ">>> Dispatching ROR KBN pre-build: versions=$KBN_VERSIONS tag=$RUN_TAG branch=$TARGET_BRANCH" + if ! GH_TOKEN="$KBN_REPO_GH_TOKEN" gh workflow run "$ROR_KBN_PUBLISH_WORKFLOW" \ + -R "$ROR_KBN_GH_REPO" \ + -f "kbn_versions=$KBN_VERSIONS" \ + -f "target_branch=$TARGET_BRANCH" \ + -f "tag=$RUN_TAG" \ + -f "force_rebuild=$FORCE_REBUILD"; then + echo "ERROR: Failed to dispatch the ROR KBN pre-build workflow" + return 3 + fi + echo ">>> Dispatch sent" +} + +# Usage: dispatch_es_prebuild_image [force rebuild] +dispatch_es_prebuild_image() { + if [ "$#" -lt 3 ]; then + echo "Usage: dispatch_es_prebuild_image [force rebuild]" + return 1 + fi + + local ES_VERSIONS TARGET_BRANCH RUN_TAG FORCE_REBUILD + ES_VERSIONS=$(normalize_elk_versions "$1") || return $? + TARGET_BRANCH=$2 + RUN_TAG=$3 + FORCE_REBUILD=${4:-false} + + if [ -z "${ES_REPO_AZURE_PAT:-}" ] || [[ "${ES_REPO_AZURE_PAT}" == '$('* ]]; then + echo "ERROR: ES_REPO_AZURE_PAT is not set or was not resolved (required to dispatch the ES pre-build pipeline)" + return 2 + fi + + local API_URL="https://dev.azure.com/${ROR_ES_AZURE_ORG}/${ROR_ES_AZURE_PROJECT}/_apis/pipelines/${ROR_ES_AZURE_PIPELINE_ID}/runs?api-version=7.1" + + # Build the request body with jq, NOT printf: TARGET_BRANCH is chosen by whoever opened the PR, + # and git-check-ref-format(1) allows `"` in a ref name. A printf-interpolated body would let a + # branch name close the templateParameters object and inject sibling top-level keys β€” e.g. + # resources.repositories.self.refName, which pins the ref the Azure pipeline executes from. + # jq --arg does the JSON escaping, so the value can only ever land as a string. + local BODY + BODY=$(jq -nc \ + --arg esVersions "$ES_VERSIONS" \ + --arg targetBranch "$TARGET_BRANCH" \ + --arg tag "$RUN_TAG" \ + --arg forceRebuild "$FORCE_REBUILD" \ + '{templateParameters: {esVersions: $esVersions, targetBranch: $targetBranch, tag: $tag, forceRebuild: $forceRebuild}}') || return 3 + + # `base64 | tr -d '\n'` rather than `base64 -w0`: the latter is GNU-only and dies on BSD/macOS, + # which matters the first time someone debugs this from a laptop. + local AUTH + AUTH=$(printf ':%s' "$ES_REPO_AZURE_PAT" | base64 | tr -d '\n') + + echo "" + echo ">>> Dispatching ROR ES pre-build: versions=$ES_VERSIONS tag=$RUN_TAG branch=$TARGET_BRANCH" + local RESPONSE_FILE HTTP_STATUS + RESPONSE_FILE=$(mktemp) + HTTP_STATUS=$(curl -s -o "$RESPONSE_FILE" -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Basic $AUTH" \ + "$API_URL" \ + -d "$BODY") + + if [ "$HTTP_STATUS" -lt 200 ] || [ "$HTTP_STATUS" -ge 300 ]; then + echo "ERROR: Failed to dispatch ES pre-build pipeline (HTTP $HTTP_STATUS)" + cat "$RESPONSE_FILE" + rm -f "$RESPONSE_FILE" + return 3 + fi + rm -f "$RESPONSE_FILE" + echo ">>> Dispatch sent (HTTP $HTTP_STATUS)" +} + +# Dispatch both plugins in one go. For consumers that build neither plugin themselves (the e2e repo). +# Usage: dispatch_prebuild_images [force rebuild] +dispatch_prebuild_images() { + if [ "$#" -lt 3 ]; then + echo "Usage: dispatch_prebuild_images [force rebuild]" + return 1 + fi + + dispatch_es_prebuild_image "$@" || return $? + dispatch_kbn_prebuild_image "$@" || return $? +} + +# --- Wait -------------------------------------------------------------------------------------- + +# Poll Docker Hub until a per-run image tag appears. Returns quickly when sources are unchanged (the +# pipelines' skip path only does a cheap retag). +# Usage: wait_for_prebuild_image [timeout seconds] +wait_for_prebuild_image() { + if [ "$#" -lt 3 ]; then + echo "Usage: wait_for_prebuild_image [timeout seconds]" + return 1 + fi + + local PLUGIN=$1 VERSION=$2 RUN_TAG=$3 TIMEOUT=${4:-} IMAGE WHERE + case "$PLUGIN" in + es) + IMAGE=$(ror_es_dev_image "$VERSION" "$RUN_TAG") + TIMEOUT=${TIMEOUT:-$ROR_ES_WAIT_TIMEOUT_SECONDS} + WHERE="the publish-pre-builds pipeline run in the ROR ES repo" + ;; + kbn) + IMAGE=$(ror_kbn_dev_image "$VERSION" "$RUN_TAG") + TIMEOUT=${TIMEOUT:-$ROR_KBN_WAIT_TIMEOUT_SECONDS} + WHERE="the '$ROR_KBN_PUBLISH_WORKFLOW' run in $ROR_KBN_GH_REPO" + ;; + *) + echo "ERROR: wait_for_prebuild_image: plugin must be 'es' or 'kbn', got '$PLUGIN'" + return 2 + ;; + esac + + local WAITED=0 + echo "" + echo ">>> Polling for $IMAGE (timeout: $((TIMEOUT / 60)) min)" + while ! docker_image_exists "$IMAGE"; do + if [ "$WAITED" -ge "$TIMEOUT" ]; then + echo "ERROR: Timed out after $((WAITED / 60)) min waiting for $IMAGE" + echo " Check $WHERE." + return 4 + fi + sleep "$ROR_PREBUILD_POLL_INTERVAL_SECONDS" + WAITED=$((WAITED + ROR_PREBUILD_POLL_INTERVAL_SECONDS)) + done + + echo ">>> Dev image is now available: $IMAGE" +} + +# Back-compat wrappers: same names and signatures as the copies currently in the plugin repos, so +# adopting this lib there needs no call-site changes. +wait_for_es_prebuild_image() { wait_for_prebuild_image es "$1" "$2"; } +wait_for_kbn_prebuild_image() { wait_for_prebuild_image kbn "$1" "$2"; } + +# Wait for both plugins across every version. Waits ES-first: it is the slower side, and by the time +# it lands the KBN images are almost always already there, so the second pass is usually a no-op. +# Usage: wait_for_prebuild_images +wait_for_prebuild_images() { + if [ "$#" -ne 2 ]; then + echo "Usage: wait_for_prebuild_images " + return 1 + fi + + local VERSIONS RUN_TAG=$2 VERSION + VERSIONS=$(normalize_elk_versions "$1") || return $? + + for VERSION in $VERSIONS; do + wait_for_prebuild_image es "$VERSION" "$RUN_TAG" || return $? + done + for VERSION in $VERSIONS; do + wait_for_prebuild_image kbn "$VERSION" "$RUN_TAG" || return $? + done +} From 2e2103df72e850709dbd3455045bedbb68e74174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 16:32:49 +0200 Subject: [PATCH 10/36] wip --- .github/workflows/all-e2e-tests.yml | 55 +++++++++++++++++++---------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index 4b25a6ec..02d695b2 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -16,18 +16,25 @@ env: jobs: # ========================================== - # E2E TESTS - MASTER BRANCH + # E2E TESTS - RELEASED (PROD) PLUGIN IMAGES # ========================================== # Runs against the RELEASED plugin images (`--mode prod`, ror-latest). The signal is "the shipped - # plugins still pass the suite", so a spec may only land on master once the plugin change it - # exercises has been released β€” specs synced from ROR KBN `develop` belong on `develop` until then. - master-e2e-tests: - name: "πŸ”¬ Master E2E Tests" + # plugins still pass the suite". + # + # Selection is base-driven: this covers master itself and every PR targeting master, so a PR is + # tested exactly the way its target branch is tested and a green PR cannot turn the nightly red + # after merge. The corollary is that a spec may only land on master once the plugin change it + # exercises has been released β€” specs synced from ROR KBN `develop` belong on `develop` until then, + # and a PR to master that fails only on such specs is reporting that correctly. + # + # Keep the matrix in sync with ELK_VERSIONS in prepare-dev-images. + prod-e2e-tests: + name: "πŸ”¬ E2E Tests (released plugins)" if: > github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || - github.ref == 'refs/heads/master' || - (github.base_ref == 'master' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) + (github.event_name == 'push' && github.ref == 'refs/heads/master') || + (github.event_name == 'pull_request' && github.base_ref == 'master' && github.event.pull_request.head.repo.fork == false) runs-on: ubuntu-latest strategy: fail-fast: false @@ -81,6 +88,8 @@ jobs: # ========================================== # BOOTSTRAP TESTS # ========================================== + # Gates on prod-e2e-tests, which covers every trigger in this job's `if` (master itself, and PRs + # targeting master). Bootstrap uses released images (no --mode), as it did before. master-bootstrap-tests: name: "πŸš€ Bootstrap Tests" if: > @@ -88,7 +97,7 @@ jobs: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/master' || (github.base_ref == 'master' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) - needs: master-e2e-tests + needs: prod-e2e-tests runs-on: ubuntu-latest strategy: fail-fast: false @@ -127,11 +136,11 @@ jobs: action: stop # ========================================== - # DEV IMAGE PREPARATION - DEVELOP BRANCH + # DEV IMAGE PREPARATION - DEVELOP AND NON-MASTER PRs # ========================================== - # Builds branch-matched dev images of BOTH plugins before the develop suite runs, the same way the - # ROR ES and ROR KBN repos do it for their own e2e jobs (ci/e2e-tests-lib.sh there). Without this - # the develop suite ran against the mutable `-ror-latest` dev tag, which is neither + # Builds branch-matched dev images of BOTH plugins before dev-e2e-tests runs, the same way the ROR + # ES and ROR KBN repos do it for their own e2e jobs (ci/e2e-tests-lib.sh there). Without this the + # develop suite ran against the mutable `-ror-latest` dev tag, which is neither # branch-matched nor stable for the duration of a run. # # One job, not one per matrix cell: both pre-build endpoints accept a version LIST, so the whole @@ -139,11 +148,17 @@ jobs: # # `target_branch` is passed verbatim; both pipelines fall back to `develop` when the branch does # not exist on their side, so an e2e-only branch name is always safe. + # PRs targeting master are excluded β€” those are the prod-e2e-tests path. Everything else that is + # not master (develop, and PRs targeting develop or any other branch) lands here. + # + # Fork PRs are excluded too: the dispatch needs ES_REPO_AZURE_PAT / KBN_REPO_GH_TOKEN, which GitHub + # does not expose to workflows triggered from a fork. Fork PRs therefore get no e2e coverage β€” + # unchanged from before, when they were excluded from the master job for the same reason. prepare-dev-images: name: "πŸ—οΈ Prepare dev images" if: > - github.ref == 'refs/heads/develop' || - (github.base_ref == 'develop' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)) + (github.event_name == 'push' && github.ref == 'refs/heads/develop') || + (github.event_name == 'pull_request' && github.base_ref != 'master' && github.event.pull_request.head.repo.fork == false) runs-on: ubuntu-latest # Ceiling for the two polls in ci/prebuild-images-lib.sh (ES 45 min, KBN 30 min), which only run # long when the pre-build pipelines actually rebuild; the unchanged-sources path is a cheap retag. @@ -159,8 +174,9 @@ jobs: - name: Dispatch and await ROR plugin pre-builds id: prepare env: - # Single source of truth for the develop matrix: emitted as JSON for `fromJSON` below and + # Single source of truth for the dev-e2e-tests matrix: emitted as JSON for `fromJSON` and # passed as a space-separated list to the dispatchers, so the two can never drift. + # Mirrors the prod-e2e-tests matrix β€” keep both in sync. ELK_VERSIONS: "9.4.4 9.3.8 8.19.19 7.17.29" # PR runs must build from the PR head, not the merge ref. TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} @@ -181,11 +197,12 @@ jobs: wait_for_prebuild_images "$ELK_VERSIONS" "$RUN_TAG" # ========================================== - # E2E TESTS - DEVELOP BRANCH + # E2E TESTS - PRE-BUILD (DEV) PLUGIN IMAGES # ========================================== - # Runs against the per-run dev images produced by prepare-dev-images. - develop-e2e-tests: - name: "πŸ§ͺ Develop E2E Tests" + # Every non-fork pull request and every push to develop, against the per-run, branch-matched dev + # images produced by prepare-dev-images. Skipped automatically when that job is skipped. + dev-e2e-tests: + name: "πŸ§ͺ E2E Tests (pre-build plugins)" needs: prepare-dev-images runs-on: ubuntu-latest strategy: From 4bda902c7750c54c87235b3e524cd8fd6fe05629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 16:38:23 +0200 Subject: [PATCH 11/36] wip --- e2e-tests/cypress/e2e/Hide_apps.cy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/cypress/e2e/Hide_apps.cy.ts b/e2e-tests/cypress/e2e/Hide_apps.cy.ts index 000e23f7..181e39c2 100644 --- a/e2e-tests/cypress/e2e/Hide_apps.cy.ts +++ b/e2e-tests/cypress/e2e/Hide_apps.cy.ts @@ -16,7 +16,7 @@ describe('hidden apps', () => { }); context('Stack Management navigation', () => { - // hiddenSpaceManagementSettings.yaml hides the "Space Management" sub-page + // hiddenSpaceManagementSettings.yaml hides the "Space Management" sub-page // and most other apps, leaving Stack Management with only the Reporting, // Data Views (Index Patterns on 7.x), and Saved Objects sub-pages visible. it('shows only the allowlisted Stack Management sub-pages when most apps are hidden', () => { From d95d71a6df2c620a951cbb4a017cc6bbc1a7dd4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 16:42:17 +0200 Subject: [PATCH 12/36] wip --- e2e-tests/cypress/e2e/Hide_apps.cy.ts | 2 +- environments/elk-ror/conf/es/elasticsearch.yml | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/e2e-tests/cypress/e2e/Hide_apps.cy.ts b/e2e-tests/cypress/e2e/Hide_apps.cy.ts index 181e39c2..000e23f7 100644 --- a/e2e-tests/cypress/e2e/Hide_apps.cy.ts +++ b/e2e-tests/cypress/e2e/Hide_apps.cy.ts @@ -16,7 +16,7 @@ describe('hidden apps', () => { }); context('Stack Management navigation', () => { - // hiddenSpaceManagementSettings.yaml hides the "Space Management" sub-page + // hiddenSpaceManagementSettings.yaml hides the "Space Management" sub-page // and most other apps, leaving Stack Management with only the Reporting, // Data Views (Index Patterns on 7.x), and Saved Objects sub-pages visible. it('shows only the allowlisted Stack Management sub-pages when most apps are hidden', () => { diff --git a/environments/elk-ror/conf/es/elasticsearch.yml b/environments/elk-ror/conf/es/elasticsearch.yml index 272494c4..b785bdcd 100644 --- a/environments/elk-ror/conf/es/elasticsearch.yml +++ b/environments/elk-ror/conf/es/elasticsearch.yml @@ -2,6 +2,20 @@ cluster.name: ror-cluster node.name: ror-es01 network.host: 0.0.0.0 +# This cluster is a single node that lives for one test run, holds a handful of tiny indices, and is +# deleted afterwards β€” it never needs the disk headroom the watermarks protect. But the watermarks +# still fire, because they measure the CI HOST's filesystem, which the run's own image pulls push +# past 90%. Above the high watermark the allocator refuses to allocate the new primaries Kibana and +# ROR create at boot: the cluster goes RED ("reconcile-desired-balance"), every +# .readonlyrest_kbn_sessions search 503s with no_shard_available_action_exception, and Kibana dies +# with "Unable to complete saved object migrations for the [.kibana*] index". +# +# Observed on the ROR KBN self-hosted runners: gh-ror-kbn-6 started a run with 100 GB free (87% used) +# and the run's own footprint took it to 51 GB (93.7%) before Kibana finished booting. Reclaiming +# host disk does not fix this β€” `docker system/image/volume/builder prune` freed 0 B there, because +# the baseline usage is not Docker's. +cluster.routing.allocation.disk.threshold_enabled: false + xpack.security.enabled: true xpack.security.http.ssl.enabled: true xpack.security.http.ssl.key: elasticsearch.key From 5984c512f8583f009b877f975c7fc75d90ca2917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 19:12:50 +0200 Subject: [PATCH 13/36] fix --- .../fixtures/allowedApiPathsSettings.yaml | 121 ++++++++++-------- 1 file changed, 67 insertions(+), 54 deletions(-) diff --git a/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml b/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml index 96379e4a..ac564894 100644 --- a/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml +++ b/e2e-tests/cypress/fixtures/allowedApiPathsSettings.yaml @@ -1,60 +1,73 @@ readonlyrest: - ssl: - enable: true - keystore_file: '/etc/elasticsearch/ror-keystore.jks' - keystore_pass: readonlyrest - key_pass: readonlyrest - access_control_rules: - - name: 'Kibana service account - user/pass' - verbosity: error - auth_key: kibana:kibana - # api_only user restricted to a single exact Kibana API path. - - name: API_ONLY_RESTRICTED - auth_key: api_only_restricted_user:dev - kibana: - access: api_only - index: '.kibana_api_restricted' - allowed_api_paths: - - http_method: GET - http_path: '^/api/spaces/space$' + access_control_rules: + + - name: "Kibana service account - user/pass" + verbosity: error + auth_key: kibana:kibana + + # <-- related to ECK environment --> + - name: "Kibana service account - token" + verbosity: error + token_authentication: + token: "Bearer ${KIBANA_SERVICE_ACCOUNT_TOKEN}" + username: service_account - # api_only user restricted via regexp β€” allows all /api/spaces/* paths. - - name: API_ONLY_RESTRICTED_REGEXP - auth_key: api_only_restricted_regexp_user:dev - kibana: - access: api_only - index: '.kibana_api_restricted' - allowed_api_paths: - - http_method: GET - http_path: '^/api/spaces.*$' + - name: "PROBE" + verbosity: error + auth_key: "elastic-internal-probe:${INTERNAL_PROBE_PASS}" - # api_only user restricted to a space-aware path β€” only /s/default/api/spaces/space. - - name: API_ONLY_SPACE_RESTRICTED - auth_key: api_only_space_restricted_user:dev - kibana: - access: api_only - index: '.kibana_api_restricted' - allowed_api_paths: - - http_method: GET - http_path: '^/s/default/api/spaces/space$' + - name: "ELASTIC-INTERNAL" + verbosity: error + auth_key: "elastic-internal:${INTERNAL_USR_PASS}" + # + + # api_only user restricted to a single exact Kibana API path. + - name: API_ONLY_RESTRICTED + auth_key: api_only_restricted_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/spaces/space$' - # api_only user restricted to Kibana internal API paths (/internal/*). - - name: API_ONLY_INTERNAL_RESTRICTED - auth_key: api_only_internal_user:dev - kibana: - access: api_only - index: '.kibana_api_restricted' - allowed_api_paths: - - http_method: GET - http_path: '^/internal/spaces/get_all.*$' + # api_only user restricted via regexp β€” allows all /api/spaces/* paths. + - name: API_ONLY_RESTRICTED_REGEXP + auth_key: api_only_restricted_regexp_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/spaces.*$' - # api_only user restricted to ReadonlyREST public API paths (/api/ror/*). - - name: API_ONLY_ROR_RESTRICTED - auth_key: api_only_ror_user:dev - kibana: - access: api_only - index: '.kibana_api_restricted' - allowed_api_paths: - - http_method: GET - http_path: '^/api/ror/user/tenants$' + # api_only user restricted to a space-aware path β€” only /s/default/api/spaces/space. + - name: API_ONLY_SPACE_RESTRICTED + auth_key: api_only_space_restricted_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/s/default/api/spaces/space$' + + # api_only user restricted to Kibana internal API paths (/internal/*). + - name: API_ONLY_INTERNAL_RESTRICTED + auth_key: api_only_internal_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/internal/spaces/get_all.*$' + + # api_only user restricted to ReadonlyREST public API paths (/api/ror/*). + - name: API_ONLY_ROR_RESTRICTED + auth_key: api_only_ror_user:dev + kibana: + access: api_only + index: '.kibana_api_restricted' + allowed_api_paths: + - http_method: GET + http_path: '^/api/ror/user/tenants$' From 2bc0aaed037ca3487fe1bab6c3d55bc24d8502b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Sun, 26 Jul 2026 22:24:11 +0200 Subject: [PATCH 14/36] fix --- .github/workflows/all-e2e-tests.yml | 30 +++++++++++++----- .github/workflows/targeted-e2e-tests.yml | 11 +++++-- e2e-tests/cypress/e2e/User-settings.cy.ts | 32 +++++++++++--------- environments/elk-ror/base.docker-compose.yml | 4 +++ environments/elk-ror/start.sh | 18 +++++++++++ 5 files changed, 71 insertions(+), 24 deletions(-) diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index 02d695b2..3af62a1d 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -9,10 +9,16 @@ on: env: # These runs use docker directly on the GitHub-hosted runner (host docker, not nested), so the - # compose *.limits.docker-compose.yml overlays apply cleanly and protect the ~16 GB runner from OOM. - # start.sh leaves resource limits off by default (safe for the ROR KBN Docker-in-Docker flow); - # opt in here. (eck-env runs don't use docker-compose limits, so this is a no-op for them.) - APPLY_RESOURCE_LIMITS: "true" + # compose *.limits.docker-compose.yml overlays apply cleanly. start.sh leaves resource limits off + # by default (safe for the ROR KBN Docker-in-Docker flow); `auto` lets it decide from MemTotal, + # the same 12 GB threshold ci/e2e-tests-lib.sh uses in the ROR ES repo. + # + # This was hardcoded to "true", which put es-ror under a 2g cap on a 16 GB runner. It sat at + # 97-99% of that cap for entire runs and on 7.17.29/docker it was OOM-killed four minutes in and + # never came back, failing 18 consecutive specs. GitHub-hosted runners resolve to false here; + # small self-hosted agents still get the limits they need. + # (eck-env runs don't use docker-compose limits, so this is a no-op for them.) + APPLY_RESOURCE_LIMITS: "auto" jobs: # ========================================== @@ -77,8 +83,11 @@ jobs: uses: ./.github/docker-memory-monitor with: action: stop + # Never fail the job on the artifact upload: this step only runs when the tests already + # failed, and a credential problem here would replace that diagnosis with its own. - name: S3 Upload Videos & show logs if: failure() + continue-on-error: true uses: ./.github/upload-videos with: access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} @@ -248,9 +257,16 @@ jobs: uses: ./.github/docker-memory-monitor with: action: stop + # Credentials go through `with:`, not `env:` β€” the composite action sets + # AWS_ACCESS_KEY_ID from `inputs.access_key_id`, so an env-only form is overwritten with the + # empty default and every upload dies on "Unable to locate credentials". + # Never fail the job on the artifact upload: this step only runs when the tests already + # failed, and a credential problem here would replace that diagnosis with its own. - name: S3 Upload Videos & show logs if: failure() + continue-on-error: true uses: ./.github/upload-videos - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + with: + access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} + secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + endpoint_url: ${{ secrets.AWS_ENDPOINT_URL }} diff --git a/.github/workflows/targeted-e2e-tests.yml b/.github/workflows/targeted-e2e-tests.yml index ed582930..3a8ad26b 100644 --- a/.github/workflows/targeted-e2e-tests.yml +++ b/.github/workflows/targeted-e2e-tests.yml @@ -27,9 +27,11 @@ on: env: # Runs use docker directly on the GitHub-hosted runner (host docker, not nested), so the compose - # *.limits.docker-compose.yml overlays apply cleanly and protect the runner from OOM. start.sh - # leaves resource limits off by default (safe for the ROR KBN Docker-in-Docker flow); opt in here. - APPLY_RESOURCE_LIMITS: "true" + # *.limits.docker-compose.yml overlays apply cleanly. start.sh leaves resource limits off by + # default (safe for the ROR KBN Docker-in-Docker flow); `auto` lets it decide from MemTotal, the + # same 12 GB threshold ci/e2e-tests-lib.sh uses in the ROR ES repo. Forcing them on capped es-ror + # at 2g against a 1g heap, which ran at 97-99% of the cap and got OOM-killed on 7.17.29/docker. + APPLY_RESOURCE_LIMITS: "auto" jobs: e2e-tests: @@ -81,8 +83,11 @@ jobs: with: action: stop + # Never fail the job on the artifact upload: this step only runs when the tests already + # failed, and a credential problem here would replace that diagnosis with its own. - name: S3 Upload Videos & show logs if: failure() + continue-on-error: true uses: ./.github/upload-videos with: access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/e2e-tests/cypress/e2e/User-settings.cy.ts b/e2e-tests/cypress/e2e/User-settings.cy.ts index 25355bb7..a46332d4 100644 --- a/e2e-tests/cypress/e2e/User-settings.cy.ts +++ b/e2e-tests/cypress/e2e/User-settings.cy.ts @@ -6,6 +6,24 @@ import { getKibanaVersion } from '../support/helpers'; import { RorMenu } from '../support/page-objects/RorMenu'; import { Loader } from '../support/page-objects/Loader'; +// Kibana 8.x lazily loads plugin chunks (securitySolution, observability, enterpriseSearch) during +// the theme reload below. When those chunks fail, Kibana's plugin lifecycle throws "executing a +// cancelled action" as an unhandled rejection. Neither failure is related to what these tests verify. +// +// Registered with `Cypress.on` at spec scope rather than `cy.on` inside the test: `cy.on` listeners +// are torn down when the test body ends, so a rejection arriving during the `afterEach` below went +// unhandled and failed the hook β€” which skips the rest of the suite. Spec scope covers hooks too, +// and still keeps the suppression out of every other spec. +Cypress.on('uncaught:exception', err => { + if ( + err.message.includes('ChunkLoadError') || + err.message.includes('Loading chunk') || + err.message.includes('executing a cancelled action') + ) { + return false; + } +}); + describe('User settings', () => { beforeEach(() => { Login.initialization(); @@ -16,20 +34,6 @@ describe('User settings', () => { }); it('should verify user settings change', () => { - // Kibana 8.x lazily loads plugin chunks (securitySolution, observability, enterpriseSearch) - // during the reload below. When those chunks fail, Kibana's plugin lifecycle throws - // "executing a cancelled action" as an unhandled rejection. Neither failure is related to - // what this test verifies. - cy.on('uncaught:exception', err => { - if ( - err.message.includes('ChunkLoadError') || - err.message.includes('Loading chunk') || - err.message.includes('executing a cancelled action') - ) { - return false; - } - }); - cy.log('Change theme'); UserSettings.open(); diff --git a/environments/elk-ror/base.docker-compose.yml b/environments/elk-ror/base.docker-compose.yml index 6f2c87c0..35c857af 100644 --- a/environments/elk-ror/base.docker-compose.yml +++ b/environments/elk-ror/base.docker-compose.yml @@ -1,6 +1,10 @@ services: es-ror: + # Without this a single OOM kill ends the run: the container exits 137, nothing brings it back, + # and every remaining spec fails against a dead cluster. Restarting the same container keeps the + # writable layer (there is no volume here), so indices and ROR settings survive the bounce. + restart: always build: context: . dockerfile: images/es/Dockerfile diff --git a/environments/elk-ror/start.sh b/environments/elk-ror/start.sh index 62281947..bbc86ddf 100755 --- a/environments/elk-ror/start.sh +++ b/environments/elk-ror/start.sh @@ -151,8 +151,26 @@ echo "Cluster type: $CLUSTER_TYPE" # /sys/fs/cgroup/docker can't enable the memory controller and a mem_limit would prevent containers # from starting. Set APPLY_RESOURCE_LIMITS=true to apply them; needed on small host-docker agents # (e.g. the ~7.9 GB Azure host) to avoid OOM. +# +# `auto` picks per host size, mirroring ci/e2e-tests-lib.sh in the ROR ES repo. The limits are a +# floor, not a ceiling: es-ror gets mem_limit 2g against a 1g heap, and Lucene mmap + direct buffers +# + metaspace push its cgroup usage to 97-99% on any full suite run. On a host with memory to spare +# that headroom is pure downside β€” a single GC spike gets es-ror OOM-killed (exit 137) and, since it +# has no restart policy, it stays dead and every remaining spec fails. So only squeeze when the host +# genuinely cannot fit the stack unconstrained. APPLY_RESOURCE_LIMITS="${APPLY_RESOURCE_LIMITS:-false}" +if [[ "$APPLY_RESOURCE_LIMITS" == "auto" ]]; then + HOST_MEM_KB="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)" + if [[ "${HOST_MEM_KB:-0}" -gt 0 && "$HOST_MEM_KB" -lt 12000000 ]]; then + APPLY_RESOURCE_LIMITS="true" + echo "Resource limits: auto -> true (host has ${HOST_MEM_KB} kB, below the 12 GB threshold)" + else + APPLY_RESOURCE_LIMITS="false" + echo "Resource limits: auto -> false (host has ${HOST_MEM_KB} kB, at or above the 12 GB threshold)" + fi +fi + # Set compose files based on cluster type if [[ "$CLUSTER_TYPE" == "base" ]]; then DOCKER_COMPOSE_FILES="-f base.docker-compose.yml" From 28f2c85177919961e0d5b77aee85300827a12034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 27 Jul 2026 11:11:33 +0200 Subject: [PATCH 15/36] fix --- .../RoAndRoStrictKibanaAccessAssertions.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts index aa310047..a3ff442f 100644 --- a/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts +++ b/e2e-tests/cypress/support/page-objects/RoAndRoStrictKibanaAccessAssertions.ts @@ -23,7 +23,15 @@ export class RoAndRoStrictKibanaAccessAssertions { Home.loadSampleDataButtonHidden(); cy.log('Verify Dashboard features'); - if (semver.gte(getKibanaVersion(), '9.4.0')) { + // The 9.x threshold here is 9.3.0, not the 9.4.0 this was synced from ROR KBN with: on 9.3.8 the + // dashboards listing never issues `POST /content_management/rpc/search`, so the branch below + // times out after 30s on every environment and every retry. 8.19.19 still issues it (50+ calls + // per run) and passes, and 9.4.4 passes on this branch, so the change landed somewhere in + // (8.19.19, 9.3.8]. This repo's e2e matrix has no 9.0-9.2 leg to narrow it further, so those + // versions are deliberately left on the branch below rather than silently moved. + // ROR KBN only e2e-tests 9.4.4 / 8.19.19 / 7.17.29, so 9.3.x has no coverage upstream and this + // divergence needs reporting there before the next sync overwrites it. + if (semver.gte(getKibanaVersion(), '9.3.0')) { cy.intercept('GET', '/s/default/app/dashboards**').as('dashboardsApp'); Tenancy.getTenancyFromUrl().then(tenancy => { cy.visit(`/s/default/app/dashboards?${TENANCY_QUERY_STRING_KEY}=${tenancy}`); From 29ac50028b3f37575d3bc5c5f14b9f8d1dd3bd6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 27 Jul 2026 11:24:26 +0200 Subject: [PATCH 16/36] fix --- environments/elk-ror/apm.docker-compose.yml | 5 +++++ environments/elk-ror/base.docker-compose.yml | 4 ++++ environments/elk-ror/start.sh | 19 +++++++++++++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/environments/elk-ror/apm.docker-compose.yml b/environments/elk-ror/apm.docker-compose.yml index 2a0c7f98..efe1d5f0 100644 --- a/environments/elk-ror/apm.docker-compose.yml +++ b/environments/elk-ror/apm.docker-compose.yml @@ -1,6 +1,8 @@ services: apm-server: + # Symmetrical with the eck environment, where Kubernetes restarts every pod by default. + restart: always build: context: . dockerfile: images/apm/Dockerfile @@ -22,6 +24,9 @@ services: retries: 3 node-apm-app: + # Generates the APM traffic Observability.cy.ts polls for, so a silent death here surfaces as a + # data-never-arrives timeout rather than an obvious crash. Symmetrical with eck. + restart: always build: context: ../common/images/node-apm-app/ dockerfile: Dockerfile diff --git a/environments/elk-ror/base.docker-compose.yml b/environments/elk-ror/base.docker-compose.yml index 35c857af..e086d815 100644 --- a/environments/elk-ror/base.docker-compose.yml +++ b/environments/elk-ror/base.docker-compose.yml @@ -77,6 +77,10 @@ services: hard: -1 kbn-proxy: + # Every spec reaches Kibana through this proxy on :5601, so it has the largest blast radius of + # any container here β€” if it dies the whole suite dies with it. Kept symmetrical with the eck + # environment, where Kubernetes restarts every pod by default (restartPolicy: Always). + restart: always build: context: . dockerfile: images/kbn/Proxy-Dockerfile diff --git a/environments/elk-ror/start.sh b/environments/elk-ror/start.sh index bbc86ddf..de1ecf49 100755 --- a/environments/elk-ror/start.sh +++ b/environments/elk-ror/start.sh @@ -164,11 +164,10 @@ if [[ "$APPLY_RESOURCE_LIMITS" == "auto" ]]; then HOST_MEM_KB="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)" if [[ "${HOST_MEM_KB:-0}" -gt 0 && "$HOST_MEM_KB" -lt 12000000 ]]; then APPLY_RESOURCE_LIMITS="true" - echo "Resource limits: auto -> true (host has ${HOST_MEM_KB} kB, below the 12 GB threshold)" else APPLY_RESOURCE_LIMITS="false" - echo "Resource limits: auto -> false (host has ${HOST_MEM_KB} kB, at or above the 12 GB threshold)" fi + APPLY_RESOURCE_LIMITS_REASON=" (auto: host has ${HOST_MEM_KB} kB, threshold is 12 GB)" fi # Set compose files based on cluster type @@ -181,7 +180,7 @@ elif [[ "$CLUSTER_TYPE" == "apm" ]]; then [[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && DOCKER_COMPOSE_FILES="$DOCKER_COMPOSE_FILES -f base.limits.docker-compose.yml -f apm.limits.docker-compose.yml" echo "Starting cluster with APM (Elasticsearch + Kibana + ReadonlyREST + APM Server + APM App)" fi -echo "Resource limits: $([[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && echo "applied" || echo "disabled (APPLY_RESOURCE_LIMITS=$APPLY_RESOURCE_LIMITS)")" +echo "Resource limits: $([[ "$APPLY_RESOURCE_LIMITS" == "true" ]] && echo "applied" || echo "disabled")${APPLY_RESOURCE_LIMITS_REASON:-}" if ! docker compose $DOCKER_COMPOSE_FILES config > /dev/null; then echo "Cannot validate docker compose configuration." @@ -195,6 +194,18 @@ handle_docker_compose_error() { trap 'handle_docker_compose_error' ERR -docker compose $DOCKER_COMPOSE_FILES up -d --build --remove-orphans --force-recreate --wait +# Bound the startup wait, mirroring TIMEOUT_IN_SECONDS in environments/eck-ror/start.sh. Bare +# `--wait` blocks forever, so until now the only backstop was the 60-minute retry-action budget in +# CI: a stack that never came up burned the whole hour and reported a generic "Timeout of 3600000ms +# hit" with no logs. With the timeout the ERR trap fires instead, dumping elk-ror.log. +# +# 600s is ~4x the observed startup (1:35-2:26 across CI runs, build included). The theoretical +# worst case is larger β€” the healthchecks chain serially through depends_on and allow ~20 min +# between them β€” but a stack that slow has already failed in practice, and failing at 10 minutes +# with logs beats hanging for 60 without them. +TIMEOUT_IN_SECONDS=600 + +docker compose $DOCKER_COMPOSE_FILES up -d --build --remove-orphans --force-recreate \ + --wait --wait-timeout $TIMEOUT_IN_SECONDS echo "The environment is ready" \ No newline at end of file From b963fa74961c6a8abd6a23e10386c2616d5c6be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 27 Jul 2026 13:32:05 +0200 Subject: [PATCH 17/36] Cut e2e retry budget to 35m and make the monitor diagnose wedges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A healthy full suite is 16-33 min, but an attempt that wedges (stack comes up fine, then Kibana stays alive and healthy while it stops serving) ran the full 60-minute budget before retrying, pushing jobs to 85-95 min and leaving the retry too little room. 35 min cuts the wedged attempt loose early and gives the retry a full budget of its own. targeted-e2e-tests.yml keeps 60 β€” it runs max_attempts: 1, so there is no retry to hand the saved time to and a premature kill is unrecoverable. The monitor now records RestartCount, StartedAt and Health/FailingStreak per container, and dumps Warning events on the eck side. Status alone could not distinguish a container that bounced between two 10s samples from one that never moved, which is exactly what blocked the last diagnosis. Co-Authored-By: Claude Opus 5 --- .github/docker-memory-monitor/action.yml | 14 +++++++++++++- .github/workflows/all-e2e-tests.yml | 12 ++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.github/docker-memory-monitor/action.yml b/.github/docker-memory-monitor/action.yml index 1909cc56..585c70a4 100644 --- a/.github/docker-memory-monitor/action.yml +++ b/.github/docker-memory-monitor/action.yml @@ -26,9 +26,14 @@ runs: echo echo "== container OOM status ==" + # RestartCount / StartedAt / Health matter as much as OOMKilled here. A container that + # bounces between two 10s samples looks identical to one that never moved if you only + # print Status, and the failure mode we keep hitting is a container that stays "running" + # and "healthy" while it has stopped serving traffic β€” the healthcheck runs inside the + # container against localhost, so a wedged-but-listening process still reports healthy. for c in $(docker ps -aq 2>/dev/null); do docker inspect "$c" \ - --format 'Name={{.Name}} Status={{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}}' + --format 'Name={{.Name}} Status={{.State.Status}} OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} RestartCount={{.RestartCount}} StartedAt={{.State.StartedAt}} Health={{if .State.Health}}{{.State.Health.Status}}/{{.State.Health.FailingStreak}}{{else}}none{{end}} Memory={{.HostConfig.Memory}} MemorySwap={{.HostConfig.MemorySwap}}' done echo @@ -47,6 +52,13 @@ runs: echo "== kubectl OOM events ==" kubectl get events -A --field-selector=reason=OOMKilling 2>/dev/null || true + # The eck environment wedges the same way the docker one does, and reason=OOMKilling + # alone never showed anything for it. Warnings cover the restarts, probe failures and + # evictions that would explain a pod that is Running but not serving. + echo + echo "== kubectl warning events ==" + kubectl get events -A --field-selector=type=Warning 2>/dev/null | tail -40 || true + echo sleep 10 done diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index 3af62a1d..b0ae4f1d 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -66,11 +66,18 @@ jobs: # - npm error 429 Too Many Requests from registry.npmjs.org # - Docker image pull failures (e.g., beshultd/kibana-readonlyrest:*-ror-latest not found) # These errors are typically temporary and resolve with a simple retry. + # + # 35 minutes, not 60: a healthy full suite is 16-33 min (measured across the matrix). The + # failure mode this guards against is an attempt where the stack comes up fine and then wedges + # β€” Kibana stays alive and healthy but stops serving, specs go from ~20s to 4-16 min each, and + # the attempt runs out the clock. At 60 min that burns an hour before the retry starts and + # pushes jobs to 85-95 min; at 35 the wedged attempt is cut loose early and the retry (which + # usually passes) gets a full budget of its own. - name: Run E2E tests uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 with: max_attempts: 2 - timeout_minutes: 60 + timeout_minutes: 35 retry_wait_seconds: 120 retry_on: any command: | @@ -240,11 +247,12 @@ jobs: # - npm error 429 Too Many Requests from registry.npmjs.org # - Docker image pull failures (e.g., beshultd/kibana-readonlyrest:*-ror-latest not found) # These errors are typically temporary and resolve with a simple retry. + # See the prod job above for why the budget is 35 minutes rather than 60. - name: Run E2E tests uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 with: max_attempts: 2 - timeout_minutes: 60 + timeout_minutes: 35 retry_wait_seconds: 120 retry_on: any command: | From 84bd00de9f358e0e5ee93242e165f06a66b64df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Ko=C5=82odziejczyk?= Date: Mon, 27 Jul 2026 14:28:42 +0200 Subject: [PATCH 18/36] wip --- .github/workflows/all-e2e-tests.yml | 5 +- ci/prebuild-images-lib.sh | 160 +++++++++++++++------------- 2 files changed, 90 insertions(+), 75 deletions(-) diff --git a/.github/workflows/all-e2e-tests.yml b/.github/workflows/all-e2e-tests.yml index b0ae4f1d..8951f4f4 100644 --- a/.github/workflows/all-e2e-tests.yml +++ b/.github/workflows/all-e2e-tests.yml @@ -196,7 +196,10 @@ jobs: ELK_VERSIONS: "9.4.4 9.3.8 8.19.19 7.17.29" # PR runs must build from the PR head, not the merge ref. TARGET_BRANCH: ${{ github.head_ref || github.ref_name }} - ES_REPO_AZURE_PAT: ${{ secrets.ES_REPO_AZURE_PAT }} + # Both pre-builds are GitHub Actions workflows now β€” the ES side was ported off Azure + # DevOps, so ES_REPO_AZURE_PAT is no longer used. ES_REPO_GH_TOKEN needs `actions: write` + # on sscarduzio/elasticsearch-readonlyrest-plugin, same shape as the KBN token. + ES_REPO_GH_TOKEN: ${{ secrets.ES_REPO_GH_TOKEN }} KBN_REPO_GH_TOKEN: ${{ secrets.KBN_REPO_GH_TOKEN }} run: | set -euo pipefail diff --git a/ci/prebuild-images-lib.sh b/ci/prebuild-images-lib.sh index fd36e82f..1952971f 100644 --- a/ci/prebuild-images-lib.sh +++ b/ci/prebuild-images-lib.sh @@ -24,9 +24,8 @@ # layer is genuinely shared, and it is where the subtle details live (JSON escaping of # attacker-controlled branch names, poll timeouts, skip-optimization semantics). # -# Required tooling is needed only at call time, not at source time: `gh` for the KBN dispatch, -# `curl` + `jq` for the ES dispatch, `docker` for the polls. A consumer that never calls a given -# function never needs its tools. +# Required tooling is needed only at call time, not at source time: `gh` for either dispatch, +# `docker` for the polls. A consumer that never calls a given function never needs its tools. # # Nothing here relies on the caller running under `set -e`: every step returns a non-zero status and # composite helpers propagate it explicitly with `|| return $?`. @@ -41,24 +40,36 @@ _ROR_PREBUILD_IMAGES_LIB_SOURCED=1 # Overridable via the environment so a fork or a dry run can point elsewhere; the defaults are the # real ones and are what every consumer uses. -# ROR KBN pre-builds: a GitHub Actions workflow dispatched with the `gh` CLI. +# Both plugins publish pre-builds from a manually-dispatchable GitHub Actions workflow, driven here +# with the `gh` CLI. The ES side used to be an Azure DevOps pipeline hit over the REST API; it was +# ported to Actions and .azure/ was deleted, so the Azure coordinates are gone with it. ROR_KBN_GH_REPO="${ROR_KBN_GH_REPO:-sscarduzio/readonlyrest_kbn}" ROR_KBN_PUBLISH_WORKFLOW="${ROR_KBN_PUBLISH_WORKFLOW:-publish-pre-builds.yml}" -# ROR ES pre-builds: an Azure DevOps pipeline dispatched over the REST API (.azure/publish-pre-builds.yml -# in the ES repo, definitionId=7). Hardcoded by convention β€” only the auth token is a secret. The -# project name is URL-encoded because it contains spaces. -ROR_ES_AZURE_ORG="${ROR_ES_AZURE_ORG:-beshu-tech}" -ROR_ES_AZURE_PROJECT="${ROR_ES_AZURE_PROJECT:-ReadonlyREST%20for%20Elasticsearch}" -ROR_ES_AZURE_PIPELINE_ID="${ROR_ES_AZURE_PIPELINE_ID:-7}" +ROR_ES_GH_REPO="${ROR_ES_GH_REPO:-sscarduzio/elasticsearch-readonlyrest-plugin}" +ROR_ES_PUBLISH_WORKFLOW="${ROR_ES_PUBLISH_WORKFLOW:-publish-pre-builds.yml}" + +# Which ref the workflow is READ from. The workflow only needs to exist on this ref; which branch's +# sources actually get built is the separate target-branch input. Both are pinned to develop because +# that is where both repos keep the current pre-build workflow, and it matches the escape hatch the +# workflows document: dispatch from develop, build any branch. +# +# Pinning matters because `gh workflow run` otherwise resolves against each repo's DEFAULT branch, +# and those differ β€” ROR KBN defaults to develop (so it happened to be right by accident), ROR ES +# defaults to master (where the workflow does not exist, so an unpinned dispatch 404s). Spelling +# both out means the destination no longer depends on a per-repo setting nobody looks at. +# +# Set either to empty to fall back to gh's default-branch behaviour. +ROR_ES_PUBLISH_WORKFLOW_REF="${ROR_ES_PUBLISH_WORKFLOW_REF-develop}" +ROR_KBN_PUBLISH_WORKFLOW_REF="${ROR_KBN_PUBLISH_WORKFLOW_REF-develop}" ROR_ES_DEV_IMAGE_REPO="${ROR_ES_DEV_IMAGE_REPO:-beshultd/elasticsearch-readonlyrest-dev}" ROR_KBN_DEV_IMAGE_REPO="${ROR_KBN_DEV_IMAGE_REPO:-beshultd/kibana-readonlyrest-dev}" -# Default poll ceilings. ES gets the longer one: several dispatches can land on the same Azure -# pipeline definition at once and queue behind each other if the org has fewer free parallel jobs -# than legs β€” even on the cheap retag path, where each run still pays agent startup, checkout and -# Gradle cache restore. +# Default poll ceilings. ES gets the longer one: it builds the plugin with Gradle across every +# requested version, and concurrent dispatches queue behind each other when the runner pool is +# busier than the matrix is wide β€” even on the cheap retag path, where each run still pays runner +# startup, checkout and Gradle cache restore. ROR_ES_WAIT_TIMEOUT_SECONDS="${ROR_ES_WAIT_TIMEOUT_SECONDS:-$((45 * 60))}" ROR_KBN_WAIT_TIMEOUT_SECONDS="${ROR_KBN_WAIT_TIMEOUT_SECONDS:-$((30 * 60))}" ROR_PREBUILD_POLL_INTERVAL_SECONDS="${ROR_PREBUILD_POLL_INTERVAL_SECONDS:-30}" @@ -113,6 +124,44 @@ normalize_elk_versions() { # per version because their pipelines are per-version; a matrix consumer should instead pass every # version in one call and dispatch twice per run rather than 2Γ—N times. +# Shared mechanics for both dispatches. The two plugin workflows differ only in coordinates, token +# and input KEY names, so the varying `-f key=value` pairs are passed through as trailing args and +# each caller keeps its own names visible at the call site. +# +# No JSON is assembled here any more. The Azure REST body had to be built with `jq --arg` because +# TARGET_BRANCH is chosen by whoever opened the PR and git-check-ref-format(1) permits `"` in a ref +# name, so a printf-interpolated body could close templateParameters and inject sibling top-level +# keys. `gh workflow run -f` passes each value as a single argv entry and gh does the encoding, so +# that whole class of injection is gone rather than merely defended against. +# +# Usage: _dispatch_prebuild_workflow