diff --git a/.claude/skills/test-audit/TIERS.md b/.claude/skills/test-audit/TIERS.md index 97351ea02..62b052056 100644 --- a/.claude/skills/test-audit/TIERS.md +++ b/.claude/skills/test-audit/TIERS.md @@ -12,6 +12,7 @@ workflows, binaries, or matrix builders change; check it against | Static gates | `build` (tsc, jsii), `pnpm package`, eslint, prettier, knip | types, JSII-compilable API, packaging for all languages, formatting, dependency hygiene | always on PRs; labels cannot skip them | | Unit | `cdktn-cli`, `@cdktn/commons`, `@cdktn/hcl2cdk`, `@cdktn/hcl2json`, `@cdktn/hcl-tools` (Nx tag `unit-test`) | behavior of one package through its public entry point | PR: `nx affected`, one job, the image's default terraform; `main`: every package except `@cdktn/hcl-tools` | | Unit, binary matrix | `cdktn`, `@cdktn/cli-core`, `@cdktn/provider-generator`, `@cdktn/provider-schema` (Nx tag `unit-test:terraform`) | the same, plus behavior that varies by Terraform or OpenTofu version | PR: `nx affected` once per binary in the `pr-unit.yml` matrix; `main`: every package, Terraform only | +| Telemetry e2e | `tools/validate-sentry-e2e.sh` with `tools/sentry-sink.mjs` | what the real esbuild bundle delivers to a local Sentry sink: run accounting, stack aggregation, the identity sent, no leaked names or paths | PR: its own job after quality and build, skipped by `ci/skip-unit`; not on `main` | | Integration | `test///test.ts` | the packaged CLI and library, installed from `dist/`, against a real binary; and each language binding actually installing, compiling and running | every file, once per tested Terraform version, plus an HCL-mode run for files that use an HCL gate (`tools/build-test-matrix.mjs`) | | Provider integration | `test/provider-tests`, one template stamped per key in `providers.json` | `cdktn get` on real, large provider schemas; the only Windows coverage | every provider, Linux and Windows | | Examples | `examples/**` (`tools/build-example-matrix.mjs`) | real-provider bindings type-check and synthesize in every language; the docs snippets compile | build and synth only; an example's own `test` target never runs in CI | @@ -66,6 +67,13 @@ cd test && pnpm edge:install # before edge scenarios cd test && pnpm run test:provider # one provider test ``` +The telemetry e2e needs `pnpm build`, not `pnpm package`; it builds its own +scratch bundle and leaves `bundle/` alone. + +```bash +bash tools/validate-sentry-e2e.sh +``` + Formatting, lint (warnings fail), and CI routing: ```bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c71f63fb1..fbe9bed7f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -51,6 +51,14 @@ jobs: concurrency_group_prefix: pr secrets: inherit + telemetry_e2e: + name: "Telemetry E2E" + needs: [quality, build] + if: ${{ !contains(github.event.pull_request.labels.*.name, 'ci/skip-unit') }} + uses: ./.github/workflows/telemetry-e2e.yml + with: + concurrency_group_prefix: pr + examples: name: "Examples Integration Tests - PR" needs: [all_unit_tests] @@ -88,6 +96,7 @@ jobs: quality, build, all_unit_tests, + telemetry_e2e, examples, integration_test, provider_integration_test, diff --git a/.github/workflows/telemetry-e2e.yml b/.github/workflows/telemetry-e2e.yml new file mode 100644 index 000000000..99ab5c3e6 --- /dev/null +++ b/.github/workflows/telemetry-e2e.yml @@ -0,0 +1,61 @@ +name: Telemetry E2E +on: + workflow_call: + inputs: + concurrency_group_prefix: + default: pr + required: false + type: string + +env: + HUSKY: "0" + +concurrency: + group: ${{ inputs.concurrency_group_prefix }}-telemetry-e2e-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + telemetry-e2e: + name: "Telemetry delivery e2e" + runs-on: ubuntu-latest + container: + image: ghcr.io/open-constructs/jsii-terraform@sha256:62c00a313a1c9457c0d8d3b6ee86c77d241c2d18c4cc1f9b9015836d6d6a98c8 + permissions: + contents: read + timeout-minutes: 60 + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: "Add Git safe.directory" # Go 1.18+ embeds repo info in builds + run: git config --global --add safe.directory /__w/cdk-terrain/cdk-terrain + - name: ensure correct user + run: chown -R root /__w/cdk-terrain + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: .nvmrc + - name: Setup corepack-managed pnpm + uses: ./.github/actions/setup-corepack-pnpm + - name: Get cache directory paths + id: global-cache-dir-path + run: | + mkdir -p /usr/local/share/.cache/go + echo "go=/usr/local/share/.cache/go" >> $GITHUB_OUTPUT + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: ${{ steps.global-cache-dir-path.outputs.go }} + key: go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}-telemetry-e2e + restore-keys: | + go-${{ runner.os }}-${{ hashFiles('**/go.sum') }}- + go-${{ runner.os }}- + - name: install dependencies + run: pnpm ci + - name: build the CLI and its dependencies + run: pnpm exec nx build cdktn-cli + env: + GOCACHE: ${{ steps.global-cache-dir-path.outputs.go }} + # No SENTRY_DSN and no CHECKPOINT_DISABLE here: the script bakes a + # local-sink DSN into its own scratch bundle and needs telemetry on. + - name: telemetry delivery e2e + run: bash tools/validate-sentry-e2e.sh + timeout-minutes: 10 diff --git a/packages/@cdktn/commons/src/telemetry.test.ts b/packages/@cdktn/commons/src/telemetry.test.ts index ae48738e8..68631c750 100644 --- a/packages/@cdktn/commons/src/telemetry.test.ts +++ b/packages/@cdktn/commons/src/telemetry.test.ts @@ -44,6 +44,8 @@ type MetricItem = { attributes: Record; }; +// Mirrors recordEnvelope in tools/sentry-sink.mjs: an envelope-format change +// is fixed in both. function parseMetricItems(envelopeBodies: string[]): MetricItem[] { const items: MetricItem[] = []; for (const body of envelopeBodies) { diff --git a/packages/cdktn-cli/.gitignore b/packages/cdktn-cli/.gitignore index 7e6b8e554..ea7bcd959 100644 --- a/packages/cdktn-cli/.gitignore +++ b/packages/cdktn-cli/.gitignore @@ -2,5 +2,6 @@ .yalc yalc.lock bundle +bundle-e2e build-config !ambient.d.ts diff --git a/packages/cdktn-cli/build.ts b/packages/cdktn-cli/build.ts index b2f0db025..4d13c42be 100644 --- a/packages/cdktn-cli/build.ts +++ b/packages/cdktn-cli/build.ts @@ -52,11 +52,15 @@ const nativeNodeModulesPlugin = { }, }; +// tools/validate-sentry-e2e.sh builds a throwaway copy with a local-sink DSN +// next to the shipped bundle, which it never touches. +const outdir = process.env.CDKTN_BUNDLE_OUTDIR || "./bundle"; + const config: esbuild.BuildOptions = { entryPoints: ["src/bin/cdktn.ts", "src/bin/cmds/handlers.ts"], outbase: "src", bundle: true, - outdir: "./bundle", + outdir, format: "cjs", target: "node22", minify: enableWatch ? false : true, @@ -104,7 +108,7 @@ const config: esbuild.BuildOptions = { (async () => { console.log("Building…"); await esbuild.build(config); - fs.copySync("../@cdktn/cli-core/templates", "./bundle/templates"); + fs.copySync("../@cdktn/cli-core/templates", `${outdir}/templates`); if (enableWatch) { const ctx = await esbuild.context(config); diff --git a/packages/cdktn-cli/src/bin/cmds/helper/utilities.ts b/packages/cdktn-cli/src/bin/cmds/helper/utilities.ts index 28ea1d9fc..db0a50c2c 100644 --- a/packages/cdktn-cli/src/bin/cmds/helper/utilities.ts +++ b/packages/cdktn-cli/src/bin/cmds/helper/utilities.ts @@ -27,9 +27,9 @@ export const projectRootPath = () => { // deferred require to keep cdktn-cli main entrypoint small (e.g. for fast shell completions) export const requireHandlers = () => { - // if file exists relative to this file return its file path - // otherwise return the file path relative to the project root - const filePath = path.join(__dirname, "..", "handlers.js"); + // Once bundled, __dirname is /bin: a bundle loads its own handlers + // wherever it was built. Unbundled code falls back to the shipped bundle. + const filePath = path.join(__dirname, "cmds", "handlers.js"); if (fs.existsSync(filePath)) { return localRequire(filePath); } diff --git a/tools/sentry-sink.mjs b/tools/sentry-sink.mjs new file mode 100755 index 000000000..b46dfff0a --- /dev/null +++ b/tools/sentry-sink.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +// Copyright (c) HashiCorp, Inc +// SPDX-License-Identifier: MPL-2.0 +// +// Local Sentry sink for tools/validate-sentry-e2e.sh: records the envelopes +// POSTed to /api//envelope/. Usage: node tools/sentry-sink.mjs [port=0] +// port 0 picks a free port; the chosen one is printed on the first line +// GET /__items -> recorded items as JSON; GET /__raw -> decoded envelope bodies +import * as http from "node:http"; +import * as zlib from "node:zlib"; + +const port = Number(process.argv[2] ?? 0); +const items = []; +const bodies = []; + +// Mirrors parseMetricItems in packages/@cdktn/commons/src/telemetry.test.ts: +// an envelope-format change is fixed in both. +function recordEnvelope(body) { + bodies.push(body); + const lines = body.split("\n").filter(Boolean); + for (let i = 1; i < lines.length; i++) { + let header; + try { + header = JSON.parse(lines[i]); + } catch { + continue; + } + if (!header || typeof header.type !== "string") continue; + const item = { type: header.type }; + try { + const payload = JSON.parse(lines[i + 1]); + if (header.type === "trace_metric") { + item.metrics = payload.items.map((m) => ({ + name: m.name, + value: m.value, + attributes: Object.fromEntries( + Object.entries(m.attributes ?? {}).map(([k, v]) => [k, v.value]), + ), + })); + } else if (header.type === "event") { + const { server_name, environment, user } = payload; + item.event = { server_name, environment, user }; + } + } catch { + /* ignore malformed payloads */ + } + items.push(item); + console.log(`[sentry-sink] ${JSON.stringify(item)}`); + } +} + +const server = http.createServer((req, res) => { + if (req.method === "GET" && req.url === "/__items") { + res.setHeader("content-type", "application/json"); + return res.end(JSON.stringify(items)); + } + if (req.method === "GET" && req.url === "/__raw") { + res.setHeader("content-type", "text/plain"); + return res.end(bodies.join("\n")); + } + + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + let body = Buffer.concat(chunks); + const encoding = req.headers["content-encoding"]; + try { + if (encoding === "gzip") body = zlib.gunzipSync(body); + else if (encoding === "deflate") body = zlib.inflateSync(body); + else if (encoding === "br") body = zlib.brotliDecompressSync(body); + } catch { + /* fall through with the raw body */ + } + console.log( + `[sentry-sink] ${req.method} ${req.url} (${body.length} bytes, encoding=${encoding ?? "none"})`, + ); + // the SDK appends auth as a query string: /api/

