Files
api/services/nginx/app/resources/edge-gateway-agent/gateway-launcher.sh
T

337 lines
11 KiB
Bash

#!/usr/bin/env bash
set -Eeuo pipefail
ACTION="${1:-up}"
INSTALL_DIR="${TRUCKWASH_INSTALL_DIR:-/opt/truckwash-edge-agent}"
CONFIG_PATH="$INSTALL_DIR/config.json"
COMPOSE_FILE="$INSTALL_DIR/docker-compose.gateway.yml"
ENV_FILE="$INSTALL_DIR/.env"
RUNTIME_DIR="$INSTALL_DIR/runtime"
ROLLBACK_STATUS_PATH="$RUNTIME_DIR/rollback-status.json"
STAGED_UPDATE_PATH="$RUNTIME_DIR/staged-update.json"
STACK_HEALTHCHECK_TIMEOUT_SECONDS="${STACK_HEALTHCHECK_TIMEOUT_SECONDS:-300}"
log() {
printf '[gateway-launcher] %s\n' "$1"
}
compose_cmd() {
if docker compose version >/dev/null 2>&1; then
docker compose "$@"
return
fi
if command -v docker-compose >/dev/null 2>&1; then
docker-compose "$@"
return
fi
echo "Docker Compose is required." >&2
return 1
}
print_compose_diagnostics() {
local compose_project_name
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
log "docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}'"
docker ps -a --format '{{.Names}} {{.Status}} {{.Ports}}' || true
log "compose ps -a --no-trunc"
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" ps -a --no-trunc || true
log "compose logs --no-color --no-log-prefix --tail=120"
COMPOSE_PROJECT_NAME="$compose_project_name" compose_cmd -f "$COMPOSE_FILE" logs --no-color --no-log-prefix --tail=120 || true
}
config_value() {
local key="$1"
local fallback="${2:-}"
php -r '
$path = $argv[1];
$key = $argv[2];
$fallback = $argv[3];
if (!is_file($path)) {
echo $fallback;
exit(0);
}
$decoded = json_decode((string)file_get_contents($path), true);
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
echo $fallback;
exit(0);
}
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
' "$CONFIG_PATH" "$key" "$fallback"
}
staged_update_value() {
local key="$1"
local fallback="${2:-}"
php -r '
$path = $argv[1];
$key = $argv[2];
$fallback = $argv[3];
if (!is_file($path)) {
echo $fallback;
exit(0);
}
$decoded = json_decode((string)file_get_contents($path), true);
if (!is_array($decoded) || !array_key_exists($key, $decoded) || $decoded[$key] === null || $decoded[$key] === "") {
echo $fallback;
exit(0);
}
echo is_scalar($decoded[$key]) ? (string)$decoded[$key] : json_encode($decoded[$key], JSON_UNESCAPED_SLASHES);
' "$STAGED_UPDATE_PATH" "$key" "$fallback"
}
staged_update_is_applied() {
local status
status="$(staged_update_value status '')"
[ "$status" = "APPLIED" ]
}
ensure_dirs() {
mkdir -p "$RUNTIME_DIR" "$RUNTIME_DIR/backups"
}
ensure_stack_env() {
php -r '
$path = $argv[1];
$content = is_file($path) ? (string)file_get_contents($path) : "";
$hasValue = static function (string $name) use ($content): bool {
if (!preg_match("/^" . preg_quote($name, "/") . "=(.*)$/m", $content, $matches)) {
return false;
}
return trim((string)$matches[1], " \t\"") !== "";
};
$secret = static function (int $bytes): string {
return rtrim(strtr(base64_encode(random_bytes($bytes)), "+/", "-_"), "=");
};
$generated = [];
$required = [
"REDIS_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_PASSWORD" => static fn(): string => $secret(32),
"MARIADB_ROOT_PASSWORD" => static fn(): string => $secret(32),
"MINIO_ROOT_USER" => static fn(): string => "twminio" . bin2hex(random_bytes(12)),
"MINIO_ROOT_PASSWORD" => static fn(): string => $secret(32),
];
foreach ($required as $name => $factory) {
if (!$hasValue($name)) {
$generated[] = $name . "=" . $factory();
}
}
if ($generated === []) {
exit(0);
}
$prefix = ($content !== "" && !str_ends_with($content, "\n")) ? "\n" : "";
file_put_contents($path, $prefix . implode("\n", $generated) . "\n", FILE_APPEND | LOCK_EX);
' "$ENV_FILE"
chmod 0600 "$ENV_FILE"
}
within_update_window() {
local window
window="$(staged_update_value update_window '')"
if [ -z "$window" ]; then
window="$(config_value updateWindow '02:00-04:00')"
fi
php -r '
$window = $argv[1];
[$start, $end] = array_pad(explode("-", $window, 2), 2, "");
$parse = static function (string $value): ?int {
if (!preg_match("/^(\d{2}):(\d{2})$/", trim($value), $matches)) {
return null;
}
return ((int)$matches[1] * 60) + (int)$matches[2];
};
$startMinutes = $parse($start);
$endMinutes = $parse($end);
if ($startMinutes === null || $endMinutes === null) {
exit(1);
}
$nowMinutes = ((int)date("G") * 60) + (int)date("i");
if ($startMinutes <= $endMinutes) {
exit(($nowMinutes >= $startMinutes && $nowMinutes <= $endMinutes) ? 0 : 1);
}
exit(($nowMinutes >= $startMinutes || $nowMinutes <= $endMinutes) ? 0 : 1);
' "$window"
}
write_rollback_status() {
local state="$1"
local reason="${2:-}"
local rolled_back_to="${3:-}"
cat > "$ROLLBACK_STATUS_PATH" <<EOF_JSON
{
"state": "$state",
"reason": "$reason",
"rolled_back_to": "$rolled_back_to",
"at": "$(date --iso-8601=seconds)"
}
EOF_JSON
}
apply_stack() {
local edge_base_image
local worker_base_image
local auto_updater_base_image
local redis_base_image
local mariadb_base_image
local minio_base_image
local compose_project_name
edge_base_image="$(config_value edgeAgentBaseImage 'php:8.2-cli-bookworm')"
worker_base_image="$(config_value lanWorkerBaseImage 'php:8.2-cli-bookworm')"
auto_updater_base_image="$(config_value autoUpdaterBaseImage 'php:8.2-cli-bookworm')"
redis_base_image="$(config_value redisBaseImage 'redis:7-alpine')"
mariadb_base_image="$(config_value mariadbBaseImage 'mariadb:11')"
minio_base_image="$(config_value minioBaseImage 'minio/minio:latest')"
compose_project_name="$(config_value composeProjectName 'truckwash-edge-gateway')"
ensure_stack_env
cd "$INSTALL_DIR"
COMPOSE_PROJECT_NAME="$compose_project_name" \
EDGE_AGENT_BASE_IMAGE="$edge_base_image" \
LAN_WORKER_BASE_IMAGE="$worker_base_image" \
AUTO_UPDATER_BASE_IMAGE="$auto_updater_base_image" \
REDIS_BASE_IMAGE="$redis_base_image" \
MARIADB_BASE_IMAGE="$mariadb_base_image" \
MINIO_BASE_IMAGE="$minio_base_image" \
compose_cmd -f "$COMPOSE_FILE" up -d --build
}
rollback_stack() {
local installed_version
installed_version="$(config_value installedVersion '')"
for file in \
agent.php \
lan-worker.php \
auto-updater.php \
docker-compose.gateway.yml \
Dockerfile.edge-agent \
Dockerfile.lan-worker \
Dockerfile.auto-updater \
gateway-launcher.sh \
truckwash-edge-gateway-stack.service \
truckwash-edge-agent.service; do
if [ -f "$INSTALL_DIR/$file.bak" ]; then
mv -f "$INSTALL_DIR/$file.bak" "$INSTALL_DIR/$file"
fi
done
if ! apply_stack; then
write_rollback_status "FAILED" "rollback_apply_failed" "$installed_version"
return 1
fi
write_rollback_status "ROLLED_BACK" "healthcheck_failed" "$installed_version"
if [ -f "$STAGED_UPDATE_PATH" ]; then
php -r '
$path = $argv[1];
$decoded = is_file($path) ? json_decode((string)file_get_contents($path), true) : [];
if (!is_array($decoded)) {
$decoded = [];
}
$decoded["status"] = "ROLLED_BACK";
$decoded["rolled_back_at"] = date(DATE_ATOM);
file_put_contents($path, json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
' "$STAGED_UPDATE_PATH"
fi
}
container_is_healthy() {
local container_name="$1"
local health
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_name" 2>/dev/null || echo missing)"
[ "$health" = "healthy" ] || [ "$health" = "running" ]
}
healthcheck_stack() {
container_is_healthy truckwash-redis &&
container_is_healthy truckwash-mariadb &&
container_is_healthy truckwash-minio &&
container_is_healthy truckwash-lan-worker &&
container_is_healthy truckwash-edge-agent &&
container_is_healthy truckwash-auto-updater
}
wait_for_stack_health() {
local timeout_seconds="${1:-$STACK_HEALTHCHECK_TIMEOUT_SECONDS}"
local elapsed=0
while [ "$elapsed" -lt "$timeout_seconds" ]; do
if healthcheck_stack; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
reconcile_stack() {
local installed_version
installed_version="$(config_value installedVersion '')"
ensure_dirs
log "Reconciling compose stack"
if ! apply_stack; then
log "Compose rollout failed during build/startup"
print_compose_diagnostics
write_rollback_status "FAILED" "compose_up_failed" "$installed_version"
return 1
fi
if ! wait_for_stack_health "$STACK_HEALTHCHECK_TIMEOUT_SECONDS"; then
log "Healthcheck failed after compose rollout; reverting to previous artifacts"
print_compose_diagnostics
rollback_stack
return 1
fi
write_rollback_status "IDLE" "" ""
if [ -f "$STAGED_UPDATE_PATH" ] && within_update_window; then
php -r '
$configPath = $argv[1];
$stagedPath = $argv[2];
$config = is_file($configPath) ? json_decode((string)file_get_contents($configPath), true) : [];
$staged = is_file($stagedPath) ? json_decode((string)file_get_contents($stagedPath), true) : [];
if (!is_array($config) || !is_array($staged)) {
exit(0);
}
$targetVersion = trim((string)($staged["target_version"] ?? ""));
if ($targetVersion !== "") {
$config["installedVersion"] = $targetVersion;
$config["targetVersion"] = $targetVersion;
file_put_contents($configPath, json_encode($config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
$staged["status"] = "APPLIED";
$staged["applied_at"] = date(DATE_ATOM);
file_put_contents($stagedPath, json_encode($staged, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
}
' "$CONFIG_PATH" "$STAGED_UPDATE_PATH"
fi
}
case "$ACTION" in
up)
reconcile_stack
;;
reconcile)
if [ -f "$STAGED_UPDATE_PATH" ] && staged_update_is_applied; then
log "Staged update already applied; nothing to reconcile"
exit 0
fi
if [ -f "$STAGED_UPDATE_PATH" ] && ! within_update_window; then
log "Update is staged but outside the maintenance window; keeping current stack running"
exit 0
fi
reconcile_stack
;;
down)
cd "$INSTALL_DIR"
compose_cmd -f "$COMPOSE_FILE" down
;;
*)
echo "Usage: gateway-launcher.sh [up|reconcile|down]" >&2
exit 1
;;
esac