#!/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 # ============================================================================= 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} $*"; } # --- 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[@]}" \ -t "${IMAGE}:${VERSION}" \ -t "${IMAGE}:latest" \ . info "Pushing..." docker push "${IMAGE}:${VERSION}" docker push "${IMAGE}:latest" ;; --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 \ -t "${IMAGE}:${VERSION}" \ -t "${IMAGE}:latest" \ --push \ . ;; *) info "Building for current platform (local only)..." docker build "${BUILD_ARGS[@]}" \ -t "${IMAGE}:${VERSION}" \ -t "${IMAGE}:latest" \ . ;; 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