diff --git a/Apps/_template/docker-compose.yaml b/Apps/_template/docker-compose.yaml index 177dd51..b6563c1 100644 --- a/Apps/_template/docker-compose.yaml +++ b/Apps/_template/docker-compose.yaml @@ -1,8 +1,12 @@ name: sample-app +x-image: + namespace: ${IMAGE_NAMESPACE:-joafri} + tag: ${IMAGE_TAG:-main} + services: app: - image: ghcr.io/example/sample-app:1.0.0 + image: ${IMAGE_NAMESPACE:-joafri}/sample-app-app:${IMAGE_TAG:-main} container_name: sample-app restart: unless-stopped diff --git a/Apps/caddy-autogen/docker-compose.yaml b/Apps/caddy-autogen/docker-compose.yaml index a4b622e..18b6111 100644 --- a/Apps/caddy-autogen/docker-compose.yaml +++ b/Apps/caddy-autogen/docker-compose.yaml @@ -1,7 +1,12 @@ name: caddy-autogen +x-image: + namespace: ${IMAGE_NAMESPACE:-joafri} + tag: ${IMAGE_TAG:-main} + services: caddy: + image: ${IMAGE_NAMESPACE:-joafri}/caddy-autogen-caddy:${IMAGE_TAG:-main} build: context: ./caddy dockerfile: Dockerfile @@ -79,6 +84,7 @@ services: - ALL discovery-agent: + image: ${IMAGE_NAMESPACE:-joafri}/caddy-autogen-discovery-agent:${IMAGE_TAG:-main} build: context: ./agent dockerfile: Dockerfile diff --git a/Apps/docker-ip-addr-manager/docker-compose.yaml b/Apps/docker-ip-addr-manager/docker-compose.yaml index 885e5c8..e66d8a5 100644 --- a/Apps/docker-ip-addr-manager/docker-compose.yaml +++ b/Apps/docker-ip-addr-manager/docker-compose.yaml @@ -1,7 +1,12 @@ name: docker-ip-addr-manager +x-image: + namespace: ${IMAGE_NAMESPACE:-joafri} + tag: ${IMAGE_TAG:-main} + services: app: + image: ${IMAGE_NAMESPACE:-joafri}/docker-ip-addr-manager-app:${IMAGE_TAG:-main} build: context: ./backend dockerfile: Dockerfile diff --git a/Apps/steam-headless/docker-compose.yaml b/Apps/steam-headless/docker-compose.yaml index b8a5c5b..ea4bb5a 100644 --- a/Apps/steam-headless/docker-compose.yaml +++ b/Apps/steam-headless/docker-compose.yaml @@ -1,5 +1,9 @@ name: steam-headless +x-image: + namespace: ${IMAGE_NAMESPACE:-joafri} + tag: ${IMAGE_TAG:-main} + services: steam: image: lscr.io/linuxserver/steam:version-f4f48542@sha256:d7b9fbf302e05ae79248d1171fe9751b354f8397eafa1e13a3df0aa6a75de0b4 diff --git a/Apps/steam-moonlight/docker-compose.yaml b/Apps/steam-moonlight/docker-compose.yaml index ac2b6d1..0727037 100644 --- a/Apps/steam-moonlight/docker-compose.yaml +++ b/Apps/steam-moonlight/docker-compose.yaml @@ -1,5 +1,9 @@ name: steam-moonlight +x-image: + namespace: ${IMAGE_NAMESPACE:-joafri} + tag: ${IMAGE_TAG:-main} + x-steam-common: &steam-common image: josh5/steam-headless:debian-0.2.0@sha256:540366bee31297c5679a5006a84dbca039ca62aaab695852b51b5f62dffd2c14 restart: unless-stopped diff --git a/README.md b/README.md index 13ff3ae..28efd99 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ Skelett för att bygga och underhålla ZimaOS/CasaOS-appar i ett eget appstore-r ├── featured-apps.json ├── recommend-list.json └── scripts/ + ├── build-appstore-zip.sh + ├── build-and-push-image.sh └── validate-appstore.sh ``` @@ -46,6 +48,16 @@ Inför release/publicering, kör strikt validering för högrisk-inställningar: ./scripts/validate-appstore.sh --enforce-risk-docs ``` +Bygg och publicera app-specifika custom images (Docker Hub namespace `joafri` som default): + +```bash +./scripts/build-and-push-image.sh --app-id caddy-autogen --tag 0.1.0 +``` + +Scriptet läser `Apps//docker-compose.yaml` och bygger alla services som har `build:` om `--component` inte anges. +`--component` kan användas för en enskild service (service-namn eller context-path). +Arkitekturer hämtas från `x-casaos.architectures`; builden kör fail-open per arkitektur och visar varningar i slutet för misslyckade arch-builds. + ## Säkerhetsriktlinjer - Undvik privilegierad container, host network och `docker.sock` om det inte är absolut nödvändigt. diff --git a/scripts/build-and-push-image.sh b/scripts/build-and-push-image.sh new file mode 100755 index 0000000..e6f88e1 --- /dev/null +++ b/scripts/build-and-push-image.sh @@ -0,0 +1,355 @@ +#!/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) + --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 + +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 + ;; + -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 + +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 + echo "ERROR: no buildable services found in $compose_file" >&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