Compare commits

..
11 Commits
Author SHA1 Message Date
darroyo 2c727244df Pasa el nombre de la imagen a minúsculas para que Docker acepte la carpeta del servicio.
Un directorio como SALE-HITES-BFF deja de fallar el build por el formato del repositorio.
2026-10-06 19:35:39 -03:00
darroyo fa125ed616 Agrega un script para actualizar varios repositorios git desde un archivo de rutas.
Pide usuario y contraseña una sola vez y sigue con el resto si alguno falla.
2026-09-29 10:17:46 -03:00
darroyo b65730c45f El nombre de la imagen sale de la carpeta del servicio y el Dockerfile depende de si es java o web.
Así se puede desplegar sin pom.xml y ubicar el Dockerfile en la raíz cuando el servicio es web.
2026-09-25 12:55:43 -03:00
darroyo 45f7663258 version nueva 2026-09-25 09:42:01 -03:00
darroyo fc4cb86f24 log final 2026-09-25 09:32:33 -03:00
darroyo 782a130bb9 build: deploy several Quarkus services from one project list
Add deploy-services.sh so a single command runs build-and-push.sh for every path in a project file, keeps going after a failure, and reports which services failed.
2026-09-24 22:54:45 -03:00
darroyo f7bcad0b38 cambios 2026-09-24 18:50:08 -03:00
darroyo 4417645639 version 2026-09-24 18:33:21 -03:00
darroyo 803f05a60b tag con fecha y hora 2026-09-24 18:21:35 -03:00
darroyo faabf0b82b cambios cleanup del registry remoto y tags 2026-09-24 17:18:05 -03:00
darroyo 5b4bbce5c8 cambios en script y version 2026-09-24 16:41:36 -03:00
7 changed files with 1033 additions and 77 deletions
+3
View File
@@ -41,3 +41,6 @@ nb-configuration.xml
# Plugin directory
/.quarkus/cli/plugins/
# deploy-services.sh summary logs
deploy-*.log
+35 -5
View File
@@ -7,16 +7,46 @@ If you want to learn more about Quarkus, please visit its website: https://quark
## Building and deploying natively
Build the native image with the Mandrel-based multi-stage Dockerfile and push it
directly to a remote Docker host via [unregistry](https://github.com/psviderski/unregistry)
(no external registry required; only missing layers are transferred over SSH):
to a remote Docker host over an SSH tunnel. The script starts a temporary
`registry:2` container on the remote (localhost only), forwards it to the build
machine, pushes the image through that tunnel, then pulls and retags it on the
remote Docker daemon. No external registry is required.
```shell script
./scripts/build-and-push.sh --remote deploy@prod.example.com
```
The script reads `artifactId` and `version` from `pom.xml` for the image name
and tag, builds for `linux/amd64`, and verifies the image is present on the
remote after pushing. See `./scripts/build-and-push.sh --help` for all flags.
To push every service in one project, pass that project's list file to
`./scripts/deploy-services.sh`. It runs `build-and-push.sh` once per path and
reports any failures at the end. See `scripts/README.md` for the list format.
The script uses the last directory of the service location, lowercased, as the image name
and reads `version` from `pom.xml` for the local build tag (`1.0.0-SNAPSHOT`
if there is no pom). It builds for `linux/amd64` and verifies the deploy on
the remote.
On the remote, durable tags are only:
- `<name>:latest` — always the most recent successful push
- `<name>:vYYYY.MM.DD-HH.MM` — UTC timestamp of that push (for rollback)
Older releases stay tagged by date only: the script keeps the current image (with
`latest` and its date tag) and the previous date tag, and removes other tags and
images (including old `1.0.x`, `sha256-*`, and third copies). Run
`--cleanup` on the remote to apply the same pruning without a new push.
To roll back on the remote, run a previous `v…` tag from `docker images` instead of `:latest`.
The temporary registry container and SSH tunnel are removed when the script
finishes. See `./scripts/build-and-push.sh --help` for all flags.
To remove leftover temporary registry containers and `localhost:<port>/...` image
tags on the remote (for example after an interrupted push or manual tests):
```shell script
./scripts/build-and-push.sh --remote deploy@prod.example.com --cleanup
```
This does not rebuild or push; it only needs SSH access to the remote host.
It can be invoked from any directory: by default it resolves the project root
from the script's own location. Override with `--project-dir PATH` or the
+1 -1
View File
@@ -4,7 +4,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>test-quarkus</artifactId>
<version>1.0.0-SNAPSHOT</version>
<version>1.0.4</version>
<packaging>jar</packaging>
<name>Test Quarkus</name>
+58 -8
View File
@@ -8,8 +8,8 @@ Construye la imagen nativa de Quarkus y la envía al daemon Docker de un host re
### Qué hace el flujo
1. Lee `artifactId` y `version` de [`pom.xml`](../pom.xml) para formar la referencia de imagen (`nombre:tag`), salvo que uses `--name` o `--tag`.
2. Ejecuta `docker build` con [`src/main/docker/Dockerfile`](../src/main/docker/Dockerfile) para la plataforma fija `linux/amd64` (útil si construyes desde un Mac arm64).
1. Toma el nombre de la imagen de la última carpeta de la ubicación del servicio, en minúsculas, y lee `version` de [`pom.xml`](../pom.xml) para el tag, salvo que uses `--name` o `--tag`. Si no hay `pom.xml`, el tag es `1.0.0-SNAPSHOT`. `--name` también se pasa a minúsculas.
2. Ejecuta `docker build` para la plataforma fija `linux/amd64` (útil si construyes desde un Mac arm64). Con `--type java` (el valor por defecto) usa [`src/main/docker/Dockerfile`](../src/main/docker/Dockerfile). Con `--type web` usa `Dockerfile` en la raíz del servicio.
3. Ejecuta `docker pussh` hacia el destino SSH indicado.
4. Comprueba por SSH que la imagen existe en el remoto (salvo `--no-verify`).
5. Muestra un comando `docker run` sugerido para arrancar el contenedor en el remoto.
@@ -31,7 +31,7 @@ curl -fsSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-p
chmod +x ~/.docker/cli-plugins/docker-pussh
```
`xmllint` es opcional: si no está en el PATH, el script obtiene `artifactId` y `version` del POM con `grep`/`sed`.
`xmllint` es opcional: si no está en el PATH y existe `pom.xml`, el script obtiene `version` del POM con `grep`/`sed`.
### Invocación
@@ -60,24 +60,25 @@ Ayuda integrada (en inglés):
|--------|-------------|
| `--remote TARGET` | Destino SSH: `usuario@host[:puerto]` o un alias definido en `~/.ssh/config` (sin `@`). |
**Identidad de la imagen** (por defecto desde `pom.xml`)
**Identidad de la imagen** (el nombre es la última carpeta del servicio, en minúsculas; el tag sale de `pom.xml` si existe)
| Opción | Descripción |
|--------|-------------|
| `--name NAME` | Nombre del repositorio de la imagen. |
| `--name NAME` | Nombre del repositorio de la imagen. Se guarda en minúsculas. |
| `--tag TAG` | Tag de la imagen. |
**Directorio de trabajo**
| Opción | Descripción |
|--------|-------------|
| `--project-dir PATH` | Raíz del proyecto (debe contener `pom.xml`). |
| `--project-dir PATH` | Raíz del servicio. El nombre de la imagen es su última carpeta, en minúsculas. |
| `PROJECT_DIR` | Misma función vía entorno. |
**Build**
| Opción | Descripción |
|--------|-------------|
| `--type` | Dónde está el Dockerfile. `java` (por defecto): `src/main/docker/Dockerfile`. `web`: `Dockerfile` en la raíz del servicio. |
| `--skip-build` | No construye; solo hace `pussh` de una imagen que ya exista en local. La plataforma de build sigue siendo `linux/amd64` cuando no se omite el build. |
**SSH** (se reenvían a `docker pussh` y, en la verificación, a `ssh`)
@@ -165,10 +166,10 @@ Al terminar con éxito, el script sugiere algo equivalente a:
```shell
ssh deploy@prod.example.com \
"docker run -d --name <artifactId> --restart unless-stopped -p 8080:8080 <artifactId>:<version>"
"docker run -d --name <carpeta> --restart unless-stopped -p 8080:8080 <carpeta>:<version>"
```
Sustituye `<artifactId>` y `<version>` por los valores que haya usado el build (los del POM o los de `--name` / `--tag`).
Sustituye `<carpeta>` por la última carpeta de la ubicación del servicio en minúsculas (o el valor de `--name`, también en minúsculas) y `<version>` por el tag del build (el de `pom.xml`, `1.0.0-SNAPSHOT` si no hay POM, o el de `--tag`).
### Solución de problemas
@@ -196,3 +197,52 @@ Comprueba que la primera línea del log sea `Image: test-quarkus:1.0.0-SNAPSHOT`
```
Opcional: instala `xmllint` para que el script lea el POM con XPath (`libxml2-utils` en Debian/Ubuntu).
## `deploy-services.sh`
Script independiente, en el mismo directorio que `build-and-push.sh`. No lo modifica: lo ejecuta una vez por cada servicio de un archivo de lista. Si un servicio falla, sigue con el resto y al terminar indica cuáles fallaron. El código de salida es `1` si alguno falló.
Al terminar el lote, ese resumen queda también en `deploy-YYYYMMDD-HHMMSS.log` en el directorio desde el que se lanzó el script (hora local de inicio). Si ese nombre ya existe, se añade el PID. Si no se puede escribir el archivo, el script avisa y conserva el código de salida del lote.
No hay lista por defecto. `--services` es obligatorio, así cada proyecto puede tener su propio archivo.
```shell
./scripts/deploy-services.sh --remote prod --services /srv/proyecto-a/services.txt
./scripts/deploy-services.sh --remote prod --services /srv/proyecto-b/services.txt --skip-build
```
Formato del archivo: una ruta por línea y, si hace falta, un segundo campo `java` o `web`. Si el tipo falta, es `java`. Se ignoran las líneas vacías y las que empiezan por `#`. Una ruta relativa se resuelve desde el directorio del archivo de lista, no desde el directorio donde lanzas el script.
```text
# /srv/proyecto-a/services.txt
./api
./portal web
```
`java` usa `src/main/docker/Dockerfile`. `web` usa `Dockerfile` en la raíz del servicio. El nombre de la imagen es la última carpeta de esa ruta, en minúsculas. El tag sale de `pom.xml` si existe; si no, es `1.0.0-SNAPSHOT`.
Se reenvían a cada servicio: `--ssh-key`, `--ssh-config`, `--skip-build`, `--no-verify` y `--cleanup`, más el `--type` de esa línea. No se aceptan `--name`, `--tag`, `--project-dir` ni `--type`.
Ctrl+C interrumpe el lote. Ayuda: `./scripts/deploy-services.sh --help`.
## `update-repos.sh`
Actualiza un repositorio git por cada ruta de un archivo de lista. Ejecuta `git pull` en cada uno. Si alguno falla, sigue con el resto y al terminar indica cuáles fallaron. El código de salida es `1` si alguno falló.
No hay lista por defecto. `--repos` es obligatorio.
```shell
./scripts/update-repos.sh --repos /srv/proyecto/repos.txt
```
Al arrancar pide `Usuario:` y `Contraseña:` en la terminal, una sola vez para todo el lote. Esas credenciales se usan en los remotos HTTPS. No se leen desde la línea de comandos ni desde el archivo, y no se guardan. Un remoto SSH se actualiza con su acceso habitual (clave), sin esa contraseña.
Formato del archivo: una ruta por línea. Se ignoran las líneas vacías y las que empiezan por `#`. Una ruta relativa se resuelve desde el directorio del archivo de lista, no desde el directorio donde lanzas el script. Cada ruta debe ser la raíz de un repositorio git.
```text
# /srv/proyecto/repos.txt
/srv/repos/api
./portal
```
Ctrl+C interrumpe el lote. Ayuda: `./scripts/update-repos.sh --help`.
+312 -63
View File
@@ -1,20 +1,19 @@
#!/usr/bin/env bash
#
# Build the native Quarkus image and push it to a remote Docker host
# via docker-pussh (unregistry): https://github.com/psviderski/unregistry
# via a temporary registry:2 on the remote, reached through an SSH tunnel.
#
# - Image name/tag default to pom.xml properties (quarkus.application.*)
# - Image name defaults to the last directory of the service location
# - Image tag defaults to the pom.xml version (1.0.0-SNAPSHOT if there is no pom)
# - Builds for linux/amd64 by default (cross-compile from arm64 hosts)
# - Transfers only missing layers over SSH (no external registry required)
# - Transfers layers over the tunnel (no external registry required)
# - On the remote, durable tags are :latest and vYYYY.MM.DD-HH.MM (UTC) only
set -euo pipefail
IFS=$'\n\t'
readonly DOCKERFILE="src/main/docker/Dockerfile"
readonly DEFAULT_PLATFORM="linux/amd64"
readonly UNREGISTRY_PLUGIN="https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh"
readonly PLUGIN_DIR="${HOME}/.docker/cli-plugins"
readonly PLUGIN_PATH="${PLUGIN_DIR}/docker-pussh"
readonly TEMP_REGISTRY_IMAGE="registry:2"
readonly SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
REMOTE=""
@@ -23,8 +22,11 @@ IMAGE_TAG=""
SSH_KEY=""
SSH_CONFIG=""
PROJECT_DIR="${PROJECT_DIR:-${SCRIPT_DIR}/..}"
APP_TYPE="java"
DOCKERFILE=""
SKIP_BUILD=0
SKIP_VERIFY=0
CLEANUP=0
# ---------- logging ----------
if [[ -t 1 ]]; then
@@ -56,32 +58,43 @@ usage() {
Usage: $(basename "$0") --remote USER@HOST[:PORT] [options]
Build the native Quarkus image and push it to a remote Docker host using
unregistry (docker-pussh).
a temporary Docker registry on the remote, reached through an SSH tunnel.
Required:
--remote TARGET SSH destination. Either 'user@host[:port]'
or a bare alias resolved from ~/.ssh/config
(e.g. 'prod'). See SSH config aliases below.
Image identity (defaults read from pom.xml):
--name NAME Image repository name
--tag TAG Image tag
Image identity:
--name NAME Image repository name (default: last directory of
the service location, lowercased)
--tag TAG Local build tag (pom.xml version by default, or
1.0.0-SNAPSHOT if there is no pom.xml). On the
remote only :latest and vYYYY.MM.DD-HH.MM (UTC).
Working directory (defaults to the parent of the script's directory):
--project-dir PATH Project root containing pom.xml
(override with env PROJECT_DIR)
--project-dir PATH Service root. The image name is its last directory,
lowercased (override with env PROJECT_DIR)
Build options:
--skip-build Skip docker build, only pussh an existing image
--type java|web Dockerfile location. java (default):
src/main/docker/Dockerfile
web: Dockerfile at the service root
--skip-build Skip docker build, only push an existing image
(platform is fixed to ${DEFAULT_PLATFORM})
SSH options forwarded to docker pussh:
SSH options:
--ssh-key PATH Private key file (overrides SSH config)
--ssh-config PATH SSH config file (default: ~/.ssh/config)
Post-push:
--no-verify Skip the ssh/docker images verification step
Maintenance:
--cleanup Remove temporary registry leftovers on the remote,
drop stale tags (version, sha256-*, etc.), and keep
at most two app images (current + previous release)
Other:
-h, --help Show this help
@@ -96,6 +109,8 @@ Examples:
$(basename "$0") --remote prod # uses 'Host prod' in ~/.ssh/config
$(basename "$0") --remote ubuntu@1.2.3.4 --ssh-key ~/.ssh/deploy_ed25519
$(basename "$0") --remote deploy@prod --skip-build --tag 2.0.0
$(basename "$0") --remote prod --type web
$(basename "$0") --remote prod --cleanup
/absolute/path/to/test-quarkus/scripts/$(basename "$0") --remote deploy@prod
PROJECT_DIR=/srv/proj $(basename "$0") --remote deploy@prod --skip-build
EOF
@@ -104,16 +119,44 @@ EOF
read_pom() {
local xpath="$1" fallback="$2" tag="${3:-}"
local value=""
if [[ ! -f pom.xml ]]; then
printf '%s' "$fallback"
return 0
fi
if command -v xmllint >/dev/null 2>&1; then
value=$(xmllint --xpath "string($xpath)" pom.xml 2>/dev/null || true)
fi
if [[ -z "$value" && -n "$tag" ]]; then
value=$(grep -E "<${tag}>" pom.xml | head -1 \
| sed -E "s:.*<${tag}>(.*)</${tag}>.*:\1:")
| sed -E "s:.*<${tag}>(.*)</${tag}>.*:\1:" || true)
fi
printf '%s' "${value:-$fallback}"
}
# java: src/main/docker/Dockerfile; web: Dockerfile at the service root.
resolve_dockerfile() {
case "$APP_TYPE" in
java) DOCKERFILE="src/main/docker/Dockerfile" ;;
web) DOCKERFILE="Dockerfile" ;;
*) die "Unknown --type '$APP_TYPE' (use java or web)." ;;
esac
}
# Docker repository names must be lowercase. ${var,,} needs bash 4+.
lowercase() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
# Last directory of the service location (cwd must already be that directory).
image_name_from_location() {
local dir
dir="$(pwd)"
dir="${dir%/}"
[[ -n "$dir" && "$dir" != "/" ]] \
|| die "Cannot derive image name from project directory."
lowercase "${dir##*/}"
}
require() {
command -v "$1" >/dev/null 2>&1 || die "Required command '$1' not found in PATH."
}
@@ -178,34 +221,214 @@ resolve_ssh_alias() {
printf '%s@%s:%s|%s\n' "$user" "$hostname" "$port" "$identity"
}
random_port() {
echo $((55000 + RANDOM % 10536))
}
# REMOTE_TARGET is user@host:port; OpenSSH needs -p and user@host separately.
parse_ssh_target() {
if [[ "$REMOTE_TARGET" == *:* ]]; then
SSH_PORT="${REMOTE_TARGET##*:}"
SSH_HOST_USER="${REMOTE_TARGET%:*}"
else
SSH_PORT="22"
SSH_HOST_USER="$REMOTE_TARGET"
fi
}
# Populates global ssh_args for ssh(1).
build_ssh_args() {
ssh_args=()
[[ -n "$SSH_KEY" ]] && ssh_args+=(-i "$SSH_KEY")
[[ -n "$SSH_CONFIG" ]] && ssh_args+=(-F "$SSH_CONFIG")
[[ "$SSH_PORT" != "22" ]] && ssh_args+=(-p "$SSH_PORT")
ssh_args+=("$SSH_HOST_USER")
}
run_remote() {
ssh "${ssh_args[@]}" "$@"
}
# Sets REMOTE_DOCKER to "docker" or "sudo -n docker".
detect_remote_docker() {
if run_remote "docker info" >/dev/null 2>&1; then
REMOTE_DOCKER="docker"
return 0
fi
if run_remote "sudo -n docker info" >/dev/null 2>&1; then
REMOTE_DOCKER="sudo -n docker"
return 0
fi
die "Cannot run docker on ${REMOTE_TARGET}. Ensure Docker is installed and the SSH user may run docker (or passwordless sudo docker)."
}
cleanup_remote_registry() {
log "Cleaning temporary registry leftovers on ${REMOTE_TARGET}…"
run_remote "${REMOTE_DOCKER} ps -aq --filter name=reg-push- | xargs -r ${REMOTE_DOCKER} rm -f" \
>/dev/null 2>&1 || true
run_remote "${REMOTE_DOCKER} rm -f regtest" >/dev/null 2>&1 || true
run_remote "${REMOTE_DOCKER} images --format '{{.Repository}}:{{.Tag}}' \
| grep -E '^localhost:[0-9]+/' | xargs -r ${REMOTE_DOCKER} rmi" \
>/dev/null 2>&1 || true
ok "Remote cleanup complete."
}
# Keep :latest plus the two newest vYYYY.MM.DD-HH.MM releases; drop other tags/IDs.
prune_remote_image_history() {
local repo="${1:-$IMAGE_NAME}"
[[ -n "$repo" ]] || return 0
log "Pruning ${repo} on remote (keep latest and previous release)…"
run_remote "bash -s" <<EOS
set -euo pipefail
RD='${REMOTE_DOCKER}'
REPO='${repo}'
release_re='^v[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[0-9]{2}\\.[0-9]{2}\$'
rmi_ref() {
\${RD} rmi "\$1" 2>/dev/null || true
}
while IFS= read -r ref; do
tag="\${ref#*:}"
[[ "\$tag" == latest ]] && continue
if [[ "\$tag" =~ \${release_re} ]]; then continue; fi
rmi_ref "\$ref"
done < <(\${RD} images --format '{{.Repository}}:{{.Tag}}' | grep -E "^\${REPO}:" || true)
mapfile -t release_refs < <(\${RD} images --format '{{.Repository}}:{{.Tag}}' | grep -E "^\${REPO}:v[0-9]{4}\\." | sort -t: -k2)
declare -A keep_ids=()
n=\${#release_refs[@]}
if (( n == 0 )); then exit 0; fi
for (( i=n-1; i>=0 && i>=n-2; i-- )); do
id=\$(\${RD} images --format '{{.ID}}' "\${release_refs[i]}" | head -1)
[[ -n "\$id" ]] && keep_ids["\$id"]=1
done
while IFS= read -r ref; do
id=\$(\${RD} images --format '{{.ID}}' "\$ref" | head -1)
[[ -n "\${keep_ids[\$id]:-}" ]] && continue
rmi_ref "\$ref"
done < <(\${RD} images --format '{{.Repository}}:{{.Tag}}' | grep -E "^\${REPO}:" || true)
EOS
ok "Remote ${repo} pruned."
}
REG_PUSH_CONTAINER=""
REG_REMOTE_PORT=""
REG_LOCAL_PORT=""
TUNNEL_PID=""
LOCAL_REGISTRY_REF=""
RELEASE_REF=""
cleanup_push_resources() {
[[ -n "${LOCAL_REGISTRY_REF:-}" ]] \
&& docker rmi "$LOCAL_REGISTRY_REF" >/dev/null 2>&1 || true
if [[ -n "${TUNNEL_PID:-}" ]]; then
kill "$TUNNEL_PID" >/dev/null 2>&1 || true
wait "$TUNNEL_PID" 2>/dev/null || true
fi
if [[ -n "${REG_PUSH_CONTAINER:-}" && -n "${REMOTE_DOCKER:-}" && -n "${REMOTE_TARGET:-}" ]]; then
parse_ssh_target
build_ssh_args
run_remote "${REMOTE_DOCKER} rm -f ${REG_PUSH_CONTAINER}" >/dev/null 2>&1 || true
fi
}
start_remote_registry() {
local attempt output
run_remote "${REMOTE_DOCKER} image inspect ${TEMP_REGISTRY_IMAGE}" >/dev/null 2>&1 \
|| run_remote "${REMOTE_DOCKER} pull ${TEMP_REGISTRY_IMAGE}"
for attempt in $(seq 1 10); do
REG_REMOTE_PORT=$(random_port)
REG_PUSH_CONTAINER="reg-push-$$-${REG_REMOTE_PORT}"
if output=$(run_remote "${REMOTE_DOCKER} run -d \
--name ${REG_PUSH_CONTAINER} \
-p 127.0.0.1:${REG_REMOTE_PORT}:5000 \
${TEMP_REGISTRY_IMAGE}" 2>&1); then
return 0
fi
run_remote "${REMOTE_DOCKER} rm -f ${REG_PUSH_CONTAINER}" >/dev/null 2>&1 || true
if ! echo "$output" | grep -qi bind; then
die "Failed to start temporary registry on remote:\n${output}"
fi
done
die "Failed to start temporary registry on remote (port binding)."
}
start_ssh_tunnel() {
local attempt
for attempt in $(seq 1 10); do
REG_LOCAL_PORT=$(random_port)
if command -v nc >/dev/null 2>&1 && nc -z 127.0.0.1 "$REG_LOCAL_PORT" 2>/dev/null; then
continue
fi
ssh "${ssh_args[@]}" \
-o ExitOnForwardFailure=yes \
-N \
-L "127.0.0.1:${REG_LOCAL_PORT}:127.0.0.1:${REG_REMOTE_PORT}" &
TUNNEL_PID=$!
sleep 1
if kill -0 "$TUNNEL_PID" 2>/dev/null; then
return 0
fi
TUNNEL_PID=""
done
die "Failed to establish SSH tunnel to remote registry."
}
build_release_tag() {
printf 'v%s' "$(TZ=UTC date +%Y.%m.%d-%H.%M)"
}
push_via_registry_tunnel() {
LOCAL_REGISTRY_REF="localhost:${REG_LOCAL_PORT}/${IMAGE_NAME}:${IMAGE_TAG}"
local remote_registry_ref="localhost:${REG_REMOTE_PORT}/${IMAGE_NAME}:${IMAGE_TAG}"
local latest_ref="${IMAGE_NAME}:latest"
local release_tag
release_tag=$(build_release_tag)
RELEASE_REF="${IMAGE_NAME}:${release_tag}"
log "Tagging ${IMAGE_REF} as ${LOCAL_REGISTRY_REF}…"
docker tag "$IMAGE_REF" "$LOCAL_REGISTRY_REF"
log "Pushing ${LOCAL_REGISTRY_REF} through SSH tunnel…"
docker push "$LOCAL_REGISTRY_REF"
log "Tagging local image as ${latest_ref} and ${RELEASE_REF}…"
docker tag "$IMAGE_REF" "$latest_ref"
docker tag "$IMAGE_REF" "$RELEASE_REF"
log "Pulling image on remote and tagging ${latest_ref}, ${RELEASE_REF}…"
run_remote "${REMOTE_DOCKER} pull ${remote_registry_ref}"
run_remote "${REMOTE_DOCKER} tag ${remote_registry_ref} ${latest_ref}"
run_remote "${REMOTE_DOCKER} tag ${remote_registry_ref} ${RELEASE_REF}"
run_remote "${REMOTE_DOCKER} rmi ${remote_registry_ref}" >/dev/null || true
prune_remote_image_history "${IMAGE_NAME}"
ok "Remote tags: ${latest_ref}, ${RELEASE_REF}."
}
# ---------- arg parsing ----------
while [[ $# -gt 0 ]]; do
case "$1" in
--remote) REMOTE="$2"; shift 2 ;;
--name) IMAGE_NAME="$2"; shift 2 ;;
--name) IMAGE_NAME="$(lowercase "$2")"; shift 2 ;;
--tag) IMAGE_TAG="$2"; shift 2 ;;
--ssh-key) SSH_KEY="$2"; shift 2 ;;
--ssh-config) SSH_CONFIG="$2"; shift 2 ;;
--project-dir) PROJECT_DIR="$2"; shift 2 ;;
--type) APP_TYPE="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
--no-verify) SKIP_VERIFY=1; shift ;;
--cleanup) CLEANUP=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown argument: $1 (use --help)" ;;
esac
done
# ---------- resolve project root ----------
if ! cd "${PROJECT_DIR}" 2>/dev/null; then
die "Invalid --project-dir: ${PROJECT_DIR}"
fi
PROJECT_DIR="$(pwd)"
# ---------- defaults ----------
[[ -z "$IMAGE_NAME" ]] && IMAGE_NAME=$(read_pom "/*[local-name()='project']/*[local-name()='artifactId']" "test-quarkus" "artifactId")
[[ -z "$IMAGE_TAG" ]] && IMAGE_TAG=$(read_pom "/*[local-name()='project']/*[local-name()='version']" "1.0.0-SNAPSHOT" "version")
[[ -z "$REMOTE" ]] && die "Missing --remote USER@HOST[:PORT] (use --help)."
IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}"
[[ -z "$REMOTE" ]] && die "Missing --remote USER@HOST[:PORT] (use --help)."
resolve_dockerfile
# ---------- resolve SSH target ----------
resolved=$(resolve_ssh_alias "$REMOTE")
@@ -219,6 +442,34 @@ if [[ -z "$SSH_KEY" && -n "$REMOTE_IDENTITY" ]]; then
log "Using IdentityFile from SSH config: ${SSH_KEY}"
fi
# ---------- remote cleanup only ----------
if [[ "$CLEANUP" -eq 1 ]]; then
log "Remote: ${REMOTE_TARGET}"
require ssh
if ! cd "${PROJECT_DIR}" 2>/dev/null; then
die "Invalid --project-dir: ${PROJECT_DIR}"
fi
[[ -z "$IMAGE_NAME" ]] && IMAGE_NAME="$(image_name_from_location)"
parse_ssh_target
build_ssh_args
detect_remote_docker
cleanup_remote_registry
prune_remote_image_history "${IMAGE_NAME}"
exit 0
fi
# ---------- resolve project root ----------
if ! cd "${PROJECT_DIR}" 2>/dev/null; then
die "Invalid --project-dir: ${PROJECT_DIR}"
fi
PROJECT_DIR="$(pwd)"
# ---------- defaults ----------
[[ -z "$IMAGE_NAME" ]] && IMAGE_NAME="$(image_name_from_location)"
[[ -z "$IMAGE_TAG" ]] && IMAGE_TAG=$(read_pom "/*[local-name()='project']/*[local-name()='version']" "1.0.0-SNAPSHOT" "version")
IMAGE_REF="${IMAGE_NAME}:${IMAGE_TAG}"
# ---------- pre-flight ----------
log "Image: ${IMAGE_REF}"
log "Platform: ${DEFAULT_PLATFORM}"
@@ -226,25 +477,15 @@ log "Dockerfile: ${DOCKERFILE}"
log "Remote: ${REMOTE_TARGET}"
require docker
require ssh
docker info >/dev/null 2>&1 \
|| die "Docker daemon is not reachable. Start Docker Desktop (or your daemon) and retry."
if ! docker pussh --help >/dev/null 2>&1; then
cat >&2 <<EOF
'docker pussh' plugin not found. Install it with:
mkdir -p ${PLUGIN_DIR}
curl -fsSL ${UNREGISTRY_PLUGIN} -o ${PLUGIN_PATH}
chmod +x ${PLUGIN_PATH}
Then rerun this script.
EOF
exit 1
fi
[[ -f "$DOCKERFILE" ]] || die "Dockerfile not found at ${DOCKERFILE}."
[[ -f pom.xml ]] || die "pom.xml not found in current directory."
parse_ssh_target
build_ssh_args
detect_remote_docker
# ---------- build ----------
if [[ "$SKIP_BUILD" -eq 1 ]]; then
@@ -261,32 +502,40 @@ else
ok "Build finished."
fi
# ---------- pussh ----------
log "Pusshing ${IMAGE_REF} to ${REMOTE_TARGET} via unregistry…"
# ---------- push via temporary registry ----------
trap cleanup_push_resources EXIT
pussh_args=( "$REMOTE_TARGET" )
[[ -n "$SSH_KEY" ]] && pussh_args+=( -i "$SSH_KEY" )
[[ -n "$SSH_CONFIG" ]] && pussh_args+=( -F "$SSH_CONFIG" )
log "Pushing ${IMAGE_REF} to ${REMOTE_TARGET} via temporary registry…"
docker pussh "${IMAGE_REF}" "${pussh_args[@]}"
ok "Pussh complete."
start_remote_registry
ok "Temporary registry listening on remote 127.0.0.1:${REG_REMOTE_PORT}."
start_ssh_tunnel
ok "SSH tunnel localhost:${REG_LOCAL_PORT} → remote:${REG_REMOTE_PORT}."
push_via_registry_tunnel
ok "Push complete."
# ---------- verify ----------
if [[ "$SKIP_VERIFY" -eq 1 ]]; then
warn "Skipping post-push verification (--no-verify)."
else
log "Verifying ${IMAGE_REF} on ${REMOTE_TARGET}…"
ssh_args=( "$REMOTE_TARGET" )
[[ -n "$SSH_KEY" ]] && ssh_args=( -i "$SSH_KEY" "${ssh_args[@]}" )
[[ -n "$SSH_CONFIG" ]] && ssh_args=( -F "$SSH_CONFIG" "${ssh_args[@]}" )
if ! ssh "${ssh_args[@]}" "docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E '^${IMAGE_NAME}:${IMAGE_TAG} '"; then
die "Image ${IMAGE_REF} not found on ${REMOTE_TARGET} after pussh."
fi
ok "Image present on remote."
log "Verifying tags on ${REMOTE_TARGET}…"
remote_images=$(run_remote "${REMOTE_DOCKER} images --format '{{.Repository}}:{{.Tag}}'")
missing=0
for ref in "${IMAGE_NAME}:latest" "${RELEASE_REF}"; do
if ! printf '%s\n' "$remote_images" | grep -Fxq "$ref"; then
err "Missing on remote: ${ref}"
missing=1
fi
done
[[ "$missing" -eq 0 ]] || die "Expected tags were not found on ${REMOTE_TARGET} after push."
ok "Remote has ${IMAGE_NAME}:latest and ${RELEASE_REF}."
fi
# ---------- next step hint ----------
printf '\n%s→ To run on the remote:%s\n' "$C_OK" "$C_RESET"
printf ' ssh %s "docker run -d --name %s --restart unless-stopped -p 8080:8080 %s"\n\n' \
"$REMOTE_TARGET" "$IMAGE_NAME" "$IMAGE_REF"
printf '\n%s→ To run the latest image on the remote:%s\n' "$C_OK" "$C_RESET"
printf ' ssh %s "docker run -d --name %s --restart unless-stopped -p 8080:8080 %s:latest"\n' \
"$REMOTE_TARGET" "$IMAGE_NAME" "$IMAGE_NAME"
printf '%s→ Rollback: list tags with docker images %s, then run a previous vYYYY.MM.DD-HH.MM tag.%s\n\n' \
"$C_OK" "$IMAGE_NAME" "$C_RESET"
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env bash
#
# Run scripts/build-and-push.sh once per service listed in a project file.
# Continues after a failure and prints a summary when the batch finishes.
# That summary is also written to deploy-YYYYMMDD-HHMMSS.log in the directory
# from which the script was launched.
#
# The list path is required (--services). Relative service paths are resolved
# against the directory that contains that file, not the caller's cwd.
# An optional second field, java or web, selects the Dockerfile location.
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
readonly BUILD_SCRIPT="${SCRIPT_DIR}/build-and-push.sh"
SERVICES_FILE=""
REMOTE=""
forward_args=()
# ---------- logging ----------
if [[ -t 1 ]]; then
readonly C_INFO=$'\033[0;36m'
readonly C_OK=$'\033[0;32m'
readonly C_WARN=$'\033[0;33m'
readonly C_ERR=$'\033[0;31m'
readonly C_RESET=$'\033[0m'
else
readonly C_INFO="" C_OK="" C_WARN="" C_ERR="" C_RESET=""
fi
log() { printf '%s[INFO]%s %s\n' "$C_INFO" "$C_RESET" "$*"; }
ok() { printf '%s[OK]%s %s\n' "$C_OK" "$C_RESET" "$*"; }
warn() { printf '%s[WARN]%s %s\n' "$C_WARN" "$C_RESET" "$*" >&2; }
err() { printf '%s[ERROR]%s %s\n' "$C_ERR" "$C_RESET" "$*" >&2; }
die() { err "$*"; exit 1; }
on_error() {
local exit_code=$?
err "Script aborted on line ${BASH_LINENO[0]} (exit ${exit_code})."
exit "$exit_code"
}
trap on_error ERR
# ---------- helpers ----------
usage() {
cat <<EOF
Usage: $(basename "$0") --remote TARGET --services FILE [options]
Run build-and-push.sh once for every service listed in FILE. One list file
per project: pass a different --services path to deploy another project.
A failure does not stop the batch. The exit code is 1 if any service failed.
When the batch finishes, that summary is written to deploy-YYYYMMDD-HHMMSS.log
in the current directory (local start time). If that name already exists, the
process id is appended.
Required:
--remote TARGET SSH destination forwarded to build-and-push.sh
('user@host[:port]' or a ~/.ssh/config alias)
--services FILE Service list for one project. One path per line,
with an optional second field: java or web
(default java). Blank lines and lines starting
with '#' are ignored. Relative paths resolve
against the directory that contains FILE.
Forwarded to each service (build-and-push.sh):
--ssh-key PATH Private key file
--ssh-config PATH SSH config file
--skip-build Push an existing local image
--no-verify Skip the remote image check
--cleanup Prune the remote without building or pushing
Not accepted (each service has its own directory; --type comes from the
list, and --project-dir is set from that path):
--name, --tag, --project-dir, --type
Other:
-h, --help Show this help
Examples:
$(basename "$0") --remote prod --services /srv/proyecto-a/services.txt
$(basename "$0") --remote prod --services /srv/proyecto-b/services.txt --skip-build
EOF
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
require_value() {
[[ $# -ge 2 && -n "${2:-}" && "$2" != -* ]] \
|| die "Missing value for $1"
}
# ---------- arg parsing ----------
while [[ $# -gt 0 ]]; do
case "$1" in
--services)
require_value "$@"
SERVICES_FILE="$2"
shift 2
;;
--remote)
require_value "$@"
REMOTE="$2"
shift 2
;;
--ssh-key|--ssh-config)
require_value "$@"
forward_args+=("$1" "$2")
shift 2
;;
--skip-build|--no-verify|--cleanup)
forward_args+=("$1")
shift
;;
--name|--tag|--project-dir|--type)
die "$1 is not accepted. Each service uses its own directory; --type comes from the services file."
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1 (use --help)"
;;
esac
done
[[ -n "$REMOTE" ]] || die "Missing --remote TARGET (use --help)."
[[ -n "$SERVICES_FILE" ]] || die "Missing --services FILE (use --help)."
[[ -f "$BUILD_SCRIPT" ]] || die "build-and-push.sh not found next to this script: ${BUILD_SCRIPT}"
[[ -f "$SERVICES_FILE" ]] || die "Services file not found: ${SERVICES_FILE}"
SERVICES_FILE="$(readlink -f "$SERVICES_FILE")"
SERVICES_DIR="$(cd "$(dirname "$SERVICES_FILE")" && pwd)"
service_paths=()
service_types=()
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="$(trim "$raw_line")"
[[ -z "$line" || "$line" == \#* ]] && continue
svc_path=""
svc_type=""
svc_extra=""
IFS=$' \t' read -r svc_path svc_type svc_extra <<< "$line"
[[ -n "$svc_extra" ]] && die "Service line has an extra field: ${line}"
[[ -n "$svc_path" ]] || die "Service line is missing a path: ${line}"
svc_path="${svc_path/#\~/$HOME}"
[[ -z "$svc_type" ]] && svc_type="java"
case "$svc_type" in
java|web) ;;
*) die "Unknown service type '${svc_type}' in ${SERVICES_FILE} (use java or web)." ;;
esac
service_paths+=("$svc_path")
service_types+=("$svc_type")
done < "$SERVICES_FILE"
[[ ${#service_paths[@]} -gt 0 ]] || die "Services file is empty: ${SERVICES_FILE}"
run_build() {
local project_dir="$1"
local app_type="$2"
if [[ ${#forward_args[@]} -eq 0 ]]; then
"$BUILD_SCRIPT" --project-dir "$project_dir" --remote "$REMOTE" --type "$app_type"
else
"$BUILD_SCRIPT" --project-dir "$project_dir" --remote "$REMOTE" --type "$app_type" "${forward_args[@]}"
fi
}
resolve_service() {
local entry="$1"
if [[ "$entry" == /* ]]; then
printf '%s' "$entry"
else
printf '%s' "${SERVICES_DIR}/${entry}"
fi
}
# Collapse "." and ".." without requiring the path to exist.
normalize_path() {
local path="$1"
local part joined=""
local -a stack=()
local old_ifs="$IFS"
set -f
IFS=/
# shellcheck disable=SC2206
local -a parts=($path)
IFS="$old_ifs"
set +f
for part in "${parts[@]}"; do
case "$part" in
""|.) ;;
..)
if [[ ${#stack[@]} -gt 0 ]]; then
unset 'stack[${#stack[@]}-1]'
fi
;;
*) stack+=("$part") ;;
esac
done
if [[ ${#stack[@]} -eq 0 ]]; then
printf '/'
return
fi
for part in "${stack[@]}"; do
joined+="/${part}"
done
printf '%s' "$joined"
}
# Plain-text summary in the directory from which the batch was launched.
# A write failure is reported and does not change the batch exit code.
write_summary_log() {
local finished="" summary_index=0 note="" path="" status=""
local -a lines=()
finished="$(date '+%Y-%m-%d %H:%M:%S %z')" || {
warn "Could not write log file: ${log_path}"
return 0
}
lines+=("started: ${run_started}")
lines+=("finished: ${finished}")
lines+=("remote: ${REMOTE}")
lines+=("services_file: ${SERVICES_FILE}")
lines+=("")
while [[ "$summary_index" -lt "$total" ]]; do
note="${result_notes[$summary_index]}"
path="${result_paths[$summary_index]}"
if [[ "$note" == "0" ]]; then
status="ok"
else
status="$note"
fi
lines+=("${path} ${status}")
summary_index=$((summary_index + 1))
done
lines+=("")
lines+=("succeeded: ${ok_count}")
lines+=("failed: ${fail_count}")
if ! printf '%s\n' "${lines[@]}" >"$log_path"; then
warn "Could not write log file: ${log_path}"
return 0
fi
log "Log file: ${log_path}"
}
total="${#service_paths[@]}"
index=0
ok_count=0
fail_count=0
result_paths=()
result_notes=()
launch_dir="$(pwd)"
run_started="$(date '+%Y-%m-%d %H:%M:%S %z')"
log_stamp="$(date '+%Y%m%d-%H%M%S')"
log_path="${launch_dir}/deploy-${log_stamp}.log"
if [[ -e "$log_path" ]]; then
log_path="${launch_dir}/deploy-${log_stamp}-$$.log"
fi
log "Services file: ${SERVICES_FILE}"
log "Remote: ${REMOTE}"
log "Services: ${total}"
while [[ "$index" -lt "$total" ]]; do
entry="${service_paths[$index]}"
app_type="${service_types[$index]}"
index=$((index + 1))
resolved="$(normalize_path "$(resolve_service "$entry")")"
if [[ -d "$resolved" ]]; then
resolved="$(cd "$resolved" && pwd)"
fi
printf '\n%s========== [%s/%s] %s ==========%s\n' \
"$C_INFO" "$index" "$total" "$resolved" "$C_RESET"
if [[ ! -d "$resolved" ]]; then
err "Service directory not found: ${resolved}"
result_paths+=("$resolved")
result_notes+=("not found")
fail_count=$((fail_count + 1))
continue
fi
exit_code=0
if run_build "$resolved" "$app_type"; then
ok "Finished ${resolved}"
result_paths+=("$resolved")
result_notes+=("0")
ok_count=$((ok_count + 1))
else
exit_code=$?
err "Failed ${resolved} (exit ${exit_code})"
result_paths+=("$resolved")
result_notes+=("exit ${exit_code}")
fail_count=$((fail_count + 1))
fi
done
printf '\n%s========== Summary ==========%s\n' "$C_INFO" "$C_RESET"
summary_index=0
while [[ "$summary_index" -lt "$total" ]]; do
note="${result_notes[$summary_index]}"
path="${result_paths[$summary_index]}"
if [[ "$note" == "0" ]]; then
ok "${path}"
else
err "${path} (${note})"
fi
summary_index=$((summary_index + 1))
done
batch_exit=0
if [[ "$fail_count" -gt 0 ]]; then
err "${ok_count} succeeded, ${fail_count} failed."
batch_exit=1
else
ok "${ok_count} succeeded, 0 failed."
fi
write_summary_log
exit "$batch_exit"
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env bash
#
# Update one git repository per line in a list file.
# Prompts once for a username and password and uses them for HTTPS remotes.
# Continues after a failure and prints a summary when the batch finishes.
#
# The list path is required (--repos). Relative paths are resolved against
# the directory that contains that file, not the caller's cwd.
set -euo pipefail
IFS=$'\n\t'
REPOS_FILE=""
GIT_USER=""
GIT_PASS=""
ASKPASS_FILE=""
# ---------- logging ----------
if [[ -t 1 ]]; then
readonly C_INFO=$'\033[0;36m'
readonly C_OK=$'\033[0;32m'
readonly C_WARN=$'\033[0;33m'
readonly C_ERR=$'\033[0;31m'
readonly C_RESET=$'\033[0m'
else
readonly C_INFO="" C_OK="" C_WARN="" C_ERR="" C_RESET=""
fi
log() { printf '%s[INFO]%s %s\n' "$C_INFO" "$C_RESET" "$*"; }
ok() { printf '%s[OK]%s %s\n' "$C_OK" "$C_RESET" "$*"; }
warn() { printf '%s[WARN]%s %s\n' "$C_WARN" "$C_RESET" "$*" >&2; }
err() { printf '%s[ERROR]%s %s\n' "$C_ERR" "$C_RESET" "$*" >&2; }
die() { err "$*"; exit 1; }
cleanup() {
GIT_USER=""
GIT_PASS=""
unset GIT_USER GIT_PASS
if [[ -n "${ASKPASS_FILE:-}" && -f "$ASKPASS_FILE" ]]; then
rm -f "$ASKPASS_FILE"
fi
ASKPASS_FILE=""
}
on_error() {
local exit_code=$?
err "Script aborted on line ${BASH_LINENO[0]} (exit ${exit_code})."
exit "$exit_code"
}
trap cleanup EXIT
trap on_error ERR
# ---------- helpers ----------
usage() {
cat <<EOF
Usage: $(basename "$0") --repos FILE
Run git pull once for every repository listed in FILE. A failure does not
stop the batch. The exit code is 1 if any repository failed.
The script asks for a username and password on the terminal and uses them
for HTTPS remotes. They are not read from the command line or the list file.
Required:
--repos FILE Repository list. One path per line. Blank lines
and lines starting with '#' are ignored. Relative
paths resolve against the directory that contains
FILE.
Other:
-h, --help Show this help
Examples:
$(basename "$0") --repos /srv/proyecto/repos.txt
$(basename "$0") --repos ./repos.txt
EOF
}
trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
value="${value%$'\r'}"
printf '%s' "$value"
}
require_value() {
[[ $# -ge 2 && -n "${2:-}" && "$2" != -* ]] \
|| die "Missing value for $1"
}
resolve_repo() {
local entry="$1"
if [[ "$entry" == /* ]]; then
printf '%s' "$entry"
else
printf '%s' "${REPOS_DIR}/${entry}"
fi
}
# Collapse "." and ".." without requiring the path to exist.
normalize_path() {
local path="$1"
local part joined=""
local -a stack=()
local old_ifs="$IFS"
set -f
IFS=/
# shellcheck disable=SC2206
local -a parts=($path)
IFS="$old_ifs"
set +f
for part in "${parts[@]}"; do
case "$part" in
""|.) ;;
..)
if [[ ${#stack[@]} -gt 0 ]]; then
unset 'stack[${#stack[@]}-1]'
fi
;;
*) stack+=("$part") ;;
esac
done
if [[ ${#stack[@]} -eq 0 ]]; then
printf '/'
return
fi
for part in "${stack[@]}"; do
joined+="/${part}"
done
printf '%s' "$joined"
}
prompt_credentials() {
local user="" pass=""
[[ -r /dev/tty && -w /dev/tty ]] \
|| die "A terminal is required to enter the username and password."
read -r -p "Usuario: " user </dev/tty
read -r -s -p "Contraseña: " pass </dev/tty
printf '\n' >/dev/tty
[[ -n "$user" && -n "$pass" ]] || die "Username and password are required."
GIT_USER="$user"
GIT_PASS="$pass"
}
write_askpass() {
ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/git-update-askpass.XXXXXX")"
cat >"$ASKPASS_FILE" <<'EOF'
#!/bin/sh
prompt=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')
case "$prompt" in
*username*) printf '%s\n' "${GIT_UPDATE_USER:-}" ;;
*password*) printf '%s\n' "${GIT_UPDATE_PASS:-}" ;;
*) exit 1 ;;
esac
EOF
chmod 700 "$ASKPASS_FILE"
}
remote_uses_http() {
local repo="$1"
local upstream="" remote="" url=""
upstream="$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)"
[[ -n "$upstream" && "$upstream" == */* ]] || return 1
remote="${upstream%%/*}"
url="$(git -C "$repo" remote get-url "$remote" 2>/dev/null || true)"
[[ "$url" == http://* || "$url" == https://* ]]
}
pull_repo() {
local repo="$1"
if remote_uses_http "$repo"; then
GIT_ASKPASS="$ASKPASS_FILE" \
GIT_TERMINAL_PROMPT=0 \
GIT_UPDATE_USER="$GIT_USER" \
GIT_UPDATE_PASS="$GIT_PASS" \
git -C "$repo" -c credential.helper= pull
else
git -C "$repo" pull
fi
}
# ---------- arg parsing ----------
while [[ $# -gt 0 ]]; do
case "$1" in
--repos)
require_value "$@"
REPOS_FILE="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
die "Unknown argument: $1 (use --help)"
;;
esac
done
[[ -n "$REPOS_FILE" ]] || die "Missing --repos FILE (use --help)."
[[ -f "$REPOS_FILE" ]] || die "Repository list not found: ${REPOS_FILE}"
REPOS_FILE="$(readlink -f "$REPOS_FILE")"
REPOS_DIR="$(cd "$(dirname "$REPOS_FILE")" && pwd)"
repos=()
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
line="$(trim "$raw_line")"
[[ -z "$line" || "$line" == \#* ]] && continue
line="${line/#\~/$HOME}"
repos+=("$line")
done < "$REPOS_FILE"
[[ ${#repos[@]} -gt 0 ]] || die "Repository list is empty: ${REPOS_FILE}"
prompt_credentials
write_askpass
total="${#repos[@]}"
index=0
ok_count=0
fail_count=0
result_paths=()
result_notes=()
log "Repository list: ${REPOS_FILE}"
log "Repositories: ${total}"
for entry in "${repos[@]}"; do
index=$((index + 1))
resolved="$(normalize_path "$(resolve_repo "$entry")")"
if [[ -d "$resolved" ]]; then
resolved="$(cd "$resolved" && pwd)"
fi
printf '\n%s========== [%s/%s] %s ==========%s\n' \
"$C_INFO" "$index" "$total" "$resolved" "$C_RESET"
if [[ ! -d "$resolved" ]]; then
err "Directory not found: ${resolved}"
result_paths+=("$resolved")
result_notes+=("not found")
fail_count=$((fail_count + 1))
continue
fi
toplevel="$(git -C "$resolved" rev-parse --show-toplevel 2>/dev/null || true)"
if [[ -z "$toplevel" || "$(cd "$resolved" && pwd -P)" != "$(cd "$toplevel" && pwd -P)" ]]; then
err "Not a git work tree: ${resolved}"
result_paths+=("$resolved")
result_notes+=("not a git work tree")
fail_count=$((fail_count + 1))
continue
fi
exit_code=0
if pull_repo "$resolved"; then
ok "Updated ${resolved}"
result_paths+=("$resolved")
result_notes+=("0")
ok_count=$((ok_count + 1))
else
exit_code=$?
err "Failed ${resolved} (exit ${exit_code})"
result_paths+=("$resolved")
result_notes+=("exit ${exit_code}")
fail_count=$((fail_count + 1))
fi
done
printf '\n%s========== Summary ==========%s\n' "$C_INFO" "$C_RESET"
summary_index=0
while [[ "$summary_index" -lt "$total" ]]; do
note="${result_notes[$summary_index]}"
path="${result_paths[$summary_index]}"
if [[ "$note" == "0" ]]; then
ok "${path}"
else
err "${path} (${note})"
fi
summary_index=$((summary_index + 1))
done
if [[ "$fail_count" -gt 0 ]]; then
err "${ok_count} updated, ${fail_count} failed."
exit 1
fi
ok "${ok_count} updated, 0 failed."