2fe634141f
gates / gates (push) Successful in 45s
R-1142: the HDD_PATH root is appdata/<own folder>, with a compiled-in folder map (paperless-ngx -> paperless). R-981: the allow-list is empty (homepage's socket served no Felhom feature). R-1063: finalizeDecommissionWith asks the agent before it marks the drive or moves the default; the 403/409 come back typed and reach the household as a plain 409. R-1069: the agent's no-durable-id format refusal is ErrNoDurableID and a plain sentence. R-1143: build.sh carries :latest only for x.y.z. Each pinned by a red-proofed test. MinAgent 0.158.1.
315 lines
14 KiB
Bash
Executable File
315 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# =============================================================================
|
||
# felhom-controller — Docker image build script
|
||
# =============================================================================
|
||
# Location: /mnt/5_hdd/felhom.eu/build/felhom-controller/build.sh (moved off the DooPlex SSD 2026-07-18)
|
||
#
|
||
# Copies source from the git repo, syncs app assets, and builds the image.
|
||
# Build artifacts stay here — the git repo stays clean.
|
||
#
|
||
# Usage:
|
||
# ./build.sh # Build for current platform, tag as :dev
|
||
# ./build.sh 0.1.0 # Build with version tag
|
||
# ./build.sh 0.1.0 --push # Build + push to Gitea registry
|
||
# ./build.sh 0.1.0 --multiarch # Build amd64+arm64 + push
|
||
# ./build.sh 0.1.0 --print-tags # Print the tags a build would carry, and stop (the R-1143 test)
|
||
#
|
||
# :latest is carried ONLY by a release tag (x.y.z). An -rc, dev or any other tag never moves it (R-1143).
|
||
# =============================================================================
|
||
set -euo pipefail
|
||
|
||
# --- Configuration (edit these if your paths differ) ---
|
||
# The default is the shared DooPlex clone, and the release runbook names it. It is overridable
|
||
# because that clone is shared: when another session has work in progress in it, the clean-tree gate
|
||
# rightly forbids building over them, and the way to ship is a clean clone at the pushed commit —
|
||
# not stashing somebody else's work. Set FELHOM_REPO_DIR to that clone.
|
||
REPO_DIR="${FELHOM_REPO_DIR:-/mnt/5_hdd/felhom.eu/git/felhom-controller}"
|
||
CONTROLLER_SRC="${REPO_DIR}/controller"
|
||
WEBSITE_ASSETS_DIR="/mnt/5_hdd/felhom.eu/git/felhom.eu/website/assets"
|
||
REGISTRY="gitea.dooplex.hu/admin"
|
||
IMAGE="${REGISTRY}/felhom-controller"
|
||
|
||
# Build workspace — a temp directory next to this script, NOT in the git repo
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
BUILD_DIR="${SCRIPT_DIR}/workspace"
|
||
|
||
# --- Parse arguments ---
|
||
VERSION="${1:-dev}"
|
||
ACTION="${2:-}" # --push or --multiarch
|
||
|
||
# --- Colors ---
|
||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||
error() { echo -e "${RED}[ERROR]${NC} $*"; }
|
||
step() { echo -e "${CYAN}[STEP]${NC} $*"; }
|
||
|
||
# --- The tags this build carries (R-1143, 2026-10-18) ---
|
||
# Until v0.328.0 every build moved :latest, release candidates included (rc.1–rc.5 each did on 2026-10-17). Nothing
|
||
# current pulls :latest — the update button takes the highest x.y.z tag — but a legacy-shaped box following it would
|
||
# have taken an rc. So :latest moves with a RELEASE only. Pinned by scripts/test_build_push_once.sh.
|
||
TAGS=("${IMAGE}:${VERSION}")
|
||
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||
TAGS+=("${IMAGE}:latest")
|
||
fi
|
||
TAG_ARGS=()
|
||
for t in "${TAGS[@]}"; do TAG_ARGS+=(-t "$t"); done
|
||
if [[ "${ACTION}" == "--print-tags" ]]; then
|
||
printf '%s\n' "${TAGS[@]}"
|
||
exit 0
|
||
fi
|
||
|
||
# --- Pre-flight checks ---
|
||
if [[ ! -d "${CONTROLLER_SRC}" ]]; then
|
||
error "Controller source not found: ${CONTROLLER_SRC}"
|
||
error "Clone the repo first: git clone https://gitea.dooplex.hu/admin/felhom-controller.git ${REPO_DIR}"
|
||
exit 1
|
||
fi
|
||
|
||
if ! command -v docker &>/dev/null; then
|
||
error "Docker not found."
|
||
exit 1
|
||
fi
|
||
|
||
# Git metadata from the repo
|
||
GIT_COMMIT="unknown"
|
||
if [[ -d "${REPO_DIR}/.git" ]]; then
|
||
GIT_COMMIT=$(cd "${REPO_DIR}" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||
fi
|
||
|
||
echo ""
|
||
info "╔══════════════════════════════════════╗"
|
||
info "║ felhom-controller image builder ║"
|
||
info "╚══════════════════════════════════════╝"
|
||
info "Version: ${VERSION}"
|
||
info "Commit: ${GIT_COMMIT}"
|
||
info "Repo: ${REPO_DIR}"
|
||
info "Source: ${CONTROLLER_SRC}"
|
||
info "Build dir: ${BUILD_DIR}"
|
||
info "Image: ${IMAGE}:${VERSION}"
|
||
echo ""
|
||
|
||
# =========================================================================
|
||
# A VERSION NUMBER IS PUSHED ONCE (v0.316.0)
|
||
# =========================================================================
|
||
# 0.315.0 was built and pushed THREE times from three different commits. A box that pulled between
|
||
# the first and the last runs different code under the same number — and will never fetch the fix,
|
||
# because to the box, and to the floor, that number is already current (`09-update-architecture.md`:
|
||
# a box compares VERSIONS, and re-pulls a tag it already has only when the tag changes). Nothing in
|
||
# the build stopped it. This does.
|
||
#
|
||
# The check runs BEFORE the build, so a refusal costs no minutes. A registry that cannot be reached
|
||
# is a REFUSAL too: the point is to prove the tag is new, and "I could not ask" is not that proof.
|
||
# Local builds (no --push) are unaffected — reusing a version while iterating is the normal case.
|
||
#
|
||
# `docker manifest inspect` is the probe, so a test can put a fake `docker` on PATH
|
||
# (scripts/test_build_push_once.sh).
|
||
if [[ "${ACTION}" == "--push" || "${ACTION}" == "--multiarch" ]]; then
|
||
step "0/4 — Checking the registry: is ${VERSION} already published?"
|
||
# `set -e` is on, and a failing command substitution would abort the script before the rc is
|
||
# ever read — which would turn "the registry said no such tag" into a silent exit. The `|| true`
|
||
# keeps the failure for us to classify; that classification is the whole check.
|
||
probe_rc=0
|
||
probe_out=$(docker manifest inspect "${IMAGE}:${VERSION}" 2>&1) || probe_rc=$?
|
||
if [[ ${probe_rc} -eq 0 ]]; then
|
||
error "${IMAGE}:${VERSION} already exists in the registry — bump the version."
|
||
error "A version number is pushed once. Two images under one number mean a box can run"
|
||
error "either of them and never learn the difference. If this is a fix for something already"
|
||
error "pushed, release it as the next patch version instead."
|
||
exit 1
|
||
fi
|
||
# Absent is the ONLY other acceptable answer. Anything that is not a clean "not found" (the
|
||
# registry is down, the login expired, DNS failed) leaves the question unanswered, and an
|
||
# unanswered question is a refusal.
|
||
if ! grep -qiE 'no such manifest|manifest unknown|not found|does not exist|NAME_UNKNOWN|MANIFEST_UNKNOWN' <<<"${probe_out}"; then
|
||
error "Could not establish whether ${IMAGE}:${VERSION} exists — refusing to build."
|
||
error "The registry answered: ${probe_out}"
|
||
error "Proving the tag is new is the point of this check; 'I could not ask' is not that proof."
|
||
exit 1
|
||
fi
|
||
info "${VERSION} is not present in the registry — proceeding."
|
||
|
||
# AND THE TREE IT BUILDS FROM MUST BE CLEAN AND PUSHED.
|
||
#
|
||
# Until v0.316.3 this was a sentence in a runbook, and a rule without a mechanism is a wish. It
|
||
# cost a bad push the same evening it was written down: the shared clone had 31 files of another
|
||
# session's work in progress, and a build from it put their unreleased code into a published
|
||
# image under my version number. Nobody deployed it; that was luck, not a control.
|
||
#
|
||
# Two questions, both of which must be YES:
|
||
# is every tracked file committed? — otherwise the image holds something git does not
|
||
# is HEAD what origin/main has? — otherwise the image holds something nobody can fetch
|
||
# „An unpushed change does not exist" (workspace CLAUDE.md), and an image built from one is a
|
||
# thing that exists and cannot be reproduced.
|
||
if [[ -d "${REPO_DIR}/.git" ]]; then
|
||
dirty=$(cd "${REPO_DIR}" && git status --porcelain 2>/dev/null | wc -l)
|
||
if [[ "${dirty}" -ne 0 ]]; then
|
||
error "${REPO_DIR} has ${dirty} uncommitted change(s) — refusing to push a build from it."
|
||
error "An image built from a dirty tree cannot be reproduced, and on a SHARED clone it"
|
||
error "may carry work that is not yours. Commit and push, or build from a clean clone"
|
||
error "with FELHOM_REPO_DIR pointing at it."
|
||
(cd "${REPO_DIR}" && git status --porcelain | head -10 | sed 's/^/ /')
|
||
exit 1
|
||
fi
|
||
head_sha=$(cd "${REPO_DIR}" && git rev-parse HEAD 2>/dev/null || echo unknown)
|
||
main_sha=$(cd "${REPO_DIR}" && git rev-parse origin/main 2>/dev/null || echo unknown)
|
||
if [[ "${head_sha}" != "${main_sha}" ]]; then
|
||
error "${REPO_DIR} is not at origin/main — refusing to push."
|
||
error " HEAD ${head_sha}"
|
||
error " origin/main ${main_sha}"
|
||
error "An unpushed change does not exist; an image built from one cannot be fetched again."
|
||
exit 1
|
||
fi
|
||
info "Source tree is clean and at origin/main."
|
||
else
|
||
error "${REPO_DIR} is not a git checkout — refusing to push a build whose source cannot be identified."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
# =========================================================================
|
||
# Step 1: Prepare clean build workspace
|
||
# =========================================================================
|
||
step "1/4 — Preparing build workspace..."
|
||
|
||
rm -rf "${BUILD_DIR}"
|
||
mkdir -p "${BUILD_DIR}"
|
||
|
||
# Copy the entire controller directory (preserving subdirectory structure)
|
||
cp -a "${CONTROLLER_SRC}/." "${BUILD_DIR}/"
|
||
|
||
# Verify the expected Go package structure exists
|
||
MISSING=()
|
||
for required in cmd/controller/main.go internal/config/config.go internal/stacks/manager.go internal/api/router.go internal/web/server.go; do
|
||
if [[ ! -f "${BUILD_DIR}/${required}" ]]; then
|
||
MISSING+=("${required}")
|
||
fi
|
||
done
|
||
|
||
if [[ ${#MISSING[@]} -gt 0 ]]; then
|
||
error "Directory structure check FAILED. Missing files:"
|
||
for f in "${MISSING[@]}"; do
|
||
error " ✗ ${f}"
|
||
done
|
||
error ""
|
||
error "The Go source must be in package subdirectories, not flat."
|
||
error "Run the restructure script first:"
|
||
error " cd ${CONTROLLER_SRC} && bash restructure.sh"
|
||
error " git add -A && git commit -m 'Restructure into Go package dirs'"
|
||
rm -rf "${BUILD_DIR}"
|
||
exit 1
|
||
fi
|
||
|
||
GO_COUNT=$(find "${BUILD_DIR}" -name '*.go' | wc -l)
|
||
info "Source copied: ${GO_COUNT} Go files"
|
||
|
||
# =========================================================================
|
||
# Step 2: Sync app assets (logos + screenshots)
|
||
# =========================================================================
|
||
step "2/4 — Syncing app assets..."
|
||
|
||
mkdir -p "${BUILD_DIR}/assets"
|
||
|
||
if [[ -d "${WEBSITE_ASSETS_DIR}" ]]; then
|
||
# Count before copy
|
||
SVG_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-logo.svg' 2>/dev/null | wc -l)
|
||
PNG_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-logo.png' 2>/dev/null | wc -l)
|
||
SS_N=$(find "${WEBSITE_ASSETS_DIR}" -maxdepth 1 -name '*-screenshot-*.webp' 2>/dev/null | wc -l)
|
||
|
||
cp "${WEBSITE_ASSETS_DIR}"/*-logo.svg "${BUILD_DIR}/assets/" 2>/dev/null || true
|
||
cp "${WEBSITE_ASSETS_DIR}"/*-logo.png "${BUILD_DIR}/assets/" 2>/dev/null || true
|
||
cp "${WEBSITE_ASSETS_DIR}"/*-screenshot-*.webp "${BUILD_DIR}/assets/" 2>/dev/null || true
|
||
|
||
TOTAL=$(find "${BUILD_DIR}/assets" -type f ! -name 'README.md' 2>/dev/null | wc -l)
|
||
info "Synced ${TOTAL} assets (${SVG_N} SVG + ${PNG_N} PNG logos, ${SS_N} screenshots)"
|
||
else
|
||
warn "Website assets not found: ${WEBSITE_ASSETS_DIR}"
|
||
warn "Building without logos/screenshots. Set WEBSITE_ASSETS_DIR if needed."
|
||
fi
|
||
|
||
# =========================================================================
|
||
# Step 3: Run go mod tidy (to generate go.sum if missing)
|
||
# =========================================================================
|
||
step "3/4 — Preparing Go modules..."
|
||
|
||
cd "${BUILD_DIR}"
|
||
|
||
# The Dockerfile handles go mod download internally, but verify go.mod exists
|
||
if [[ ! -f "go.mod" ]]; then
|
||
error "go.mod not found in build workspace!"
|
||
exit 1
|
||
fi
|
||
|
||
# If go is installed locally, run tidy to catch issues early
|
||
if command -v go &>/dev/null; then
|
||
info "Running go mod tidy..."
|
||
go mod tidy 2>&1 || warn "go mod tidy had issues (Docker build may still work)"
|
||
else
|
||
info "Go not installed locally — Docker build stage will handle dependencies."
|
||
fi
|
||
|
||
# =========================================================================
|
||
# Step 4: Docker build
|
||
# =========================================================================
|
||
step "4/4 — Building Docker image..."
|
||
|
||
BUILD_ARGS=(
|
||
--build-arg "VERSION=${VERSION}"
|
||
--build-arg "GIT_COMMIT=${GIT_COMMIT}"
|
||
)
|
||
|
||
case "${ACTION}" in
|
||
--push)
|
||
info "Building for current platform + pushing..."
|
||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||
|
||
info "Pushing..."
|
||
for t in "${TAGS[@]}"; do docker push "$t"; done
|
||
;;
|
||
|
||
--multiarch)
|
||
info "Building multi-arch (amd64 + arm64) + pushing..."
|
||
|
||
# Ensure buildx builder exists
|
||
if ! docker buildx inspect felhom-builder &>/dev/null; then
|
||
info "Creating buildx builder (one-time setup)..."
|
||
docker buildx create --name felhom-builder --use --bootstrap
|
||
else
|
||
docker buildx use felhom-builder
|
||
fi
|
||
|
||
docker buildx build "${BUILD_ARGS[@]}" \
|
||
--platform linux/amd64,linux/arm64 \
|
||
"${TAG_ARGS[@]}" \
|
||
--push \
|
||
.
|
||
;;
|
||
|
||
*)
|
||
info "Building for current platform (local only)..."
|
||
docker build "${BUILD_ARGS[@]}" "${TAG_ARGS[@]}" .
|
||
;;
|
||
esac
|
||
|
||
# =========================================================================
|
||
# Summary
|
||
# =========================================================================
|
||
echo ""
|
||
info "╔══════════════════════════════════════╗"
|
||
info "║ Build complete ✓ ║"
|
||
info "╚══════════════════════════════════════╝"
|
||
info "Image: ${IMAGE}:${VERSION}"
|
||
|
||
# Show image size if available locally
|
||
SIZE=$(docker image inspect "${IMAGE}:${VERSION}" --format='{{.Size}}' 2>/dev/null || echo "")
|
||
if [[ -n "${SIZE}" ]]; then
|
||
SIZE_HUMAN=$(numfmt --to=iec "${SIZE}" 2>/dev/null || echo "${SIZE} bytes")
|
||
info "Size: ${SIZE_HUMAN}"
|
||
fi
|
||
|
||
echo ""
|
||
if [[ "${ACTION}" == "" ]]; then
|
||
info "Image is local only. To push:"
|
||
info " ./build.sh ${VERSION} --push # current arch"
|
||
info " ./build.sh ${VERSION} --multiarch # amd64 + arm64"
|
||
fi |