Refuse to restart a node that already serves the release

696129384e96 · AtlantisPleb · · parent 2c123e1f625f

Refuse to restart a node that already serves the release

Re-running a release that had already rolled took production down. The roll
wrote the startup-script metadata and ran the startup runner on every node
unconditionally, and the runner stops the container and brings it back, so
each node answered 502 for the length of a restart it did not need. The log
read as harmless because the metadata write reported no change while the
restart happened anyway.

Three guards, each against a way this goes wrong:

A node is rolled only when it needs it — its metadata does not pin this
digest, or it is not serving this revision. Both must already hold to skip,
because a node can serve the right revision from a hand restart while its
metadata still pins the old digest, and that node reverts on its next reboot.

A node is never taken down while another is not answering. The roll is only
safe because the rest of the fleet is serving; two of three down is an outage,
not a rolling replacement.

One release runs at a time, under an atomic lock naming its pid and sha, so
two runs cannot roll the same three nodes at once.

The roll now says how many nodes it rolled and how many it left alone, so a
run that changed nothing reads as having changed nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified ops/deploy/release-to-production.sh

Diff

1 file changed, +56 -0

ops/deploy/release-to-production.sh modified +56

@@ -20,6 +20,19 @@ sha=${1:-}

20 20
21 21
project=openagentsgemini
22 22
registry=us-central1-docker.pkg.dev/openagents-staging-20260820/openagents-staging/openagents
23
24
# One release at a time. Two runs rolling the same three nodes can take down
25
# more than one at once, which is an outage rather than a rolling replacement.
26
# The lock is a directory because mkdir is atomic on every filesystem this
27
# runs on, and it carries the pid and sha so a stale one names its owner.
28
lock_dir=${TMPDIR:-/tmp}/openagents-release.lock
29
if ! mkdir "$lock_dir" 2>/dev/null; then
30
  echo "a release is already running: $(cat "$lock_dir/owner" 2>/dev/null || echo 'unknown')" >&2
31
  echo "if that is stale, remove $lock_dir and run again" >&2
32
  exit 1
33
fi
34
echo "pid=$$ sha=${1:-} started=$(date -u +%FT%TZ)" > "$lock_dir/owner"
35
trap 'rm -rf "$lock_dir"' EXIT INT TERM
23 36
# The automation service account: the interactive account hits Workspace
24 37
# reauthentication and cannot refresh in a headless run.
25 38
CLOUDSDK_CONFIG=${CLOUDSDK_CONFIG:-/Users/christopherdavid/work/.secrets/gcloud-sa-config}

@@ -169,11 +182,52 @@ startup=$(mktemp)

169 182
sed "s|__IMAGE_DIGEST__|$digest|g" "$(dirname "$0")/fleet-startup.template.sh" > "$startup"
170 183
grep -q '__IMAGE_DIGEST__' "$startup" && { echo "startup template not fully filled" >&2; exit 1; }
171 184
185
# Restarting a node that already serves this release is not a no-op: the
186
# startup runner stops the container and brings it back, so the node answers
187
# 502 for the length of that restart. Re-running a release that had already
188
# rolled is what took production down for a window, and the roll looked
189
# harmless in the log because the metadata write reported "No change
190
# requested" while the restart happened anyway.
191
#
192
# So a node is rolled only when it actually needs rolling: either its metadata
193
# does not yet pin this digest, or it is not serving this revision. Both have
194
# to be already true to skip it, because a node can serve the right revision
195
# from a hand restart while its metadata still pins the old digest, and that
196
# node would revert on its next reboot.
197
rolled=0
198
skipped=0
199
172 200
for entry in $nodes; do
173 201
  instance=${entry%%:*}
174 202
  zone=${entry##*:}
175 203
  echo "--> $instance ($zone)"
176 204
205
  pinned=$(gcloud compute instances describe "$instance" --zone="$zone" \
206
    --project="$project" \
207
    --format="value(metadata.items.filter(\"key:startup-script\").extract(value))" 2>/dev/null |
208
    grep -c "$digest" || true)
209
  serving=$(health_of "$instance" "$zone")
210
211
  if [ "$pinned" != "0" ] && [ "$serving" = "$sha" ]; then
212
    echo "    already on $sha with this digest pinned; not restarting"
213
    skipped=$((skipped + 1))
214
    continue
215
  fi
216
217
  # Never take a node down while another is already down. The roll is only
218
  # safe because the rest of the fleet is serving; without this a second node
219
  # can go before the first is back, and two of three down is an outage.
220
  for other in $nodes; do
221
    other_instance=${other%%:*}
222
    other_zone=${other##*:}
223
    [ "$other_instance" = "$instance" ] && continue
224
    if [ -z "$(health_of "$other_instance" "$other_zone")" ]; then
225
      echo "$other_instance is not answering; refusing to restart $instance too" >&2
226
      exit 1
227
    fi
228
  done
229
230
  rolled=$((rolled + 1))
177 231
  gcloud compute instances add-metadata "$instance" --zone="$zone" \
178 232
    --project="$project" --metadata-from-file=startup-script="$startup" >/dev/null
179 233
  on_node "$instance" "$zone" 'sudo google_metadata_script_runner startup >/tmp/roll.log 2>&1 &'

@@ -194,6 +248,8 @@ for entry in $nodes; do

194 248
done
195 249
rm -f "$startup"
196 250
251
echo "rolled $rolled node(s), skipped $skipped already on $sha"
252
197 253
echo "==> settle"
198 254
settle=$(mktemp)
199 255
cat > "$settle" <<ELIXIR

This page updates live while a promote is in flight · changelog