Files
felhom-controller/controller/build.sh
T
admin 2fe634141f
gates / gates (push) Successful in 45s
v0.329.0: each app reaches only its own appdata folder; empty allow-list; drive retirement asks the agent first; plain no-durable-id refusal; :latest only for a release
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.
2026-10-17 23:11:56 +02:00

315 lines
14 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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