#!/bin/sh # # tapstate quickstart — brings up the local demo from an empty directory: it downloads the compose stack, # the platform's CLI, and the demo connector jars, generates a demo workspace and a .env with a random # admin password, then starts the stack and runs a real MySQL -> MongoDB pipeline. Nothing is built. # # Usage, either form: # # curl -sSL /quickstart.sh | sh # # Piped, the script takes a directory of its own (./tapstate-demo) so everything it adds stays inside # one removable directory. Download-then-run works the same and is the form to pick when you want to # read the script first, re-run it, or inspect a failure -- the saved file marks the directory to # work in, so nothing nests: # # mkdir tapstate-demo && cd tapstate-demo # curl -fLO /quickstart.sh # sh quickstart.sh # # It never uses sudo, never edits a shell rc, and installs the CLI in place (./tapstate, not on PATH), so # `rm -rf` of this directory removes everything it added. An unsupported platform fails before anything is # fetched, leaving this directory as you found it. # # Environment seams: # TAPSTATE_QUICKSTART_BASE_URL where install.sh, the compose file, and the seed SQL are fetched from. # TAPSTATE_BASE_URL CLI release base, passed through to install.sh. # TAPSTATE_VERSION pin the CLI version (default: the pinned CLI_VERSION below). # TAPSTATE_CONNECTORS_URL base URL for the demo connector jars. # TAPSTATE_QUICKSTART_PREPARE_ONLY stop after preparing the directory, before Docker (used by tests). # # POSIX sh, no bashisms. All work is inside main(). set -eu # The CLI release this quickstart installs. install.sh's own default resolves /releases/latest, and # GitHub fills that only from full releases -- the CLI ships as a prerelease, so that lookup finds # nothing and install.sh refuses, which would strand the quickstart at the CLI step on a clean machine. # Pin it here instead, the same way the demo connector jars are pinned to a published tag. This must # match the version in pom.xml; quickstart-smoke.sh fails the build when the two drift apart. CLI_VERSION="0.1.0" die() { printf 'quickstart: %s\n' "$1" >&2 exit 1 } # Download $1 to the file $2 with whichever of curl / wget is present. fetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$1" -o "$2" elif command -v wget >/dev/null 2>&1; then wget -q "$1" -O "$2" else die "neither curl nor wget is available to download $1." fi } # Generate the demo workspace: a source, a target, and the pipeline joining them, ready to apply. The # addresses are compose service names because the connector runs inside the server container, where # loopback is the server itself. The heredocs are quoted so $customer stays the literal DSL rename token # it is, not a shell variable. This mirrors the online walkthrough's sample on purpose -- one sample, not # two that drift. The pipeline carries every change through, deletes included: a map leaves a delete (which # has no after image) untouched, so the sink removes the row by key. The decimal `amount` column is only # ever passed through, never named in a CEL: numeric columns have no CEL overload in this preview. generate_workspace() { mkdir -p work/source work/pipeline cat > work/source/db_src.tap.yml <<'YAML' version: tapstate/v1 kind: source id: db_src connector: mysql config: { host: mysql, port: 3306, database: appdb, username: root, password: secret } mode: cdc tables: [ orders ] YAML cat > work/source/warehouse.tap.yml <<'YAML' version: tapstate/v1 kind: source id: warehouse connector: mongodb config: { isUri: true, uri: "mongodb://mongo:27017/warehouse?directConnection=true" } YAML cat > work/pipeline/sync_orders.tap.yml <<'YAML' version: tapstate/v1 kind: pipeline id: sync_orders source: db_src settings: { read_mode: snapshot_and_cdc } transforms: - id: shape_orders from: [ orders ] type: map fields: customer_name: $customer label: "=after.customer + ' <' + src + '>'" serve: from: shape_orders sync: - source: warehouse YAML } # Closing instructions: how to watch the pipeline, exercise CDC, and remove everything. The teardown is # printed because "back to a clean machine" is only honest if the images are called out too. The CDC # section walks insert, update and delete -- the pipeline carries all three -- and each read retries for a # second or two so a change still in flight is never misread as a change that did not happen. print_next_steps() { demo_dir="$(basename "$PWD")" uri="mongodb://mongo:27017/warehouse?directConnection=true" cat < EOF } main() { # The piped form has no saved file and no stack beside it, so it takes a directory of its own -- # everything this script adds must stay inside one removable directory. Either marker file says # "work here": the saved script is the download-then-run form, the compose file is a re-run of an # earlier one (piped re-runs land back in the same directory rather than nesting a second). if [ ! -f ./quickstart.sh ] && [ ! -f ./docker-compose.yml ]; then mkdir -p tapstate-demo cd tapstate-demo printf 'quickstart: working in %s\n' "$PWD" fi # The whole product runs as a compose stack, so a machine without Docker is refused here, before # anything is downloaded -- an actionable sentence beats "docker: command not found" three # downloads later. The prepare-only test seam deliberately skips this: it exists to stop before # Docker, so it must not require it. The CLI alone needs neither; say where to get it. if [ -z "${TAPSTATE_QUICKSTART_PREPARE_ONLY:-}" ]; then command -v docker >/dev/null 2>&1 \ || die "Docker is required to run the stack. Install Docker with the Compose v2 plugin, or install only the offline CLI: curl -sSL https://install.tapstate.dev/cli | sh" docker compose version >/dev/null 2>&1 \ || die "Docker is present but the Compose v2 plugin is not ('docker compose version' failed). Update Docker, or install only the offline CLI: curl -sSL https://install.tapstate.dev/cli | sh" fi # Where the stack's assets come from: the same release the CLI is pinned to, derived from that pin # rather than named separately. A branch would keep moving after the release, handing a later user a # CLI frozen at one version beside a compose file from another -- a mismatch that shows up only on # their machine. Deriving it means the release tag decides both, and there is no step to remember. qbase="${TAPSTATE_QUICKSTART_BASE_URL:-https://raw.githubusercontent.com/tapstate/tapstate/v${CLI_VERSION}}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT INT TERM # Platform gate: fetch install.sh into the throwaway work area and reuse its detection. This refuses # an unsupported platform (Windows shell, musl, unknown OS/arch) before anything is written into the # demo directory, and shares one copy of the uname mapping rather than duplicating it here. fetch "${qbase}/install/install.sh" "$work/install.sh" if ! platform="$(sh "$work/install.sh" --print-platform)"; then exit 1 # install.sh already said why (musl / Windows / unknown), pointing to WSL or source fi # Fetch the stack into this directory, each asset only if absent, so a re-run neither re-downloads a # verified asset nor overwrites an edit the user made to it. The seed dir is created empty on purpose: # a registered jar's bytes live in the store, and the demo registers over the CLI upload path, so the # seed stays the documented empty convenience rather than the route registration depends on. mkdir -p mysql-init connectors [ -f ./docker-compose.yml ] || fetch "${qbase}/deploy/quickstart/docker-compose.yml" ./docker-compose.yml [ -f ./mysql-init/01-orders.sql ] || fetch "${qbase}/deploy/quickstart/mysql-init/01-orders.sql" ./mysql-init/01-orders.sql # Install the CLI in place as ./tapstate, reusing install.sh wholesale (download, checksum, atomic # place). TAPSTATE_INSTALL_DIR here is the seam that keeps it out of PATH: `rm -rf` of this directory # removes it. install.sh's own stdout (a PATH hint that does not apply in place) is dropped; its # errors still surface and abort under set -e. if [ ! -x ./tapstate ]; then TAPSTATE_INSTALL_DIR="$PWD" TAPSTATE_VERSION="${TAPSTATE_VERSION:-$CLI_VERSION}" \ sh "$work/install.sh" >/dev/null # A binary fetched by a browser carries macOS's quarantine attribute, which blocks it from running # until cleared; install.sh's atomic move preserves it. Strip it -- only on macOS, only if xattr # is present, and tolerating the case where the attribute was never set. case "$platform" in darwin-*) if command -v xattr >/dev/null 2>&1; then xattr -d com.apple.quarantine ./tapstate 2>/dev/null || true; fi ;; esac fi # The demo connector jars. Any PDK jar registers the same way; these two are fetched so the demo runs # without the user choosing. They sit outside connectors/ so the seed dir stays empty. cbase="${TAPSTATE_CONNECTORS_URL:-${TAPSTATE_BASE_URL:-https://github.com/tapstate/tapstate/releases}/download/connectors-preview}" [ -f ./mysql-connector.jar ] || fetch "${cbase}/mysql-connector.jar" ./mysql-connector.jar [ -f ./mongodb-connector.jar ] || fetch "${cbase}/mongodb-connector.jar" ./mongodb-connector.jar # A random admin password replaces the shipped admin/admin default so a stack left running is not # trivially reachable. It is written only to .env (readable by this user alone) and announced once # here -- never passed as a CLI argument, so it stays out of the process table and shell history. A # re-run keeps the existing .env: regenerating it would lock the user out of the admin already # bootstrapped against the old password. if [ ! -f .env ]; then admin_pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 24)" printf 'TAPSTATE_ADMIN_USER=admin\nTAPSTATE_ADMIN_PASSWORD=%s\n' "$admin_pw" > .env chmod 600 .env printf 'quickstart: generated a random admin password, saved to .env: %s\n' "$admin_pw" fi # Generate the demo workspace, unless one is already here: a re-run must not clobber edits the user # made to their resources. if [ ! -d work ]; then generate_workspace fi if [ -n "${TAPSTATE_QUICKSTART_PREPARE_ONLY:-}" ]; then return fi # Bring up the stack. The compose file pins the published image, so this pulls rather than builds. docker compose up -d # Wait until the server container reports healthy -- its image carries the /healthz healthcheck -- so # the online verbs are not driven before the server can answer. By then the bootstrap sidecar has # created the admin from .env. printf 'quickstart: waiting for the stack to become healthy' i=0 while [ "$i" -lt 90 ]; do if docker compose ps --format json server 2>/dev/null | grep -q '"Health":"healthy"'; then break fi i=$((i + 1)); printf '.'; sleep 2 done printf '\n' docker compose ps --format json server 2>/dev/null | grep -q '"Health":"healthy"' \ || die "the server did not become healthy in time; inspect it with: docker compose logs server" # Drive the online verbs through the REPL, feeding the password on stdin (the login prompt reads the # next line) so it is never a process argument or a shell-history entry. Workspace paths resolve # against work/, so the jars beside it are ../. admin_pw="$(sed -n 's/^TAPSTATE_ADMIN_PASSWORD=//p' .env)" printf 'connect http://127.0.0.1:8080\nlogin admin\n%s\nregister ../mysql-connector.jar\nregister ../mongodb-connector.jar\napply\ndiscover-schema db_src\nstart sync_orders\nexit\n' "$admin_pw" \ | ./tapstate -w work # Snapshot verification, printed automatically: the demo's payoff is a real row count in the target, # not an "it should have worked". A fresh snapshot of the seeded rows is quick, but the read still # retries so a slow first run is not misreported as an empty target. echo 'quickstart: waiting for the snapshot to reach the target' seeded=5 # rows the demo seed puts in MySQL; the target is complete once it holds them all rows=0; i=0 while [ "$i" -lt 30 ]; do rows="$(docker compose exec -T mongo mongosh --quiet \ 'mongodb://mongo:27017/warehouse?directConnection=true' \ --eval 'db.orders.countDocuments()' 2>/dev/null | tr -d '[:space:]')" case "$rows" in ''|*[!0-9]*) rows=0 ;; esac [ "$rows" -ge "$seeded" ] && break i=$((i + 1)); sleep 2 done # Falling out of that loop short is a failed run, and it has to be said with a non-zero exit. The # REPL above cannot say it: an interactive session does not end because one command was rejected, # so it exits 0 whether the verbs took or errored, and set -e sees nothing wrong. This count is the # only evidence the script has that a pipeline is moving data. The stack is left standing rather # than torn down -- the server log is the next thing to read, and a teardown would take it along. [ "$rows" -ge "$seeded" ] \ || die "the snapshot did not reach the target ($rows of $seeded rows); inspect it with: docker compose logs server" printf 'quickstart: the target now holds %s rows (MySQL orders -> MongoDB warehouse.orders)\n' "$rows" print_next_steps } main "$@"