diff --git a/README.md b/README.md index 69d6051..19a00d1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Stack allows building and deployment of a system of related containerized applications as a single "stack". Transparently deploy to local Docker, Podman or to remote Kubernetes. -![Building and deploying a three-container todo app — front end, API and PostgreSQL — to local Docker with stack](./docs/images/quickstart.gif) +![Building a three-container todo app — front end, API and PostgreSQL — and deploying it with stack, first to local Docker and then, unchanged, to a Kubernetes cluster over HTTPS](./docs/images/quickstart.gif) -_An unedited recording of the [Docker quick start](#docker) below. Regenerate it with `./demo/record-quickstart.sh`._ +_An unedited recording of the [Docker quick start](#docker) below, followed by the same stack deployed to a real Kubernetes cluster. Regenerate it with `./demo/k8s-host.sh create && ./demo/record-quickstart.sh` (see [demo/README.md](./demo/README.md))._ ## What is Stack good for? Stack is useful for a wide category of software applications including those that have a web app component, back-end services and optionally a database: web systems. diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..080e84c --- /dev/null +++ b/demo/README.md @@ -0,0 +1,85 @@ +# Demo recordings + +`docs/images/quickstart.gif`, the animation at the top of the project README, is +recorded from `quickstart.tape` by [vhs](https://github.com/charmbracelet/vhs). +Nothing in it is faked or edited: every command really runs, against the +[example todo stack](https://github.com/bozemanpass/example-todo-list), and it +deploys that stack twice — first to local Docker, then the same stack, unchanged +except for its deployment spec, to a real single-node Kubernetes (k3s) cluster +on a cloud VM, reached over HTTPS with a real Let's Encrypt certificate. + +## Recording it + +The Kubernetes cluster is provisioned separately from the recording, because +creating a VM and installing k3s, cert-manager and the rest takes minutes and +costs money, while getting a take you like usually means recording several +times: + +```bash +./demo/k8s-host.sh create # once, ~5 minutes +./demo/record-quickstart.sh # as many takes as you like +./demo/k8s-host.sh destroy # when you are done for the day +``` + +`k8s-host.sh info` prints the current host. The host state (its name, id and +FQDN, and a kubeconfig that reaches it) lives in `~/.cache/stack-demo/k8s-host`, +overridable with `STACK_DEMO_STATE_DIR`. + +## Requirements + +* `vhs`, `ttyd`, `ffmpeg`, `docker`, `jq`, `openssl`, `kubectl`, and `stack` on `PATH` + (`sudo apt-get install -y ffmpeg ttyd`; vhs from its + [releases page](https://github.com/charmbracelet/vhs/releases)) +* [`machine`](https://github.com/stirlingbridge/machine), for `k8s-host.sh` +* Environment for the cloud host and the image registry — the same variable + names the k3s CI job passes to `tests/k3s-deploy/run-k3s-deploy-test.sh`: + `MACHINE_DO_TOKEN`, `MACHINE_SSH_KEY_NAME`, `MACHINE_SSH_KEY_FILE`, + `MACHINE_DNS_ZONE`, `MACHINE_PROJECT`, `STACK_IMAGE_REGISTRY`, + `STACK_IMAGE_REGISTRY_USER`, `STACK_IMAGE_REGISTRY_TOKEN`, + `LETSENCRYPT_EMAIL`. `k8s-host.sh` provisions the host exactly the way that + test provisions its cluster, so a working setup for one works for the other. + +`record-quickstart.sh` only needs the registry variables and an existing host; +it never creates or destroys cloud resources. + +## How the recording stays short + +The animation has to be watchable in a README, so the recorder prepares state +off camera such that each recorded command still does real work but returns +quickly: the repo clone happens into a scratch `STACK_REPO_BASE_DIR`, the todo +image *tags* are dropped while Docker's layer cache is kept (so the recorded +build is genuine but takes seconds), and the images are pushed to the registry +in advance so the recorded `push-images` has nothing left to upload. + +The waits that cannot be prepared away — the frontend's dev server coming up +under Docker, and on the cluster the pods becoming ready, the gateway routing +to them and, on a first deployment of a hostname, the ACME HTTP-01 exchange — +happen in `Hide` blocks. Each of those blocks ends with a `clear` *inside* the +block: `Hide` stops recording frames but the terminal still holds the line that +was typed, so without it the wait loop would sit on screen for the rest of the +recording. + +Two details keep repeated takes cheap: + +* The recorded `stack deploy` passes a fixed `--cluster` name. That name is the + Kubernetes namespace, and the certificate Secret is named after it, so + redeploying reuses the certificate cert-manager already holds instead of + asking Let's Encrypt for a new one every take (see `remove_https_listener` in + `src/stack/deploy/k8s/gateway.py`). +* The VM's node image cache and the registry both survive between takes. + +## The tape is a template + +The Kubernetes host's FQDN, its kubeconfig path, the image registry and the +host ports the Docker half curls are all decided outside the tape, so +`quickstart.tape` carries them as `@@...@@` placeholders. +`record-quickstart.sh` substitutes them into a rendered copy under +`/tmp/stack-demo` and runs vhs against that. Run vhs against the rendered copy, +not against `quickstart.tape` itself. + +The ports are worked out by generating a throwaway Compose spec and reading the +mapped host ports out of it, rather than being written down anywhere — the todo +stack has moved its frontend and backend ports before, and a recording that +curls a stale port either prints a failure or, worse, quietly gets an answer +from something else. For the same reason the recorder refuses to start if +anything is already listening on one of those ports. diff --git a/demo/k8s-host.sh b/demo/k8s-host.sh new file mode 100755 index 0000000..847642f --- /dev/null +++ b/demo/k8s-host.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# +# Create, inspect and destroy the Kubernetes host used by the quick-start +# recording. +# +# The recording (demo/quickstart.tape) deploys the same stack twice: once to +# local Docker, then to a real single-node k3s cluster with a real hostname and +# a real Let's Encrypt certificate. That cluster lives on a cloud VM, which +# takes several minutes and real money to provision -- far too slow to do on +# every take. So the VM's lifecycle is separate from the recording's: +# +# ./demo/k8s-host.sh create # once, ~5 minutes +# ./demo/record-quickstart.sh # as many takes as you like +# ./demo/k8s-host.sh destroy # when you are done for the day +# +# The VM is provisioned exactly the way the k3s CI job provisions its test +# cluster (tests/k3s-deploy/run-k3s-deploy-test.sh) -- the same "machine" +# utility, the same machine-provisioning scripts, the same environment variable +# names -- so a host that works for one works for the other. +# +# State (the machine's name, id and FQDN, and a kubeconfig that reaches it) is +# written to $STACK_DEMO_STATE_DIR, where record-quickstart.sh picks it up. +# +# Requires: machine (https://github.com/stirlingbridge/machine), jq, ssh. +# +# Required environment (same names the k3s test takes): +# MACHINE_DO_TOKEN DigitalOcean API token +# MACHINE_SSH_KEY_NAME Name of an SSH key registered at the provider +# MACHINE_SSH_KEY_FILE Path to the matching private key file +# MACHINE_DNS_ZONE DNS zone hosted at the provider; the demo hostname +# is a name under this zone +# STACK_IMAGE_REGISTRY Registry the demo's images are pushed to and the +# cluster pulls them from, including any org path +# STACK_IMAGE_REGISTRY_USER +# STACK_IMAGE_REGISTRY_TOKEN +# Credentials for that registry (for a DigitalOcean +# registry, pass the API token as both) +# LETSENCRYPT_EMAIL Let's Encrypt contact address +# +# Optional environment: +# STACK_DEMO_STATE_DIR Where to keep the host state +# (default ~/.cache/stack-demo/k8s-host) +# STACK_DEMO_MACHINE_NAME Machine hostname under the zone (default stackdemo) +# MACHINE_REGION Provider region (default nyc3) +# MACHINE_SIZE Machine size slug (default s-2vcpu-4gb) +# MACHINE_IMAGE Machine image (default ubuntu-24-04-x64) +# MACHINE_PROJECT DigitalOcean project to assign the VM to +# MACHINE_PROVISIONING_URL URL of combine.sh (default: the main branch) +# MACHINE_CMD The machine command to run (default: machine) +# +set -e + +if [ -n "$STACK_SCRIPT_DEBUG" ]; then + set -x +fi + +MACHINE_CMD=${MACHINE_CMD:-machine} +MACHINE_REGION=${MACHINE_REGION:-nyc3} +MACHINE_SIZE=${MACHINE_SIZE:-s-2vcpu-4gb} +MACHINE_IMAGE=${MACHINE_IMAGE:-ubuntu-24-04-x64} +MACHINE_PROVISIONING_URL=${MACHINE_PROVISIONING_URL:-https://raw.githubusercontent.com/stirlingbridge/machine-provisioning/refs/heads/main/scripts/combine.sh} +MACHINE_NEW_USER=stackdemo + +STATE_DIR=${STACK_DEMO_STATE_DIR:-$HOME/.cache/stack-demo/k8s-host} +MACHINE_NAME=${STACK_DEMO_MACHINE_NAME:-stackdemo} + +# The demo deploys to the Gateway API arrangement (k3s-node.sh's own default), +# which is what gets the application its own Let's Encrypt certificate. +machine_config=$STATE_DIR/machine-config.yml +host_env=$STATE_DIR/host.env +kube_config=$STATE_DIR/kubeconfig + +# How long to allow for the VM to boot and cloud-init to install k3s, +# cert-manager etc. (seconds). +PROVISION_TIMEOUT=1800 + +usage () { + cat <<'USAGE' +Usage: demo/k8s-host.sh + + create Provision the demo's k3s host (no-op if one already exists) + info Print the current host's name, id and FQDN + destroy Destroy the host and delete its DNS record +USAGE +} + +require_tools () { + for cmd in "$MACHINE_CMD" jq ssh; do + if ! command -v "$cmd" &> /dev/null; then + echo "Error: $cmd is not installed." >&2 + exit 1 + fi + done +} + +require_env () { + missing="" + for var in MACHINE_DO_TOKEN MACHINE_SSH_KEY_NAME MACHINE_SSH_KEY_FILE MACHINE_DNS_ZONE \ + STACK_IMAGE_REGISTRY STACK_IMAGE_REGISTRY_USER STACK_IMAGE_REGISTRY_TOKEN LETSENCRYPT_EMAIL; do + if [ -z "${!var}" ]; then + missing="$missing $var" + fi + done + if [ -n "$missing" ]; then + echo "Error: required environment not set:$missing" >&2 + exit 1 + fi + if [ ! -f "$MACHINE_SSH_KEY_FILE" ]; then + echo "Error: MACHINE_SSH_KEY_FILE $MACHINE_SSH_KEY_FILE does not exist" >&2 + exit 1 + fi +} + +write_machine_config () { + # The registry credentials in script-args end up in the VM's cloud-init user + # data; use a registry and credentials dedicated to demos and testing. + # health.sh serves the status endpoint that "machine status" polls; + # k3s-node.sh needs the registry credentials so the cluster can pull the + # demo's images. + mkdir -p "$STATE_DIR" + cat > "$machine_config" <> "$machine_config" + fi + cat >> "$machine_config" </dev/null \ + | jq -r 'length' 2>/dev/null)" == "1" ]; then + echo "Demo k8s host already exists:" + do_info + echo + echo "Destroy it with ./demo/k8s-host.sh destroy, or record with ./demo/record-quickstart.sh" + return 0 + fi + echo "Stale host state in $STATE_DIR (no such machine); re-creating" + rm -f "$host_env" "$kube_config" + fi + + write_machine_config + + echo "Creating machine $machine_fqdn" + $MACHINE_CMD --config-file "$machine_config" create --name "$MACHINE_NAME" --type k8s-stack-host --wait-for-ip + + machine_id=$($MACHINE_CMD --config-file "$machine_config" list --name "$MACHINE_NAME" --output json | jq -r '.[0].id') + if [ -z "$machine_id" ] || [ "$machine_id" == "null" ]; then + echo "Error: could not determine the id of the created machine" >&2 + exit 1 + fi + echo "Machine created with id $machine_id" + # Record the id before provisioning finishes, so a failed or interrupted + # provision still leaves something "destroy" can clean up. + cat > "$host_env" <&2 + machine_ssh "$machine_fqdn" "sudo tail -100 /var/log/cloud-init-output.log" || true + exit 1 + ;; + *) + sleep 15 + ;; + esac + done + if [ "$provision_status" != "UP" ]; then + echo "Error: timed out waiting for provisioning to complete (last status: $provision_status)" >&2 + exit 1 + fi + echo "Provisioning complete" + + # The kubeconfig k3s writes names the local address; the machine's FQDN is in + # the API server certificate (k3s-node.sh adds it as a tls-san), so + # substituting it yields a kubeconfig that works remotely. + machine_ssh "$machine_fqdn" "sudo cat /etc/rancher/k3s/k3s.yaml" \ + | sed "s/127.0.0.1/${machine_fqdn}/g" > "$kube_config" + chmod 600 "$kube_config" + if ! grep -q "$machine_fqdn" "$kube_config"; then + echo "Error: failed to fetch a usable kubeconfig from the machine" >&2 + exit 1 + fi + echo "Fetched kubeconfig to $kube_config" + + # The hostname must resolve locally before the recording's HTTPS checks can + # pass (the authoritative record was just created; Let's Encrypt resolves it + # independently). + echo "Waiting for $machine_fqdn to resolve..." + for _ in {1..60}; do + if getent hosts "$machine_fqdn" > /dev/null; then + break + fi + sleep 5 + done + + echo + echo "Demo k8s host ready:" + do_info + echo + echo "Now record with ./demo/record-quickstart.sh, and destroy the host with" + echo "./demo/k8s-host.sh destroy when you are finished." +} + +do_info () { + if [ ! -f "$host_env" ]; then + echo "No demo k8s host (nothing in $STATE_DIR)." + echo "Create one with ./demo/k8s-host.sh create" + return 1 + fi + # shellcheck disable=SC1090 + . "$host_env" + echo " name: $STACK_DEMO_MACHINE_NAME" + echo " id: $STACK_DEMO_MACHINE_ID" + echo " fqdn: $STACK_DEMO_MACHINE_FQDN" + echo " kubeconfig: $kube_config" +} + +do_destroy () { + require_tools + if [ ! -f "$host_env" ]; then + echo "No demo k8s host to destroy (nothing in $STATE_DIR)." + return 0 + fi + # shellcheck disable=SC1090 + . "$host_env" + echo "Destroying machine $STACK_DEMO_MACHINE_NAME ($STACK_DEMO_MACHINE_ID)" + $MACHINE_CMD --config-file "$machine_config" destroy --no-confirm --delete-dns "$STACK_DEMO_MACHINE_ID" + rm -rf "$STATE_DIR" + echo "Destroyed" +} + +case "${1:-}" in + create) + do_create + ;; + info) + do_info + ;; + destroy) + do_destroy + ;; + ""|-h|--help|help) + usage + ;; + *) + echo "Error: unknown command '$1'" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/demo/quickstart.tape b/demo/quickstart.tape index 277d812..bb02e96 100644 --- a/demo/quickstart.tape +++ b/demo/quickstart.tape @@ -1,13 +1,18 @@ # VHS tape for the README quick-start animation. # -# Renders docs/images/quickstart.gif — a real, unfaked run of the Docker quick -# start from README.md against the example todo stack. +# Renders docs/images/quickstart.gif — a real, unfaked run of the quick start +# from README.md against the example todo stack: first to local Docker, then +# the same stack to a real Kubernetes cluster with a real hostname and a real +# Let's Encrypt certificate. # # Run it via ./demo/record-quickstart.sh, which prepares the caches this tape -# assumes. Running `vhs demo/quickstart.tape` directly will still work, but the -# build and clone steps may take minutes instead of seconds. +# assumes and fills in the @@...@@ placeholders below from the Kubernetes host +# created by ./demo/k8s-host.sh. Because of those placeholders this file is a +# template: run `vhs` against the rendered copy the record script writes, not +# against this file. # -# Requires: vhs, ttyd, ffmpeg, docker, and the `stack` CLI on PATH. +# Requires: vhs, ttyd, ffmpeg, docker, jq, openssl, kubectl, and the `stack` +# CLI on PATH. Output docs/images/quickstart.gif @@ -69,19 +74,19 @@ Wait+Line@120s /^▸$/ Sleep 2s # --- generate the deployment spec, then the deployment --------------------- +# Say out loud which target this half of the recording is for: the same stack +# is deployed to Kubernetes further down, and the two halves should be read as +# a pair. +Type "# first, deploy this stack to Docker on this laptop" +Enter +Sleep 2s + # init and deploy are near-instant and quiet, so pause a beat to let them be read. Type "stack init --stack todo --output todo.yml --deploy-to compose --map-ports-to-host localhost-same" Enter Wait+Line@120s /^▸$/ Sleep 1s -# The whole generated spec is only 20 lines, so show all of it: the proxy -# routes, the host port mappings and the db volume are the interesting part. -Type "cat todo.yml" -Enter -Wait+Line@60s /^▸$/ -Sleep 2500ms - Type "mkdir -p ~/deployments" Enter Wait+Line@60s /^▸$/ @@ -99,7 +104,20 @@ Enter Type "stack manage --dir ~/deployments/todo-demo start" Enter Wait+Line@300s /^▸$/ -Sleep 1500ms +Sleep 2500ms + +# Off camera: the frontend's dev server needs a few seconds more after its +# container starts before it answers, which is dead air. The `clear` is inside +# the hidden block on purpose: Hide stops recording frames but the terminal +# still holds the typed line, so without it the wait loop would be on screen +# for the rest of the recording. +Hide +Type `for i in $(seq 60); do curl -sf -o /dev/null @@FRONTEND_URL@@ && break; sleep 2; done` +Enter +Wait+Line@180s /^▸$/ +Type "clear" +Enter +Show Type "stack manage --dir ~/deployments/todo-demo status" Enter @@ -107,22 +125,25 @@ Wait+Line@120s /^▸$/ Sleep 2s # --- prove the whole system works, not just that a port answers ------------ -# The web app is served... -Type `curl -s -o /dev/null -w "frontend HTTP %{http_code}\n" localhost:3000` +# The web app is served on the host ports the spec asked for. Those ports come +# from the stack and have changed before, so record-quickstart.sh reads them out +# of a generated spec and substitutes them here — and refuses to record at all if +# anything else already holds them, so these really are the deployment answering. +Type `curl -s -o /dev/null -w "frontend HTTP %{http_code}\n" @@FRONTEND_URL@@` Enter Wait+Line@60s /^▸$/ Sleep 1500ms # ...and the API is backed by a working database. The list starts empty # because the recorder removes the deployment (and its db volume) each run. -Type "curl -s localhost:5000/ | jq -c ." +Type "curl -s @@API_URL@@/ | jq -c ." Enter Wait+Line@60s /^▸$/ Sleep 2s # The id comes back from postgres, so a successful round trip proves the # backend and the database, not merely that the web server responded. -Type `curl -s -X POST localhost:5000/ \` +Type `curl -s -X POST @@API_URL@@/ \` Enter Type ` -H 'Content-Type: application/json' \` Enter @@ -131,7 +152,18 @@ Enter Wait+Line@60s /^▸$/ Sleep 2500ms -Type `curl -s localhost:5000/ | jq -c '.[] | {id, title, completed}'` +# Tick it off, so the recording exercises a write that updates an existing row +# rather than only inserting one. +Type `curl -s -X PUT @@API_URL@@/1 \` +Enter +Type ` -H 'Content-Type: application/json' \` +Enter +Type ` -d '{"completed":true}' | jq -c '{id, title, completed}'` +Enter +Wait+Line@60s /^▸$/ +Sleep 2s + +Type `curl -s @@API_URL@@/ | jq -c '.[] | {id, title, completed}'` Enter Wait+Line@60s /^▸$/ Sleep 3s @@ -141,3 +173,148 @@ Type "stack manage --dir ~/deployments/todo-demo stop" Enter Wait+Line@180s /^▸$/ Sleep 3s + +Type "clear" +Enter + +# --- the same stack, on Kubernetes ----------------------------------------- +# Nothing about the stack changes here: the same containers built above are +# pushed to a registry and run on a real single-node k3s cluster on a cloud VM +# (created out of band by ./demo/k8s-host.sh). Only the deployment spec differs. +Type "# now deploy the same stack to a Kubernetes cluster in the cloud" +Enter +Sleep 2s + +# --http-proxy-fqdn is the cluster's hostname, so the deployment gets a real +# DNS name — and, from cert-manager on the cluster, a real Let's Encrypt +# certificate for it. +Type `stack init --stack todo --output todo-k8s.yml --deploy-to k8s \` +Enter +Type ` --kube-config @@KUBE_CONFIG@@ --image-registry @@IMAGE_REGISTRY@@ \` +Enter +Type ` --http-proxy-fqdn @@K8S_FQDN@@ \` +Enter +Type ` --config REACT_APP_API_URL=https://@@K8S_FQDN@@/api/todos` +Enter +Wait+Line@120s /^▸$/ +Sleep 1s + +Type "clear" +Enter + +# A fixed cluster name means a redeployment lands in the same namespace, so +# cert-manager reuses the certificate it already holds instead of asking Let's +# Encrypt for another one on every take. +Type `stack deploy --cluster stack-todo-demo --spec-file todo-k8s.yml \` +Enter +Type ` --deployment-dir ~/deployments/todo-k8s` +Enter +Wait+Line@180s /^▸$/ +Sleep 1500ms + +# The cluster cannot see this laptop's image cache, so the images built earlier +# go to the registry named in the spec. Layers already pushed are skipped. +Type "stack manage --dir ~/deployments/todo-k8s push-images" +Enter +Wait+Line@900s /^▸$/ +Sleep 1500ms + +Type "clear" +Enter + +# --- run it on the cluster ------------------------------------------------- +Type "stack manage --dir ~/deployments/todo-k8s start" +Enter +Wait+Line@600s /^▸$/ +Sleep 2500ms + +# Off camera, as in the Docker half above: wait for the pods to become ready +# and the gateway to route to them, and — the first time this hostname is +# deployed — for the ACME HTTP-01 exchange to finish. That is real waiting, up +# to a minute or two, and it is all dead air. +Hide +Type `for i in $(seq 120); do curl -sf -o /dev/null https://@@K8S_FQDN@@/api/todos && break; sleep 5; done` +Enter +Wait+Line@700s /^▸$/ +Type "clear" +Enter +Show + +# status reports the certificate the cluster holds for this deployment, so the +# TLS line here is the certificate the curls below are about to use. +Type "stack manage --dir ~/deployments/todo-k8s status" +Enter +Wait+Line@300s /^▸$/ +Sleep 3s + +Type "clear" +Enter + +# stack's own status output is deliberately target-agnostic, which can make the +# Kubernetes deployment look like it happened by magic. This is what it +# actually built: ordinary Deployments, Services and a Gateway API HTTPRoute in +# a namespace named for the deployment's cluster — nothing a `kubectl` user +# would find surprising, and all of it removed again by `stack manage stop`. +Type "export KUBECONFIG=@@KUBE_CONFIG@@" +Enter +Wait+Line@60s /^▸$/ + +Type "kubectl -n stack-todo-demo get pods,svc,httproute" +Enter +Wait+Line@120s /^▸$/ +Sleep 4s + +# And the other half of what the deployment asked the cluster for: cert-manager +# holding the Let's Encrypt certificate for this hostname, which is what the +# HTTPS requests below use. +Type "kubectl get certificate -A" +Enter +Wait+Line@120s /^▸$/ +Sleep 3s + +Type "clear" +Enter + +# --- prove it, over HTTPS, from outside the cluster ------------------------ +# No -k: curl verifying the chain is itself the proof that the certificate is +# real and publicly trusted. +Type `curl -s -o /dev/null -w "frontend HTTPS %{http_code}\n" https://@@K8S_FQDN@@` +Enter +Wait+Line@120s /^▸$/ +Sleep 1500ms + +Type `echo | openssl s_client -connect @@K8S_FQDN@@:443 2>/dev/null | openssl x509 -noout -issuer` +Enter +Wait+Line@120s /^▸$/ +Sleep 2500ms + +# Same round trip as the Docker half, through the cluster's proxy this time: +# the id comes back from postgres running in the cluster. +Type `curl -s -X POST https://@@K8S_FQDN@@/api/todos \` +Enter +Type ` -H 'Content-Type: application/json' \` +Enter +Type ` -d '{"title":"Same stack, no PaaS"}' | jq -c '{id, title, completed}'` +Enter +Wait+Line@120s /^▸$/ +Sleep 2500ms + +Type `curl -s -X PUT https://@@K8S_FQDN@@/api/todos/1 \` +Enter +Type ` -H 'Content-Type: application/json' \` +Enter +Type ` -d '{"completed":true}' | jq -c '{id, title, completed}'` +Enter +Wait+Line@120s /^▸$/ +Sleep 2s + +Type `curl -s https://@@K8S_FQDN@@/api/todos | jq -c '.[] | {id, title, completed}'` +Enter +Wait+Line@120s /^▸$/ +Sleep 3s + +# --- and tear down --------------------------------------------------------- +Type "stack manage --dir ~/deployments/todo-k8s stop" +Enter +Wait+Line@300s /^▸$/ +Sleep 3s diff --git a/demo/record-quickstart.sh b/demo/record-quickstart.sh index 410bb41..e53146f 100755 --- a/demo/record-quickstart.sh +++ b/demo/record-quickstart.sh @@ -3,9 +3,21 @@ # Record docs/images/quickstart.gif from demo/quickstart.tape. # # The recording is a real run of the README quick start — no output is faked. -# To keep it short enough to work as a README animation, this script prepares -# state off camera so that each recorded command does genuine work but returns -# quickly: +# It deploys the example todo stack twice: to local Docker, then the same stack +# to a real Kubernetes cluster, over HTTPS with a real Let's Encrypt +# certificate. +# +# The cluster is NOT created here. Provisioning a cloud VM takes minutes and +# costs money, and this script is expected to be run many times over while +# getting a take you like, so the host has its own lifecycle: +# +# ./demo/k8s-host.sh create # once +# ./demo/record-quickstart.sh # as many takes as you like +# ./demo/k8s-host.sh destroy # when you are done +# +# To keep the animation short enough to work as a README animation, this script +# prepares state off camera so that each recorded command does genuine work but +# returns quickly: # # * STACK_REPO_BASE_DIR points at a scratch dir, so the recorded `stack fetch` # performs a real clone. This also means your existing clones under @@ -13,8 +25,18 @@ # * The todo image *tags* are removed, but Docker's build layer cache is left # intact, so the recorded `stack prepare` runs a real build (~8s) rather # than reporting "existing-image" or rebuilding from scratch (minutes). +# * The images are pushed to the registry off camera, so the recorded +# `stack manage push-images` pushes a real (already-present) image set in +# seconds rather than uploading hundreds of megabytes on camera. +# +# quickstart.tape is a template: the Kubernetes host's FQDN, kubeconfig path +# and image registry are only known once the host exists, so they appear in the +# tape as @@...@@ placeholders and are substituted here into a rendered copy +# under the scratch dir, which is what vhs actually runs. # -# Requires: vhs, ttyd, ffmpeg, docker, and the `stack` CLI on PATH. +# Requires: vhs, ttyd, ffmpeg, docker, jq, openssl, kubectl, and the `stack` CLI +# on PATH, plus STACK_IMAGE_REGISTRY / STACK_IMAGE_REGISTRY_USER / +# STACK_IMAGE_REGISTRY_TOKEN for the registry the cluster pulls from. set -euo pipefail @@ -24,11 +46,18 @@ REPO_ROOT="$PWD" # so a scratch dir under $HOME would put the recorder's username in the recording. SCRATCH="/tmp/stack-demo" DEPLOYMENT="$HOME/deployments/todo-demo" +K8S_DEPLOYMENT="$HOME/deployments/todo-k8s" + +STATE_DIR=${STACK_DEMO_STATE_DIR:-$HOME/.cache/stack-demo/k8s-host} +# The kubeconfig is copied into the scratch dir because the recorded `stack +# init` and `export KUBECONFIG` lines both show its path, and a path under +# $HOME would put the recorder's username in the recording. +DEMO_KUBE_CONFIG="$SCRATCH/kubeconfig" export STACK_REPO_BASE_DIR="$SCRATCH/repos" missing=() -for tool in vhs ttyd ffmpeg docker stack; do +for tool in vhs ttyd ffmpeg docker jq openssl kubectl stack; do command -v "$tool" >/dev/null || missing+=("$tool") done if [ ${#missing[@]} -gt 0 ]; then @@ -38,14 +67,59 @@ if [ ${#missing[@]} -gt 0 ]; then exit 1 fi +if [ ! -f "$STATE_DIR/host.env" ] || [ ! -f "$STATE_DIR/kubeconfig" ]; then + echo "error: no Kubernetes host to record against (looked in $STATE_DIR)" >&2 + echo " create one with: ./demo/k8s-host.sh create" >&2 + exit 1 +fi +# shellcheck disable=SC1091 +. "$STATE_DIR/host.env" + +missing=() +for var in STACK_IMAGE_REGISTRY STACK_IMAGE_REGISTRY_USER STACK_IMAGE_REGISTRY_TOKEN; do + [ -n "${!var:-}" ] || missing+=("$var") +done +if [ ${#missing[@]} -gt 0 ]; then + echo "error: required environment not set: ${missing[*]}" >&2 + exit 1 +fi + +# The host port a service maps to comes from the stack, and the stack is free to +# change it (it has). Read the ports out of a spec generated the same way the +# recording generates its own, rather than hardcoding them here and in the tape. +# Emits "service host-port" per line. +spec_host_ports () { + awk ' + /^ ports:/ { in_ports = 1; next } + !in_ports { next } + /^ [A-Za-z0-9_-]+:/ { svc = $1; sub(":", "", svc); next } + /^ *- / { n = split($2, a, ":"); print svc, (n == 3 ? a[2] : a[1]); next } + /^ [A-Za-z]/ { in_ports = 0 } + ' "$1" +} + +k8s_stop () { + # Best effort — the deployment may already be stopped, or never started. + # --delete-volumes drops the database so the recorded todo list starts + # empty, the same as the Docker half. + if [ -d "$K8S_DEPLOYMENT" ]; then + stack manage --dir "$K8S_DEPLOYMENT" stop --delete-volumes || true + rm -rf "$K8S_DEPLOYMENT" + fi +} + +echo "==> Recording against Kubernetes host $STACK_DEMO_MACHINE_FQDN" + echo "==> Cleaning previous demo state" if [ -d "$DEPLOYMENT" ]; then - # Best effort — the deployment may already be stopped. stack manage --dir "$DEPLOYMENT" stop || true rm -rf "$DEPLOYMENT" fi +k8s_stop rm -rf "$SCRATCH" mkdir -p "$SCRATCH/repos" +cp "$STATE_DIR/kubeconfig" "$DEMO_KUBE_CONFIG" +chmod 600 "$DEMO_KUBE_CONFIG" # Note: ~/deployments is deliberately NOT created here. `stack deploy` requires # the parent of --deployment-dir to already exist, and the tape creates it on # camera so the recording matches the README. @@ -56,6 +130,58 @@ cd "$SCRATCH" stack fetch repo bozemanpass/example-todo-list stack prepare --stack todo +echo "==> Pushing the images to $STACK_IMAGE_REGISTRY (off camera)" +echo "$STACK_IMAGE_REGISTRY_TOKEN" | docker login "${STACK_IMAGE_REGISTRY%%/*}" \ + --username "$STACK_IMAGE_REGISTRY_USER" --password-stdin +# push-images works from a deployment, so make a throwaway one. It is never +# started, so nothing reaches the cluster here — only the registry. +warm_spec="$SCRATCH/warm-k8s.yml" +warm_dir="$SCRATCH/warm-k8s-deployment" +stack init --stack todo --output "$warm_spec" --deploy-to k8s \ + --kube-config "$DEMO_KUBE_CONFIG" --image-registry "$STACK_IMAGE_REGISTRY" \ + --http-proxy-fqdn "$STACK_DEMO_MACHINE_FQDN" +stack deploy --spec-file "$warm_spec" --deployment-dir "$warm_dir" +stack manage --dir "$warm_dir" push-images +rm -rf "$warm_dir" "$warm_spec" + +echo "==> Working out the host ports this stack maps to" +probe_spec="$SCRATCH/host-ports.yml" +stack init --stack todo --output "$probe_spec" --deploy-to compose --map-ports-to-host localhost-same +frontend_port=$(spec_host_ports "$probe_spec" | awk '$1 == "frontend" { print $2; exit }') +api_port=$(spec_host_ports "$probe_spec" | awk '$1 == "backend" { print $2; exit }') +if [ -z "$frontend_port" ] || [ -z "$api_port" ]; then + echo "error: could not read the frontend/backend host ports from $probe_spec" >&2 + exit 1 +fi +# Bare "localhost" reads better than "localhost:80" in the recording. +if [ "$frontend_port" = "80" ]; then + frontend_url="localhost" +else + frontend_url="localhost:$frontend_port" +fi +api_url="localhost:$api_port" +echo " frontend: $frontend_url api: $api_url" + +# The Docker half maps the stack's ports to the same ports on localhost, and the +# recording proves the deployment works by curling them. Anything else already +# listening on one of them would answer those curls instead, and the recording +# would look like a pass while showing someone else's container. +echo "==> Checking those ports are free" +busy=() +while read -r _ port; do + if (ss -ltn "sport = :$port" 2>/dev/null || netstat -ltn 2>/dev/null) | grep -qE "[:.]$port\b"; then + busy+=("$port") + fi +done < <(spec_host_ports "$probe_spec") +if [ ${#busy[@]} -gt 0 ]; then + echo "error: something is already listening on: ${busy[*]}" >&2 + echo " The recorded curls would hit it instead of the demo deployment." >&2 + echo " Stop it and try again — e.g. a stray copy of the example app:" >&2 + echo " docker ps --filter name=example-todo-list" >&2 + exit 1 +fi +rm -f "$probe_spec" + echo "==> Dropping image tags so the recorded build is real but cached" docker images --format '{{.Repository}}:{{.Tag}}' \ | grep -E '^bozemanpass/todo-' \ @@ -65,17 +191,32 @@ echo "==> Resetting the scratch clone so the recorded fetch is a real clone" rm -rf "$SCRATCH/repos" mkdir -p "$SCRATCH/repos" -echo "==> Recording" +echo "==> Rendering the tape for this host" cd "$REPO_ROOT" -vhs demo/quickstart.tape +rendered_tape="$SCRATCH/quickstart.tape" +sed -e "s|@@K8S_FQDN@@|$STACK_DEMO_MACHINE_FQDN|g" \ + -e "s|@@KUBE_CONFIG@@|$DEMO_KUBE_CONFIG|g" \ + -e "s|@@IMAGE_REGISTRY@@|$STACK_IMAGE_REGISTRY|g" \ + -e "s|@@FRONTEND_URL@@|$frontend_url|g" \ + -e "s|@@API_URL@@|$api_url|g" \ + demo/quickstart.tape > "$rendered_tape" + +echo "==> Recording" +# vhs resolves the tape's Output path against its own cwd, so stay at the repo +# root even though the tape itself lives under the scratch dir. +vhs "$rendered_tape" echo "==> Cleaning up" if [ -d "$DEPLOYMENT" ]; then stack manage --dir "$DEPLOYMENT" stop || true rm -rf "$DEPLOYMENT" fi +k8s_stop rm -rf "$SCRATCH" echo echo "Wrote $REPO_ROOT/docs/images/quickstart.gif" ls -lh "$REPO_ROOT/docs/images/quickstart.gif" +echo +echo "The Kubernetes host is still running. Destroy it with:" +echo " ./demo/k8s-host.sh destroy" diff --git a/docs/images/quickstart.gif b/docs/images/quickstart.gif index 0f8f4ea..e667762 100644 Binary files a/docs/images/quickstart.gif and b/docs/images/quickstart.gif differ