Something went wrong. Try again.
Infrastructure-as-code for running lance.blue
Something went wrong. Try again.
Shell
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260#!/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.blueassume_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 shiftdone
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=0tofu -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 0fi
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 ;; esacfi
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 0fi
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 0fi
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}"
echoecho "$text"echoecho "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_PASSWORDsession="$(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##*/}"