#!/usr/bin/env bash # A deploy in one command: plan, read the plan, apply it, then post to # lance.blue's Bluesky account when a release id moved. The post is how anyone # watching finds out that something shipped. # # scripts/deploy.sh [--yes] [--dry-run] [--no-post] [env] # # env defaults to lance.blue, the only one there is. Credentials are the # caller's: whatever already works - SSO session, env var, instance role, # named profile - exactly as for a hand-run apply. # # It applies a saved plan rather than re-planning, so what you read on screen # is what runs. The one confirmation is this script's, not tofu's - applying a # plan file never prompts - and --yes skips it for an unattended run. # # Posting needs an app password for the lance.blue account - never the account # password. It comes from the login keyring: # # secret-tool store --label='lance.blue app password' \ # service lance.blue key atp-app-password # # ATP_APP_PASSWORD overrides the keyring for a machine that has none. Either # way the password is never printed and never reaches a command line, and it # is deliberately nowhere near AWS: whoever takes the apply role gets the # infrastructure, and must not also get the account that speaks for it. # # Without a password the deploy still happens and the post is skipped, because # a missing credential is not a reason to hold up a deploy. set -euo pipefail source "$(dirname "$0")/lib/aws.sh" usage() { echo "usage: scripts/deploy.sh [--yes] [--dry-run] [--no-post] [env]" } env_name=lance.blue assume_yes="" dry_run="" post=1 while [ $# -gt 0 ]; do case "$1" in -y | --yes) assume_yes=1 ;; -n | --dry-run) dry_run=1 ;; --no-post) post="" ;; -h | --help) usage exit 0 ;; -*) echo "unknown option: $1" >&2 usage >&2 exit 2 ;; *) env_name="$1" ;; esac shift done cd "$(dirname "$0")/.." env_dir="envs/$env_name" [ -d "$env_dir" ] || { echo "no such environment: infra/$env_dir" >&2 exit 2 } for tool in tofu aws jq curl; do command -v "$tool" >/dev/null || { echo "$tool is not installed." >&2 exit 1 } done require_aws_credentials # Resolved here rather than after the apply: finding out the credential is # missing once the deploy is already live helps nobody. # # secret-tool is libsecret's CLI and needs a Secret Service on the session - # gnome-keyring or KWallet. Over ssh or on a bare window manager there may be # none, which is what ATP_APP_PASSWORD is for. keyring_service="${ATP_SECRET_SERVICE:-lance.blue}" keyring_key="${ATP_SECRET_KEY:-atp-app-password}" if [ -n "$post" ] && [ -z "${ATP_APP_PASSWORD:-}" ] && command -v secret-tool >/dev/null; then # A miss and a locked keyring both come back empty; neither is fatal. ATP_APP_PASSWORD="$(secret-tool lookup \ service "$keyring_service" key "$keyring_key" 2>/dev/null || true)" fi if [ -n "$post" ] && [ -z "${ATP_APP_PASSWORD:-}" ]; then echo "No app password in the keyring or the environment - deploying without posting." >&2 echo " secret-tool store --label='lance.blue app password' \\" >&2 echo " service $keyring_service key $keyring_key" >&2 post="" fi # Tagged onto every resource, so a running resource says which commit planned # it. Absent outside a checkout; the variable's default handles that. plan_args=() commit="$(git rev-parse --short=12 HEAD 2>/dev/null || true)" [ -z "$commit" ] || plan_args+=(-var "git_commit=$commit") # What is live now. Read before the plan, because after the apply it is gone - # and the pair is the whole basis for deciding there is something to post. before="$(tofu -chdir="$env_dir" output -json releases 2>/dev/null || true)" # Plans can embed resolved variable values, so the file lives outside the repo # and does not outlive the run. plan_file="$(mktemp)" trap 'rm -f "$plan_file"' EXIT rc=0 tofu -chdir="$env_dir" plan -detailed-exitcode -out="$plan_file" \ ${plan_args[@]+"${plan_args[@]}"} || rc=$? case "$rc" in 0) echo echo "No changes. Nothing to apply, nothing to post." exit 0 ;; 2) ;; # changes to make *) exit "$rc" ;; esac if [ -n "$dry_run" ]; then echo echo "Dry run: planned only, nothing applied." exit 0 fi if [ -z "$assume_yes" ]; then echo printf 'Apply this plan to %s? [y/N] ' "$env_name" # Nothing on stdin - piped, or a cron that forgot --yes - is a no, and says # so. Without the fallback, set -e would end the run with no explanation. read -r reply || reply="" case "$reply" in y | Y | yes | Yes) ;; *) echo "Aborted." exit 1 ;; esac fi tofu -chdir="$env_dir" apply "$plan_file" [ -n "$post" ] || exit 0 # Only three outputs are read, and all three are public knowledge: the release # ids, and the two addresses anyone can already visit. Nothing that describes # the account's insides - bucket names, the instance id, security group ids - # goes anywhere near a public feed. Widening this is a deliberate act. after="$(tofu -chdir="$env_dir" output -json releases)" if [ -z "$before" ]; then echo echo "No releases output in state before this apply, so there is nothing to" echo "compare against. The next deploy posts." exit 0 fi changed="$(jq -rn --argjson before "$before" --argjson after "$after" ' $after | to_entries[] | ($before[.key]) as $old | if $old == null then "\(.key): \(.value)" elif $old != .value then "\(.key): \($old) -> \(.value)" else empty end ')" if [ -z "$changed" ]; then echo echo "Applied. No release id moved, so nothing is posted." exit 0 fi links=("$(tofu -chdir="$env_dir" output -raw site_url)") # no need to do API url, yet # if printf '%s\n' "$changed" | grep -q '^api: '; then # links+=("$(tofu -chdir="$env_dir" output -raw api_url)") # fi text="Deployed lance.blue"$'\n\n'"$changed"$'\n\n'"$(printf '%s\n' "${links[@]}")" # A post is 300 characters. Release ids are long and three of them can move at # once, so there is a shorter thing to say when they do not fit. if [ "${#text}" -gt 300 ]; then names="$(printf '%s\n' "$changed" | cut -d: -f1 | paste -sd, - | sed 's/,/, /g')" text="Deployed lance.blue: $names"$'\n\n'"$(printf '%s\n' "${links[@]}")" fi # Bluesky does not linkify anything on its own: a URL is a link only when a # facet names the byte range it occupies. Bytes, not characters - hence wc -c. facets='[]' add_link() { local url="$1" pre start end case "$text" in *"$url"*) ;; *) return ;; esac pre="${text%%"$url"*}" start="$(printf '%s' "$pre" | wc -c | tr -d ' ')" end="$((start + $(printf '%s' "$url" | wc -c | tr -d ' ')))" facets="$(jq -n --argjson f "$facets" --arg u "$url" \ --argjson s "$start" --argjson e "$end" ' $f + [{ index: { byteStart: $s, byteEnd: $e }, features: [{ "$type": "app.bsky.richtext.facet#link", uri: $u }] }]')" } for url in "${links[@]}"; do add_link "$url"; done identifier="${ATP_IDENTIFIER:-lance.blue}" # The account lives on somebody else's PDS - this repo hosts no identity. Set # ATP_PDS if it ever moves off bsky.social. pds="${ATP_PDS:-https://bsky.social}" echo echo "$text" echo echo "Posting as $identifier..." # The password is handed to jq through the environment rather than as an # argument, so it never appears in the process list. export ATP_APP_PASSWORD session="$(jq -n --arg id "$identifier" \ '{ identifier: $id, password: env.ATP_APP_PASSWORD }' | curl -fsS --max-time 30 -X POST \ "$pds/xrpc/com.atproto.server.createSession" \ -H 'content-type: application/json' --data-binary @-)" did="$(jq -r .did <<<"$session")" jwt="$(jq -r .accessJwt <<<"$session")" created="$(jq -n --arg did "$did" --arg text "$text" \ --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --argjson facets "$facets" ' { repo: $did, collection: "app.bsky.feed.post", record: { "$type": "app.bsky.feed.post", text: $text, createdAt: $now, langs: ["en"] } + (if ($facets | length) > 0 then { facets: $facets } else {} end) }' | curl -fsS --max-time 30 -X POST \ "$pds/xrpc/com.atproto.repo.createRecord" \ -H "authorization: Bearer $jwt" \ -H 'content-type: application/json' --data-binary @-)" uri="$(jq -r .uri <<<"$created")" echo "Posted: https://bsky.app/profile/$identifier/post/${uri##*/}"