From 7295545cde2fc7b0674ba17b3447d89be2f85218 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 12 May 2026 14:26:59 +0700 Subject: [PATCH] chore: add updater image configuration to Docker workflow - Introduced a new job for building and pushing an updater Docker image, dynamically setting its name based on the repository structure. - Updated existing image name configurations for frontend and backend to strip version numbers, ensuring cleaner image tags. - Enhanced the Docker workflow with steps for checking out the repository, configuring Buildx, and logging into the Gitea registry. --- .gitea/workflows/docker.yml | 50 +++- deploy/docker-compose.yml | 46 +++ deploy/updater/Dockerfile | 23 ++ deploy/updater/entrypoint.sh | 449 ++++++++++++++++++++++++++++ deploy/updater/targets.json.example | 24 ++ deploy/updater/test-staging.sh | 25 ++ deploy/updater/validate.sh | 15 + 7 files changed, 630 insertions(+), 2 deletions(-) create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/updater/Dockerfile create mode 100644 deploy/updater/entrypoint.sh create mode 100644 deploy/updater/targets.json.example create mode 100644 deploy/updater/test-staging.sh create mode 100644 deploy/updater/validate.sh diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml index 225ec6c..c165d0f 100644 --- a/.gitea/workflows/docker.yml +++ b/.gitea/workflows/docker.yml @@ -22,7 +22,8 @@ jobs: set -euo pipefail owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]') name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]') - echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${name}-backend" >> "${GITHUB_ENV}" + stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//') + echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-backend" >> "${GITHUB_ENV}" - name: Prepare backend build context shell: bash @@ -81,7 +82,8 @@ jobs: set -euo pipefail owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]') name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]') - echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${name}-frontend" >> "${GITHUB_ENV}" + stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//') + echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-frontend" >> "${GITHUB_ENV}" - name: Prepare frontend build context shell: bash @@ -135,6 +137,50 @@ jobs: org.opencontainers.image.revision=${{ gitea.sha }} org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} + updater-image: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure updater image name + shell: bash + run: | + set -euo pipefail + owner=$(echo "${{ gitea.repository }}" | cut -d/ -f1 | tr '[:upper:]' '[:lower:]') + name=$(echo "${{ gitea.repository }}" | cut -d/ -f2 | tr '[:upper:]' '[:lower:]') + stem=$(printf '%s' "$name" | sed -E 's/-[0-9]+$//') + echo "IMAGE_NAME=${{ env.REGISTRY }}/${owner}/${stem}-updater" >> "${GITHUB_ENV}" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Gitea Registry + uses: docker/login-action@v3 + with: + registry: git.shts.su + username: ${{ gitea.actor }} + password: ${{ secrets.ACTIONS_PAT }} + + - name: Build and push updater image + uses: docker/build-push-action@v5 + with: + context: deploy/updater + file: deploy/updater/Dockerfile + platforms: linux/amd64 + push: true + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ gitea.sha }} + cache-from: type=gha,scope=updater + cache-to: type=gha,mode=max,scope=updater + labels: | + org.opencontainers.image.title=mmapp-updater + org.opencontainers.image.description=MikroTik Manager autonomous Docker updater + org.opencontainers.image.version=latest + org.opencontainers.image.revision=${{ gitea.sha }} + org.opencontainers.image.created=${{ gitea.event.head_commit.timestamp }} + notify-webhook: if: ${{ secrets.DEPLOY_WEBHOOK_URL != '' }} needs: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..54029bc --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,46 @@ +services: + backend: + image: git.shts.su/denozord/mikrotikmanager-backend:latest + container_name: mmapp-backend + restart: unless-stopped + ports: + - "8000:8000" + environment: + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} + volumes: + - backend-data:/app/data + labels: + mmapp.updater.managed: "true" + mmapp.updater.target: backend + mmapp.updater.image: git.shts.su/denozord/mikrotikmanager-backend:latest + + frontend: + image: git.shts.su/denozord/mikrotikmanager-frontend:latest + container_name: mmapp-frontend + restart: unless-stopped + ports: + - "3000:3000" + labels: + mmapp.updater.managed: "true" + mmapp.updater.target: frontend + mmapp.updater.image: git.shts.su/denozord/mikrotikmanager-frontend:latest + + updater: + image: git.shts.su/denozord/mikrotikmanager-updater:latest + container_name: mmapp-updater + restart: unless-stopped + environment: + REGISTRY: git.shts.su + REGISTRY_USERNAME: ${REGISTRY_USERNAME:-} + REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-} + POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300} + HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120} + STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - updater-state:/state + - ./updater/targets.json.example:/etc/updater/targets.json:ro + +volumes: + backend-data: + updater-state: diff --git a/deploy/updater/Dockerfile b/deploy/updater/Dockerfile new file mode 100644 index 0000000..093b4c1 --- /dev/null +++ b/deploy/updater/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +FROM docker:27-cli + +RUN apk add --no-cache bash curl jq coreutils + +WORKDIR /updater + +COPY entrypoint.sh /updater/entrypoint.sh +COPY targets.json.example /etc/updater/targets.json + +RUN chmod +x /updater/entrypoint.sh + +ENV TARGETS_FILE=/etc/updater/targets.json +ENV STATE_FILE=/state/updater-state.json +ENV POLL_INTERVAL_SECONDS=300 +ENV HEALTH_TIMEOUT_SECONDS=120 +ENV STOP_TIMEOUT_SECONDS=30 +ENV REGISTRY=git.shts.su + +VOLUME ["/state"] + +ENTRYPOINT ["/updater/entrypoint.sh"] diff --git a/deploy/updater/entrypoint.sh b/deploy/updater/entrypoint.sh new file mode 100644 index 0000000..6afb3fb --- /dev/null +++ b/deploy/updater/entrypoint.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGETS_FILE="${TARGETS_FILE:-/etc/updater/targets.json}" +STATE_FILE="${STATE_FILE:-/state/updater-state.json}" +POLL_INTERVAL_SECONDS="${POLL_INTERVAL_SECONDS:-300}" +HEALTH_TIMEOUT_SECONDS="${HEALTH_TIMEOUT_SECONDS:-120}" +STOP_TIMEOUT_SECONDS="${STOP_TIMEOUT_SECONDS:-30}" +REGISTRY="${REGISTRY:-git.shts.su}" +MANAGED_LABEL="mmapp.updater.managed" +TARGET_LABEL="mmapp.updater.target" +IMAGE_LABEL="mmapp.updater.image" + +log() { + local level="$1" + shift + printf '%s level=%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$level" "$*" +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || { + log error "missing_command=$1" + exit 1 + } +} + +registry_login() { + if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then + log info "action=registry_login registry=${REGISTRY}" + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null + fi +} + +validate_targets_file() { + if [[ ! -f "$TARGETS_FILE" ]]; then + log error "targets_file_missing path=${TARGETS_FILE}" + exit 1 + fi + jq -e '.targets | type == "array" and length > 0' "$TARGETS_FILE" >/dev/null +} + +state_init() { + mkdir -p "$(dirname "$STATE_FILE")" + if [[ ! -f "$STATE_FILE" ]]; then + printf '{}\n' >"$STATE_FILE" + fi +} + +state_get() { + local target_id="$1" + local field="$2" + jq -r --arg id "$target_id" --arg field "$field" '.[$id][$field] // empty' "$STATE_FILE" +} + +state_set() { + local target_id="$1" + local field="$2" + local value="$3" + local tmp + tmp="$(mktemp)" + jq --arg id "$target_id" --arg field "$field" --arg value "$value" \ + '.[$id] = (.[$id] // {}) | .[$id][$field] = $value' "$STATE_FILE" >"$tmp" + mv "$tmp" "$STATE_FILE" +} + +state_clear_error() { + local target_id="$1" + local tmp + tmp="$(mktemp)" + jq --arg id "$target_id" '.[$id].last_error = null' "$STATE_FILE" >"$tmp" + mv "$tmp" "$STATE_FILE" +} + +state_set_error() { + local target_id="$1" + local message="$2" + local tmp + tmp="$(mktemp)" + jq --arg id "$target_id" --arg message "$message" '.[$id].last_error = $message' "$STATE_FILE" >"$tmp" + mv "$tmp" "$STATE_FILE" +} + +with_target_lock() { + local target_id="$1" + shift + local lock_dir="/state/locks/${target_id}" + mkdir -p "$(dirname "$lock_dir")" + if ! mkdir "$lock_dir" 2>/dev/null; then + log warn "target=${target_id} action=skip reason=lock_busy" + return 0 + fi + trap 'rmdir "'"$lock_dir"'" 2>/dev/null || true' RETURN + "$@" +} + +remote_digest() { + local image="$1" + docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' 2>/dev/null | jq -er '.digest // empty' +} + +running_image_digest() { + local container_name="$1" + local image_id + image_id="$(docker inspect --format '{{.Image}}' "$container_name" 2>/dev/null || true)" + if [[ -z "$image_id" ]]; then + return 1 + fi + docker image inspect "$image_id" --format '{{.Id}}' +} + +validate_managed_container() { + local target_id="$1" + local container_name="$2" + local expected_image="$3" + + if ! docker inspect "$container_name" >/dev/null 2>&1; then + log warn "target=${target_id} action=skip reason=container_missing name=${container_name}" + return 1 + fi + + local managed target_label image_label + managed="$(docker inspect --format "{{ index .Config.Labels \"${MANAGED_LABEL}\" }}" "$container_name")" + target_label="$(docker inspect --format "{{ index .Config.Labels \"${TARGET_LABEL}\" }}" "$container_name")" + image_label="$(docker inspect --format "{{ index .Config.Labels \"${IMAGE_LABEL}\" }}" "$container_name")" + + if [[ "$managed" != "true" ]]; then + log warn "target=${target_id} action=skip reason=not_managed name=${container_name}" + return 1 + fi + + if [[ -n "$target_label" && "$target_label" != "$target_id" ]]; then + log warn "target=${target_id} action=skip reason=target_label_mismatch label=${target_label}" + return 1 + fi + + if [[ -n "$image_label" && "$image_label" != "$expected_image" ]]; then + log warn "target=${target_id} action=skip reason=image_label_mismatch label=${image_label}" + return 1 + fi + + return 0 +} + +snapshot_container() { + local container_name="$1" + local snapshot_file="$2" + docker inspect "$container_name" | jq '.[0]' >"$snapshot_file" +} + +image_with_digest() { + local image="$1" + local digest="$2" + local base="${image%@*}" + printf '%s@%s' "$base" "$digest" +} + +build_run_args_from_snapshot() { + local snapshot_file="$1" + local args=() + + while IFS= read -r env_line; do + args+=(--env "$env_line") + done < <(jq -r '.Config.Env[]?' "$snapshot_file") + + while IFS= read -r mount_line; do + args+=(--mount "$mount_line") + done < <(jq -r ' + .Mounts[]? + | if .Type == "bind" then + "type=bind,source=\(.Source),target=\(.Destination)\(if .RW == false then ",readonly" else "" end)" + elif .Type == "volume" then + "type=volume,source=\(.Name),target=\(.Destination)" + else empty end + ' "$snapshot_file") + + while IFS= read -r publish_line; do + args+=(-p "$publish_line") + done < <(jq -r ' + .HostConfig.PortBindings // {} + | to_entries[] + | .key as $containerPort + | .value[0] as $binding + | if ($binding.HostIp // "") != "" and $binding.HostIp != "0.0.0.0" then + "\($binding.HostIp):\($binding.HostPort):\($containerPort | sub("/tcp$"; "") | sub("/udp$"; ""))" + else + "\($binding.HostPort):\($containerPort | sub("/tcp$"; "") | sub("/udp$"; ""))" + end + ' "$snapshot_file") + + local restart_policy + restart_policy="$(jq -r '.HostConfig.RestartPolicy.Name // empty' "$snapshot_file")" + if [[ -n "$restart_policy" && "$restart_policy" != "no" ]]; then + args+=(--restart "$restart_policy") + fi + + local user + user="$(jq -r '.Config.User // empty' "$snapshot_file")" + if [[ -n "$user" ]]; then + args+=(--user "$user") + fi + + while IFS= read -r label_line; do + args+=(--label "$label_line") + done < <(jq -r ' + .Config.Labels // {} + | to_entries[] + | "\(.key)=\(.value)" + ' "$snapshot_file") + + local network_mode + network_mode="$(jq -r '.HostConfig.NetworkMode // empty' "$snapshot_file")" + if [[ "$network_mode" == "host" ]]; then + args+=(--network host) + elif [[ -n "$network_mode" && "$network_mode" != "default" && "$network_mode" != "bridge" ]]; then + args+=(--network "$network_mode") + else + local custom_network + custom_network="$(jq -r '.NetworkSettings.Networks // {} | keys[0] // empty' "$snapshot_file")" + if [[ -n "$custom_network" && "$custom_network" != "bridge" ]]; then + args+=(--network "$custom_network") + fi + fi + + if [[ "$(jq -r '.HostConfig.Privileged // false' "$snapshot_file")" == "true" ]]; then + args+=(--privileged) + fi + + if [[ "$(jq -r '.HostConfig.ReadonlyRootfs // false' "$snapshot_file")" == "true" ]]; then + args+=(--read-only) + fi + + printf '%s\0' "${args[@]}" +} + +run_container_from_snapshot() { + local container_name="$1" + local snapshot_file="$2" + local image="$3" + + local -a run_args=() + mapfile -d '' -t run_args < <(build_run_args_from_snapshot "$snapshot_file") + + docker run -d --name "$container_name" "${run_args[@]}" "$image" +} + +wait_for_health() { + local target_id="$1" + local health_type="$2" + local health_url="$3" + local expect_status="$4" + local deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS)) + + while (( SECONDS < deadline )); do + if [[ "$health_type" == "http" ]]; then + local status + status="$(curl --silent --output /dev/null --write-out '%{http_code}' --max-time 5 "$health_url" || true)" + if [[ "$status" == "$expect_status" ]]; then + log info "target=${target_id} action=health_ok status=${status}" + return 0 + fi + else + log error "target=${target_id} action=health_fail reason=unsupported_type type=${health_type}" + return 1 + fi + sleep 2 + done + + log error "target=${target_id} action=health_fail reason=timeout seconds=${HEALTH_TIMEOUT_SECONDS}" + return 1 +} + +ensure_baseline_state() { + local target_id="$1" + local container_name="$2" + local image="$3" + + local applied + applied="$(state_get "$target_id" "last_applied_digest")" + if [[ -n "$applied" ]]; then + return 0 + fi + + local digest="" + if digest="$(running_image_digest "$container_name" 2>/dev/null)"; then + : + elif digest="$(remote_digest "$image" 2>/dev/null)"; then + : + fi + + if [[ -n "$digest" ]]; then + state_set "$target_id" "image" "$image" + state_set "$target_id" "last_applied_digest" "$digest" + log info "target=${target_id} action=baseline_state digest=${digest}" + fi +} + +restore_container() { + local target_id="$1" + local container_name="$2" + local snapshot_file="$3" + local image="$4" + local previous_digest="$5" + + docker rm -f "$container_name" >/dev/null 2>&1 || true + if [[ -n "$previous_digest" ]]; then + run_container_from_snapshot "$container_name" "$snapshot_file" "$(image_with_digest "$image" "$previous_digest")" || { + log error "target=${target_id} action=rollback_failed" + return 1 + } + log info "target=${target_id} action=rollback_success digest=${previous_digest}" + else + log error "target=${target_id} action=rollback_skipped reason=no_previous_digest" + return 1 + fi +} + +update_target() { + local target_id="$1" + local container_name="$2" + local image="$3" + local health_type="$4" + local health_url="$5" + local expect_status="$6" + + validate_managed_container "$target_id" "$container_name" "$image" || return 0 + ensure_baseline_state "$target_id" "$container_name" "$image" + + local remote + if ! remote="$(remote_digest "$image")"; then + state_set_error "$target_id" "remote_digest_unavailable" + log error "target=${target_id} action=remote_digest_failed image=${image}" + return 1 + fi + + local applied + applied="$(state_get "$target_id" "last_applied_digest")" + if [[ "$remote" == "$applied" ]]; then + log info "target=${target_id} action=noop digest=${remote}" + state_clear_error "$target_id" + return 0 + fi + + log info "target=${target_id} action=update_start remote_digest=${remote} applied_digest=${applied:-none}" + + if ! docker pull "$image"; then + state_set_error "$target_id" "pull_failed" + log error "target=${target_id} action=pull_failed image=${image}" + return 1 + fi + + local snapshot_file + snapshot_file="$(mktemp)" + snapshot_container "$container_name" "$snapshot_file" + + local previous_digest + previous_digest="$(running_image_digest "$container_name" || true)" + + docker stop -t "$STOP_TIMEOUT_SECONDS" "$container_name" >/dev/null + docker rm "$container_name" >/dev/null + + if ! run_container_from_snapshot "$container_name" "$snapshot_file" "$image"; then + state_set_error "$target_id" "recreate_failed" + log error "target=${target_id} action=recreate_failed" + restore_container "$target_id" "$container_name" "$snapshot_file" "$image" "$previous_digest" || true + rm -f "$snapshot_file" + return 1 + fi + + if wait_for_health "$target_id" "$health_type" "$health_url" "$expect_status"; then + state_set "$target_id" "image" "$image" + state_set "$target_id" "last_applied_digest" "$remote" + if [[ -n "$previous_digest" ]]; then + state_set "$target_id" "previous_digest" "$previous_digest" + fi + state_set "$target_id" "last_success_at" "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + state_clear_error "$target_id" + log info "target=${target_id} action=update_success digest=${remote}" + rm -f "$snapshot_file" + return 0 + fi + + state_set_error "$target_id" "health_failed" + log error "target=${target_id} action=rollback_start" + restore_container "$target_id" "$container_name" "$snapshot_file" "$image" "$previous_digest" || true + rm -f "$snapshot_file" + return 1 +} + +process_targets() { + local had_error=0 + local target_count + target_count="$(jq '.targets | length' "$TARGETS_FILE")" + + for (( index=0; index 0 )); then + jitter=$((RANDOM % (jitter_max + 1))) + fi + sleep $((base + jitter)) +} + +main() { + require_command docker + require_command jq + require_command curl + + validate_targets_file + state_init + registry_login + + log info "action=startup targets_file=${TARGETS_FILE} state_file=${STATE_FILE} poll_interval=${POLL_INTERVAL_SECONDS}" + + local backoff=0 + while true; do + if process_targets; then + backoff=0 + else + backoff=$((POLL_INTERVAL_SECONDS * 2)) + log warn "action=cycle_failed backoff_seconds=${backoff}" + fi + + local sleep_for="$POLL_INTERVAL_SECONDS" + if (( backoff > 0 )); then + sleep_for="$backoff" + fi + sleep_with_jitter "$sleep_for" + done +} + +main "$@" diff --git a/deploy/updater/targets.json.example b/deploy/updater/targets.json.example new file mode 100644 index 0000000..9e6ce28 --- /dev/null +++ b/deploy/updater/targets.json.example @@ -0,0 +1,24 @@ +{ + "targets": [ + { + "id": "backend", + "container_name": "mmapp-backend", + "image": "git.shts.su/denozord/mikrotikmanager-backend:latest", + "health": { + "type": "http", + "url": "http://127.0.0.1:8000/health", + "expect_status": 200 + } + }, + { + "id": "frontend", + "container_name": "mmapp-frontend", + "image": "git.shts.su/denozord/mikrotikmanager-frontend:latest", + "health": { + "type": "http", + "url": "http://127.0.0.1:3000/", + "expect_status": 200 + } + } + ] +} diff --git a/deploy/updater/test-staging.sh b/deploy/updater/test-staging.sh new file mode 100644 index 0000000..401e96d --- /dev/null +++ b/deploy/updater/test-staging.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +cat <<'EOF' +Staging checklist for mmapp-updater: + +1. Independent updates + - Push a new backend :latest image only. + - Wait for POLL_INTERVAL_SECONDS. + - Confirm mmapp-backend was recreated and mmapp-frontend stayed on the previous digest. + +2. Failed pull + - Point one target image to a non-existent repository tag. + - Confirm the running container stays up and updater-state records last_error=pull_failed. + +3. Health fail rollback + - Deploy a backend image that exits immediately or fails /health. + - Confirm updater rolls back to previous_digest and keeps mmapp-backend running. + +4. Registry outage + - Block access to git.shts.su temporarily. + - Confirm updater logs remote_digest_failed and does not stop managed containers. + +Run ./validate.sh before executing this checklist on a staging host. +EOF diff --git a/deploy/updater/validate.sh b/deploy/updater/validate.sh new file mode 100644 index 0000000..3e99e1e --- /dev/null +++ b/deploy/updater/validate.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +bash -n "${ROOT}/entrypoint.sh" + +jq -e '.targets | type == "array" and length > 0' "${ROOT}/targets.json.example" >/dev/null + +for target in backend frontend; do + jq -e --arg target "$target" '.targets[] | select(.id == $target) | .image and .container_name and .health.url' \ + "${ROOT}/targets.json.example" >/dev/null +done + +printf 'updater validation passed\n'