/envelope/?sentry_key=… + if (req.method === "POST" && /\/envelope\/?(\?|$)/.test(req.url ?? "")) { + recordEnvelope(body.toString("utf8")); + } + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + res.end("{}"); + }); +}); + +server.listen(port, () => { + console.log( + `[sentry-sink] listening on http://localhost:${server.address().port}`, + ); +}); diff --git a/tools/validate-sentry-e2e.sh b/tools/validate-sentry-e2e.sh new file mode 100755 index 000000000..7b7da65c2 --- /dev/null +++ b/tools/validate-sentry-e2e.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# Copyright (c) HashiCorp, Inc +# SPDX-License-Identifier: MPL-2.0 +# +# End-to-end check of cdktn-cli telemetry on the real esbuild bundle: builds a +# throwaway copy of it with a local-sink DSN, runs convert (success), a failing +# synth (error), two hand-written stacks (stack metrics) and a crashing +# command (entrypoint failure path), then asserts on what reached +# tools/sentry-sink.mjs. The shipped bundle is never touched. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" +OUTDIR="$ROOT/packages/cdktn-cli/bundle-e2e" +CDKTN="$OUTDIR/bin/cdktn.js" + +# convert --language typescript never reads dist, so `pnpm build` is enough. +for output in \ + packages/@cdktn/commons/build/index.js \ + packages/@cdktn/cli-core/build/lib/index.js \ + packages/@cdktn/hcl2json/build/index.js \ + packages/@cdktn/hcl2json/main.wasm.gz \ + packages/@cdktn/hcl2cdk/build/index.js \ + packages/cdktn/lib/index.js; do + [ -f "$ROOT/$output" ] || { echo "FAIL: $output is missing; run pnpm build first" >&2; exit 1; } +done + +SINK_LOG="$(mktemp)" +E2E_HOME="$(mktemp -d)" +SINK_PID="" + +cleanup() { + local status=$? + if [ -n "$SINK_PID" ]; then + kill "$SINK_PID" 2>/dev/null || true + wait "$SINK_PID" 2>/dev/null || true + fi + rm -rf "$OUTDIR" "$SINK_LOG" "$E2E_HOME" + exit "$status" +} +trap cleanup EXIT + +echo "==> starting sentry sink" +node "$ROOT/tools/sentry-sink.mjs" "${1:-0}" >"$SINK_LOG" 2>&1 & +SINK_PID=$! +PORT="" +for _ in $(seq 1 50); do + PORT="$(sed -n 's/.*listening on http:\/\/localhost:\([0-9]*\).*/\1/p' "$SINK_LOG" | head -1)" + [ -n "$PORT" ] && break + sleep 0.1 +done +[ -n "$PORT" ] || { cat "$SINK_LOG" >&2; echo "FAIL: sink did not start" >&2; exit 1; } +DSN="http://cdktn@localhost:${PORT}/1" + +echo "==> building a scratch bundle with DSN $DSN baked in" +(cd "$ROOT/packages/cdktn-cli" && pnpm run compile-build-config >/dev/null && SENTRY_DSN="$DSN" CDKTN_BUNDLE_OUTDIR="$OUTDIR" node build-config/build.js) + +# Polls the sink until every pattern was recorded or 10 s pass; a timeout +# names what never arrived instead of leaving it to the assertions below. +await_items() { + local i pattern missing + for i in $(seq 1 100); do + ITEMS="$(curl -sf "http://localhost:${PORT}/__items" || true)" + missing="" + for pattern in "$@"; do + echo "$ITEMS" | grep -q -- "$pattern" || missing="$missing $pattern" + done + [ -z "$missing" ] && return 0 + sleep 0.1 + done + echo "FAIL: timed out waiting for$missing" >&2 + exit 1 +} + +WORK="$(mktemp -d)" +pushd "$WORK" >/dev/null +unset CHECKPOINT_DISABLE +# An empty home makes the first run mint the anonymous install id checked below. +export CDKTF_HOME="$E2E_HOME" +# Sentry env vars a user or CI may export; none of their values may be sent. +export SENTRY_ENVIRONMENT="LEAK-ENV-SENTRY" +export SENTRY_TRACE="0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-1" +export SENTRY_BAGGAGE="sentry-environment=LEAK-BAGGAGE-SENTRY" +printf '{ "language": "typescript", "app": "true", "projectId": "e2e-validation", "sendCrashReports": true, "sendUsageTelemetry": true }' > cdktf.json + +echo "==> SUCCESS trigger: cdktn convert" +echo 'resource "null_resource" "x" {}' | node "$CDKTN" convert --language typescript >/dev/null + +echo "==> ERROR trigger: cdktn synth (failing app)" +node "$CDKTN" synth --app "node -e 'process.exit(1)'" >/dev/null 2>&1 || true + +popd >/dev/null +rm -rf "$WORK" + +STACK_WORK="$(mktemp -d)" +pushd "$STACK_WORK" >/dev/null +printf '{ "language": "typescript", "app": "node fake-app.js", "projectId": "e2e-validation", "sendCrashReports": true, "sendUsageTelemetry": true, "terraformProviders": ["aws@~>5.0"] }' > cdktf.json +cat > fake-app.js <<'JS' +const fs = require("fs"); +const path = require("path"); +const outdir = process.env.CDKTF_OUTDIR; +const aws = { source: "aws", version: "~> 5.0" }; +const random = { source: "hashicorp/random", version: "3.6.0" }; +const stacks = { + "E2E-SECRET-STACK-NAME": { + metadata: { + overrides: { aws_s3_bucket: ["tags"] }, + imports: { aws_s3_bucket: ["e2e-secret-resource-id"] }, + }, + providers: { + aws, + random, + vault: { source: "tfe.leak-host.example/leak-org/vault", version: "~> 3.0" }, + local: { source: "./leak-provider" }, + }, + }, + "E2E-SECRET-STACK-NAME-2": { metadata: {}, providers: { aws, random } }, +}; +const manifest = { version: "0.0.0-e2e", stacks: {} }; +for (const [name, { metadata, providers }] of Object.entries(stacks)) { + fs.mkdirSync(path.join(outdir, "stacks", name), { recursive: true }); + fs.writeFileSync( + path.join(outdir, "stacks", name, "cdk.tf.json"), + JSON.stringify({ + "//": { + metadata: { version: "0.0.0-e2e", stackName: name, backend: "local", ...metadata }, + outputs: {}, + }, + terraform: { required_providers: providers }, + }), + ); + manifest.stacks[name] = { + name, + constructPath: name, + workingDirectory: `stacks/${name}`, + synthesizedStackPath: `stacks/${name}/cdk.tf.json`, + stackMetadataPath: `stacks/${name}/metadata.json`, + annotations: [], + dependencies: [], + }; +} +fs.writeFileSync(path.join(outdir, "manifest.json"), JSON.stringify(manifest)); +JS + +echo "==> STACK trigger: cdktn synth (two hand-written stacks)" +node "$CDKTN" synth --check-code-maker-output=false >/dev/null \ + || { echo "FAIL: the stack trigger synth exited non-zero; an unrelated synth regression also fails here" >&2; exit 1; } + +popd >/dev/null +rm -rf "$STACK_WORK" + +# A corrupt synthesized stack read with --skip-synth is an unexpected error +# that reaches runCli's failure reporter: crash event + cli.command.error. +CRASH_WORK="$(mktemp -d)" +pushd "$CRASH_WORK" >/dev/null +mkdir -p cdktf.out/stacks/broken +printf '{ "language": "typescript", "app": "true", "projectId": "e2e-validation", "sendCrashReports": true, "sendUsageTelemetry": true }' > cdktf.json +printf '{ "version": "0.0.0-e2e", "stacks": { "broken": { "name": "broken", "constructPath": "broken", "workingDirectory": "stacks/broken", "synthesizedStackPath": "stacks/broken/cdk.tf.json", "stackMetadataPath": "stacks/broken/metadata.json", "annotations": [], "dependencies": [] } } }' > cdktf.out/manifest.json +printf '{ not json' > cdktf.out/stacks/broken/cdk.tf.json + +echo "==> CRASH trigger: cdktn output --skip-synth (corrupt cdk.tf.json)" +node "$CDKTN" output --skip-synth >/dev/null 2>&1 || true + +popd >/dev/null +rm -rf "$CRASH_WORK" + +await_items 'cli.command.invoked' 'cli.command.completed' 'cli.command.error' '"cli.stack.provider"' '"type":"event"' +RAW="$(curl -sf "http://localhost:${PORT}/__raw")" +echo "==> sink recorded: $ITEMS" + +fail() { echo "FAIL: $1" >&2; exit 1; } + +echo "$ITEMS" | grep -q '"binary"' \ + || fail "binary attribute missing on the command metrics" + +# Every run counts once as invoked at start, then once as completed or error: +# the four triggers above, sorted as " ". +RUNS="$(node -e ' + const runs = []; + for (const item of JSON.parse(process.argv[1])) { + for (const metric of item.metrics ?? []) { + if (metric.name.startsWith("cli.command.")) { + runs.push(`${metric.name} ${metric.attributes.command}`); + } + } + } + console.log(runs.sort().join("\n")); +' "$ITEMS")" +EXPECTED_RUNS="$(printf '%s\n' \ + 'cli.command.completed convert' \ + 'cli.command.completed synth' \ + 'cli.command.error output' \ + 'cli.command.error synth' \ + 'cli.command.invoked convert' \ + 'cli.command.invoked output' \ + 'cli.command.invoked synth' \ + 'cli.command.invoked synth')" +[ "$RUNS" = "$EXPECTED_RUNS" ] \ + || fail "run metrics differ from one invoked per run plus one completed or error; got: +$RUNS" + +# Stack metrics are aggregated per run: one item per distinct identity, its +# value the number of stacks sharing it and its counts summed over them. +# Sorted as " ". +STACK_METRICS="$(node -e ' + const lines = []; + for (const item of JSON.parse(process.argv[1])) { + for (const { name, value, attributes } of item.metrics ?? []) { + if (name.startsWith("cli.stack")) { + const identity = + attributes.provider ?? attributes.resource_type ?? attributes.backend; + const sums = + name === "cli.stack" + ? ` overrides=${attributes.override_count} imports=${attributes.import_count}` + : ""; + lines.push(`${name} ${identity} ${value}${sums}`); + } + } + } + console.log(lines.sort().join("\n")); +' "$ITEMS")" +EXPECTED_STACK_METRICS="$(printf '%s\n' \ + 'cli.stack local 2 overrides=1 imports=1' \ + 'cli.stack.override aws_s3_bucket 1' \ + 'cli.stack.provider hashicorp/aws 2' \ + 'cli.stack.provider hashicorp/random 2' \ + 'cli.stack.provider other 1' \ + 'cli.stack.provider private-registry 1')" +[ "$STACK_METRICS" = "$EXPECTED_STACK_METRICS" ] \ + || fail "stack metrics differ from one item per distinct backend, provider and overridden type; got: +$STACK_METRICS" +for key in binding library_version; do + echo "$ITEMS" | grep -q "\"$key\"" \ + || fail "$key attribute missing on the stack metrics" +done + +# The only identity on the wire: the random install id minted into CDKTF_HOME, +# a fixed server name and a fixed environment, on every metric and the event. +USER_ID="$(node -p 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")).userId' "$E2E_HOME/config.json" 2>/dev/null)" \ + || fail "no install id was minted into CDKTF_HOME" +[[ "$USER_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ ]] \ + || fail "the install id in CDKTF_HOME is not a random UUID: $USER_ID" +IDENTITIES="$(node -e ' + const seen = new Set(); + for (const item of JSON.parse(process.argv[1])) { + for (const { attributes } of item.metrics ?? []) { + seen.add( + `metric ${attributes["user.id"]} ${attributes["server.address"]} ${attributes["sentry.environment"]}`, + ); + } + if (item.type === "event") seen.add(`event ${JSON.stringify(item.event)}`); + } + console.log([...seen].sort().join("\n")); +' "$ITEMS")" +EXPECTED_IDENTITIES="$(printf '%s\n' \ + "event {\"server_name\":\"cdktn-cli\",\"environment\":\"production\",\"user\":{\"id\":\"$USER_ID\"}}" \ + "metric $USER_ID cdktn-cli production")" +[ "$IDENTITIES" = "$EXPECTED_IDENTITIES" ] \ + || fail "user, server name or environment differ from the install id, cdktn-cli and production; got: +$IDENTITIES" + +HOST="$(uname -n)" +HOST="${HOST%%.*}" +USERNAME="$(id -un)" +[ -n "$HOST" ] && [ -n "$USERNAME" ] || fail "cannot determine the hostname and username to scan for" +for secret in E2E-SECRET-STACK-NAME e2e-secret-resource-id leak-host.example leak-org leak-provider LEAK-ENV-SENTRY LEAK-BAGGAGE-SENTRY 0af7651916cd43dd8448eb211c80319c "$HOST" "$WORK" "$STACK_WORK" "$CRASH_WORK" "$E2E_HOME"; do + if echo "$RAW" | grep -qF -- "$secret"; then + fail "$secret reached the sink: stack names, resource ids, provider hosts/paths, SENTRY_* env values, the hostname and the working directories must never be sent" + fi +done +# The crash event's stack frames name the bundle's own files by absolute path, +# which may sit under a home directory; the username may appear nowhere else. +if echo "${RAW//"$OUTDIR"/}" | grep -qwF -- "$USERNAME"; then + fail "the username $USERNAME reached the sink outside the bundle's install path" +fi +echo "$RAW" | grep -q '"error_type":{"value":"unexpected"' \ + || fail "the crash trigger was not counted as an unexpected cli.command.error" +echo "$RAW" | grep -q '"converted_lines"' \ + || fail "the convert scalars did not ride on cli.command.completed" + +# A cheap bundle scan; the sink assertions above are what prove the transport. +HASHICORP_REFS="$(grep -rl "checkpoint-api.hashicorp.com" "$OUTDIR/bin" || true)" +[ -z "$HASHICORP_REFS" ] || fail "bundle still references checkpoint-api.hashicorp.com: $HASHICORP_REFS" + +echo "PASS: success-path, error-path, per-stack and entrypoint-failure telemetry delivered to the local sink"