#!/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 --tag [options] Required: --app-id App folder under Apps/, for example: caddy-autogen --tag Docker tag base (must not be latest) Options: --component Optional. Service name OR build context path under app. If omitted, build all services with build contexts. --repo 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//docker-compose.yaml. - Reads architectures from x-casaos.architectures in the same compose file. - Builds each architecture separately and tags as -. - When pushing, creates a manifest 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