shoc-backend/scripts/package-elastic-beanstalk.sh
Adam Moussa f8bf102f35 feat(terraform): add safe backend environment adoption
Introduce import-guarded environment roots and retire temporary bootstrap and POC provisioning after ownership transfer.
2026-08-30 16:34:38 -04:00

164 lines
5.5 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# package-elastic-beanstalk.sh — build a deterministic Elastic Beanstalk source
# bundle for the shoc-backend .NET 8 application.
#
# Layout of the resulting ZIP (the Beanstalk application root):
# ./ published Api.SeaHavenIndustries (self-contained, linux-x64)
# ./efbundle self-contained EF Core 8.0.8 migrations bundle (linux-x64, +x)
# ./.ebextensions/* leader-only migration container command
#
# The bundle reads ConnectionStrings__DefaultConnection from the runtime
# environment (Elastic Beanstalk property). No connection string or secret is
# written into the package.
#
# The script only ever removes its own generated directory (.artifacts/elastic-beanstalk)
# and validates that path before doing so.
#
# Usage:
# bash scripts/package-elastic-beanstalk.sh [output.zip]
#
# Environment:
# DOTNET_BIN optional path to the dotnet executable
# RUNTIME optional target RID for local validation (default: linux-x64)
set -euo pipefail
OUTPUT_ZIP="${1:-.artifacts/elastic-beanstalk/site.zip}"
STAGING_DIR=".artifacts/elastic-beanstalk/staging"
TOOLS_DIR=".artifacts/dotnet-tools"
log() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
die() { printf '\033[31mERR\033[0m %s\n' "$1" >&2; exit 1; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
case "$OUTPUT_ZIP" in
.artifacts/elastic-beanstalk/*.zip) : ;;
*) die "output must be a .zip beneath .artifacts/elastic-beanstalk" ;;
esac
if [[ -n "${DOTNET_BIN:-}" ]]; then
DOTNET="$DOTNET_BIN"
elif command -v dotnet >/dev/null 2>&1; then
DOTNET="$(command -v dotnet)"
elif [[ -x "$HOME/.dotnet/dotnet" ]]; then
DOTNET="$HOME/.dotnet/dotnet"
else
die "dotnet is unavailable; set DOTNET_BIN or install the .NET 8 SDK."
fi
DOTNET_DIR="$(cd "$(dirname "$DOTNET")" && pwd)"
export PATH="$DOTNET_DIR:$PATH"
if [[ -d "$DOTNET_DIR/host/fxr" ]]; then
export DOTNET_ROOT="$DOTNET_DIR"
fi
export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_NOLOGO=1
export TZ=UTC
export SOURCE_DATE_EPOCH="315532800"
API_PROJECT="Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj"
MIGRATIONS_PROJECT="Data.SeaHavenIndustries/Data.SeaHavenIndustries.csproj"
EF_VERSION="8.0.8"
RUNTIME="${RUNTIME:-linux-x64}"
[[ -f "$API_PROJECT" ]] || die "missing API project: $API_PROJECT"
[[ -f "$MIGRATIONS_PROJECT" ]] || die "missing migrations project: $MIGRATIONS_PROJECT"
if command -v zip >/dev/null 2>&1; then
ARCHIVER="zip"
elif command -v python >/dev/null 2>&1; then
ARCHIVER="python"
else
die "zip or python is required to build the source bundle."
fi
GENERATED_ROOT="$(dirname "$STAGING_DIR")"
case "$GENERATED_ROOT" in
.artifacts/elastic-beanstalk) : ;;
*) die "refusing to remove unexpected generated dir: $GENERATED_ROOT" ;;
esac
log "clean generated artifacts"
rm -rf "$GENERATED_ROOT" "$TOOLS_DIR"
mkdir -p "$STAGING_DIR" "$TOOLS_DIR"
log "publish $API_PROJECT (Release, self-contained, $RUNTIME)"
"$DOTNET" publish "$API_PROJECT" \
-c Release \
--self-contained \
--runtime "$RUNTIME" \
-o "$STAGING_DIR" \
-p:ContinuousIntegrationBuild=true \
-p:UseAppHost=true
log "install dotnet-ef $EF_VERSION (local tool path)"
if ! "$DOTNET" tool install dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR" 2>/dev/null; then
"$DOTNET" tool update dotnet-ef --version "$EF_VERSION" --tool-path "$TOOLS_DIR"
fi
EF="$TOOLS_DIR/dotnet-ef"
log "build EF migrations bundle (self-contained, $RUNTIME)"
"$EF" migrations bundle \
--project "$MIGRATIONS_PROJECT" \
--startup-project "$API_PROJECT" \
--configuration Release \
--self-contained \
--target-runtime "$RUNTIME" \
--output "$STAGING_DIR/efbundle"
chmod 0755 "$STAGING_DIR/efbundle"
log "copy .ebextensions into bundle root"
mkdir -p "$STAGING_DIR/.ebextensions"
cp -R .ebextensions/. "$STAGING_DIR/.ebextensions/"
log "verify no committed secret placeholders survived publish"
if grep -rIEl -- 'Server=.*;.*Password=|AccountKey=|aws_secret|AKIA[0-9A-Z]{16}' "$STAGING_DIR" 2>/dev/null; then
die "potential secret detected in publish output; refusing to package."
fi
log "assemble source bundle (contents, not the containing directory)"
if [[ "$ARCHIVER" == "zip" ]]; then
(
cd "$STAGING_DIR"
# ZIP stores file mtimes. Normalize them so identical source/build inputs
# produce byte-identical source bundles.
find . -type f -exec touch -t 198001010000 {} +
find . -type f -print | LC_ALL=C sort \
| zip -q -X -@ "$REPO_ROOT/$OUTPUT_ZIP"
)
else
python - "$STAGING_DIR" "$REPO_ROOT/$OUTPUT_ZIP" <<'PY'
import pathlib
import stat
import sys
import zipfile
root = pathlib.Path(sys.argv[1])
output = pathlib.Path(sys.argv[2])
with zipfile.ZipFile(
output,
mode="w",
compression=zipfile.ZIP_DEFLATED,
compresslevel=9,
) as archive:
for path in sorted(item for item in root.rglob("*") if item.is_file()):
info = zipfile.ZipInfo(
path.relative_to(root).as_posix(),
date_time=(1980, 1, 1, 0, 0, 0),
)
info.compress_type = zipfile.ZIP_DEFLATED
mode = path.stat().st_mode
if path.name == "efbundle":
mode |= stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
info.external_attr = (mode & 0xFFFF) << 16
archive.writestr(info, path.read_bytes(), compresslevel=9)
PY
fi
log "package written: $OUTPUT_ZIP"
printf ' contents: %d files\n' "$(find "$STAGING_DIR" -type f | wc -l | tr -d ' ')"
printf ' size: %s bytes\n' "$(wc -c < "$REPO_ROOT/$OUTPUT_ZIP" | tr -d ' ')"
printf ' efbundle: %s\n' "$(file -b "$STAGING_DIR/efbundle" 2>/dev/null || echo present)"