Skip to content

Commit 9bc955f

Browse files
authored
test: fix cloud functional tests (#585)
* test: skip missing cloud functional items * ci: drop cloud functional soft fail * test: read serverless list items for ids * ci: ensure cloud qa fixtures exist * ci: create hosted deployment fixture * test: skip upgrade assistant before version
1 parent 8917ae5 commit 9bc955f

6 files changed

Lines changed: 245 additions & 13 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env bash
2+
# Copyright Elasticsearch B.V. and contributors
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Idempotent QA fixtures so Cloud item GET tests have something to fetch.
6+
# Creates one serverless project per type, one serverless traffic filter,
7+
# one hosted traffic-filter ruleset, and one hosted deployment when the
8+
# matching list is empty. Skips extensions (need a plugin zip). Does not
9+
# print create responses (they can contain creds).
10+
11+
set -euo pipefail
12+
13+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
14+
ELASTIC=(node "$REPO_ROOT/dist/cli.js" --json)
15+
REGION="${CLOUD_FIXTURE_REGION:-gcp-us-central1}"
16+
RULES='[{"source":"192.0.2.1"}]'
17+
18+
first_id () {
19+
jq -r '
20+
if type == "array" then .[0].id // empty
21+
else
22+
.items[0].id
23+
// .projects[0].id
24+
// .filters[0].id
25+
// .rulesets[0].id
26+
// .deployments[0].id
27+
// empty
28+
end
29+
'
30+
}
31+
32+
ensure () {
33+
local label="$1"
34+
shift
35+
local list_args=()
36+
while [ "$1" != "--" ]; do
37+
list_args+=("$1")
38+
shift
39+
done
40+
shift
41+
local id
42+
id="$("${ELASTIC[@]}" "${list_args[@]}" | first_id)"
43+
if [ -n "$id" ] && [ "$id" != "null" ]; then
44+
echo "fixture exists: $label $id"
45+
return 0
46+
fi
47+
echo "creating fixture: $label"
48+
# stdout discarded so create responses never land in CI logs
49+
"${ELASTIC[@]}" "$@" >/dev/null
50+
}
51+
52+
ensure "search project" \
53+
cloud serverless projects search list -- \
54+
cloud serverless projects search create \
55+
--name cli-functional-search \
56+
--region-id "$REGION" \
57+
--optimized-for general_purpose \
58+
--yes
59+
60+
ensure "observability project" \
61+
cloud serverless projects observability list -- \
62+
cloud serverless projects observability create \
63+
--name cli-functional-o11y \
64+
--region-id "$REGION" \
65+
--product-tier logs_essentials \
66+
--yes
67+
68+
ensure "security project" \
69+
cloud serverless projects security list -- \
70+
cloud serverless projects security create \
71+
--name cli-functional-security \
72+
--region-id "$REGION" \
73+
--yes
74+
75+
ensure "serverless traffic filter" \
76+
cloud serverless traffic-filters list-traffic-filters -- \
77+
cloud serverless traffic-filters create-traffic-filter \
78+
--name cli-functional-filter \
79+
--type ip \
80+
--region "$REGION" \
81+
--rules "$RULES" \
82+
--yes
83+
84+
ensure "hosted traffic filter ruleset" \
85+
cloud hosted traffic-filters get-traffic-filter-rulesets -- \
86+
cloud hosted traffic-filters create-traffic-filter-ruleset \
87+
--name cli-functional-ruleset \
88+
--type ip \
89+
--include-by-default false \
90+
--region "$REGION" \
91+
--rules "$RULES"
92+
93+
ensure "hosted deployment" \
94+
cloud hosted deployments list-deployments -- \
95+
cloud hosted deployments create-deployment \
96+
--name cli-functional \
97+
--region "$REGION" \
98+
--template-id gcp-general-purpose

.buildkite/pipeline.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,3 @@ steps:
6565
command: ".buildkite/run-cloud-tests.sh"
6666
artifact_paths:
6767
- "test/functional/cloud/*.log"
68-
soft_fail: true

.buildkite/run-cloud-tests.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ npm run build
3535
echo "--- Setting up Cloud credentials"
3636
source .buildkite/setup-env.sh
3737

38+
echo "--- Ensuring Cloud fixtures"
39+
.buildkite/ensure-cloud-fixtures.sh
40+
3841
echo "--- Generating Cloud functional tests"
3942
npm run codegen:functional:cloud
4043

codegen/functional/cloud.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ for (const file of yamlFiles) {
6767

6868
const result = generateScript(testFile, apis, {
6969
clientArgs: ['cloud'],
70-
preamble: CLOUD_PREAMBLE
70+
preamble: CLOUD_PREAMBLE,
71+
skipEmptySet: true,
72+
skipNotFound: true
7173
})
7274

7375
for (const action of result.skippedActions) allSkippedActions.add(action)

codegen/functional/generator.ts

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ export interface GenerateOptions {
4646
clientArgs?: string[]
4747
/** Preamble lines defining the `$ELASTIC` invocation and `$RESPONSE` (default: the `elastic` binary). */
4848
preamble?: string[]
49+
/**
50+
* If a `set` extraction is empty or `null`, try `.[0].id` (bare-array list
51+
* responses) and skip the script when still empty. Used by Cloud tests
52+
* against orgs that have no deployments or projects.
53+
*/
54+
skipEmptySet?: boolean
55+
/**
56+
* If a do-step exits non-zero and the JSON error mentions 404 (or a
57+
* hosted deployment with no version yet), skip the script. Used by Cloud
58+
* tests for endpoints the org or public QA API does not expose.
59+
*/
60+
skipNotFound?: boolean
4961
}
5062

5163
const DEFAULT_PREAMBLE = ['exec < /dev/null', 'ELASTIC="elastic --json"', 'RESPONSE=""']
@@ -57,6 +69,8 @@ export function generateScript (
5769
): GenerateResult {
5870
const clientArgs = opts.clientArgs ?? ['stack', 'es']
5971
const preamble = opts.preamble ?? DEFAULT_PREAMBLE
72+
const skipEmptySet = opts.skipEmptySet === true
73+
const skipNotFound = opts.skipNotFound === true
6074
const actionMap = buildActionMap(definitions)
6175
const skippedActions: string[] = []
6276
const lines: string[] = []
@@ -70,7 +84,7 @@ export function generateScript (
7084

7185
if (testFile.teardown.length > 0) {
7286
const teardownBody: string[] = []
73-
renderSteps(testFile.teardown, actionMap, clientArgs, teardownBody, skippedActions, ' ')
87+
renderSteps(testFile.teardown, actionMap, clientArgs, teardownBody, skippedActions, ' ', false, skipEmptySet, skipNotFound)
7488
if (!hasExecutableLine(teardownBody)) {
7589
teardownBody.push(' :')
7690
}
@@ -98,13 +112,13 @@ export function generateScript (
98112

99113
if (testFile.setup.length > 0) {
100114
lines.push('# --- Setup ---')
101-
hadSkippedDo = renderSteps(testFile.setup, actionMap, clientArgs, lines, skippedActions, '')
115+
hadSkippedDo = renderSteps(testFile.setup, actionMap, clientArgs, lines, skippedActions, '', false, skipEmptySet, skipNotFound)
102116
lines.push('')
103117
}
104118

105119
for (const section of testFile.tests) {
106120
lines.push(`# --- Test: ${section.name} ---`)
107-
renderSteps(section.steps, actionMap, clientArgs, lines, skippedActions, '', hadSkippedDo)
121+
renderSteps(section.steps, actionMap, clientArgs, lines, skippedActions, '', hadSkippedDo, skipEmptySet, skipNotFound)
108122
lines.push('')
109123
}
110124

@@ -255,7 +269,9 @@ function renderSteps (
255269
lines: string[],
256270
skippedActions: string[],
257271
indent: string,
258-
initialHadSkippedDo = false
272+
initialHadSkippedDo = false,
273+
skipEmptySet = false,
274+
skipNotFound = false
259275
): boolean {
260276
// Assertions and set-steps read $RESPONSE, which is written by the most
261277
// recent successful `do`. If the last `do` was skipped (unmapped action,
@@ -279,7 +295,7 @@ function renderSteps (
279295
}
280296
// Only pass allowFailure from the setup→test propagation, not from
281297
// prior skipped steps within the same section (those are unrelated).
282-
const result = renderDo(step, actionMap, clientArgs, lines, skippedActions, indent, initialHadSkippedDo)
298+
const result = renderDo(step, actionMap, clientArgs, lines, skippedActions, indent, initialHadSkippedDo, skipNotFound)
283299
if (result === 'skipped') {
284300
responseFromLastDo = false
285301
hadSkippedDo = true
@@ -310,7 +326,7 @@ function renderSteps (
310326

311327
switch (step.kind) {
312328
case 'set':
313-
renderSet(step, lines, indent)
329+
renderSet(step, lines, indent, skipEmptySet)
314330
// If a variable was previously unset, it's now set
315331
for (const varName of Object.values(step.assignments)) {
316332
unsetVars.delete(varName.toUpperCase().replace(/[^A-Z0-9]/g, '_'))
@@ -360,7 +376,8 @@ function renderDo (
360376
lines: string[],
361377
skippedActions: string[],
362378
indent: string,
363-
allowFailure = false
379+
allowFailure = false,
380+
skipNotFound = false
364381
): 'executed' | 'optional' | 'skipped' {
365382
if (step.catch != null) {
366383
lines.push(`${indent}# SKIPPED: catch not supported in MVP (catch: ${step.catch})`)
@@ -384,9 +401,25 @@ function renderDo (
384401
if (optional) {
385402
lines.push(`${indent}RESPONSE=$(${cmd}) || true`)
386403
return 'optional'
387-
} else {
388-
lines.push(`${indent}RESPONSE=$(${cmd})`)
389404
}
405+
if (skipNotFound) {
406+
// Handler errors go to stderr (`writeErr`); keep them out of $RESPONSE.
407+
const errFile = '"${TMPDIR:-/tmp}/elastic-cli-do-err.$$"'
408+
lines.push(`${indent}set +e`)
409+
lines.push(`${indent}RESPONSE=$(${cmd} 2>${errFile})`)
410+
lines.push(`${indent}_ec=$?`)
411+
lines.push(`${indent}set -e`)
412+
lines.push(`${indent}if [ "$_ec" -ne 0 ]; then`)
413+
lines.push(`${indent} if jq -e '(.error.message | tostring | test("404|Could not determine the version"))' ${errFile} >/dev/null 2>&1; then`)
414+
lines.push(`${indent} echo "SKIP: ${step.action} not available"`)
415+
lines.push(`${indent} exit 0`)
416+
lines.push(`${indent} fi`)
417+
lines.push(`${indent} cat ${errFile} >&2`)
418+
lines.push(`${indent} exit $_ec`)
419+
lines.push(`${indent}fi`)
420+
return 'executed'
421+
}
422+
lines.push(`${indent}RESPONSE=$(${cmd})`)
390423
return 'executed'
391424
}
392425

@@ -510,11 +543,26 @@ function buildCommand (mapped: MappedAction, step: DoStep): string {
510543
return base
511544
}
512545

513-
function renderSet (step: SetStep, lines: string[], indent: string): void {
546+
function renderSet (step: SetStep, lines: string[], indent: string, skipEmptySet = false): void {
514547
for (const [responsePath, varName] of Object.entries(step.assignments)) {
515548
const bashVar = varName.toUpperCase().replace(/[^A-Z0-9]/g, '_')
516549
const jqPath = toJqPath(responsePath)
517-
lines.push(`${indent}${bashVar}=$(echo "$RESPONSE" | jq -r '${jqPath}')`)
550+
if (skipEmptySet) {
551+
// `try` so a nested path like `.regions[0].id` against a bare array
552+
// yields empty instead of aborting the script (jq cannot index an
553+
// array with a string).
554+
lines.push(`${indent}${bashVar}=$(echo "$RESPONSE" | jq -r 'try (${jqPath} // empty) catch empty')`)
555+
lines.push(`${indent}if [ -z "$${bashVar}" ] || [ "$${bashVar}" = "null" ]; then`)
556+
// Serverless lists wrap items in `.items`; regions return a bare array.
557+
lines.push(`${indent} ${bashVar}=$(echo "$RESPONSE" | jq -r 'if type=="array" then .[0].id // empty else .items[0].id // empty end')`)
558+
lines.push(`${indent}fi`)
559+
lines.push(`${indent}if [ -z "$${bashVar}" ] || [ "$${bashVar}" = "null" ]; then`)
560+
lines.push(`${indent} echo "SKIP: no ${varName} in list response"`)
561+
lines.push(`${indent} exit 0`)
562+
lines.push(`${indent}fi`)
563+
} else {
564+
lines.push(`${indent}${bashVar}=$(echo "$RESPONSE" | jq -r '${jqPath}')`)
565+
}
518566
}
519567
}
520568

codegen/functional/test/generator.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { describe, it } from 'node:test'
77
import assert from 'node:assert/strict'
8+
import { execFileSync } from 'node:child_process'
89
import { readFileSync } from 'node:fs'
910
import { join } from 'node:path'
1011
import { parseTestFile } from '../parser.ts'
@@ -226,6 +227,87 @@ describe('generateScript', () => {
226227
assert.ok(result.script.includes('echo "PASS: get.yml"'))
227228
})
228229

230+
it('skipEmptySet retries bare array id then skips when empty', () => {
231+
const testFile: TestFile = {
232+
sourceFile: 'list.yml',
233+
requires: { serverless: true, stack: true },
234+
setup: [],
235+
teardown: [],
236+
tests: [{
237+
name: 'get item',
238+
steps: [
239+
{ kind: 'do', action: 'count', params: { index: 'x' }, body: undefined },
240+
{ kind: 'set', assignments: { 'regions.0.id': 'id' } }
241+
]
242+
}]
243+
}
244+
const result = generateScript(testFile, testDefs, { skipEmptySet: true })
245+
assert.ok(result.script.includes('try (.regions[0].id // empty) catch empty'))
246+
assert.ok(result.script.includes('if type=="array" then .[0].id // empty else .items[0].id // empty end'))
247+
assert.ok(result.script.includes('SKIP: no id in list response'))
248+
assert.ok(result.script.includes('exit 0'))
249+
assert.equal(
250+
execFileSync('jq', ['-r', 'try (.regions[0].id // empty) catch empty'], {
251+
input: '[{"id":"r1"}]',
252+
encoding: 'utf8'
253+
}).trim(),
254+
''
255+
)
256+
const fallback = 'if type=="array" then .[0].id // empty else .items[0].id // empty end'
257+
assert.equal(
258+
execFileSync('jq', ['-r', fallback], { input: '[{"id":"r1"}]', encoding: 'utf8' }).trim(),
259+
'r1'
260+
)
261+
assert.equal(
262+
execFileSync('jq', ['-r', fallback], { input: '{"items":[{"id":"p1"}]}', encoding: 'utf8' }).trim(),
263+
'p1'
264+
)
265+
assert.equal(
266+
execFileSync('jq', ['-r', 'try (.deployments[0].id // empty) catch empty'], {
267+
input: '{"deployments":[]}',
268+
encoding: 'utf8'
269+
}).trim(),
270+
''
271+
)
272+
})
273+
274+
it('does not skip empty set extractions by default', () => {
275+
const testFile: TestFile = {
276+
sourceFile: 'list.yml',
277+
requires: { serverless: true, stack: true },
278+
setup: [],
279+
teardown: [],
280+
tests: [{
281+
name: 'get item',
282+
steps: [
283+
{ kind: 'do', action: 'count', params: { index: 'x' }, body: undefined },
284+
{ kind: 'set', assignments: { 'regions.0.id': 'id' } }
285+
]
286+
}]
287+
}
288+
const result = generateScript(testFile, testDefs)
289+
assert.equal(result.script.includes('SKIP: no id in list response'), false)
290+
assert.equal(result.script.includes('.[0].id // empty'), false)
291+
})
292+
293+
it('skipNotFound wraps do-steps to skip 404 errors', () => {
294+
const content = readFileSync(join(fixturesDir, 'get.yml'), 'utf-8')
295+
const testFile = parseTestFile(content, 'get.yml')
296+
const result = generateScript(testFile, testDefs, { skipNotFound: true })
297+
assert.ok(result.script.includes('set +e'))
298+
assert.ok(result.script.includes('elastic-cli-do-err.$$'))
299+
assert.ok(result.script.includes('not available'))
300+
assert.ok(result.script.includes('Could not determine the version'))
301+
})
302+
303+
it('does not wrap do-steps for 404 by default', () => {
304+
const content = readFileSync(join(fixturesDir, 'get.yml'), 'utf-8')
305+
const testFile = parseTestFile(content, 'get.yml')
306+
const result = generateScript(testFile, testDefs)
307+
assert.equal(result.script.includes('not available'), false)
308+
assert.equal(result.script.includes('elastic-cli-do-err.$$'), false)
309+
})
310+
229311
it('tracks skipped actions for unregistered APIs', () => {
230312
const content = readFileSync(join(fixturesDir, 'get.yml'), 'utf-8')
231313
const testFile = parseTestFile(content, 'get.yml')

0 commit comments

Comments
 (0)