Rebuild the shell, add Calendar and Learn, and govern reads
Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Executable
+185
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Poll the registry for a new release and deploy it. Runs on the production
|
||||
# host, on a timer. Installation: see deploy/README.md.
|
||||
#
|
||||
# This is the half of "push to deploy" that does not require handing the CI
|
||||
# runner a key to this machine. CI publishes an image when a human tags
|
||||
# `release-*`; this pulls. The only credential involved lives HERE, is
|
||||
# read-only, and can do nothing but fetch a manifest.
|
||||
#
|
||||
# What it does, in order:
|
||||
#
|
||||
# 1. Ask the registry for the newest release-* tag.
|
||||
# 2. Compare that tag's manifest digest with the digest of the image the
|
||||
# running app container was started from. Equal -> exit 0, silently. This
|
||||
# is the normal case and it happens every five minutes.
|
||||
# 3. Otherwise check the working tree out at that tag — the compose file and
|
||||
# the migrations must come from the same commit as the image — and hand
|
||||
# over to scripts/deploy.sh with PIG_IMAGE set.
|
||||
#
|
||||
# A failed deploy is recorded and NOT retried. A poller that reattempts the
|
||||
# same broken release every five minutes turns one bad tag into a restart loop;
|
||||
# deleting the record (or tagging a new release) is the human's signal to try
|
||||
# again.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# git checkout rewrites this file while bash is reading it — bash reads a
|
||||
# script incrementally, by byte offset, so a checkout mid-run resumes at an
|
||||
# offset into different content. Re-exec from a private copy before touching
|
||||
# git. This has a long history of producing genuinely baffling syntax errors.
|
||||
if [ "${PIG_AUTODEPLOY_REEXEC:-}" != '1' ]; then
|
||||
_copy=$(mktemp -t pig-autodeploy.XXXXXX)
|
||||
cat "$0" > "$_copy"
|
||||
PIG_AUTODEPLOY_REEXEC=1 exec bash "$_copy" "$@"
|
||||
fi
|
||||
trap 'rm -f "$0"' EXIT
|
||||
|
||||
REPO_DIR="${PIG_REPO_DIR:-/opt/pig}"
|
||||
REGISTRY="${PIG_REGISTRY:-git.karti.ai}"
|
||||
# Gitea namespaces packages under the lowercased owner: PIG/pig -> pig/pig.
|
||||
IMAGE_REPO="${PIG_IMAGE_REPO:-pig/pig}"
|
||||
TOKEN_FILE="${PIG_REGISTRY_TOKEN_FILE:-/etc/pig/registry-token}"
|
||||
REGISTRY_USER="${PIG_REGISTRY_USER:-pig-deploy}"
|
||||
STATE_DIR="${PIG_STATE_DIR:-/var/lib/pig}"
|
||||
TAG_PREFIX="${PIG_RELEASE_TAG_PREFIX:-release-}"
|
||||
|
||||
IMAGE_NAME="$REGISTRY/$IMAGE_REPO"
|
||||
FAILED_MARKER="$STATE_DIR/failed-release"
|
||||
|
||||
log() { printf '%s autodeploy: %s\n' "$(date -Is)" "$*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# Only one deploy at a time. The systemd timer will not overlap a run with
|
||||
# itself, but a human running this by hand during a scheduled run would.
|
||||
LOCK="${PIG_LOCK_FILE:-/var/lock/pig-autodeploy.lock}"
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -n 9; then
|
||||
log "another run holds $LOCK; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[ -r "$TOKEN_FILE" ] || die "no readable registry token at $TOKEN_FILE"
|
||||
# The token is the only secret here and it is pull-only, but a world-readable
|
||||
# credential on a production host is still worth refusing.
|
||||
PERMS=$(stat -c '%a' "$TOKEN_FILE")
|
||||
case "$PERMS" in
|
||||
600|400) ;;
|
||||
*) die "$TOKEN_FILE is mode $PERMS; must be 0600" ;;
|
||||
esac
|
||||
REGISTRY_TOKEN=$(tr -d '\r\n' < "$TOKEN_FILE")
|
||||
[ -n "$REGISTRY_TOKEN" ] || die "$TOKEN_FILE is empty"
|
||||
|
||||
cd "$REPO_DIR" || die "no checkout at $REPO_DIR"
|
||||
|
||||
# ---------------------------------------------------------------- registry
|
||||
#
|
||||
# Gitea's registry speaks the standard v2 token dance: an unauthenticated
|
||||
# request gets a 401 pointing at /v2/token. Exchange the pull-only credential
|
||||
# for a bearer token scoped to this one repository and fall back to basic auth
|
||||
# if the exchange gives nothing, because Gitea accepts both and which one you
|
||||
# get has changed across versions.
|
||||
AUTH_ARGS=()
|
||||
BEARER=$(curl -sS --max-time 20 -u "$REGISTRY_USER:$REGISTRY_TOKEN" \
|
||||
"https://$REGISTRY/v2/token?service=$REGISTRY&scope=repository:$IMAGE_REPO:pull" \
|
||||
2>/dev/null | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' | head -n1 || true)
|
||||
if [ -n "$BEARER" ]; then
|
||||
AUTH_ARGS=(-H "Authorization: Bearer $BEARER")
|
||||
else
|
||||
AUTH_ARGS=(-u "$REGISTRY_USER:$REGISTRY_TOKEN")
|
||||
fi
|
||||
|
||||
MANIFEST_ACCEPT=(
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json'
|
||||
-H 'Accept: application/vnd.oci.image.manifest.v1+json'
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json'
|
||||
)
|
||||
|
||||
TAGS_JSON=$(curl -sS --max-time 30 "${AUTH_ARGS[@]}" \
|
||||
"https://$REGISTRY/v2/$IMAGE_REPO/tags/list?n=1000") \
|
||||
|| die "could not reach https://$REGISTRY/v2/$IMAGE_REPO/tags/list"
|
||||
|
||||
# sort -V rather than sort: release-10 must come after release-9. This assumes
|
||||
# release tags sort sensibly — date-stamped or semver. A tag scheme that does
|
||||
# not is a tag scheme this poller will deploy in the wrong order.
|
||||
LATEST_TAG=$(printf '%s' "$TAGS_JSON" | tr ',' '\n' \
|
||||
| sed -n "s/.*\"\(${TAG_PREFIX}[^\"]*\)\".*/\1/p" | sort -V | tail -n1)
|
||||
[ -n "$LATEST_TAG" ] || { log "no ${TAG_PREFIX}* tags published yet"; exit 0; }
|
||||
|
||||
REMOTE_DIGEST=$(curl -sSI --max-time 30 "${AUTH_ARGS[@]}" "${MANIFEST_ACCEPT[@]}" \
|
||||
"https://$REGISTRY/v2/$IMAGE_REPO/manifests/$LATEST_TAG" \
|
||||
| tr -d '\r' | sed -n 's/^[Dd]ocker-[Cc]ontent-[Dd]igest:[[:space:]]*//p' | tail -n1)
|
||||
[ -n "$REMOTE_DIGEST" ] || die "no digest for $IMAGE_NAME:$LATEST_TAG"
|
||||
|
||||
# ---------------------------------------------------------------- compare
|
||||
#
|
||||
# Compare digests, not tags. A tag can be moved; a digest is the content. This
|
||||
# also means a re-pushed tag redeploys, which is what you want the one time it
|
||||
# matters.
|
||||
RUNNING_DIGEST=""
|
||||
RUNNING_CID=$(docker compose -p pig ps -q app 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$RUNNING_CID" ]; then
|
||||
RUNNING_IMAGE=$(docker inspect -f '{{.Image}}' "$RUNNING_CID" 2>/dev/null || true)
|
||||
if [ -n "$RUNNING_IMAGE" ]; then
|
||||
RUNNING_DIGEST=$(docker image inspect "$RUNNING_IMAGE" \
|
||||
--format '{{range .RepoDigests}}{{println .}}{{end}}' 2>/dev/null \
|
||||
| sed -n "s|^$IMAGE_NAME@||p" | head -n1 || true)
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$RUNNING_DIGEST" = "$REMOTE_DIGEST" ]; then
|
||||
log "up to date at $LATEST_TAG ($REMOTE_DIGEST)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$FAILED_MARKER" ] && [ "$(cat "$FAILED_MARKER")" = "$REMOTE_DIGEST" ]; then
|
||||
log "$LATEST_TAG ($REMOTE_DIGEST) already failed to deploy; not retrying."
|
||||
log "Remove $FAILED_MARKER to try again, or publish a new release tag."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "deploying $LATEST_TAG"
|
||||
log " running: ${RUNNING_DIGEST:-<none>}"
|
||||
log " wanted: $REMOTE_DIGEST"
|
||||
|
||||
# ---------------------------------------------------------------- deploy
|
||||
#
|
||||
# Log in as root, which is the identity `sudo docker` in deploy.sh runs as, so
|
||||
# the pull there finds this credential. Deliberately not logged out afterwards:
|
||||
# a human re-running deploy.sh by hand needs the same pull to work, and the
|
||||
# token is read-only.
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin > /dev/null \
|
||||
|| die "docker login to $REGISTRY failed"
|
||||
|
||||
git fetch --tags --force --prune origin
|
||||
# Detached, at the exact tag. The image, the compose file and the migration set
|
||||
# must all come from one commit; a tree left on main while running a tagged
|
||||
# image is how a migration gets applied that the image knows nothing about.
|
||||
git checkout --detach --force "refs/tags/$LATEST_TAG" \
|
||||
|| die "cannot check out refs/tags/$LATEST_TAG"
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
if PIG_IMAGE="$IMAGE_NAME:$LATEST_TAG" bash scripts/deploy.sh; then
|
||||
rm -f "$FAILED_MARKER"
|
||||
printf '%s %s\n' "$LATEST_TAG" "$REMOTE_DIGEST" > "$STATE_DIR/current-release"
|
||||
log "deployed $LATEST_TAG"
|
||||
else
|
||||
STATUS=$?
|
||||
printf '%s' "$REMOTE_DIGEST" > "$FAILED_MARKER"
|
||||
log "deploy of $LATEST_TAG FAILED (exit $STATUS)."
|
||||
# What is serving right now depends on WHICH gate failed, and this line is
|
||||
# what the on-call reads at 04:00. It used to claim a rollback unconditionally
|
||||
# — including in the one case where the broken release is still up, which is
|
||||
# precisely the case that needs hands. deploy.sh exits 3 for that.
|
||||
if [ "$STATUS" -eq 3 ]; then
|
||||
log "THE FAILING RELEASE IS STILL LIVE — deploy.sh did not roll back (it"
|
||||
log "either judged the fault external to the release, had no previous image,"
|
||||
log "or the restored image did not come up). Check the site NOW."
|
||||
else
|
||||
log "deploy.sh rolls the app back on a failed health, auth or public-marker"
|
||||
log "gate, so the previous release should still be serving — verify that first."
|
||||
fi
|
||||
exit "$STATUS"
|
||||
fi
|
||||
+184
-20
@@ -9,23 +9,74 @@
|
||||
# the CI runner, which is a meaningful escalation for a project this size. CI
|
||||
# proves the commit is sound; a human decides when it ships.
|
||||
#
|
||||
# That constraint still holds under the tag-to-ship flow added later. Nothing
|
||||
# on the CI runner can reach this host: CI publishes an image, and this host
|
||||
# PULLS it (scripts/autodeploy.sh). The direction of the credential is the
|
||||
# whole point — a read-only registry token here, no host credential there. And
|
||||
# a human still decides when it ships, by choosing to create a `release-*` tag.
|
||||
#
|
||||
# PIG_IMAGE unset Build from this working tree, as it always has.
|
||||
# PIG_IMAGE set Pull that published image instead of building, and leave
|
||||
# the checkout exactly where the caller put it. The caller
|
||||
# is responsible for having checked out the matching
|
||||
# commit, because the compose file, the migrations and the
|
||||
# image have to agree.
|
||||
#
|
||||
# Safe to re-run. Migrations are additive and tracked.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> Fetching"
|
||||
git fetch -q origin
|
||||
BEFORE=$(git rev-parse --short HEAD)
|
||||
git reset --hard -q origin/main
|
||||
AFTER=$(git rev-parse --short HEAD)
|
||||
# What compose will run. Must match the `image:` default in docker-compose.yml,
|
||||
# because that is the tag a rollback re-points at the previous image.
|
||||
IMAGE_REF="${PIG_IMAGE:-pig:local}"
|
||||
|
||||
if [ "$BEFORE" = "$AFTER" ]; then
|
||||
echo " Already at $AFTER"
|
||||
# Every compose invocation goes through this. sudo's default `env_reset` drops
|
||||
# PIG_IMAGE, so a plain `sudo docker compose` interpolates the `pig:local`
|
||||
# fallback in docker-compose.yml instead of the release tag: the pull then fails
|
||||
# with "pull access denied for pig", and — worse, if the pull is ever made
|
||||
# non-fatal — the migrate, the `up` and the rollback all silently run whatever
|
||||
# `pig:local` happens to be while the log reports the release. Pass it on the
|
||||
# command line via env(1): `sudo -E` and bare `sudo VAR=val` are both refused by
|
||||
# the default sudoers policy, `sudo env VAR=val …` is not.
|
||||
dc() {
|
||||
sudo env PIG_IMAGE="$IMAGE_REF" docker compose -p pig "$@"
|
||||
}
|
||||
|
||||
# Gate failures come in two kinds and the caller must be able to tell them
|
||||
# apart: scripts/autodeploy.sh reports to the journal what is serving right now.
|
||||
# 1 a gate failed and the previous image was restored — the old release is up
|
||||
# 3 a gate failed and the release under test is STILL LIVE
|
||||
EXIT_STILL_LIVE=3
|
||||
|
||||
# Read one key out of .env WITHOUT sourcing it. Sourcing an environment file
|
||||
# executes it, and this one holds every secret the deployment has.
|
||||
env_value() {
|
||||
[ -f .env ] || return 0
|
||||
sed -n "s/^[[:space:]]*$1=//p" .env | tail -n1 | sed -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"
|
||||
}
|
||||
|
||||
if [ -n "${PIG_IMAGE:-}" ]; then
|
||||
echo "==> Deploying published image $PIG_IMAGE"
|
||||
# No git sync. The caller has already detached this checkout at the tag the
|
||||
# image was built from; resetting to origin/main here would silently deploy a
|
||||
# compose file and a migration set from a different commit than the image.
|
||||
AFTER=$(git rev-parse --short HEAD)
|
||||
echo " tree at $AFTER"
|
||||
else
|
||||
echo " $BEFORE -> $AFTER"
|
||||
git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/ /'
|
||||
echo "==> Fetching"
|
||||
git fetch -q origin
|
||||
BEFORE=$(git rev-parse --short HEAD)
|
||||
git reset --hard -q origin/main
|
||||
AFTER=$(git rev-parse --short HEAD)
|
||||
|
||||
if [ "$BEFORE" = "$AFTER" ]; then
|
||||
echo " Already at $AFTER"
|
||||
else
|
||||
echo " $BEFORE -> $AFTER"
|
||||
git --no-pager log --oneline "$BEFORE..$AFTER" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> Backing up the database first"
|
||||
@@ -33,19 +84,24 @@ echo "==> Backing up the database first"
|
||||
# data is not something to discover without a dump in hand.
|
||||
mkdir -p backups
|
||||
BACKUP="backups/pig-$(date +%Y%m%d-%H%M%S).sql.gz"
|
||||
sudo docker compose -p pig exec -T db pg_dump -U pig pig | gzip > "$BACKUP"
|
||||
dc exec -T db pg_dump -U pig pig | gzip > "$BACKUP"
|
||||
echo " $BACKUP ($(du -h "$BACKUP" | cut -f1))"
|
||||
|
||||
echo "==> Building"
|
||||
sudo docker compose -p pig build app
|
||||
if [ -n "${PIG_IMAGE:-}" ]; then
|
||||
echo "==> Pulling"
|
||||
dc pull app
|
||||
else
|
||||
echo "==> Building"
|
||||
dc build app
|
||||
fi
|
||||
|
||||
echo "==> Starting the database"
|
||||
sudo docker compose -p pig up -d db
|
||||
dc up -d db
|
||||
for _ in $(seq 1 60); do
|
||||
if sudo docker compose -p pig exec -T db pg_isready -U pig -d pig > /dev/null; then break; fi
|
||||
if dc exec -T db pg_isready -U pig -d pig > /dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if ! sudo docker compose -p pig exec -T db pg_isready -U pig -d pig > /dev/null; then
|
||||
if ! dc exec -T db pg_isready -U pig -d pig > /dev/null; then
|
||||
echo "ERROR: database did not become ready within 60 seconds" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -54,10 +110,52 @@ echo "==> Migrating before the schema-dependent app starts"
|
||||
# A release may query a newly introduced table during startup. Running the
|
||||
# migration from a one-off container prevents that app from crash-looping
|
||||
# before an `exec`-based migration can reach it.
|
||||
sudo docker compose -p pig run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
|
||||
dc run --rm --no-deps app pnpm exec tsx packages/db/src/migrate.ts
|
||||
|
||||
# The image the current container is running, captured before it is replaced.
|
||||
# Without this a failed release stays live: both gates below used to exit 1 and
|
||||
# leave the broken version serving, which is tolerable when a human is reading
|
||||
# the terminal and an outage when the poller ran this at 04:00.
|
||||
PREVIOUS_IMAGE=""
|
||||
PREVIOUS_CID=$(dc ps -q app 2>/dev/null | head -n1 || true)
|
||||
if [ -n "$PREVIOUS_CID" ]; then
|
||||
PREVIOUS_IMAGE=$(sudo docker inspect -f '{{.Image}}' "$PREVIOUS_CID" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Restore the previous image and exit non-zero. Deliberately NOT a database
|
||||
# rollback: migrations are additive, so the previous code runs against the new
|
||||
# schema, and the dump taken above is the escape hatch for the case where it
|
||||
# does not. The re-tag makes $IMAGE_REF point back at the old image locally; the
|
||||
# next successful pull moves it forward again.
|
||||
roll_back() {
|
||||
echo " !! $1"
|
||||
dc logs app --tail 40 || true
|
||||
|
||||
if [ -z "$PREVIOUS_IMAGE" ]; then
|
||||
echo " NO PREVIOUS IMAGE TO ROLL BACK TO — the broken release is live" >&2
|
||||
exit "$EXIT_STILL_LIVE"
|
||||
fi
|
||||
|
||||
echo "==> Rolling back to $PREVIOUS_IMAGE"
|
||||
sudo docker tag "$PREVIOUS_IMAGE" "$IMAGE_REF"
|
||||
dc up -d --no-build app
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:8920/api/health > /dev/null; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
|
||||
echo " rolled back, previous release is healthy"
|
||||
exit 1
|
||||
fi
|
||||
# The restore ran but did not come up. Reporting "rolled back" here would tell
|
||||
# the on-call the previous release is serving when nothing is.
|
||||
echo " ROLLBACK ALSO UNHEALTHY — this host needs a human" >&2
|
||||
exit "$EXIT_STILL_LIVE"
|
||||
}
|
||||
|
||||
echo "==> Starting the app"
|
||||
sudo docker compose -p pig up -d app
|
||||
dc up -d app
|
||||
|
||||
echo "==> Waiting for health"
|
||||
for _ in $(seq 1 60); do
|
||||
@@ -70,8 +168,7 @@ if curl -sf http://127.0.0.1:8920/api/health | grep -q '"ok":true'; then
|
||||
echo " health ok"
|
||||
else
|
||||
echo " HEALTH CHECK FAILED"
|
||||
sudo docker compose -p pig logs app --tail 40
|
||||
exit 1
|
||||
roll_back "health check failed"
|
||||
fi
|
||||
|
||||
# Authentication must be enforced. A deploy that accidentally serves the CRM
|
||||
@@ -79,8 +176,75 @@ fi
|
||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8920/api/dashboard)
|
||||
if [ "$CODE" != "401" ]; then
|
||||
echo " UNAUTHENTICATED REQUEST RETURNED $CODE, EXPECTED 401"
|
||||
exit 1
|
||||
roll_back "unauthenticated request returned $CODE, expected 401"
|
||||
fi
|
||||
echo " auth enforced"
|
||||
|
||||
# Everything above proves the container is well. It proves nothing about what
|
||||
# the public actually gets, and there is a failure on this host that every
|
||||
# check so far passes: a Caddy site block missing `bind 10.0.0.2` lands in a
|
||||
# separate server on :443, wins for traffic arriving on that address, and
|
||||
# answers with a valid certificate, HTTP 200 and an EMPTY body. The app is
|
||||
# fine; the request never reached it. So ask the origin, from outside the
|
||||
# compose network, and insist on seeing something the app actually renders.
|
||||
PUBLIC_URL="${PIG_DEPLOY_PUBLIC_URL:-$(env_value PIG_PUBLIC_URL)}"
|
||||
PUBLIC_URL="${PUBLIC_URL:-https://primeintellectgrowth.com}"
|
||||
PUBLIC_MARKER="${PIG_DEPLOY_PUBLIC_MARKER:-<div id=\"root\">}"
|
||||
|
||||
echo "==> Verifying the public origin ($PUBLIC_URL)"
|
||||
# Three outcomes, and conflating any two of them has already cost a deploy.
|
||||
# `|| true` used to fold "curl never got an answer" into the empty string, so a
|
||||
# host where the public name does not hairpin, or where egress to 443 is
|
||||
# filtered, failed a completely healthy release — and autodeploy.sh then
|
||||
# blacklists that digest for good. Keep the transport result separate from the
|
||||
# body.
|
||||
PUBLIC_ERR=$(mktemp)
|
||||
trap 'rm -f "$PUBLIC_ERR"' EXIT
|
||||
if PUBLIC_BODY=$(curl -sS --max-time 20 --location "${PUBLIC_URL%/}/" 2>"$PUBLIC_ERR"); then
|
||||
CURL_STATUS=0
|
||||
else
|
||||
CURL_STATUS=$?
|
||||
fi
|
||||
PUBLIC_BYTES=${#PUBLIC_BODY}
|
||||
|
||||
if [ "$CURL_STATUS" -ne 0 ]; then
|
||||
# Could not connect at all: DNS, TLS, no hairpin, filtered egress. This says
|
||||
# nothing about the release, and nothing about the proxy either — from this
|
||||
# host the two are indistinguishable. Do not fail a deploy that passed every
|
||||
# check that can actually be trusted; set PIG_DEPLOY_REQUIRE_PUBLIC=1 on a
|
||||
# host where the public name IS reachable from here and this must block.
|
||||
echo " could not reach the public origin (curl exit $CURL_STATUS)" >&2
|
||||
sed 's/^/ /' "$PUBLIC_ERR" >&2
|
||||
if [ "${PIG_DEPLOY_REQUIRE_PUBLIC:-0}" = '1' ]; then
|
||||
echo " PIG_DEPLOY_REQUIRE_PUBLIC=1, so this is fatal." >&2
|
||||
exit "$EXIT_STILL_LIVE"
|
||||
fi
|
||||
echo " Not treated as a failure: the container passed every local gate," >&2
|
||||
echo " and this host may simply not be able to route to its own name." >&2
|
||||
echo " Verify $PUBLIC_URL from somewhere else." >&2
|
||||
elif [ "$PUBLIC_BYTES" -eq 0 ]; then
|
||||
echo " PUBLIC ORIGIN ANSWERED WITH AN EMPTY BODY" >&2
|
||||
echo " Something terminated TLS and answered, so this is the proxy, not" >&2
|
||||
echo " the release. Check that every Caddy site block on this host has" >&2
|
||||
echo " 'bind 10.0.0.2'." >&2
|
||||
# Deliberately no rollback: the previous image would fail this same check.
|
||||
# Rolling back would churn the service and not fix a proxy fault. The release
|
||||
# under test therefore stays live, which is what EXIT_STILL_LIVE tells the
|
||||
# poller to say.
|
||||
exit "$EXIT_STILL_LIVE"
|
||||
elif ! printf '%s' "$PUBLIC_BODY" | grep -qF "$PUBLIC_MARKER"; then
|
||||
echo " PUBLIC ORIGIN ANSWERED $PUBLIC_BYTES BYTES WITHOUT '$PUBLIC_MARKER'" >&2
|
||||
# This one DOES roll back, unlike the empty-body case above. A proxy fault
|
||||
# cannot serve a wrong-but-non-empty page for this hostname; a bad release
|
||||
# can — a changed Vite `base`, a Dockerfile that stopped copying
|
||||
# apps/web/dist. Every earlier gate passes in that state (the API is well,
|
||||
# /api/dashboard still 401s) and only this one fires, so if it is the release
|
||||
# then refusing to roll back leaves the broken release serving the public.
|
||||
# Restoring the previous image is right when it is, and harmless when it is
|
||||
# not: the marker check is the last gate, so the rollback costs one restart.
|
||||
roll_back "public origin answered without '$PUBLIC_MARKER'"
|
||||
else
|
||||
echo " public origin serving PIG ($PUBLIC_BYTES bytes)"
|
||||
fi
|
||||
|
||||
echo "==> Deployed $AFTER"
|
||||
|
||||
Reference in New Issue
Block a user