337 lines
11 KiB
Bash
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
|