Files
zima-apps/scripts/build-and-push-image.sh
Joachim Friberg 231aba08b0 Updated script
2026-04-19 22:23:57 +02:00

371 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
default_repo="joafri"
usage() {
cat <<'USAGE'
Usage:
./scripts/build-and-push-image.sh --app-id <app-id> --tag <tag> [options]
Required:
--app-id <app-id> App folder under Apps/, for example: caddy-autogen
--tag <tag> Docker tag base (must not be latest)
Options:
--component <value> Optional. Service name OR build context path under app.
If omitted, build all services with build contexts.
--repo <namespace> Docker namespace/org (default: joafri)
--skip-non-buildable Exit 0 (with warning) when app has no services with build context
--no-push Build only, no push or manifest creation
-h, --help Show this help
Behavior:
- Reads buildable services from Apps/<app-id>/docker-compose.yaml.
- Reads architectures from x-casaos.architectures in the same compose file.
- Builds each architecture separately and tags as <tag>-<arch>.
- When pushing, creates a manifest tag <tag> from successful arch tags.
- Fail-open per architecture: failed arch builds are reported as warnings.
Examples:
./scripts/build-and-push-image.sh --app-id caddy-autogen --tag 0.2.0
./scripts/build-and-push-image.sh --app-id caddy-autogen --component agent --tag 0.2.0
USAGE
}
app_id=""
component=""
tag=""
repo_ns="$default_repo"
push_image=1
skip_non_buildable=0
while [[ $# -gt 0 ]]; do
case "$1" in
--app-id)
app_id="${2:-}"
shift 2
;;
--component)
component="${2:-}"
shift 2
;;
--tag)
tag="${2:-}"
shift 2
;;
--repo)
repo_ns="${2:-}"
shift 2
;;
--no-push)
push_image=0
shift
;;
--skip-non-buildable)
skip_non_buildable=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
echo "ERROR: unknown argument: $1" >&2
usage
exit 2
;;
esac
done
if [[ -z "$app_id" || -z "$tag" ]]; then
echo "ERROR: --app-id and --tag are required" >&2
usage
exit 2
fi
if [[ "$tag" == "latest" ]]; then
echo "ERROR: tag 'latest' is not allowed by repo policy" >&2
exit 1
fi
if [[ "$app_id" =~ [^a-z0-9-] ]]; then
echo "ERROR: app-id must match [a-z0-9-]+" >&2
exit 1
fi
if [[ "$repo_ns" =~ [^a-zA-Z0-9._-] ]]; then
echo "ERROR: repo namespace contains invalid characters" >&2
exit 1
fi
component="${component#./}"
if [[ -n "$component" && ( "$component" == /* || "$component" == *".."* ) ]]; then
echo "ERROR: --component must be service name or relative subpath under Apps/$app_id" >&2
exit 1
fi
app_dir="$repo_root/Apps/$app_id"
compose_file="$app_dir/docker-compose.yaml"
if [[ ! -d "$app_dir" ]]; then
echo "ERROR: app directory not found: $app_dir" >&2
exit 1
fi
if [[ ! -f "$compose_file" ]]; then
echo "ERROR: missing compose file: $compose_file" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: docker is required" >&2
exit 1
fi
if ! docker buildx version >/dev/null 2>&1; then
echo "ERROR: docker buildx is required" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq is required" >&2
exit 1
fi
map_arch_to_platform() {
case "$1" in
amd64) echo "linux/amd64" ;;
arm64) echo "linux/arm64" ;;
arm) echo "linux/arm/v7" ;;
*) return 1 ;;
esac
}
read_compose_architectures() {
awk '
/^x-casaos:[[:space:]]*$/ { in_casa=1; next }
in_casa && /^[^[:space:]]/ { in_casa=0 }
in_casa && /^[[:space:]]+architectures:[[:space:]]*$/ { in_arch=1; next }
in_arch {
if ($0 ~ /^[[:space:]]*-[[:space:]]*[A-Za-z0-9_-]+[[:space:]]*$/) {
line=$0
sub(/^[[:space:]]*-[[:space:]]*/, "", line)
sub(/[[:space:]]*$/, "", line)
print line
next
}
if ($0 ~ /^[[:space:]]*$/) next
in_arch=0
}
' "$compose_file"
}
compose_json="$(mktemp)"
cleanup() {
rm -f "$compose_json"
}
trap cleanup EXIT
if [[ -z "${AppID:-}" ]]; then
export AppID="$app_id"
fi
docker compose -f "$compose_file" config --format json > "$compose_json"
mapfile -t all_services < <(
jq -r '.services | to_entries[] | select(.value.build != null) | .key' "$compose_json"
)
if [[ ${#all_services[@]} -eq 0 ]]; then
if [[ "$skip_non_buildable" -eq 1 ]]; then
echo "WARN: no buildable services found in $compose_file (skipped)"
exit 0
fi
echo "ERROR: no buildable services found in $compose_file" >&2
echo "Hint: pass --skip-non-buildable for batch loops over mixed app types." >&2
exit 1
fi
mapfile -t compose_arches < <(read_compose_architectures)
if [[ ${#compose_arches[@]} -eq 0 ]]; then
compose_arches=(amd64)
echo "WARN: no x-casaos.architectures found, defaulting to amd64"
fi
selected_services=()
if [[ -n "$component" ]]; then
for svc in "${all_services[@]}"; do
context_abs="$(jq -r --arg svc "$svc" '.services[$svc].build.context // empty' "$compose_json")"
if [[ -z "$context_abs" ]]; then
continue
fi
context_rel="$context_abs"
if [[ "$context_abs" == "$app_dir" ]]; then
context_rel="."
elif [[ "$context_abs" == "$app_dir"/* ]]; then
context_rel="${context_abs#"$app_dir"/}"
fi
if [[ "$svc" == "$component" || "$context_rel" == "$component" ]]; then
selected_services+=("$svc")
fi
done
if [[ ${#selected_services[@]} -eq 0 ]]; then
echo "ERROR: --component '$component' did not match any build service name or context path" >&2
echo "Build services in this app:" >&2
for svc in "${all_services[@]}"; do
ctx="$(jq -r --arg svc "$svc" '.services[$svc].build.context // empty' "$compose_json")"
if [[ "$ctx" == "$app_dir" ]]; then
ctx_rel="."
elif [[ "$ctx" == "$app_dir"/* ]]; then
ctx_rel="${ctx#"$app_dir"/}"
else
ctx_rel="$ctx"
fi
echo " - $svc (context: $ctx_rel)" >&2
done
exit 1
fi
else
selected_services=("${all_services[@]}")
fi
git_sha="unknown"
if git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git_sha="$(git -C "$repo_root" rev-parse --short HEAD)"
fi
source_url="unknown"
if git -C "$repo_root" remote get-url origin >/dev/null 2>&1; then
source_url="$(git -C "$repo_root" remote get-url origin)"
fi
echo "App: $app_id"
echo "Compose: $compose_file"
echo "Services: ${selected_services[*]}"
echo "Architectures: ${compose_arches[*]}"
if [[ "$push_image" -eq 1 ]]; then
echo "Mode: build + push + manifest"
else
echo "Mode: build only (--no-push)"
fi
warn_messages=()
total_success=0
for svc in "${selected_services[@]}"; do
context_abs="$(jq -r --arg svc "$svc" '.services[$svc].build.context // empty' "$compose_json")"
dockerfile_rel="$(jq -r --arg svc "$svc" '.services[$svc].build.dockerfile // "Dockerfile"' "$compose_json")"
if [[ -z "$context_abs" ]]; then
warn_messages+=("$svc: missing build context in compose config, skipped")
continue
fi
case "$context_abs" in
"$app_dir"|"$app_dir"/*) ;;
*)
warn_messages+=("$svc: build context outside app dir ($context_abs), skipped")
continue
;;
esac
dockerfile_abs="$context_abs/$dockerfile_rel"
if [[ ! -f "$dockerfile_abs" ]]; then
warn_messages+=("$svc: Dockerfile missing at $dockerfile_abs, skipped")
continue
fi
image_repo="${repo_ns}/${app_id}-${svc}"
success_arch_refs=()
echo
echo "=== Service: $svc ==="
echo "Context: $context_abs"
echo "Dockerfile: $dockerfile_abs"
echo "Image repo: $image_repo"
for arch in "${compose_arches[@]}"; do
if ! platform="$(map_arch_to_platform "$arch")"; then
warn_messages+=("$svc: unsupported architecture '$arch' in x-casaos.architectures")
continue
fi
arch_ref="${image_repo}:${tag}-${arch}"
echo "Building $svc for $arch ($platform) -> $arch_ref"
build_cmd=(
docker buildx build
--platform "$platform"
--file "$dockerfile_abs"
--tag "$arch_ref"
--label "org.opencontainers.image.source=$source_url"
--label "org.opencontainers.image.revision=$git_sha"
)
if [[ "$push_image" -eq 1 ]]; then
build_cmd+=(--push)
else
build_cmd+=(--load)
fi
build_cmd+=("$context_abs")
if "${build_cmd[@]}"; then
success_arch_refs+=("$arch_ref")
total_success=$((total_success + 1))
else
warn_messages+=("$svc: build failed for arch '$arch' ($platform)")
fi
done
if [[ ${#success_arch_refs[@]} -eq 0 ]]; then
warn_messages+=("$svc: no successful architecture builds")
continue
fi
if [[ "$push_image" -eq 1 ]]; then
manifest_ref="${image_repo}:${tag}"
echo "Creating manifest tag: $manifest_ref"
if docker buildx imagetools create -t "$manifest_ref" "${success_arch_refs[@]}"; then
echo "Manifest created: $manifest_ref"
else
warn_messages+=("$svc: failed to create manifest tag ${manifest_ref}")
fi
else
first_ref="${success_arch_refs[0]}"
local_ref="${image_repo}:${tag}"
if docker tag "$first_ref" "$local_ref"; then
echo "Tagged local alias: $local_ref -> $first_ref"
else
warn_messages+=("$svc: failed to tag local alias ${local_ref}")
fi
fi
done
echo
if [[ "$total_success" -eq 0 ]]; then
echo "ERROR: no successful builds were produced" >&2
if [[ ${#warn_messages[@]} -gt 0 ]]; then
echo "Warnings:" >&2
for w in "${warn_messages[@]}"; do
echo " - $w" >&2
done
fi
exit 1
fi
echo "Build completed with $total_success successful arch build(s)."
if [[ ${#warn_messages[@]} -gt 0 ]]; then
echo
echo "Warnings (fail-open):"
for w in "${warn_messages[@]}"; do
echo " - $w"
done
fi