diff --git a/.github/workflows/workshop.yml b/.github/workflows/workshop.yml index 41d45e1..ff4de47 100644 --- a/.github/workflows/workshop.yml +++ b/.github/workflows/workshop.yml @@ -36,6 +36,9 @@ jobs: find . -type f -name '*.sh' -print0 \ | xargs -0 -n1 bash -n + - name: Test staged preflight behavior + run: ./scripts/test-preflight.sh + - uses: actions/setup-python@v5 with: python-version: "3.12" diff --git a/workshops/build_workshop/app/OBSERVABILITY.md b/workshops/build_workshop/app/OBSERVABILITY.md index c7d03e0..610681d 100644 --- a/workshops/build_workshop/app/OBSERVABILITY.md +++ b/workshops/build_workshop/app/OBSERVABILITY.md @@ -124,7 +124,7 @@ docker compose --env-file .env.workshop \ | `CLICKHOUSE_ENDPOINT` | `https://${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}` | Full HTTPS endpoint (protocol + `:8443`) of the Cloud service. Assembled from the workshop's `CLICKHOUSE_HOST`/`CLICKHOUSE_PORT`. | | `CLICKHOUSE_USER` | `default` | Cloud SQL user. | | `CLICKHOUSE_PASSWORD` | (empty) | Cloud SQL password. | -| `OTLP_AUTH_TOKEN` | (empty) | Shared secret securing the collector's OTLP ingest. Clients send it back. Empty disables auth. | +| `OTLP_AUTH_TOKEN` | (empty) | Random shared secret securing the collector's OTLP ingest. Clients send it back; Module 05 preflight rejects an empty or placeholder value. | | `HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE` | `otel` (via `CLICKSTACK_DATABASE`) | Database for ClickStack's `otel_*` tables. Separate from the app data DB (`nyc_tlc_data`). | | `CUSTOM_OTELCOL_CONFIG_FILE` | `/etc/otelcol-contrib/main.config.yaml` (main) · `.../custom.config.yaml` (container-logs) | Merges an extra config onto the image's baked-in pipelines. The **main** collector uses `main.config.yaml` to redirect the unused `metrics/promql` (Prometheus remote-write) pipeline to a `nop` exporter — see below. The **container-logs** collector uses `custom.config.yaml` for the filelog receiver. | @@ -139,10 +139,11 @@ stops the error without affecting what lands in ClickHouse. Collector OTLP ports: `4317` (gRPC), `4318` (HTTP). If either host port is already taken, `docker compose ... up` fails to bind the collector; set `OTEL_GRPC_HOST_PORT` / `OTEL_HTTP_HOST_PORT` to free ports in `.env.workshop` -**before** starting the overlay (`preflight.sh` flags the clash and suggests -values). Those host mappings only matter for host-side OTLP senders — the backend -reaches the collector in-network at `http://otel-collector:4318`, so overriding -the host ports does NOT change the backend wiring. +**before** starting the overlay (`./preflight.sh --otel` flags the clash and suggests +values). Both mappings bind to `127.0.0.1`, so they are available to host-side senders +without exposing the authenticated ingest receiver to the LAN. The backend reaches the +collector in-network at `http://otel-collector:4318`, so overriding the host ports does +NOT change the backend wiring. ### Backend diff --git a/workshops/build_workshop/app/README.md b/workshops/build_workshop/app/README.md index d224276..35972e9 100644 --- a/workshops/build_workshop/app/README.md +++ b/workshops/build_workshop/app/README.md @@ -23,7 +23,7 @@ module 00 — it walks through every step below in order. plus `curl` and `git`. Host ports (override any of these in `.env.workshop` if it is already taken — -`preflight.sh` tells you which one and suggests a free port): +`preflight.sh` checks the app ports, and `preflight.sh --otel` adds the collector ports): | Service | Default host port | Override var | |---|---|---| @@ -47,6 +47,7 @@ cp .env.workshop.example .env.workshop # fill in your ClickHouse Cloud value docker compose --env-file .env.workshop -f docker-compose.workshop.yml up -d # with the ClickStack observability overlay (module 05 onward): +./preflight.sh --otel # must print "Overall: READY" (exit 0) docker compose --env-file .env.workshop \ -f docker-compose.workshop.yml -f docker-compose.otel.yml up -d --build ``` diff --git a/workshops/build_workshop/app/WORKSHOP_CHANGES.md b/workshops/build_workshop/app/WORKSHOP_CHANGES.md index 3029c24..026e28c 100644 --- a/workshops/build_workshop/app/WORKSHOP_CHANGES.md +++ b/workshops/build_workshop/app/WORKSHOP_CHANGES.md @@ -28,7 +28,7 @@ learner machine. 1. Module 00 creates ClickHouse Cloud and starts only the local frontend/backend. 2. Module 01 creates and seeds the Cloud schema. 3. Module 03 creates managed Postgres, validates it with - `./preflight.sh --require-postgres`, then explicitly enables the `cdc` trip writer. + `./preflight.sh --cdc`, then explicitly enables the `cdc` trip writer. 4. Module 05 starts the stateless collector overlay, then enables Managed ClickStack. 5. Module 08 sends chat traces to Langfuse Cloud. diff --git a/workshops/build_workshop/app/docker-compose.otel.yml b/workshops/build_workshop/app/docker-compose.otel.yml index 922ed7f..3fd7f58 100644 --- a/workshops/build_workshop/app/docker-compose.otel.yml +++ b/workshops/build_workshop/app/docker-compose.otel.yml @@ -1,7 +1,7 @@ # ClickStack observability OVERLAY. # # This file is NOT standalone. It layers on top of docker-compose.workshop.yml -# (which defines the `backend`, `frontend`, `pg-trip-writer` services) and: +# (which defines the base app and the profile-gated `pg-trip-writer`) and: # - adds a ClickStack OpenTelemetry collector that forwards traces + logs to # the participant's ClickHouse Cloud service, # - turns on OpenTelemetry in the backend and points it at that collector, @@ -34,8 +34,9 @@ services: - CLICKHOUSE_ENDPOINT=https://${CLICKHOUSE_HOST:-}:${CLICKHOUSE_PORT:-8443} - CLICKHOUSE_USER=${CLICKHOUSE_USER:-default} - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-} - # Arbitrary shared secret. Clients must send it back (see backend - # OTEL_EXPORTER_OTLP_HEADERS below). Leave empty to disable OTLP auth. + # Random shared secret. Clients must send it back (see backend + # OTEL_EXPORTER_OTLP_HEADERS below). Module 05 preflight rejects an empty + # or placeholder value. - OTLP_AUTH_TOKEN=${OTLP_AUTH_TOKEN:-} # Target database for ClickStack's own otel_* tables (distinct from the # app's data database, CLICKHOUSE_DATABASE=nyc_tlc_data). @@ -51,8 +52,8 @@ services: ports: # Host mappings are only needed if you send OTLP from the host; the backend # reaches the collector in-network at http://otel-collector:4318. - - "${OTEL_GRPC_HOST_PORT:-4317}:4317" - - "${OTEL_HTTP_HOST_PORT:-4318}:4318" + - "127.0.0.1:${OTEL_GRPC_HOST_PORT:-4317}:4317" + - "127.0.0.1:${OTEL_HTTP_HOST_PORT:-4318}:4318" # Additive overlay onto the workshop `backend`: enable OpenTelemetry and point # it at the collector. Only env keys are added here; the service body comes diff --git a/workshops/build_workshop/app/preflight.sh b/workshops/build_workshop/app/preflight.sh index 2fadcd8..e5d785b 100755 --- a/workshops/build_workshop/app/preflight.sh +++ b/workshops/build_workshop/app/preflight.sh @@ -2,16 +2,19 @@ # # preflight.sh -- ClickHouse BUILD workshop readiness check. # -# Run this from the app directory BEFORE `docker compose ... up`: +# Run this from the app directory before the relevant `docker compose ... up`: # -# ./preflight.sh +# ./preflight.sh # module 00: base app +# ./preflight.sh --cdc # module 03: Postgres CDC +# ./preflight.sh --otel # module 05: OpenTelemetry overlay # # It verifies, and prints PASS / WARN / FAIL for, everything the live bring-up of # the stack actually tripped over: the Docker CLI + daemon, that the daemon can # really start a container (catches a wedged engine), host-port collisions on the -# EFFECTIVE ports from .env.workshop, the required .env.workshop values, and -# network reachability of ClickHouse Cloud and, when requested, the managed -# Postgres created in Module 03. +# EFFECTIVE ports for the requested stage, the required .env.workshop values, +# and network reachability of ClickHouse Cloud. Managed Postgres and collector +# checks are opt-in because those services are not configured until modules 03 +# and 05. # # Every failure prints a one-line fix hint. The script exits non-zero if any check # FAILs (warnings do not affect the exit code), so it composes into scripts and CI. @@ -26,25 +29,35 @@ SCRIPT_DIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) ENV_FILE="$SCRIPT_DIR/.env.workshop" EXAMPLE_FILE="$SCRIPT_DIR/.env.workshop.example" PREFLIGHT_CTR="ch-workshop-preflight" -REQUIRE_POSTGRES=0 -case "${1:-}" in - "") ;; - --require-postgres) REQUIRE_POSTGRES=1 ;; - *) - printf 'Usage: %s [--require-postgres]\n' "$0" >&2 - exit 2 - ;; -esac +CHECK_CDC=0 +CHECK_OTEL=0 +RERUN_CMD="./preflight.sh" + +usage() { + cat <<'EOF' +Usage: ./preflight.sh [--cdc] [--otel] [--all] -# Capture the calling shell's values for the vars docker compose interpolates, -# BEFORE anything else runs, so the shell-vs-.env.workshop override check can tell -# whether an exported shell value would silently win over the file (see that -# section below). Unset -> empty, which the check treats as "not overriding". -SHELL_OPENAI_API_KEY="${OPENAI_API_KEY:-}" -SHELL_LANGFUSE_PUBLIC_KEY="${LANGFUSE_PUBLIC_KEY:-}" -SHELL_LANGFUSE_SECRET_KEY="${LANGFUSE_SECRET_KEY:-}" -SHELL_CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" +With no flags, check only the module 00 base app. + --cdc also check module 03 Postgres settings and connectivity + --otel also check module 05 OpenTelemetry collector ports + --all run every stage-specific check +EOF +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --cdc) CHECK_CDC=1 ;; + --otel) CHECK_OTEL=1 ;; + --all) CHECK_CDC=1; CHECK_OTEL=1 ;; + -h|--help) usage; exit 0 ;; + *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; + esac + shift +done + +[ "$CHECK_CDC" -eq 1 ] && RERUN_CMD="$RERUN_CMD --cdc" +[ "$CHECK_OTEL" -eq 1 ] && RERUN_CMD="$RERUN_CMD --otel" # --- Output helpers -------------------------------------------------------- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then @@ -183,6 +196,15 @@ env_get() { echo "$val" } +# Return success when a required value is blank or still contains the +# template's angle-bracket placeholder. +is_unset_or_placeholder() { + case "$1" in + ''|\<*\>) return 0 ;; + *) return 1 ;; + esac +} + # resolve_port VAR DEFAULT -- effective host port (env value, else default). resolve_port() { local v @@ -220,18 +242,19 @@ check_port() { fi } -# check_shell_override VAR SHELL_VALUE -- warn if an exported shell value would -# silently win over .env.workshop. docker compose resolves ${VAR} from the shell -# environment BEFORE the --env-file, so a value exported in the calling shell -# overrides a blank/different line in .env.workshop -- e.g. an exported -# OPENAI_API_KEY makes the app do real LLM calls even though the file looks empty. +# check_shell_override VAR -- fail if an exported shell value would silently win +# over .env.workshop. This includes exported empty values: Compose resolves ${VAR} +# from the shell before --env-file, so a stale blank PGHOST can override a newly +# completed file and send the trip writer the wrong connection tuple. check_shell_override() { - local var="$1" shell_v="$2" file_v - [ -n "$shell_v" ] || return 0 - file_v=$(env_get "$var") - if [ "$shell_v" != "$file_v" ]; then - warn "$var is set in your shell and differs from .env.workshop" \ - "docker compose uses the shell value (it overrides .env.workshop via interpolation), so the file is ignored for $var -- run 'unset $var' or set the same value in .env.workshop" + local var="$1" shell_v file_v + if env | grep -q "^${var}="; then + shell_v=$(printenv "$var") + file_v=$(env_get "$var") + if [ "$shell_v" != "$file_v" ]; then + fail "$var is exported in your shell and differs from .env.workshop" \ + "re-run: set -a; source ./.env.workshop; set +a (Compose gives the shell value precedence over --env-file)" + fi fi return 0 } @@ -239,6 +262,10 @@ check_shell_override() { # =========================================================================== printf '%sClickHouse BUILD workshop -- preflight%s\n' "$C_DIM" "$C_RST" printf '%sapp dir: %s%s\n' "$C_DIM" "$SCRIPT_DIR" "$C_RST" +if [ "$CHECK_CDC" -eq 1 ] || [ "$CHECK_OTEL" -eq 1 ]; then + printf '%sadditional checks: cdc=%s otel=%s%s\n' \ + "$C_DIM" "$CHECK_CDC" "$CHECK_OTEL" "$C_RST" +fi # --- Docker CLI + daemon --------------------------------------------------- section "Docker engine" @@ -373,6 +400,8 @@ CH_HOST="" CH_PORT="8443" CH_PW="" PGHOST_VAL="" +PGPORT_VAL="5432" +PG_CONFIG_OK=1 if [ "$HAVE_ENV" -eq 1 ]; then pass ".env.workshop found" @@ -422,63 +451,112 @@ if [ "$HAVE_ENV" -eq 1 ]; then "add LANGFUSE_PUBLIC_KEY/SECRET_KEY before module 08 if you want traces; chat works untraced without them" fi - PGHOST_VAL=$(env_get PGHOST) - case "$PGHOST_VAL" in - postgres|localhost|127.0.0.1|0.0.0.0|::1|host.docker.internal) - fail "PGHOST=$PGHOST_VAL points to a local database, which this workshop does not use" \ - "replace PGHOST with the hostname returned by 'clickhousectl cloud postgres create' in Module 03" - ;; - "") - if [ "$REQUIRE_POSTGRES" -eq 1 ]; then - fail "PGHOST is empty" \ - "complete Module 03, then add the managed Postgres hostname to .env.workshop" + if [ "$CHECK_OTEL" -eq 1 ]; then + OTLP_TOKEN=$(env_get OTLP_AUTH_TOKEN) + case "$OTLP_TOKEN" in + ""|\<*\>|change-me*|changeme*|replace-*) + fail "OTLP_AUTH_TOKEN is missing or still a placeholder for module 05" \ + "generate a random token, save it in .env.workshop, source the file again, then rerun with --otel" + ;; + *) + pass "OTLP_AUTH_TOKEN is configured for module 05" + ;; + esac + fi + + if [ "$CHECK_CDC" -eq 1 ]; then + PGHOST_VAL=$(env_get PGHOST) + PGPORT_VAL=$(env_get PGPORT); [ -n "$PGPORT_VAL" ] || PGPORT_VAL="5432" + PGSSLMODE_VAL=$(env_get PGSSLMODE) + + case "$PGHOST_VAL" in + postgres|localhost|127.0.0.1|0.0.0.0|::1|host.docker.internal) + fail "PGHOST=$PGHOST_VAL points to a local database, which this workshop does not use" \ + "replace PGHOST with the hostname returned by 'clickhousectl cloud postgres create' in Module 03" + PG_CONFIG_OK=0 + ;; + ""|\<*\>) + fail "PGHOST is not configured for module 03" \ + "create managed Postgres first, paste its hostname into .env.workshop, then rerun with --cdc" + PG_CONFIG_OK=0 + ;; + *) + pass "PGHOST=$PGHOST_VAL (using managed Postgres)" + ;; + esac + + for pg_var in PGUSER PGDATABASE PGPASSWORD; do + pg_val=$(env_get "$pg_var") + if is_unset_or_placeholder "$pg_val"; then + fail "$pg_var is not configured for module 03" \ + "fill $pg_var in .env.workshop, then rerun with --cdc" + PG_CONFIG_OK=0 else - pass "managed Postgres is not configured yet (expected before Module 03)" - fi - ;; - *) - pass "PGHOST=$PGHOST_VAL (using managed Postgres)" - if [ -z "$(env_get PGPASSWORD)" ]; then - fail "PGPASSWORD is empty for managed Postgres" \ - "add the one-time password returned by 'clickhousectl cloud postgres create'" + pass "$pg_var is set" fi - if [ "$(env_get PGSSLMODE)" != "require" ]; then - fail "PGSSLMODE must be require for managed Postgres" \ - "set PGSSLMODE=require in .env.workshop" - fi - ;; - esac + done + + if [ "$PGSSLMODE_VAL" = "require" ]; then + pass "PGSSLMODE=require (TLS required for managed Postgres)" + else + fail "PGSSLMODE must be require for managed Postgres (current: ${PGSSLMODE_VAL:-empty})" \ + "set PGSSLMODE=require in .env.workshop" + PG_CONFIG_OK=0 + fi + fi else fail ".env.workshop not found in $SCRIPT_DIR" \ "run: cp .env.workshop.example .env.workshop then fill in CLICKHOUSE_HOST and CLICKHOUSE_PASSWORD" if [ -f "$EXAMPLE_FILE" ]; then - warn "port checks below use the example defaults (8080/8000/4317/4318)" \ + warn "port checks below use the built-in defaults for the selected stage" \ "create .env.workshop so preflight checks your real, effective ports" fi + [ "$CHECK_CDC" -eq 1 ] && PG_CONFIG_OK=0 fi # --- Shell environment vs .env.workshop ------------------------------------ section "Shell environment vs .env.workshop" -_warns_before=$WARN_COUNT -check_shell_override OPENAI_API_KEY "$SHELL_OPENAI_API_KEY" -check_shell_override LANGFUSE_PUBLIC_KEY "$SHELL_LANGFUSE_PUBLIC_KEY" -check_shell_override LANGFUSE_SECRET_KEY "$SHELL_LANGFUSE_SECRET_KEY" -check_shell_override CLICKHOUSE_PASSWORD "$SHELL_CLICKHOUSE_PASSWORD" -if [ "$WARN_COUNT" -eq "$_warns_before" ]; then - pass "no shell variable is shadowing .env.workshop (OPENAI_API_KEY, LANGFUSE_*, CLICKHOUSE_PASSWORD)" +_fails_before=$FAIL_COUNT +for shell_var in \ + CLICKHOUSE_HOST CLICKHOUSE_PORT CLICKHOUSE_USER CLICKHOUSE_PASSWORD \ + CLICKHOUSE_DATABASE CLICKHOUSE_SECURE CLICKHOUSE_CONNECT_TIMEOUT \ + API_CORS_ORIGINS QUERY_TIMEOUT_SECONDS MAX_ROWS_TO_READ MAX_BYTES_TO_READ \ + OPENAI_API_KEY LLM_MODEL LLM_BASE_URL LANGFUSE_PUBLIC_KEY \ + LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL BACKEND_HOST_PORT FRONTEND_HOST_PORT; do + check_shell_override "$shell_var" +done +if [ "$CHECK_CDC" -eq 1 ]; then + for shell_var in \ + PGHOST PGPORT PGDATABASE PGUSER PGPASSWORD PGSSLMODE PG_PUBLICATION \ + RATE_PER_SEC BATCH_SIZE; do + check_shell_override "$shell_var" + done +fi +if [ "$CHECK_OTEL" -eq 1 ]; then + for shell_var in \ + OTLP_AUTH_TOKEN CLICKSTACK_DATABASE OTEL_SERVICE_NAME \ + OTEL_FRONTEND_SERVICE_NAME OTEL_GRPC_HOST_PORT OTEL_HTTP_HOST_PORT LOG_LEVEL; do + check_shell_override "$shell_var" + done +fi +if [ "$FAIL_COUNT" -eq "$_fails_before" ]; then + pass "no exported shell variable is shadowing .env.workshop for this stage" fi # --- Host ports ------------------------------------------------------------ section "Host port availability (effective ports)" FRONTEND_PORT=$(resolve_port FRONTEND_HOST_PORT 8080) BACKEND_PORT=$(resolve_port BACKEND_HOST_PORT 8000) -OTEL_GRPC_PORT=$(resolve_port OTEL_GRPC_HOST_PORT 4317) -OTEL_HTTP_PORT=$(resolve_port OTEL_HTTP_HOST_PORT 4318) check_port FRONTEND_HOST_PORT "$FRONTEND_PORT" core "" check_port BACKEND_HOST_PORT "$BACKEND_PORT" core "" -check_port OTEL_GRPC_HOST_PORT "$OTEL_GRPC_PORT" optional " (only with the otel overlay)" -check_port OTEL_HTTP_HOST_PORT "$OTEL_HTTP_PORT" optional " (only with the otel overlay)" + +if [ "$CHECK_OTEL" -eq 1 ]; then + OTEL_GRPC_PORT=$(resolve_port OTEL_GRPC_HOST_PORT 4317) + OTEL_HTTP_PORT=$(resolve_port OTEL_HTTP_HOST_PORT 4318) + check_port OTEL_GRPC_HOST_PORT "$OTEL_GRPC_PORT" core " (OpenTelemetry overlay, module 05)" + check_port OTEL_HTTP_HOST_PORT "$OTEL_HTTP_PORT" core " (OpenTelemetry overlay, module 05)" +fi # --- Connectivity ---------------------------------------------------------- section "Connectivity" @@ -507,21 +585,13 @@ else "set CLICKHOUSE_HOST in .env.workshop first" fi -if [ -n "$PGHOST_VAL" ]; then - PGPORT_VAL=$(env_get PGPORT); [ -n "$PGPORT_VAL" ] || PGPORT_VAL="5432" +if [ "$CHECK_CDC" -eq 1 ] && [ "$PG_CONFIG_OK" -eq 1 ]; then if tcp_reachable "$PGHOST_VAL" "$PGPORT_VAL" 8; then pass "managed Postgres reachable ($PGHOST_VAL:$PGPORT_VAL, TCP)" else - if [ "$REQUIRE_POSTGRES" -eq 1 ]; then - fail "managed Postgres not reachable at $PGHOST_VAL:$PGPORT_VAL" \ - "check wifi / VPN / firewall and the managed Postgres endpoint or IP allowlist" - else - warn "managed Postgres not reachable at $PGHOST_VAL:$PGPORT_VAL (needed from Module 03 onward)" \ - "check wifi / VPN / firewall and the managed Postgres endpoint or IP allowlist" - fi + fail "managed Postgres not reachable at $PGHOST_VAL:$PGPORT_VAL" \ + "wait for provisioning, then check wifi / VPN / firewall, the hostname, and its IP allowlist before rerunning with --cdc" fi -else - pass "skipping managed Postgres connectivity until Module 03" fi # --- Summary --------------------------------------------------------------- @@ -529,7 +599,7 @@ printf '\n%s==================================================%s\n' "$C_DIM" "$C printf 'Preflight summary: %d passed, %d warning(s), %d failure(s)\n' "$PASS_COUNT" "$WARN_COUNT" "$FAIL_COUNT" if [ "$FAIL_COUNT" -gt 0 ]; then - printf '\n%sFailures to fix before `docker compose ... up`:%s\n' "$C_FAIL" "$C_RST" + printf '\n%sFailures to fix before the requested docker compose up:%s\n' "$C_FAIL" "$C_RST" for line in "${FAIL_LINES[@]}"; do printf ' - %s\n' "$line" done @@ -543,11 +613,18 @@ fi printf '\n' if [ "$FAIL_COUNT" -gt 0 ]; then - printf '%sOverall: NOT READY -- fix the failures above, then re-run ./preflight.sh%s\n' "$C_FAIL" "$C_RST" + printf '%sOverall: NOT READY -- fix the failures above, then rerun %s%s\n' "$C_FAIL" "$RERUN_CMD" "$C_RST" printf '%s==================================================%s\n' "$C_DIM" "$C_RST" exit 1 fi -printf '%sOverall: READY -- start the stack with:%s\n' "$C_PASS" "$C_RST" -printf ' docker compose --env-file .env.workshop -f docker-compose.workshop.yml up -d\n' +if [ "$CHECK_CDC" -eq 1 ]; then + printf '%sOverall: READY -- module 03 Postgres checks passed.%s\n' "$C_PASS" "$C_RST" + printf ' docker compose --env-file .env.workshop -f docker-compose.workshop.yml --profile cdc up -d pg-trip-writer\n' +elif [ "$CHECK_OTEL" -eq 1 ]; then + printf '%sOverall: READY -- module 05 OpenTelemetry checks passed.%s\n' "$C_PASS" "$C_RST" +else + printf '%sOverall: READY -- start the base app with:%s\n' "$C_PASS" "$C_RST" + printf ' docker compose --env-file .env.workshop -f docker-compose.workshop.yml up -d\n' +fi printf '%s==================================================%s\n' "$C_DIM" "$C_RST" exit 0 diff --git a/workshops/build_workshop/playbook/content/docs/instructor/05-clickstack.mdx b/workshops/build_workshop/playbook/content/docs/instructor/05-clickstack.mdx index 404d9de..735be42 100644 --- a/workshops/build_workshop/playbook/content/docs/instructor/05-clickstack.mdx +++ b/workshops/build_workshop/playbook/content/docs/instructor/05-clickstack.mdx @@ -24,7 +24,7 @@ collector as early as this module allows and keep it running. - OTLP host ports 4317/4318 already in use: have them set `OTEL_GRPC_HOST_PORT` / `OTEL_HTTP_HOST_PORT` in `.env.workshop` (preflight suggests values, e.g. 24317/24318) and, from anywhere inside the cloned repository, run - `cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" && ./preflight.sh`; + `cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" && ./preflight.sh --otel`; the back end reaches the collector in-network, so the remap is safe. - Collector overlay not started (forgot the second `-f docker-compose.otel.yml`), so nothing arrives in HyperDX. diff --git a/workshops/build_workshop/playbook/content/docs/learner/00-setup.mdx b/workshops/build_workshop/playbook/content/docs/learner/00-setup.mdx index bc80909..1024803 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/00-setup.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/00-setup.mdx @@ -197,17 +197,20 @@ The secret is displayed once. Store it outside the repository; do not put it in ## Step 4 — Install `clickhousectl` and ClickHouse client ```bash +# Install clickhousectl, the Cloud resource-management CLI. curl https://clickhouse.com/cli | sh export PATH="$HOME/.local/bin:$PATH" clickhousectl --version -# Installs the stable ClickHouse binary and creates ~/.local/bin/clickhouse. +# Install the stable ClickHouse binary. It includes the ClickHouse client. +clickhousectl local install stable clickhousectl local use stable clickhouse client --version ``` -Here `local use` selects the local **client binary version**. It does not install or -start a ClickHouse server; every query in this workshop targets ClickHouse Cloud. +`clickhousectl local install stable` downloads ClickHouse. `local use stable` selects the +local **client binary version** and creates `~/.local/bin/clickhouse`; it does not start a +ClickHouse server. Every query in this workshop targets ClickHouse Cloud. Add `~/.local/bin` to your shell profile if a new terminal cannot find either command. On Windows, both commands are installed inside Ubuntu; do not install or run their Windows @@ -261,19 +264,33 @@ clickhousectl cloud service list clickhousectl cloud service get ``` -From the service **Connect** dialog, copy the hostname and verify the local client. The -`--password` flag prompts without echoing the password: +From the service **Connect** dialog, copy its bare hostname. Put that hostname and the +one-time password into the existing ClickHouse fields in `.env.workshop`, then load the +file into this terminal: + +```bash +CLICKHOUSE_HOST= +CLICKHOUSE_PASSWORD= +``` + +```bash +set -a; source ./.env.workshop; set +a +``` + +Verify the local client with the loaded values: ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ + --port 9440 \ --secure \ - --user default \ - --password \ + --user "$CLICKHOUSE_USER" \ --query "SELECT version(), currentUser()" ``` -Expected: one row containing the ClickHouse version and `default`. +The client reads `CLICKHOUSE_PASSWORD` from the exported environment, so the secret does +not appear in process arguments. Expected: one row containing the ClickHouse version and +`default`. ## Step 7 — Configure agent skills and both MCP servers once @@ -385,9 +402,10 @@ OPENAI_API_KEY=sk-... Keep the model and API-base defaults already present in `.env.workshop`. -## Step 9 — Fill `.env.workshop` +## Step 9 — Finish and reload `.env.workshop` -Copy the service values from Step 6 and the keys from Step 8 into the existing fields: +The ClickHouse fields are already filled from Step 6. Add the keys from Step 8 to their +existing fields and confirm the complete file contains these values: ```bash CLICKHOUSE_HOST= @@ -403,7 +421,16 @@ LANGFUSE_BASE_URL=https://us.cloud.langfuse.com OPENAI_API_KEY=sk-... ``` -Do not export these names in the shell: exported values override the env file. +Load the completed file into the current terminal. `set -a` exports every value so later +commands can use `$CLICKHOUSE_HOST`, `$CLICKHOUSE_USER`, and `$CLICKHOUSE_PASSWORD` +directly: + +```bash +set -a; source ./.env.workshop; set +a +``` + +Run this command again whenever you change `.env.workshop`. It keeps the current shell and +Docker Compose on the same values. Do not commit the file. ## Step 10 — Run preflight and start the app @@ -415,7 +442,9 @@ cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" ``` Continue only when the last line is `Overall: READY`. Apply any printed fix and rerun the -script. Then start the stack: +script. This default preflight checks only the Module 00 base app. It intentionally does +not validate Postgres or telemetry because those values do not exist until Modules 03 +and 05. Then start the stack: ```bash docker compose --env-file .env.workshop -f docker-compose.workshop.yml up -d @@ -429,9 +458,9 @@ server is started locally. Empty dashboards are correct until Module 01. ## Completion check - `clickhousectl cloud service get ` reports the service is ready. -- `clickhouse client ... --query "SELECT version()"` succeeds. +- `clickhouse client --host "$CLICKHOUSE_HOST" ... --query "SELECT version()"` succeeds. - Your agent lists databases through the ClickHouse MCP. - `./preflight.sh` ends with `Overall: READY` from the app directory. -- Docker services are healthy and the local app loads. +- Only `backend` and `frontend` are running, both are healthy, and the local app loads. Continue to [01 ClickHouse Cloud](/docs/learner/01-clickhouse-cloud). diff --git a/workshops/build_workshop/playbook/content/docs/learner/01-clickhouse-cloud.mdx b/workshops/build_workshop/playbook/content/docs/learner/01-clickhouse-cloud.mdx index b05c4bf..de6b23d 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/01-clickhouse-cloud.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/01-clickhouse-cloud.mdx @@ -13,15 +13,18 @@ Prerequisite: Module 00 is complete and your terminal is in ## Step 1 — Verify the client connection -Replace the two placeholders with the service hostname and password saved in Module 00: +Module 00 loaded `.env.workshop` into this terminal, so this command uses those values +directly. If you opened a new terminal, reload them first with +`set -a; source ./.env.workshop; set +a`. The client reads `CLICKHOUSE_PASSWORD` from the +exported environment, keeping the secret out of process arguments and leaving standard +input available for the copyable SQL blocks below. ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ --port 9440 \ --secure \ - --user default \ - --password '' \ + --user "$CLICKHOUSE_USER" \ --query "SELECT version(), currentUser()" ``` @@ -33,11 +36,10 @@ This is the complete schema command. Copy it from this page; do not open a local ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ --port 9440 \ --secure \ - --user default \ - --password '' \ + --user "$CLICKHOUSE_USER" \ --multiquery <<'SQL' CREATE DATABASE IF NOT EXISTS nyc_tlc_data; @@ -178,11 +180,10 @@ Verify the objects: ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ --port 9440 \ --secure \ - --user default \ - --password '' \ + --user "$CLICKHOUSE_USER" \ --query "SHOW TABLES FROM nyc_tlc_data" ``` @@ -195,11 +196,10 @@ The command is safe to rerun: each insert has a count guard. ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ --port 9440 \ --secure \ - --user default \ - --password '' \ + --user "$CLICKHOUSE_USER" \ --multiquery <<'SQL' INSERT INTO nyc_tlc_data.taxi_zones (location_id, zone, borough, subregion) SELECT LocationID, Zone, Borough, service_zone @@ -272,11 +272,10 @@ Verify the load: ```bash clickhouse client \ - --host \ + --host "$CLICKHOUSE_HOST" \ --port 9440 \ --secure \ - --user default \ - --password '' \ + --user "$CLICKHOUSE_USER" \ --query " SELECT 'taxi_zones' AS table, count() AS rows FROM nyc_tlc_data.taxi_zones UNION ALL diff --git a/workshops/build_workshop/playbook/content/docs/learner/02-base-app.mdx b/workshops/build_workshop/playbook/content/docs/learner/02-base-app.mdx index 391cb7e..787006e 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/02-base-app.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/02-base-app.mdx @@ -64,7 +64,9 @@ module 01. ```text React front end -> FastAPI back end -> ClickHouse Cloud (connected + seeded in module 01) - -> ClickHouse-managed Postgres (operational source; created in module 03) + +Module 03 adds the live path: +pg-trip-writer -> managed Postgres -> ClickPipe -> ClickHouse Cloud -> dashboard ``` Ask your coding agent to summarize the analytics endpoints: @@ -84,9 +86,9 @@ Read the FastAPI backend and list each analytics endpoint, what it returns, and ## Wrap-up -You have a mental model of the app: two dashboards, a FastAPI API, a managed source Postgres, -and a chat panel waiting to be connected. Every later module changes one part of this -picture. +You have a mental model of the app: two dashboards, a FastAPI API querying ClickHouse, +a Postgres CDC source that is added next, and a chat panel waiting to be connected. Every +later module changes one part of this picture. ## End state diff --git a/workshops/build_workshop/playbook/content/docs/learner/03-realtime-cdc.mdx b/workshops/build_workshop/playbook/content/docs/learner/03-realtime-cdc.mdx index 361ab4a..5629cae 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/03-realtime-cdc.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/03-realtime-cdc.mdx @@ -31,7 +31,7 @@ clickhousectl cloud postgres create \ Save the returned **Postgres ID**, **hostname**, and one-time `postgres` password. The beta `list` and `get` commands may return empty or `FORBIDDEN`; the required readiness -check is `./preflight.sh --require-postgres` in Step 2. +check is `./preflight.sh --cdc` in Step 2. If the password is lost, create a new one: @@ -54,16 +54,33 @@ PGSSLMODE=require PG_PUBLICATION=pub_taxi ``` -Validate the managed endpoint, then start the local writer against it and follow its log: +Reload the file so Docker Compose cannot reuse the blank `PG*` values exported during +Module 00: + +```bash +set -a; source ./.env.workshop; set +a +``` + +Run the CDC-stage preflight. Unlike the Module 00 check, `--cdc` validates the complete +`PG*` tuple, requires TLS for managed Postgres, and confirms that the new endpoint accepts +a TCP connection: ```bash cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" -./preflight.sh --require-postgres -docker compose --profile cdc --env-file .env.workshop -f docker-compose.workshop.yml up -d pg-trip-writer -docker compose --profile cdc --env-file .env.workshop -f docker-compose.workshop.yml logs -f pg-trip-writer +./preflight.sh --cdc +``` + +Provisioning can take a few minutes. Apply any printed fix and rerun the command. Continue +only when the last line is `Overall: READY`. Then start the writer and follow its log: + +```bash +docker compose --env-file .env.workshop -f docker-compose.workshop.yml \ + --profile cdc up -d pg-trip-writer +docker compose --env-file .env.workshop -f docker-compose.workshop.yml logs -f pg-trip-writer ``` -Provisioning can take a few minutes. Continue when the log repeats: +The preflight proves network reachability. The log proves the writer can initialize the +table and publication. Continue when it repeats: ```text [loadgen] ensured realtime_trips table exists @@ -164,6 +181,7 @@ Ops dashboard interval to `1m` and auto-refresh to `5s`. ## Completion check - The writer log shows repeated inserts. +- `./preflight.sh --cdc` ends with `Overall: READY` before the writer starts. - `clickhousectl cloud clickpipe get ...` reports a running pipe. - `default.realtime_trips` and the dashboard-row count both increase. - The Ops dashboard updates without a manual refresh. diff --git a/workshops/build_workshop/playbook/content/docs/learner/05-clickstack.mdx b/workshops/build_workshop/playbook/content/docs/learner/05-clickstack.mdx index 2d8a8b2..0958544 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/05-clickstack.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/05-clickstack.mdx @@ -31,14 +31,11 @@ telemetry and is not a local ClickStack or HyperDX deployment. The collector publishes OTLP on host ports **4317** and **4318**, which are commonly - already in use. If `./preflight.sh` in `ClickHouse_Demos/workshops/build_workshop/app` - WARNed on them in module 00, set - `OTEL_GRPC_HOST_PORT` and `OTEL_HTTP_HOST_PORT` in `.env.workshop` to the values - preflight suggests (for example `24317` / `24318`) before starting the overlay. The - back end reaches the collector in-network, so remapping the host ports is safe. From - anywhere inside the cloned repository, re-run - `cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" && ./preflight.sh` - to confirm the ports are clear. + already in use. After you fill and source the observability settings below, the + `--otel` preflight checks both ports and requires authenticated ingest. If it reports + a conflict, set `OTEL_GRPC_HOST_PORT` and `OTEL_HTTP_HOST_PORT` in `.env.workshop` to + the suggested values (for example `24317` / `24318`). The back end reaches the + collector in-network, so remapping these host ports is safe. ### `.env.workshop` and `docker-compose.otel.yml` @@ -46,8 +43,16 @@ telemetry and is not a local ClickStack or HyperDX deployment. These live in the ClickStack observability section of `.env.workshop.example`; fill them in your `.env.workshop`: +Generate a random ingest token: + +```bash +python3 -c 'import secrets; print(secrets.token_hex(32))' +``` + +Copy that output into `OTLP_AUTH_TOKEN`: + ```bash -OTLP_AUTH_TOKEN=change-me-workshop-token # shared secret securing OTLP ingest +OTLP_AUTH_TOKEN= # shared secret securing OTLP ingest CLICKSTACK_DATABASE=otel # ClickStack's own otel_* tables OTEL_SERVICE_NAME=nyc-taxi-backend # the service name shown in HyperDX LOG_LEVEL=DEBUG # show successful queries in Log source @@ -61,6 +66,16 @@ source .env.workshop set +a ``` +Run the Module 05 preflight from anywhere inside the cloned repository: + +```bash +cd "$(git rev-parse --show-toplevel)/workshops/build_workshop/app" +./preflight.sh --otel +``` + +Continue only when the last line is `Overall: READY`. Apply any printed fix, source the +file again if you changed it, and rerun the same check. + Now bring the stack up with the overlay. The overlay sets `OTEL_ENABLED=true` on the back end, adds the `otel-collector` service (`clickhouse/clickstack-otel-collector`), and rebuilds the front end with the browser telemetry settings: @@ -73,7 +88,8 @@ docker compose --env-file .env.workshop \ The collector reuses `CLICKHOUSE_HOST` / `CLICKHOUSE_PORT` / `CLICKHOUSE_USER` / `CLICKHOUSE_PASSWORD` from `.env.workshop`; the back end exports OTLP to `http://otel-collector:4318` (HTTP/protobuf). To also scrape raw container stdout on a -Linux host, add `--profile container-logs`. +Linux host, add `--profile container-logs`. Host OTLP ports bind to `127.0.0.1` so the +authenticated receiver is not exposed to other machines on the local network. ## Step 2 — Enable Managed ClickStack on your service diff --git a/workshops/build_workshop/playbook/content/docs/learner/troubleshooting.mdx b/workshops/build_workshop/playbook/content/docs/learner/troubleshooting.mdx index 9e60c18..c93bdc3 100644 --- a/workshops/build_workshop/playbook/content/docs/learner/troubleshooting.mdx +++ b/workshops/build_workshop/playbook/content/docs/learner/troubleshooting.mdx @@ -141,9 +141,10 @@ has a ready-to-paste prompt that turns it into your instructor. base-port + 20000 convention, for example `set FRONTEND_HOST_PORT=28080 in .env.workshop`. The override vars are `FRONTEND_HOST_PORT`, `BACKEND_HOST_PORT`, and for the observability - overlay `OTEL_GRPC_HOST_PORT` / `OTEL_HTTP_HOST_PORT`. Set the suggested value, re-run - preflight, then start the stack. Only the host ports move; the in-container ports are - unchanged, so this is safe. + overlay `OTEL_GRPC_HOST_PORT` / `OTEL_HTTP_HOST_PORT`. For an observability-port clash, + rerun the command with `--otel`. Set the suggested value, rerun the same preflight, + then start the stack. Only the host ports move; the in-container ports are unchanged, + so this is safe. ### "platform does not match" warning @@ -253,9 +254,10 @@ has a ready-to-paste prompt that turns it into your instructor. (often a stale `OPENAI_API_KEY`, a `LANGFUSE_*`, or `CLICKHOUSE_PASSWORD`). - **Why** - `docker compose` interpolates `${VAR}` from your shell first, and an exported shell variable WINS over the file. -- **Fix** - in the shell you run compose from, - `unset OPENAI_API_KEY LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL CLICKHOUSE_PASSWORD`, - then bring the stack up again. Preflight warns when it detects this. +- **Fix** - reload the current file in the shell you use for preflight and Compose: + `set -a; source ./.env.workshop; set +a`. Preflight fails when any value relevant to + the selected stage differs, so rerun `./preflight.sh`, `./preflight.sh --cdc`, or + `./preflight.sh --otel` and continue only when it reports `Overall: READY`. ### Duplicate keys in .env.workshop diff --git a/workshops/build_workshop/playbook/package.json b/workshops/build_workshop/playbook/package.json index 4f98c97..9eacfa2 100644 --- a/workshops/build_workshop/playbook/package.json +++ b/workshops/build_workshop/playbook/package.json @@ -9,7 +9,7 @@ "build": "next build", "dev": "next dev", "start": "next start", - "types:check": "fumadocs-mdx && next typegen && tsc --noEmit", + "types:check": "next typegen && fumadocs-mdx && tsc --noEmit", "postinstall": "fumadocs-mdx" }, "dependencies": { diff --git a/workshops/build_workshop/scripts/check-docs.sh b/workshops/build_workshop/scripts/check-docs.sh index 32fdd94..c29cb02 100755 --- a/workshops/build_workshop/scripts/check-docs.sh +++ b/workshops/build_workshop/scripts/check-docs.sh @@ -72,6 +72,26 @@ if [ -e "${ROOT}/app/librechat/docker-compose.librechat.yml" ]; then exit 1 fi +fail_if_found \ + "module 01 client commands must consume the loaded environment instead of placeholders" \ + '|' \ + "${CONTENT}/learner/01-clickhouse-cloud.mdx" + +fail_if_found \ + "setup client commands must consume the loaded environment instead of a hostname placeholder" \ + '' \ + "${CONTENT}/learner/00-setup.mdx" + +fail_if_found \ + "learner client commands must use the environment credential without argv exposure or stdin prompts" \ + '--ask-password|--password[[:space:]]+"?\$CLICKHOUSE_PASSWORD"?' \ + "${CONTENT}/learner" + +fail_if_found \ + "active workshop docs must use the staged preflight flags" \ + '--require-postgres' \ + "${ROOT}/README.md" "${ROOT}/app" "${CONTENT}" + require_fixed() { local description=$1 local literal=$2 @@ -239,6 +259,38 @@ require_fixed \ 'LOG_LEVEL=${LOG_LEVEL:-DEBUG}' \ "${ROOT}/app/docker-compose.otel.yml" +require_fixed \ + "setup must explicitly install the stable ClickHouse binary and client" \ + 'clickhousectl local install stable' \ + "${CONTENT}/learner/00-setup.mdx" + +require_fixed \ + "setup must verify the standalone ClickHouse client" \ + 'clickhouse client --version' \ + "${CONTENT}/learner/00-setup.mdx" + +require_count \ + "setup must reload the workshop environment after each credential update" \ + 2 \ + 'set -a; source ./.env.workshop; set +a' \ + "${CONTENT}/learner/00-setup.mdx" + +require_count \ + "every module 01 client command must use CLICKHOUSE_HOST from the loaded environment" \ + 5 \ + '--host "$CLICKHOUSE_HOST"' \ + "${CONTENT}/learner/01-clickhouse-cloud.mdx" + +require_fixed \ + "module 01 must document the ClickHouse environment credential path" \ + 'client reads `CLICKHOUSE_PASSWORD` from the' \ + "${CONTENT}/learner/01-clickhouse-cloud.mdx" + +require_fixed \ + "module 03 must reload managed Postgres credentials before Compose" \ + 'set -a; source ./.env.workshop; set +a' \ + "${CONTENT}/learner/03-realtime-cdc.mdx" + require_count \ "initial and reset Ops dashboard intervals must both be one minute" \ 2 \ @@ -251,6 +303,54 @@ require_count \ 'auto_refresh_s: 5' \ "${ROOT}/app/frontend/src/pages/DashboardPage.tsx" +require_service_set() { + local description=$1 + local expected=$2 + shift 2 + local actual + actual=$(docker compose "$@" config --services | sort | tr '\n' ' ') + if [ "${actual}" != "${expected}" ]; then + echo "ERROR: ${description} (expected '${expected}', found '${actual}')" >&2 + exit 1 + fi +} + +if command -v docker >/dev/null 2>&1; then + require_service_set \ + "module 00 must start only the base app" \ + 'backend frontend ' \ + -f "${ROOT}/app/docker-compose.workshop.yml" + + require_service_set \ + "module 03 cdc profile must add only the trip writer" \ + 'backend frontend pg-trip-writer ' \ + -f "${ROOT}/app/docker-compose.workshop.yml" --profile cdc +fi + +require_fixed \ + "preflight must expose the module 03 stage check" \ + ' --cdc also check module 03 Postgres settings and connectivity' \ + "${ROOT}/app/preflight.sh" + +require_fixed \ + "preflight must expose the module 05 stage check" \ + ' --otel also check module 05 OpenTelemetry collector ports' \ + "${ROOT}/app/preflight.sh" + +require_fixed \ + "preflight must require authenticated OTLP ingest for module 05" \ + 'OTLP_AUTH_TOKEN is missing or still a placeholder for module 05' \ + "${ROOT}/app/preflight.sh" + +for literal in \ + '127.0.0.1:${OTEL_GRPC_HOST_PORT:-4317}:4317' \ + '127.0.0.1:${OTEL_HTTP_HOST_PORT:-4318}:4318'; do + require_fixed \ + "OTLP host receivers must bind to loopback" \ + "${literal}" \ + "${ROOT}/app/docker-compose.otel.yml" +done + # MCP server definitions belong in setup. Later modules should use or link to # that setup instead of asking learners to configure the same endpoint again. if grep -RInE --exclude='00-setup.mdx' \ diff --git a/workshops/build_workshop/scripts/test-preflight.sh b/workshops/build_workshop/scripts/test-preflight.sh new file mode 100755 index 0000000..4d21e3c --- /dev/null +++ b/workshops/build_workshop/scripts/test-preflight.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TMP=$(mktemp -d "${TMPDIR:-/tmp}/workshop-preflight-test.XXXXXX") +APP="${TMP}/app" +BIN="${TMP}/bin" +trap 'rm -rf "${TMP}"' EXIT + +mkdir -p "${APP}" "${BIN}" +cp "${ROOT}/app/preflight.sh" "${APP}/preflight.sh" + +cat > "${BIN}/docker" <<'EOF' +#!/usr/bin/env bash +case "${1:-}" in + --version) + echo "Docker version 27.0.0, build test" + ;; + info) + if [ "${2:-}" = "--format" ]; then + echo "8589934592" + fi + ;; + compose) + if [ "${2:-}" = "version" ] && [ "${3:-}" = "--short" ]; then + echo "2.30.0" + fi + ;; +esac +exit 0 +EOF + +cat > "${BIN}/curl" <<'EOF' +#!/usr/bin/env bash +echo "200" +EOF + +cat > "${BIN}/nc" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + +chmod +x "${BIN}/docker" "${BIN}/curl" "${BIN}/nc" + +write_env() { + local pg_host=${1:-} + local pg_password=${2:-} + local otlp_token=${3:-} + cat > "${APP}/.env.workshop" <&1) + STATUS=$? + set -e +} + +assert_status() { + local expected=$1 + if [ "${STATUS}" -ne "${expected}" ]; then + printf 'Expected exit %s, got %s. Output:\n%s\n' "${expected}" "${STATUS}" "${OUTPUT}" >&2 + exit 1 + fi +} + +assert_contains() { + local expected=$1 + if ! printf '%s\n' "${OUTPUT}" | grep -Fq -- "${expected}"; then + printf 'Expected output to contain %s. Output:\n%s\n' "${expected}" "${OUTPUT}" >&2 + exit 1 + fi +} + +assert_not_contains() { + local unexpected=$1 + if printf '%s\n' "${OUTPUT}" | grep -Fq -- "${unexpected}"; then + printf 'Expected output not to contain %s. Output:\n%s\n' "${unexpected}" "${OUTPUT}" >&2 + exit 1 + fi +} + +run_preflight --help +assert_status 0 +assert_contains "Usage: ./preflight.sh [--cdc] [--otel] [--all]" + +run_preflight --unknown +assert_status 2 +assert_contains "Unknown option: --unknown" + +# Module 00 ignores Postgres because it is not configured until Module 03. +write_env "postgres" "local-password" +run_preflight +assert_status 0 +assert_contains "Overall: READY -- start the base app with:" +assert_not_contains "points to a local database" +assert_not_contains "OTEL_GRPC_HOST_PORT" + +# Module 03 requires the complete managed-Postgres tuple. +write_env "" "" +run_preflight --cdc +assert_status 1 +assert_contains "PGHOST is not configured for module 03" +assert_contains "PGPASSWORD is not configured for module 03" +assert_contains "rerun ./preflight.sh --cdc" + +write_env "postgres" "local-password" +run_preflight --cdc +assert_status 1 +assert_contains "points to a local database, which this workshop does not use" + +write_env "managed-postgres.example.com" "managed-password" +run_preflight --cdc +assert_status 0 +assert_contains "managed Postgres reachable" +assert_contains "Overall: READY -- module 03 Postgres checks passed." + +# Exported shell values take precedence over --env-file in Docker Compose, so +# stale values must make preflight fail instead of certifying the file as ready. +set +e +OUTPUT=$(env -i NO_COLOR=1 PATH="${BIN}:${PATH}" HOME="${HOME}" \ + TMPDIR="${TMPDIR:-/tmp}" PGHOST= PGPASSWORD= \ + "${APP}/preflight.sh" --cdc 2>&1) +STATUS=$? +set -e +assert_status 1 +assert_contains "PGHOST is exported in your shell and differs from .env.workshop" +assert_contains "PGPASSWORD is exported in your shell and differs from .env.workshop" + +# Module 05 requires authenticated ingest and checks collector ports without +# requiring Module 03 credentials. +write_env "" "" +run_preflight --otel +assert_status 1 +assert_contains "OTLP_AUTH_TOKEN is missing or still a placeholder for module 05" + +write_env "" "" "test-otel-token" +run_preflight --otel +assert_status 0 +assert_contains "OTEL_GRPC_HOST_PORT" +assert_contains "OTEL_HTTP_HOST_PORT" +assert_contains "Overall: READY -- module 05 OpenTelemetry checks passed." + +set +e +OUTPUT=$(env -i NO_COLOR=1 PATH="${BIN}:${PATH}" HOME="${HOME}" \ + TMPDIR="${TMPDIR:-/tmp}" OTEL_HTTP_HOST_PORT=39999 \ + "${APP}/preflight.sh" --otel 2>&1) +STATUS=$? +set -e +assert_status 1 +assert_contains "OTEL_HTTP_HOST_PORT is exported in your shell and differs from .env.workshop" + +write_env "managed-postgres.example.com" "managed-password" "test-otel-token" +run_preflight --all +assert_status 0 +assert_contains "additional checks: cdc=1 otel=1" +assert_contains "managed Postgres reachable" +assert_contains "OTEL_GRPC_HOST_PORT" + +echo "Workshop staged preflight behavior checks passed."