Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262#!/usr/bin/env bash# Start, stop, reach and price the AWS dev box infra/devbox/ creates. It# finds the instance by its Name tag, so it needs no tofu state. Every command# uses the AWS CLI's own credentials: run `aws sso login` first, and set# AWS_PROFILE to the profile for the did.bot account.## docs/devbox.md has the rest.set -euo pipefail
usage() { cat <<'EOF'scripts/devbox <command>
up start the box and wait until Session Manager reaches it down stop the box (the volumes stay, and still cost $0.75 a day) status state, type, market and today's estimated cost ssh [ARGS] ssh to the box as dev, over Session Manager ssh-config print a ~/.ssh/config entry for VS Code Remote-SSH and git cost today's estimated spend, from midnight UTC proxy USER PORT the ssh ProxyCommand that ssh and ssh-config use
Environment: AWS_PROFILE, AWS_REGION which account and region (default us-east-1) DEVBOX_NAME the instance's Name tag (default didbot-devbox) DEVBOX_SSH_KEY private key ssh offers; its .pub is pushed to the box for 60 seconds per connection (default ~/.ssh/id_ed25519)EOF exit "${1:-2}"}
export AWS_REGION="${AWS_REGION:-us-east-1}"NAME="${DEVBOX_NAME:-didbot-devbox}"SSH_KEY="${DEVBOX_SSH_KEY:-$HOME/.ssh/id_ed25519}"SSH_USER=dev# gp3 storage and a public IPv4 address in us-east-1, from the AWS price list# on 2026-10-12. Neither is in the EC2 API.GP3_USD_PER_GB_MONTH=0.08IPV4_USD_PER_HOUR=0.005
die() { echo "devbox: $*" >&2; exit 1; }
need() { local tool for tool in "$@"; do command -v "$tool" >/dev/null 2>&1 || die "$tool is not on PATH" done}
# The one instance with the Name tag that is not terminated, as JSON.instance() { local found found="$(aws ec2 describe-instances \ --filters "Name=tag:Name,Values=$NAME" \ "Name=instance-state-name,Values=pending,running,stopping,stopped" \ --query 'Reservations[].Instances[]' --output json)" case "$(jq length <<<"$found")" in 0) die "no instance tagged Name=$NAME in $AWS_REGION; apply infra/devbox first" ;; 1) jq '.[0]' <<<"$found" ;; *) die "more than one instance is tagged Name=$NAME" ;; esac}
field() { jq -r "$1" <<<"$2"; }
ssm_ping() { aws ssm describe-instance-information \ --filters "Key=InstanceIds,Values=$1" \ --query 'InstanceInformationList[0].PingStatus' --output text}
cmd_up() { local box id state output box="$(instance)" id="$(field .InstanceId "$box")" state="$(field .State.Name "$box")" case "$state" in stopping) echo "waiting for $id to finish stopping" aws ec2 wait instance-stopped --instance-ids "$id" ;; esac if [ "$state" != running ] && [ "$state" != pending ]; then echo "starting $id ($(field .InstanceType "$box"))" if ! output="$(aws ec2 start-instances --instance-ids "$id" 2>&1)"; then if grep -q InsufficientInstanceCapacity <<<"$output"; then die "AWS has no spot capacity for $(field .InstanceType "$box") in \$(field .Placement.AvailabilityZone "$box") now. Try again later, or move the \box to on-demand: \`tofu apply -var spot=false\` in infra/devbox replaces the \instance and keeps the data volume." fi die "$output" fi fi aws ec2 wait instance-running --instance-ids "$id" echo "running; waiting for Session Manager" local tries for ((tries = 0; tries < 60; tries++)); do if [ "$(ssm_ping "$id")" = Online ]; then echo "$id is up: scripts/devbox ssh" return fi sleep 5 done die "$id is running but Session Manager has not reached it after 5 minutes"}
cmd_down() { local box id box="$(instance)" id="$(field .InstanceId "$box")" aws ec2 stop-instances --instance-ids "$id" >/dev/null echo "stopping $id"}
# Seconds the instance ran since midnight UTC: one CPUUtilization datapoint# per five running minutes, so it is right to within five minutes per start.running_seconds_today() { local start now start="$(date -u +%Y-%m-%dT00:00:00Z)" now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" aws cloudwatch get-metric-statistics --namespace AWS/EC2 \ --metric-name CPUUtilization --dimensions "Name=InstanceId,Value=$1" \ --start-time "$start" --end-time "$now" --period 300 \ --statistics SampleCount --query 'length(Datapoints)' --output text \ | awk '{ print $1 * 300 }'}
# The hourly price the instance pays now: the zone's current spot price, or# the on-demand list price.hourly_price() { local box="$1" type az type="$(field .InstanceType "$box")" az="$(field .Placement.AvailabilityZone "$box")" if [ "$(field '.InstanceLifecycle // "on-demand"' "$box")" = spot ]; then aws ec2 describe-spot-price-history --instance-types "$type" \ --availability-zone "$az" --product-descriptions Linux/UNIX \ --start-time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ --query 'SpotPriceHistory[0].SpotPrice' --output text else aws pricing get-products --region us-east-1 --service-code AmazonEC2 \ --filters "Type=TERM_MATCH,Field=instanceType,Value=$type" \ "Type=TERM_MATCH,Field=regionCode,Value=$AWS_REGION" \ "Type=TERM_MATCH,Field=operatingSystem,Value=Linux" \ "Type=TERM_MATCH,Field=tenancy,Value=Shared" \ "Type=TERM_MATCH,Field=preInstalledSw,Value=NA" \ "Type=TERM_MATCH,Field=capacitystatus,Value=Used" \ --query 'PriceList[0]' --output text \ | jq -r '.terms.OnDemand[].priceDimensions[].pricePerUnit.USD' fi}
volume_gb() { local ids ids="$(field '[.BlockDeviceMappings[].Ebs.VolumeId] | join(" ")' "$1")" # shellcheck disable=SC2086 # one argument per volume id aws ec2 describe-volumes --volume-ids $ids \ --query 'sum(Volumes[].Size)' --output text}
print_cost() { local box="$1" id seconds price gb id="$(field .InstanceId "$box")" seconds="$(running_seconds_today "$id")" price="$(hourly_price "$box")" gb="$(volume_gb "$box")" awk -v s="$seconds" -v p="$price" -v gb="$gb" \ -v gp3="$GP3_USD_PER_GB_MONTH" -v ip="$IPV4_USD_PER_HOUR" 'BEGIN { h = s / 3600 compute = h * p ipv4 = h * ip ebs = gb * gp3 * 12 / 365 printf " ran today %.2f h (to within 5 min per start)\n", h printf " instance $%.2f at $%.4f/h, the price now\n", compute, p printf " public IPv4 $%.2f\n", ipv4 printf " EBS, %4d GB $%.2f charged running or stopped\n", gb, ebs printf " today, est. $%.2f since midnight UTC\n", compute + ipv4 + ebs }'}
cmd_status() { local box id box="$(instance)" id="$(field .InstanceId "$box")" echo "$NAME $id" echo " state $(field .State.Name "$box")" echo " type $(field .InstanceType "$box"), $(field '.InstanceLifecycle // "on-demand"' "$box")" echo " zone $(field .Placement.AvailabilityZone "$box")" echo " last start $(field .LaunchTime "$box")" if [ "$(field .State.Name "$box")" = running ]; then echo " session manager $(ssm_ping "$id")" fi print_cost "$box"}
cmd_cost() { print_cost "$(instance)"}
# ssh's ProxyCommand. EC2 Instance Connect holds the public key on the box for# 60 seconds, long enough for this connection to authenticate; Session Manager# then carries the connection to the box's own sshd. No key is left behind.cmd_proxy() { [ $# -eq 2 ] || usage local user="$1" port="$2" box id [ -r "$SSH_KEY.pub" ] || die "no public key at $SSH_KEY.pub; set DEVBOX_SSH_KEY" box="$(instance)" id="$(field .InstanceId "$box")" [ "$(field .State.Name "$box")" = running ] || die "$id is $(field .State.Name "$box"); run scripts/devbox up" aws ec2-instance-connect send-ssh-public-key --instance-id "$id" \ --instance-os-user "$user" --ssh-public-key "file://$SSH_KEY.pub" >/dev/null exec aws ssm start-session --target "$id" \ --document-name AWS-StartSSHSession --parameters "portNumber=$port"}
self() { printf '%s/%s' "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" "$(basename "${BASH_SOURCE[0]}")"; }
proxy_command() { printf 'env AWS_REGION=%q' "$AWS_REGION" [ -z "${AWS_PROFILE:-}" ] || printf ' AWS_PROFILE=%q' "$AWS_PROFILE" printf ' DEVBOX_NAME=%q DEVBOX_SSH_KEY=%q %q proxy %%r %%p' "$NAME" "$SSH_KEY" "$(self)"}
cmd_ssh() { exec ssh -o "ProxyCommand=$(proxy_command)" -o "HostKeyAlias=$NAME" \ -o IdentitiesOnly=yes -i "$SSH_KEY" -o ServerAliveInterval=30 \ "$SSH_USER@$NAME" "$@"}
cmd_ssh_config() { cat <<EOFHost $NAME User $SSH_USER HostKeyAlias $NAME IdentityFile $SSH_KEY IdentitiesOnly yes ServerAliveInterval 30 ProxyCommand $(proxy_command)EOF}
[ $# -ge 1 ] || usagecommand="$1"shiftcase "$command" in up | down | status | cost | proxy | ssh) need aws jq ;;esaccase "$command" in up) cmd_up ;; down) cmd_down ;; status) cmd_status ;; cost) cmd_cost ;; proxy) need session-manager-plugin; cmd_proxy "$@" ;; ssh) need ssh session-manager-plugin; cmd_ssh "$@" ;; ssh-config) cmd_ssh_config ;; -h | --help | help) usage 0 ;; *) echo "unknown command: $command" >&2; usage ;;esac