From 7f3e4fea9f6223eb60815e306abf568ffa4b5783 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 12:21:14 +0200 Subject: [PATCH] initial --- .env.example | 18 ++ .gitignore | 4 + .gitmodules | 4 + README.md | 6 + ac-scripts | 1 + cleanup.sh | 67 ++++++ deploy.sh | 150 ++++++++++++++ docker-compose.yml | 125 ++++++++++++ images/build-php-fpm/Dockerfile | 7 + images/crash-php-fpm/Dockerfile | 7 + nginx-seeds/README.md | 3 + nginx-seeds/nginx.apps-builder.frag | 17 ++ nginx-seeds/nginx.apps-port80.fragment | 134 ++++++++++++ nginx-seeds/nginx.apps.conf.fragment | 117 +++++++++++ nginx-seeds/nginx.fe-apps.f0xx.org.snippet | 30 +++ nginx-seeds/rssh-bastion-stream.conf.example | 20 ++ nginx/build-fe.conf | 39 ++++ nginx/crashes-fe.conf | 72 +++++++ nginx/gitea-fe.conf | 33 +++ nginx/landing.conf | 63 ++++++ runtime/db-init/.gitkeep | 1 + runtime/db-init/007_remote_access.sql | 56 +++++ runtime/db-init/007_remote_access.sqlite.sql | 50 +++++ runtime/db-init/010_schema.sql | 127 ++++++++++++ runtime/db-init/040_ticket_workflow.sql | 19 ++ runtime/db-init/050_ticket_attachments.sql | 21 ++ runtime/db-init/060_build_ecosystem.sql | 31 +++ runtime/db-init/070_graph_ecosystem.sql | 28 +++ scripts/apply-hosts.sh | 34 ++++ sim/cluster0/ARCHITECTURE.md | 77 +++++++ sim/cluster0/DEPLOY.md | 108 ++++++++++ sim/cluster0/README.md | 17 ++ sim/cluster0/bootstrap/cluster0-bootstrap.env | 5 + sim/cluster0/cluster.env | 48 +++++ sim/cluster0/credentials.txt | 22 ++ sim/cluster0/journal/README.md | 55 +++++ sim/cluster0/mariadb/primary.cnf | 10 + sim/cluster0/mariadb/replica.cnf | 11 + sim/cluster0/nginx/apps-port80.conf | 119 +++++++++++ sim/cluster0/populate_lab_setup.sh | 136 +++++++++++++ sim/cluster0/scripts/baseline.sh | 67 ++++++ sim/cluster0/scripts/configure-nginx.sh | 12 ++ sim/cluster0/scripts/configure-php-fpm.sh | 13 ++ sim/cluster0/scripts/deploy-app.sh | 109 ++++++++++ .../scripts/journal-backfill-cluster0.sh | 57 ++++++ .../scripts/journal-rebuild-summary.sh | 35 ++++ sim/cluster0/scripts/lib/common.sh | 192 ++++++++++++++++++ sim/cluster0/scripts/load-schemas.sh | 39 ++++ sim/cluster0/scripts/mariadb-primary.sh | 33 +++ sim/cluster0/scripts/mariadb-replica.sh | 73 +++++++ sim/cluster0/scripts/verify-cluster.sh | 34 ++++ sim/cluster0/verify_global_setup.sh | 64 ++++++ sim/cluster0/verify_lab_setup.sh | 105 ++++++++++ 53 files changed, 2725 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 160000 ac-scripts create mode 100755 cleanup.sh create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 images/build-php-fpm/Dockerfile create mode 100644 images/crash-php-fpm/Dockerfile create mode 100644 nginx-seeds/README.md create mode 100644 nginx-seeds/nginx.apps-builder.frag create mode 100644 nginx-seeds/nginx.apps-port80.fragment create mode 100644 nginx-seeds/nginx.apps.conf.fragment create mode 100644 nginx-seeds/nginx.fe-apps.f0xx.org.snippet create mode 100644 nginx-seeds/rssh-bastion-stream.conf.example create mode 100644 nginx/build-fe.conf create mode 100644 nginx/crashes-fe.conf create mode 100644 nginx/gitea-fe.conf create mode 100644 nginx/landing.conf create mode 100644 runtime/db-init/.gitkeep create mode 100644 runtime/db-init/007_remote_access.sql create mode 100644 runtime/db-init/007_remote_access.sqlite.sql create mode 100644 runtime/db-init/010_schema.sql create mode 100644 runtime/db-init/040_ticket_workflow.sql create mode 100644 runtime/db-init/050_ticket_attachments.sql create mode 100644 runtime/db-init/060_build_ecosystem.sql create mode 100644 runtime/db-init/070_graph_ecosystem.sql create mode 100755 scripts/apply-hosts.sh create mode 100644 sim/cluster0/ARCHITECTURE.md create mode 100644 sim/cluster0/DEPLOY.md create mode 100644 sim/cluster0/README.md create mode 100644 sim/cluster0/bootstrap/cluster0-bootstrap.env create mode 100644 sim/cluster0/cluster.env create mode 100644 sim/cluster0/credentials.txt create mode 100644 sim/cluster0/journal/README.md create mode 100644 sim/cluster0/mariadb/primary.cnf create mode 100644 sim/cluster0/mariadb/replica.cnf create mode 100644 sim/cluster0/nginx/apps-port80.conf create mode 100644 sim/cluster0/populate_lab_setup.sh create mode 100644 sim/cluster0/scripts/baseline.sh create mode 100644 sim/cluster0/scripts/configure-nginx.sh create mode 100644 sim/cluster0/scripts/configure-php-fpm.sh create mode 100644 sim/cluster0/scripts/deploy-app.sh create mode 100644 sim/cluster0/scripts/journal-backfill-cluster0.sh create mode 100644 sim/cluster0/scripts/journal-rebuild-summary.sh create mode 100644 sim/cluster0/scripts/lib/common.sh create mode 100644 sim/cluster0/scripts/load-schemas.sh create mode 100644 sim/cluster0/scripts/mariadb-primary.sh create mode 100644 sim/cluster0/scripts/mariadb-replica.sh create mode 100644 sim/cluster0/scripts/verify-cluster.sh create mode 100644 sim/cluster0/verify_global_setup.sh create mode 100644 sim/cluster0/verify_lab_setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63ce474 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +COMPOSE_PROJECT_NAME=androidcast_local + +# Public entry (landing FE) +LANDING_HTTP_PORT=8080 + +# Optional direct debug ports +GITEA_FE_PORT=8081 +CRASHES_FE_PORT=8082 +BUILD_FE_PORT=8083 + +# Internal app creds (local dev defaults) +MARIADB_ROOT_PASSWORD=androidcast_root +MARIADB_DATABASE=androidcast_crashes +MARIADB_USER=androidcast +MARIADB_PASSWORD=androidcast + +# Used for generated crash backend config + gitea ROOT_URL +PUBLIC_BASE_URL=http://localhost:8080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb026c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +runtime/* +!runtime/db-init/ +!runtime/db-init/.gitkeep diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..22e06f8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "ac-scripts"] + path = ac-scripts + url = git://f0xx.org/ac/ac-scripts + branch = next diff --git a/README.md b/README.md new file mode 100644 index 0000000..e079d50 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# ac-deploy + +Orchestration, cluster0 lab, Docker images, nginx seeds. + +- **Remote:** git://f0xx.org/ac/ac-deploy +- **Submodules:** ac-scripts diff --git a/ac-scripts b/ac-scripts new file mode 160000 index 0000000..3c197ed --- /dev/null +++ b/ac-scripts @@ -0,0 +1 @@ +Subproject commit 3c197ed9dd0863c237ac7010fecacc4c7b2c4dd4 diff --git a/cleanup.sh b/cleanup.sh new file mode 100755 index 0000000..44ce6a5 --- /dev/null +++ b/cleanup.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="$ROOT_DIR/docker-compose.yml" +ENV_FILE="$ROOT_DIR/.env" +ENV_EXAMPLE="$ROOT_DIR/.env.example" +RUNTIME_DIR="$ROOT_DIR/runtime" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found in PATH" + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "docker compose plugin is required" + exit 1 +fi + +if [[ ! -f "$ENV_FILE" && -f "$ENV_EXAMPLE" ]]; then + cp "$ENV_EXAMPLE" "$ENV_FILE" +fi + +set -a +if [[ -f "$ENV_FILE" ]]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi +set +a + +PROJECT_NAME="${COMPOSE_PROJECT_NAME:-androidcast_local}" + +echo "Stopping and removing orchestration stack..." +docker compose -f "$COMPOSE_FILE" down --remove-orphans --volumes --rmi local || true + +echo "Removing leftover containers with compose project label..." +docker ps -aq --filter "label=com.docker.compose.project=${PROJECT_NAME}" | while read -r cid; do + [[ -n "$cid" ]] && docker rm -f "$cid" || true +done + +echo "Removing leftover compose networks..." +docker network ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}" | while read -r nid; do + [[ -n "$nid" ]] && docker network rm "$nid" || true +done + +echo "Removing leftover compose volumes..." +docker volume ls -q --filter "label=com.docker.compose.project=${PROJECT_NAME}" | while read -r vid; do + [[ -n "$vid" ]] && docker volume rm "$vid" || true +done + +echo "Removing orchestration screen sessions if present..." +if command -v screen >/dev/null 2>&1; then + screen_output="$(screen -ls 2>/dev/null || true)" + printf '%s\n' "$screen_output" | awk '/androidcast|orchestration/ {print $1}' | while read -r session; do + [[ -n "$session" ]] && screen -S "$session" -X quit || true + done +fi + +echo "Cleaning generated runtime artifacts..." +rm -f "$RUNTIME_DIR/crash-config.php" +rm -f "$RUNTIME_DIR/db-init/010_schema.sql" \ + "$RUNTIME_DIR/db-init/040_ticket_workflow.sql" \ + "$RUNTIME_DIR/db-init/050_ticket_attachments.sql" +mkdir -p "$RUNTIME_DIR/db-init" +touch "$RUNTIME_DIR/db-init/.gitkeep" + +echo "Orchestration cleanup complete." diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..8e36e89 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$ROOT_DIR/.env" +ENV_EXAMPLE="$ROOT_DIR/.env.example" +RUNTIME_DIR="$ROOT_DIR/runtime" +DB_INIT_DIR="$RUNTIME_DIR/db-init" +CRASH_CONFIG="$RUNTIME_DIR/crash-config.php" +BUILD_CONFIG="$RUNTIME_DIR/build-config.php" +OTA_DIR="$RUNTIME_DIR/ota-artifacts" + +SRC_SCHEMA="$ROOT_DIR/../examples/crash_reporter/backend/sql/schema.mariadb.sql" +SRC_MIG_004="$ROOT_DIR/../examples/crash_reporter/backend/sql/migrations/004_ticket_workflow.sql" +SRC_MIG_005="$ROOT_DIR/../examples/crash_reporter/backend/sql/migrations/005_ticket_attachments.sql" +SRC_MIG_060="$ROOT_DIR/runtime/db-init/060_build_ecosystem.sql" + +if ! command -v docker >/dev/null 2>&1; then + echo "docker not found in PATH" + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "docker compose plugin is required" + exit 1 +fi + +if [[ ! -f "$ENV_FILE" ]]; then + cp "$ENV_EXAMPLE" "$ENV_FILE" + echo "Created .env from .env.example" +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +mkdir -p "$DB_INIT_DIR" "$OTA_DIR" + +cat >"$CRASH_CONFIG" < 'Android Cast Crashes', + 'base_path' => '/app/androidcast_project/crashes', + 'session_name' => 'ac_crash_sess', + 'session_cookie_path' => '/app/androidcast_project', + 'db' => [ + 'driver' => 'mysql', + 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', + 'mysql' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'socket' => '', + 'database' => '${MARIADB_DATABASE}', + 'username' => '${MARIADB_USER}', + 'password' => '${MARIADB_PASSWORD}', + 'charset' => 'utf8mb4', + ], + ], + 'debug' => true, + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], +]; +EOF + +cat >"$BUILD_CONFIG" < 'Android Cast Builder', + 'base_path' => '/app/androidcast_project/build', + 'session_name' => 'ac_crash_sess', + 'session_cookie_path' => '/app/androidcast_project', + 'db' => [ + 'driver' => 'mysql', + 'sqlite_path' => __DIR__ . '/../../crash_reporter/backend/data/crashes.sqlite', + 'mysql' => [ + 'host' => 'mariadb', + 'port' => 3306, + 'socket' => '', + 'database' => '${MARIADB_DATABASE}', + 'username' => '${MARIADB_USER}', + 'password' => '${MARIADB_PASSWORD}', + 'charset' => 'utf8mb4', + ], + ], + 'debug' => true, + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], + 'build' => [ + 'docker_image' => 'androidcast-ci:latest', + 'ci_version' => '00.01.00.1000', + 'repo_root' => '/workspace', + 'artifacts_root' => '/workspace/out/builds', + 'ota_mount' => '/workspace/orchestration/runtime/ota-artifacts', + 'ota_base_url' => '${PUBLIC_BASE_URL}', + 'runner_script' => '/workspace/scripts/docker-build-runner.sh', + 'pipeline_config' => '/workspace/build.config.yml', + 'default_channels' => ['stable', 'staging', 'dev', 'nightly'], + ], + 'links' => [ + 'hub' => '/app/androidcast_project/', + 'crashes' => '/app/androidcast_project/crashes/', + 'git' => '/app/androidcast_project/git/', + 'tickets' => '/app/androidcast_project/crashes/?view=tickets', + ], +]; +EOF + +SRC_MIG_070="$ROOT_DIR/runtime/db-init/070_graph_ecosystem.sql" +SRC_MIG_007="$ROOT_DIR/runtime/db-init/007_remote_access.sql" + +write_migration_with_db() { + local src="$1" + local dest="$2" + local tmp + tmp="$(mktemp)" + { + echo "USE \`${MARIADB_DATABASE}\`;" + cat "$src" + } >"$tmp" + mv "$tmp" "$dest" +} + +cp "$SRC_SCHEMA" "$DB_INIT_DIR/010_schema.sql" +write_migration_with_db "$SRC_MIG_004" "$DB_INIT_DIR/040_ticket_workflow.sql" +write_migration_with_db "$SRC_MIG_005" "$DB_INIT_DIR/050_ticket_attachments.sql" +write_migration_with_db "$SRC_MIG_060" "$DB_INIT_DIR/060_build_ecosystem.sql" +write_migration_with_db "$SRC_MIG_070" "$DB_INIT_DIR/070_graph_ecosystem.sql" +write_migration_with_db "$SRC_MIG_007" "$DB_INIT_DIR/007_remote_access.sql" + +echo "Pulling images..." +docker compose -f "$ROOT_DIR/docker-compose.yml" pull landing-fe gitea-fe crashes-fe build-fe mariadb gitea || true + +echo "Starting/updating stack..." +docker compose -f "$ROOT_DIR/docker-compose.yml" up -d --build --remove-orphans --force-recreate + +echo +echo "Stack is up." +echo "Landing: ${PUBLIC_BASE_URL}/app/androidcast_project/" +echo "Gitea (direct FE): http://localhost:${GITEA_FE_PORT}/app/androidcast_project/git/" +echo "Crashes (direct FE): http://localhost:${CRASHES_FE_PORT}/app/androidcast_project/crashes/" +echo "Graphs (via landing): ${PUBLIC_BASE_URL}/app/androidcast_project/graphs/" +echo "Builder (direct FE): http://localhost:${BUILD_FE_PORT:-8083}/app/androidcast_project/build/" +echo "Shared login: same user/password as Crashes (session cookie path /app/androidcast_project)" +echo +docker compose -f "$ROOT_DIR/docker-compose.yml" ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..981f60c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,125 @@ +services: + landing-fe: + image: nginx:1.27-alpine + depends_on: + - gitea-fe + - crashes-fe + - build-fe + ports: + - "${LANDING_HTTP_PORT}:8080" + volumes: + - ../:/workspace:ro + - ./nginx/landing.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - appnet + restart: unless-stopped + + gitea-fe: + image: nginx:1.27-alpine + depends_on: + - gitea + ports: + - "${GITEA_FE_PORT}:8080" + volumes: + - ./nginx/gitea-fe.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - appnet + restart: unless-stopped + + crashes-fe: + image: nginx:1.27-alpine + depends_on: + - crash-php + ports: + - "${CRASHES_FE_PORT}:8080" + volumes: + - ../:/workspace + - ./nginx/crashes-fe.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - appnet + restart: unless-stopped + + build-fe: + image: nginx:1.27-alpine + depends_on: + - build-php + ports: + - "${BUILD_FE_PORT}:8080" + volumes: + - ../:/workspace + - ./nginx/build-fe.conf:/etc/nginx/conf.d/default.conf:ro + networks: + - appnet + restart: unless-stopped + + build-php: + build: + context: . + dockerfile: images/build-php-fpm/Dockerfile + depends_on: + - mariadb + volumes: + - ../:/workspace + - /var/run/docker.sock:/var/run/docker.sock + - ./runtime/build-config.php:/workspace/examples/build_console/backend/config/config.php + - ./runtime/ota-artifacts:/workspace/orchestration/runtime/ota-artifacts + networks: + - appnet + restart: unless-stopped + + crash-php: + build: + context: . + dockerfile: images/crash-php-fpm/Dockerfile + depends_on: + - mariadb + volumes: + - ../:/workspace + - ./runtime/crash-config.php:/workspace/examples/crash_reporter/backend/config/config.php + networks: + - appnet + restart: unless-stopped + + mariadb: + image: mariadb:11.4 + environment: + MARIADB_ROOT_PASSWORD: "${MARIADB_ROOT_PASSWORD}" + MARIADB_DATABASE: "${MARIADB_DATABASE}" + MARIADB_USER: "${MARIADB_USER}" + MARIADB_PASSWORD: "${MARIADB_PASSWORD}" + command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"] + volumes: + - mariadb-data:/var/lib/mysql + - ./runtime/db-init:/docker-entrypoint-initdb.d:ro + networks: + - appnet + restart: unless-stopped + + gitea: + image: gitea/gitea:1.22 + environment: + USER_UID: "1000" + USER_GID: "1000" + GITEA__server__ROOT_URL: "http://localhost:${GITEA_FE_PORT}/" + GITEA__server__DOMAIN: "localhost" + GITEA__server__HTTP_PORT: "3000" + GITEA__server__PROTOCOL: "http" + GITEA__security__INSTALL_LOCK: "true" + GITEA__service__DISABLE_REGISTRATION: "true" + GITEA__database__DB_TYPE: "sqlite3" + volumes: + - gitea-data:/data + networks: + - appnet + restart: unless-stopped + +networks: + appnet: + driver: bridge + ipam: + config: + - subnet: 10.203.0.0/24 + +volumes: + mariadb-data: + gitea-data: diff --git a/images/build-php-fpm/Dockerfile b/images/build-php-fpm/Dockerfile new file mode 100644 index 0000000..74bdd0f --- /dev/null +++ b/images/build-php-fpm/Dockerfile @@ -0,0 +1,7 @@ +FROM php:8.2-fpm-alpine + +RUN apk add --no-cache bash docker-cli icu-dev libzip-dev oniguruma-dev mariadb-client sqlite-dev \ + && docker-php-ext-install pdo pdo_mysql pdo_sqlite \ + && rm -rf /var/cache/apk/* + +WORKDIR /workspace/examples/build_console/backend diff --git a/images/crash-php-fpm/Dockerfile b/images/crash-php-fpm/Dockerfile new file mode 100644 index 0000000..d063fa9 --- /dev/null +++ b/images/crash-php-fpm/Dockerfile @@ -0,0 +1,7 @@ +FROM php:8.2-fpm-alpine + +RUN apk add --no-cache bash icu-dev libzip-dev oniguruma-dev mariadb-client sqlite-dev \ + && docker-php-ext-install pdo pdo_mysql pdo_sqlite \ + && rm -rf /var/cache/apk/* + +WORKDIR /workspace/examples/crash_reporter/backend diff --git a/nginx-seeds/README.md b/nginx-seeds/README.md new file mode 100644 index 0000000..3c83a7c --- /dev/null +++ b/nginx-seeds/README.md @@ -0,0 +1,3 @@ +# nginx-seeds + +Nginx fragments from monolith examples/crash_reporter/backend/. diff --git a/nginx-seeds/nginx.apps-builder.frag b/nginx-seeds/nginx.apps-builder.frag new file mode 100644 index 0000000..0a6b532 --- /dev/null +++ b/nginx-seeds/nginx.apps-builder.frag @@ -0,0 +1,17 @@ +location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; +} + +location ^~ /app/androidcast_project/build/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/assets/; +} + +location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; +} diff --git a/nginx-seeds/nginx.apps-port80.fragment b/nginx-seeds/nginx.apps-port80.fragment new file mode 100644 index 0000000..7945465 --- /dev/null +++ b/nginx-seeds/nginx.apps-port80.fragment @@ -0,0 +1,134 @@ +# Full androidcast vhost for Alpine BE — listen 80 only. +# FE (apps.f0xx.org) proxies here: proxy_pass http://artc0.intra.raptor.org:80; +# Port 8089 is a different service (e.g. Janus) — not this vhost. + +server { + listen 80; + + # OTA v0 static tree — devices fetch https://apps.f0xx.org/v0/ota/channel/stable.json + # Populate: builder auto_deploy, or rsync out/ota/v0/ → .../ota-artifacts/v0/ + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location = /app/androidcast_project/issues { + return 301 /app/androidcast_project/issues/; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/issues/; + } + + location ~ ^/app/androidcast_project/crashes(/.*)?$ { + return 301 /app/androidcast_project/issues$1; + } + + location ~ ^/app/androidcast_project/(login|logout|register|two-factor|verify-email)(/|$) { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location ^~ /app/androidcast_project/issues/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/issues/api/upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/issues/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/issues/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location ^~ /app/androidcast_project/graphs/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; + } + + location ^~ /app/androidcast_project/build/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/assets/; + } + + location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; + } + + location ~ \.php$ { + include /etc/nginx/fastcgi.conf; + fastcgi_pass unix:/run/php-fpm.socket; + } +} diff --git a/nginx-seeds/nginx.apps.conf.fragment b/nginx-seeds/nginx.apps.conf.fragment new file mode 100644 index 0000000..36312c7 --- /dev/null +++ b/nginx-seeds/nginx.apps.conf.fragment @@ -0,0 +1,117 @@ +# Paste into the listen 443 (and 80) server {} in /etc/nginx/conf.d/apps.conf +# Replaces old per-URL blocks + any backend symlink paths. + + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/crashes/; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + # Hub — see nginx.apps-port80.fragment (index.php + shared footer). + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location ^~ /app/androidcast_project/crashes/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/crashes/api/upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/crashes/api/diag.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/diag.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/diag.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/reports.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/reports.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/reports.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/report_viewed.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_viewed.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_viewed.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/report_tags.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_tags.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_tags.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location ^~ /app/androidcast_project/crashes/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } diff --git a/nginx-seeds/nginx.fe-apps.f0xx.org.snippet b/nginx-seeds/nginx.fe-apps.f0xx.org.snippet new file mode 100644 index 0000000..d9fe6cc --- /dev/null +++ b/nginx-seeds/nginx.fe-apps.f0xx.org.snippet @@ -0,0 +1,30 @@ +# FE apps.f0xx.org — replace hub/crashes upstream blocks (use BE port 80, preserve URI). +# OTA at /v0/ota/ must proxy to BE :80 even when hub uses :443 — see nginx.fe-ota.snippet +# and scripts/install-fe-ota-nginx.sh (public URL 400 until this is applied on FE). +# +# File: tmp/FE_gentoo/etc/nginx/nginx.conf (inside server { listen 443; server_name apps.f0xx.org; }) +# +# WRONG (causes browser to download index.php as application/octet-stream): +# proxy_pass https://artc0.intra.raptor.org/app/androidcast_project/; +# BE :443 serves static files; hub must hit BE :80 (PHP-FPM) like location / already does. + + location /app/androidcast_project/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /app/androidcast_project/crashes/ { + proxy_pass http://artc0.intra.raptor.org:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + +# apps.conf build block must stay: +# proxy_pass http://artc0.intra.raptor.org:80; (no trailing slash) diff --git a/nginx-seeds/rssh-bastion-stream.conf.example b/nginx-seeds/rssh-bastion-stream.conf.example new file mode 100644 index 0000000..5e5e4ae --- /dev/null +++ b/nginx-seeds/rssh-bastion-stream.conf.example @@ -0,0 +1,20 @@ +# FE nginx stream — TCP proxy to BE sshd for RSSH bastion (ra.apps.f0xx.org:443). +# Install on FE (Gentoo); BE runs openssh with Match User ra-* (see sshd_config.d/ra.conf). +# +# *** DO NOT install under BE /etc/nginx/stream.d/ *** +# BE already uses listen 443 ssl in conf.d/apps.conf (http). Stream listen 443 here +# causes: nginx: [emerg] bind() to 0.0.0.0:443 failed (98: Address in use) +# +# Include from nginx.conf: +# include /etc/nginx/stream.d/rssh-bastion.conf; + +upstream rssh_bastion_be { + server 10.7.16.128:22; # artc0 / alpine-be — adjust for cluster/lab +} + +server { + listen 443; + proxy_pass rssh_bastion_be; + proxy_connect_timeout 30s; + proxy_timeout 24h; +} diff --git a/nginx/build-fe.conf b/nginx/build-fe.conf new file mode 100644 index 0000000..2f4b605 --- /dev/null +++ b/nginx/build-fe.conf @@ -0,0 +1,39 @@ +server { + listen 8080; + server_name _; + + client_max_body_size 64m; + index index.php; + + location = / { + return 302 /app/androidcast_project/build/; + } + + location /app/androidcast_project/build/ { + alias /workspace/examples/build_console/backend/public/; + index index.php; + try_files $uri $uri/ @build_front; + } + + location /app/androidcast_project/crashes/assets/ { + alias /workspace/examples/crash_reporter/backend/public/assets/; + } + + location @build_front { + rewrite ^ /app/androidcast_project/build/index.php?$query_string last; + } + + location ~ ^/app/androidcast_project/build/(.+\.php)$ { + alias /workspace/examples/build_console/backend/public/$1; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/build_console/backend/public/$1; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/$1; + fastcgi_param DOCUMENT_ROOT /workspace/examples/build_console/backend/public; + fastcgi_pass build-php:9000; + fastcgi_read_timeout 300s; + } + + location ~ /\. { + deny all; + } +} diff --git a/nginx/crashes-fe.conf b/nginx/crashes-fe.conf new file mode 100644 index 0000000..cc52003 --- /dev/null +++ b/nginx/crashes-fe.conf @@ -0,0 +1,72 @@ +server { + listen 8080; + server_name _; + + client_max_body_size 16m; + index index.php; + + location = / { + return 302 /app/androidcast_project/crashes/; + } + + location = /app/androidcast_project/graphs { + return 302 /app/androidcast_project/graphs/; + } + + location ^~ /app/androidcast_project/graphs/assets/ { + alias /workspace/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public; + fastcgi_pass crash-php:9000; + fastcgi_read_timeout 120s; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public; + fastcgi_pass crash-php:9000; + client_max_body_size 4m; + fastcgi_read_timeout 120s; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public; + fastcgi_pass crash-php:9000; + fastcgi_read_timeout 120s; + } + + location /app/androidcast_project/crashes/ { + alias /workspace/examples/crash_reporter/backend/public/; + index index.php; + try_files $uri $uri/ @crashes_front; + } + + location @crashes_front { + rewrite ^ /app/androidcast_project/crashes/index.php?$query_string last; + } + + location ~ ^/app/androidcast_project/crashes/(.+\.php)$ { + alias /workspace/examples/crash_reporter/backend/public/$1; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/crash_reporter/backend/public/$1; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/$1; + fastcgi_param DOCUMENT_ROOT /workspace/examples/crash_reporter/backend/public; + fastcgi_pass crash-php:9000; + fastcgi_read_timeout 120s; + } + + location ~ /\. { + deny all; + } +} diff --git a/nginx/gitea-fe.conf b/nginx/gitea-fe.conf new file mode 100644 index 0000000..d38e889 --- /dev/null +++ b/nginx/gitea-fe.conf @@ -0,0 +1,33 @@ +server { + listen 8080; + server_name _; + + client_max_body_size 64m; + + location /app/androidcast_project/git/ { + rewrite ^/app/androidcast_project/git/?(.*)$ /$1 break; + proxy_pass http://gitea:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location / { + proxy_pass http://gitea:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/nginx/landing.conf b/nginx/landing.conf new file mode 100644 index 0000000..50ae006 --- /dev/null +++ b/nginx/landing.conf @@ -0,0 +1,63 @@ +server { + listen 8080; + server_name _; + + client_max_body_size 16m; + + location = / { + return 302 /app/androidcast_project/; + } + + location /app/androidcast_project/git/ { + return 302 http://localhost:8081/; + } + + location /app/androidcast_project/crashes/ { + proxy_pass http://crashes-fe:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /app/androidcast_project/graphs/ { + proxy_pass http://crashes-fe:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /app/androidcast_project/build/ { + proxy_pass http://build-fe:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /app/androidcast_project/v0/ota/ { + alias /workspace/orchestration/runtime/ota-artifacts/v0/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME /workspace/examples/app_hub/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + fastcgi_pass crash-php:9000; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /workspace/examples/app_hub/; + index index.php index.html; + try_files $uri $uri/ =404; + } +} diff --git a/runtime/db-init/.gitkeep b/runtime/db-init/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/runtime/db-init/.gitkeep @@ -0,0 +1 @@ + diff --git a/runtime/db-init/007_remote_access.sql b/runtime/db-init/007_remote_access.sql new file mode 100644 index 0000000..782722e --- /dev/null +++ b/runtime/db-init/007_remote_access.sql @@ -0,0 +1,56 @@ +-- Remote access control plane (WireGuard v1; reverse SSH reserved). +-- MariaDB / production: mysql -u root -p androidcast_crashes < sql/migrations/007_remote_access.sql + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + whitelisted TINYINT(1) NOT NULL DEFAULT 0, + opt_in_mode ENUM('none','wireguard','rssh') NOT NULL DEFAULT 'none', + last_random VARCHAR(96) NULL, + app_version VARCHAR(64) NULL, + last_seen_at TIMESTAMP NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_ra_device (device_id), + KEY idx_ra_device_company (company_id), + KEY idx_ra_device_whitelist (whitelisted, opt_in_mode) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NOT NULL, + device_id VARCHAR(128) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + operator_user_id INT UNSIGNED NULL, + status ENUM('pending','active','inactive','closed','closed_timeout') NOT NULL DEFAULT 'pending', + tunnel ENUM('wireguard','ssh_reverse') NOT NULL DEFAULT 'wireguard', + endpoint VARCHAR(255) NULL, + wg_client_private_key VARCHAR(64) NULL, + wg_client_address VARCHAR(64) NULL, + wg_server_public_key VARCHAR(64) NULL, + wg_peer_allowed_ips VARCHAR(255) NULL, + expires_at INT UNSIGNED NULL, + last_activity_at TIMESTAMP NULL, + close_reason VARCHAR(255) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMP NULL, + UNIQUE KEY uq_ra_session (session_id), + KEY idx_ra_sess_device (device_id, status), + KEY idx_ra_sess_status (status, last_activity_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(36) NULL, + device_id VARCHAR(128) NOT NULL, + actor_user_id INT UNSIGNED NULL, + action VARCHAR(64) NOT NULL, + reason VARCHAR(255) NULL, + meta_json LONGTEXT NULL, + client_ip VARCHAR(64) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_ra_evt_device (device_id, created_at), + KEY idx_ra_evt_session (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/runtime/db-init/007_remote_access.sqlite.sql b/runtime/db-init/007_remote_access.sqlite.sql new file mode 100644 index 0000000..ea95223 --- /dev/null +++ b/runtime/db-init/007_remote_access.sqlite.sql @@ -0,0 +1,50 @@ +-- SQLite dev mirror for remote access tables. + +CREATE TABLE IF NOT EXISTS remote_access_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT NOT NULL UNIQUE, + company_id INTEGER NOT NULL DEFAULT 1, + whitelisted INTEGER NOT NULL DEFAULT 0, + opt_in_mode TEXT NOT NULL DEFAULT 'none', + last_random TEXT NULL, + app_version TEXT NULL, + last_seen_at TEXT NULL, + notes TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS remote_access_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL UNIQUE, + device_id TEXT NOT NULL, + company_id INTEGER NOT NULL DEFAULT 1, + operator_user_id INTEGER NULL, + status TEXT NOT NULL DEFAULT 'pending', + tunnel TEXT NOT NULL DEFAULT 'wireguard', + endpoint TEXT NULL, + wg_client_private_key TEXT NULL, + wg_client_address TEXT NULL, + wg_server_public_key TEXT NULL, + wg_peer_allowed_ips TEXT NULL, + expires_at INTEGER NULL, + last_activity_at TEXT NULL, + close_reason TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT NULL +); + +CREATE TABLE IF NOT EXISTS remote_access_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NULL, + device_id TEXT NOT NULL, + actor_user_id INTEGER NULL, + action TEXT NOT NULL, + reason TEXT NULL, + meta_json TEXT NULL, + client_ip TEXT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_ra_evt_device ON remote_access_events(device_id, created_at); +CREATE INDEX IF NOT EXISTS idx_ra_sess_device ON remote_access_sessions(device_id, status); diff --git a/runtime/db-init/010_schema.sql b/runtime/db-init/010_schema.sql new file mode 100644 index 0000000..0722610 --- /dev/null +++ b/runtime/db-init/010_schema.sql @@ -0,0 +1,127 @@ +-- Fresh MariaDB install for Android Cast crash reporter. +-- Run as root: mysql -u root -p < sql/schema.mariadb.sql +-- App user needs: SELECT, INSERT, UPDATE, DELETE on androidcast_crashes.* + +CREATE DATABASE IF NOT EXISTS androidcast_crashes + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE androidcast_crashes; + +CREATE TABLE IF NOT EXISTS companies ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + slug VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_companies_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('root', 'platform_admin', 'admin', 'viewer') NOT NULL DEFAULT 'viewer', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS company_memberships ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + company_id INT UNSIGNED NOT NULL, + role ENUM('owner', 'admin', 'member') NOT NULL DEFAULT 'member', + permissions_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_membership_user_company (user_id, company_id), + KEY idx_membership_company (company_id), + CONSTRAINT fk_membership_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT fk_membership_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS devices ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL, + external_id VARCHAR(128) NOT NULL, + display_name VARCHAR(256) NULL, + manufacturer VARCHAR(128) NULL, + model VARCHAR(128) NULL, + source ENUM('auto', 'manual', 'barcode') NOT NULL DEFAULT 'auto', + registered_at_ms BIGINT NOT NULL, + last_seen_at_ms BIGINT NOT NULL, + meta_json LONGTEXT NULL, + UNIQUE KEY uq_devices_company_external (company_id, external_id), + KEY idx_devices_company (company_id), + CONSTRAINT fk_devices_company FOREIGN KEY (company_id) REFERENCES companies (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + report_id VARCHAR(128) NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + crash_type VARCHAR(32) NOT NULL, + generated_at_ms BIGINT NOT NULL, + received_at_ms BIGINT NOT NULL, + device_model VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + payload_json LONGTEXT NOT NULL, + tags_json LONGTEXT NOT NULL, + UNIQUE KEY uq_reports_report_id (report_id), + KEY idx_reports_company (company_id), + KEY idx_reports_device (device_id), + KEY idx_reports_generated (generated_at_ms), + KEY idx_reports_received (received_at_ms), + KEY idx_reports_fingerprint (fingerprint), + KEY idx_reports_fp_received (fingerprint, received_at_ms), + CONSTRAINT fk_reports_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_reports_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS report_views ( + user_id INT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NOT NULL, + viewed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_id, report_id), + CONSTRAINT fk_report_views_report + FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS tickets ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + device_id BIGINT UNSIGNED NULL, + ticket_id VARCHAR(128) NOT NULL, + title VARCHAR(256) NOT NULL, + brief TEXT NULL, + body LONGTEXT NOT NULL, + opened_at_ms BIGINT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + owner_username VARCHAR(64) NOT NULL DEFAULT '', + verified_by_username VARCHAR(64) NULL, + device_model VARCHAR(128) NULL, + app_package VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + os_name VARCHAR(64) NULL, + os_version VARCHAR(128) NULL, + rating TINYINT UNSIGNED NOT NULL DEFAULT 0, + tags_json LONGTEXT NOT NULL, + payload_json LONGTEXT NOT NULL, + UNIQUE KEY uq_tickets_ticket_id (ticket_id), + KEY idx_tickets_company (company_id), + KEY idx_tickets_opened (opened_at_ms), + KEY idx_tickets_updated (updated_at_ms), + KEY idx_tickets_owner (owner_username), + CONSTRAINT fk_tickets_company FOREIGN KEY (company_id) REFERENCES companies (id), + CONSTRAINT fk_tickets_device FOREIGN KEY (device_id) REFERENCES devices (id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); + +INSERT IGNORE INTO users (id, username, password_hash, role) +VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root'); +-- default password: admin — change in production + +INSERT IGNORE INTO company_memberships (user_id, company_id, role) +VALUES (1, 1, 'owner'); diff --git a/runtime/db-init/040_ticket_workflow.sql b/runtime/db-init/040_ticket_workflow.sql new file mode 100644 index 0000000..ceee3bd --- /dev/null +++ b/runtime/db-init/040_ticket_workflow.sql @@ -0,0 +1,19 @@ +USE `androidcast_crashes`; +-- Ticket workflow, assignees, comments. Run as MySQL root on existing DBs. +-- mysql -u root -p androidcast_crashes < sql/migrations/004_ticket_workflow.sql + +ALTER TABLE tickets + ADD COLUMN workflow_state VARCHAR(32) NOT NULL DEFAULT 'triage' AFTER tags_json, + ADD COLUMN assignees_json LONGTEXT NOT NULL DEFAULT '[]' AFTER workflow_state; + +CREATE TABLE IF NOT EXISTS ticket_comments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT UNSIGNED NOT NULL, + author_username VARCHAR(64) NOT NULL, + body LONGTEXT NOT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + KEY idx_ticket_comments_ticket (ticket_id), + KEY idx_ticket_comments_created (created_at_ms), + CONSTRAINT fk_ticket_comments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/runtime/db-init/050_ticket_attachments.sql b/runtime/db-init/050_ticket_attachments.sql new file mode 100644 index 0000000..cc898cd --- /dev/null +++ b/runtime/db-init/050_ticket_attachments.sql @@ -0,0 +1,21 @@ +USE `androidcast_crashes`; +-- Ticket attachments: uploaded files + external links (incl. Google URLs). +-- mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql + +CREATE TABLE IF NOT EXISTS ticket_attachments ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + ticket_id BIGINT UNSIGNED NOT NULL, + kind ENUM('file', 'link') NOT NULL, + title VARCHAR(256) NOT NULL DEFAULT '', + external_url TEXT NULL, + provider VARCHAR(32) NOT NULL DEFAULT 'generic', + storage_name VARCHAR(128) NULL, + original_name VARCHAR(256) NULL, + mime_type VARCHAR(128) NULL, + size_bytes BIGINT UNSIGNED NULL, + created_by VARCHAR(64) NOT NULL, + created_at_ms BIGINT NOT NULL, + KEY idx_ticket_attachments_ticket (ticket_id), + KEY idx_ticket_attachments_created (created_at_ms), + CONSTRAINT fk_ticket_attachments_ticket FOREIGN KEY (ticket_id) REFERENCES tickets (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/runtime/db-init/060_build_ecosystem.sql b/runtime/db-init/060_build_ecosystem.sql new file mode 100644 index 0000000..95228cb --- /dev/null +++ b/runtime/db-init/060_build_ecosystem.sql @@ -0,0 +1,31 @@ +USE `androidcast_crashes`; +CREATE TABLE IF NOT EXISTS builds ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + build_code VARCHAR(64) NOT NULL, + status ENUM('queued','running','passed','failed','cancelled') NOT NULL DEFAULT 'queued', + phase VARCHAR(64) NOT NULL DEFAULT 'init', + git_ref VARCHAR(256) NULL, + git_sha VARCHAR(64) NULL, + branch VARCHAR(128) NULL, + pipeline_yaml LONGTEXT NULL, + params_json LONGTEXT NOT NULL, + dockerfile_version VARCHAR(32) NOT NULL DEFAULT '00.01.00.1000', + builder_id VARCHAR(128) NOT NULL DEFAULT 'local-docker', + version_app VARCHAR(32) NULL, + version_code INT NULL, + target_os VARCHAR(32) NOT NULL DEFAULT 'android', + min_api_level INT NOT NULL DEFAULT 24, + ota_channel VARCHAR(64) NULL, + auto_ota TINYINT(1) NOT NULL DEFAULT 0, + auto_deploy TINYINT(1) NOT NULL DEFAULT 0, + log_path VARCHAR(512) NULL, + artifacts_json LONGTEXT NULL, + error_message VARCHAR(512) NULL, + created_by_user_id INT UNSIGNED NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_builds_code (build_code), + KEY idx_builds_status (status), + KEY idx_builds_created (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/runtime/db-init/070_graph_ecosystem.sql b/runtime/db-init/070_graph_ecosystem.sql new file mode 100644 index 0000000..752a59e --- /dev/null +++ b/runtime/db-init/070_graph_ecosystem.sql @@ -0,0 +1,28 @@ +USE `androidcast_crashes`; +CREATE TABLE IF NOT EXISTS graph_sessions ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + session_id VARCHAR(96) NOT NULL, + company_id INT UNSIGNED NOT NULL DEFAULT 1, + user_id INT UNSIGNED NULL, + device_id VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + sdk_int INT NULL, + started_at_ms BIGINT NOT NULL, + ended_at_ms BIGINT NOT NULL, + duration_s INT NOT NULL, + completed TINYINT(1) NOT NULL DEFAULT 1, + direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender', + transport VARCHAR(24) NOT NULL DEFAULT 'wifi', + avg_kbps INT NOT NULL DEFAULT 0, + recv_kbps INT NOT NULL DEFAULT 0, + packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0, + ntp_source VARCHAR(32) NOT NULL DEFAULT 'device', + ntp_correction_s INT NULL, + install_source VARCHAR(32) NOT NULL DEFAULT 'direct', + meta_json LONGTEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_graph_session_id (session_id), + KEY idx_graph_company_time (company_id, started_at_ms), + KEY idx_graph_device (device_id), + KEY idx_graph_app_version (app_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/apply-hosts.sh b/scripts/apply-hosts.sh new file mode 100755 index 0000000..410d24f --- /dev/null +++ b/scripts/apply-hosts.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-add}" +HOSTS_FILE="/etc/hosts" +BEGIN_MARK="# >>> androidcast-local >>>" +END_MARK="# <<< androidcast-local <<<" + +BLOCK=$(cat <<'EOF' +# >>> androidcast-local >>> +127.0.0.1 apps.androidcast.local +127.0.0.1 git.androidcast.local +127.0.0.1 crashes.androidcast.local +# <<< androidcast-local <<< +EOF +) + +if [[ "$MODE" == "remove" ]]; then + sudo awk -v begin="$BEGIN_MARK" -v end="$END_MARK" ' + $0==begin {skip=1; next} + $0==end {skip=0; next} + !skip {print} + ' "$HOSTS_FILE" | sudo tee "$HOSTS_FILE" >/dev/null + echo "Removed androidcast-local hosts entries." + exit 0 +fi + +if grep -Fq "$BEGIN_MARK" "$HOSTS_FILE"; then + echo "Entries already exist in $HOSTS_FILE" + exit 0 +fi + +printf "\n%s\n" "$BLOCK" | sudo tee -a "$HOSTS_FILE" >/dev/null +echo "Added androidcast-local hosts entries." diff --git a/sim/cluster0/ARCHITECTURE.md b/sim/cluster0/ARCHITECTURE.md new file mode 100644 index 0000000..f3eb8d9 --- /dev/null +++ b/sim/cluster0/ARCHITECTURE.md @@ -0,0 +1,77 @@ +# cast01–cast03 lab architecture + +## Goals + +Simulate production patterns on three isolated Alpine VMs with **shared config only** on NFS (`/shared/cluster/`). No database files on NFS — each node owns `/var/lib/mysql` locally. + +## Topology + +```text + ┌─────────────────────────────────────┐ + │ FE 10.7.0.10 (NFS export /shared) │ + └──────────────────┬──────────────────┘ + │ NFS (configs/scripts only) + ┌─────────────────────────────┼─────────────────────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────┐ + │ cast01 │ async GTID │ cast02 │ async GTID │ cast03 │ + │ .16.236 │◄──────────────│ .16.237 │◄──────────────│ .16.238 │ + │ PRIMARY │ replica │ REPLICA │ replica │ REPLICA │ + │ MariaDB │ │ MariaDB │ │ MariaDB │ + │ nginx/php │ │ nginx/php │ │ nginx/php │ + └───────────┘ └───────────┘ └───────────┘ + ▲ │ │ + └──────── writes + default reads ────────────────────────────┘ + (replicas: read-only, failover drill) +``` + +| Node | IP | MariaDB | App role (planned) | +|------|-----|---------|-------------------| +| cast01 | 10.7.16.236 | **Primary** (read/write) | Crashes console primary target | +| cast02 | 10.7.16.237 | Replica (read_only) | App worker / read replica | +| cast03 | 10.7.16.238 | Replica (read_only) | App worker / read replica | + +## MariaDB + +- **Engine:** MariaDB 11.8 (Alpine 3.24 packages — no edge required) +- **Replication:** GTID async primary → two replicas (`server-id` 1/2/3) +- **Bootstrap:** app DB dump over TCP (`--skip-ssl`); GTID position copied from primary; `replicate-do-db` filters ongoing binlog to app schemas only (avoids `mysql.help_topic` dump failures) +- **Databases:** `androidcast_crashes`, `url_shortener` (schemas from project SQL in `/shared/cluster/sql/`) +- **Credentials:** see `credentials.txt` (same lab password on all nodes) + +## PHP + +- **Version:** 8.3 (`php83-fpm`) on all nodes — matches modern Alpine; prod BE remains 8.1 until upgraded separately +- **Socket:** `127.0.0.1:9000` + +## DNS + +Pending external records. Until then: `/etc/hosts` block maintained by `baseline.sh`. + +## Service split (selected for lab) + +| Service | Write DB host | Read DB host (lab default) | Future app path (local disk) | +|---------|---------------|----------------------------|------------------------------| +| Crashes / tickets / RA | cast01 | cast01 (cast02/03 for drill) | `/var/www/androidcast/` per node | +| URL shortener API | cast01 | cast01 | `/var/www/url-shortener/` on cast03 | +| Graphs ingest | cast01 | cast01 | shared crashes backend | + +App deploys stay on **local VM disk**, not NFS. + +## Operations + +```sh +sudo sh /shared/cluster/scripts/baseline.sh # per-node baseline +sudo sh /shared/cluster/scripts/mariadb-primary.sh # cast01 only +sudo sh /shared/cluster/scripts/load-schemas.sh # cast01 only +sudo sh /shared/cluster/scripts/mariadb-replica.sh # cast02 + cast03 +sudo sh /shared/cluster/scripts/verify-cluster.sh # any node +``` + +NFS mount may be `noexec` — always invoke scripts with `sh`. + +## Not in scope yet + +- ProxySQL / MaxScale (add if read splitting becomes necessary) +- Galera multi-primary (overkill for 3-node lab; async matches prod BE migration path) +- TLS between nodes (intra VLAN) diff --git a/sim/cluster0/DEPLOY.md b/sim/cluster0/DEPLOY.md new file mode 100644 index 0000000..4bfb5af --- /dev/null +++ b/sim/cluster0/DEPLOY.md @@ -0,0 +1,108 @@ +# cast cluster — unattended populate & verify + +This directory is the **single source of truth** for lab/staging cluster deployment. +Store scripts, configs, SQL, and credentials here only — **never** MariaDB datadirs or dumps. + +## Fresh cluster workflow (cluster0 → clusterN) + +### 0. FE: create NFS export + seed this tree + +Copy or rsync this entire `cluster/` tree to the FE export, e.g.: + +`10.7.0.10:/mnt/raid0/xendomains/domU_cast_cluster_0/shared/cluster/` + +Edit `cluster.env` for the new cluster (IPs, export path, `CLUSTER_NAME`, `PUBLIC_ORIGIN`). + +### 1. Create 3 empty Alpine VMs + +Each VM needs hostname `cast01` / `cast02` / `cast03` and static IP from `cluster.env`. + +Copy bootstrap env once (before NFS is mounted): + +```sh +sudo install -m 644 bootstrap/cluster0-bootstrap.env /etc/cast-cluster.env +``` + +### 2. Populate (unattended) + +**Option A — coordinator** (from a host with passwordless SSH to all cast nodes — typically your workstation, not cast01): + +```sh +ssh ai@cast01 # or run from laptop with SSH config for cast01–03 +sudo sh /shared/cluster/populate_lab_setup.sh --coordinator +``` + +If inter-node SSH is not configured, run **Option B** on each VM instead; cluster verify uses HTTP checks to peer IPs. + +**Option B — per node** (parallel OK; replicas wait for primary): + +```sh +sudo sh /shared/cluster/populate_lab_setup.sh +``` + +Phases: + +| Phase | What | +|-------|------| +| `baseline` | NFS, hosts, `apk upgrade`, nginx/php83, packages | +| `db` | cast01: GTID primary + schemas; cast02/03: replicas | +| `app` | git/bundle deploy, nginx vhost, lab `config.php` | +| `all` | baseline → db → app → `verify_lab_setup.sh --local` | + +Always invoke with `sh` — NFS may be mounted `noexec`. + +### 3. DNS + FE redirect + +Update external DNS and FE nginx upstream to point at the new cluster (`FE_PROXY_TARGET` in `cluster.env`). + +### 4. Global verify + +```sh +sudo sh /shared/cluster/verify_global_setup.sh +``` + +Checks `PUBLIC_ORIGIN` paths (crashes UI, assets, diag, short links). + +## Staging / cloud + +Same scripts; change only `cluster.env`: + +- `FE_NFS_*` → object storage or config bucket mount +- `APP_SOURCE=bundle` + `release/current/` tarball from CI +- `PUBLIC_ORIGIN` → staging URL +- IPs / hostnames → cloud private network + +## Files + +| File | Purpose | +|------|---------| +| `cluster.env` | Cluster-specific variables (IPs, NFS, URLs, git) | +| `credentials.txt` | Shared lab passwords | +| `populate_lab_setup.sh` | Main unattended installer | +| `verify_lab_setup.sh` | Internal cluster health | +| `verify_global_setup.sh` | Post-DNS public checks | +| `scripts/` | Baseline, MariaDB, deploy, nginx | +| `sql/` | Schema + migrations | +| `nginx/apps-port80.conf` | php83-fpm TCP vhost | +| `bootstrap/` | Minimal `/etc/cast-cluster.env` for first boot | + +See `ARCHITECTURE.md` for topology. + +## Journal (read from FE if VMs fail) + +Every populate/verify step appends to **`/shared/journal/{CLUSTER_NAME}.journal.tsv`** with status **OK**, **NOK**, **WARN**, or **START**. + +Human summary (regenerated automatically): + +```sh +less /shared/journal/cluster0.summary.md +grep NOK /shared/journal/cluster0.journal.tsv +``` + +On FE host: + +```sh +less /mnt/raid0/xendomains/domU_cast_cluster_0/shared/journal/cluster0.summary.md +``` + +See `/shared/journal/README.md`. VM disk snapshots are out-of-band (ask sysop) — not stored on NFS. diff --git a/sim/cluster0/README.md b/sim/cluster0/README.md new file mode 100644 index 0000000..257766a --- /dev/null +++ b/sim/cluster0/README.md @@ -0,0 +1,17 @@ +# cast01–cast03 lab cluster + +**Runbook:** [DEPLOY.md](DEPLOY.md) — unattended populate & verify. + +Quick start on a fresh node: + +```sh +sudo install -m 644 bootstrap/cluster0-bootstrap.env /etc/cast-cluster.env # once, before NFS +sudo sh /shared/cluster/populate_lab_setup.sh # or --coordinator +sudo sh /shared/cluster/verify_lab_setup.sh --local +# after DNS: +sudo sh /shared/cluster/verify_global_setup.sh +``` + +See `ARCHITECTURE.md` for topology and `cluster.env` for cluster-specific settings. + +**Journal:** every setup action is logged under `/shared/journal/` with **OK** / **NOK** — readable from FE if VMs are down. See `/shared/journal/README.md`. diff --git a/sim/cluster0/bootstrap/cluster0-bootstrap.env b/sim/cluster0/bootstrap/cluster0-bootstrap.env new file mode 100644 index 0000000..db504f5 --- /dev/null +++ b/sim/cluster0/bootstrap/cluster0-bootstrap.env @@ -0,0 +1,5 @@ +# Copy to /etc/cast-cluster.env on each fresh VM before first populate run. +# Only needed to mount NFS when /shared/cluster/cluster.env is not reachable yet. +FE_NFS_SERVER=10.7.0.10 +FE_NFS_EXPORT_PATH=/mnt/raid0/xendomains/domU_cast_cluster_0/shared +SHARED_MOUNT=/shared diff --git a/sim/cluster0/cluster.env b/sim/cluster0/cluster.env new file mode 100644 index 0000000..58a73e7 --- /dev/null +++ b/sim/cluster0/cluster.env @@ -0,0 +1,48 @@ +# cluster0 — cast01–cast03 lab (edit for new clusters: cluster1, staging, etc.) +CLUSTER_NAME=cluster0 +CLUSTER_ID=0 + +FE_NFS_SERVER=10.7.0.10 +FE_NFS_EXPORT_PATH=/mnt/raid0/xendomains/domU_cast_cluster_0/shared +SHARED_MOUNT=/shared + +CAST01_HOST=cast01 +CAST01_IP=10.7.16.236 +CAST02_HOST=cast02 +CAST02_IP=10.7.16.237 +CAST03_HOST=cast03 +CAST03_IP=10.7.16.238 +CAST_DOMAIN=intra.raptor.org + +PRIMARY_DB_HOST=cast01 +PRIMARY_DB_PORT=3306 + +ARTC0_HOST=artc0 +ARTC0_IP=10.7.16.128 + +SSH_USER=ai + +GIT_ORIGIN=git://f0xx.org/android_cast +GIT_BRANCH=next +# git | bundle — bundle uses SHARED_MOUNT/cluster/release/current/ +APP_SOURCE=git + +APP_ROOT=/var/www/localhost/htdocs/apps/app/androidcast_project +APP_BASE_PATH=/app/androidcast_project/crashes +APP_HUB_PATH=/app/androidcast_project + +# Set after DNS + FE redirect, then run verify_global_setup.sh +PUBLIC_ORIGIN=https://apps.f0xx.org +SHORT_LINKS_PUBLIC_BASE=https://s.f0xx.org +FE_PROXY_TARGET=cast01.intra.raptor.org:80 + +# Lab DNS (2026-06): acl0.f0xx.org + c1–c3.acl0.f0xx.org → FE (cast01–03 cluster) +ACL0_CNAME=acl0.f0xx.org +CAST01_PUBLIC=c1.acl0.f0xx.org +CAST02_PUBLIC=c2.acl0.f0xx.org +CAST03_PUBLIC=c3.acl0.f0xx.org + +EXPECTED_CRASHES_TABLES=16 +EXPECTED_URL_SHORTENER_TABLES=4 + +APK_PACKAGES_BASE="nginx php83 php83-fpm php83-cli php83-session php83-pdo php83-pdo_mysql php83-pdo_sqlite php83-sqlite3 php83-mbstring php83-json php83-curl php83-openssl php83-xml php83-zip php83-phar php83-opcache mariadb-client git rsync curl bash ca-certificates sqlite wireguard-tools nfs-utils openssh-client" diff --git a/sim/cluster0/credentials.txt b/sim/cluster0/credentials.txt new file mode 100644 index 0000000..655d275 --- /dev/null +++ b/sim/cluster0/credentials.txt @@ -0,0 +1,22 @@ +# cast cluster lab credentials (NOT production — shared intentionally for lab safety) +# Same values on cast01, cast02, cast03 unless noted. + +[mariadb_root] +# Alpine default: unix socket auth as root, no password + +[mariadb_app] +user=androidcast +password=cast-cluster-dev +host_write=cast01 +host_read=cast01 +port=3306 +databases=androidcast_crashes,url_shortener + +[mariadb_replication] +user=repl +password=cast-cluster-dev +allowed_from=10.7.16.% + +[ssh] +user=ai +sudo=passwordless diff --git a/sim/cluster0/journal/README.md b/sim/cluster0/journal/README.md new file mode 100644 index 0000000..f77d54e --- /dev/null +++ b/sim/cluster0/journal/README.md @@ -0,0 +1,55 @@ +# Cluster setup journal (`/shared/journal/`) + +Human-readable audit trail for populate / verify runs. **Read from FE** when VMs are down or after reboot failure — no SSH to cast nodes required. + +## Files (per cluster) + +| File | Purpose | +|------|---------| +| `{CLUSTER_NAME}.journal.tsv` | Append-only machine log (tab-separated) | +| `{CLUSTER_NAME}.summary.md` | Regenerated table with **OK** / **NOK** / **WARN** | +| `README.md` | This file | + +Example for cluster0: + +``` +/shared/journal/cluster0.journal.tsv +/shared/journal/cluster0.summary.md +``` + +## TSV columns + +``` +timestamp_utc host actor phase action status detail +``` + +**status:** `START` → work begun; `OK` / `NOK` / `WARN` → outcome. + +**actor:** script name (`populate`, `verify_lab`, `verify_global`, `baseline`, …). + +## Who writes entries + +All cluster scripts call `journal_*` helpers in `scripts/lib/common.sh`: + +- `populate_lab_setup.sh` — each phase and sub-script +- `verify_lab_setup.sh` / `verify_global_setup.sh` — pass/fail +- `run_script` — every `scripts/*.sh` invocation + +## Read from FE (10.7.0.10) + +```sh +less /mnt/raid0/xendomains/domU_cast_cluster_0/shared/journal/cluster0.summary.md +grep NOK /mnt/raid0/xendomains/domU_cast_cluster_0/shared/journal/cluster0.journal.tsv +``` + +## Rebuild summary manually + +```sh +sudo sh /shared/cluster/scripts/journal-rebuild-summary.sh +``` + +## Snapshots + +If reboot breaks a node and journal shows last **OK** before power-loss, ask sysop for **cluster snapshot restore** (XEN/HVM snapshot — out of band, not stored on NFS). + +Do **not** store VM disk images or MariaDB datadirs in `/shared/`. diff --git a/sim/cluster0/mariadb/primary.cnf b/sim/cluster0/mariadb/primary.cnf new file mode 100644 index 0000000..0f0a0b0 --- /dev/null +++ b/sim/cluster0/mariadb/primary.cnf @@ -0,0 +1,10 @@ +[mysqld] +# cast01 — MariaDB primary (GTID async replication) +server-id=1 +log_bin=mysql-bin +binlog_format=ROW +gtid_domain_id=1 +log_slave_updates=1 +expire_logs_days=7 +bind-address=0.0.0.0 +read_only=0 diff --git a/sim/cluster0/mariadb/replica.cnf b/sim/cluster0/mariadb/replica.cnf new file mode 100644 index 0000000..80fae09 --- /dev/null +++ b/sim/cluster0/mariadb/replica.cnf @@ -0,0 +1,11 @@ +[mysqld] +# cast02/cast03 — MariaDB replica (server-id set by mariadb-replica.sh) +log_bin=mysql-bin +binlog_format=ROW +gtid_domain_id=1 +log_slave_updates=1 +bind-address=0.0.0.0 +read_only=1 +relay_log=relay-bin +replicate-do-db=androidcast_crashes +replicate-do-db=url_shortener diff --git a/sim/cluster0/nginx/apps-port80.conf b/sim/cluster0/nginx/apps-port80.conf new file mode 100644 index 0000000..5889903 --- /dev/null +++ b/sim/cluster0/nginx/apps-port80.conf @@ -0,0 +1,119 @@ +# Full androidcast vhost for Alpine BE — listen 80 only. +# FE (apps.f0xx.org) proxies here: proxy_pass http://artc0.intra.raptor.org:80; +# Port 8089 is a different service (e.g. Janus) — not this vhost. + +server { + listen 80 default_server; + listen [::]:80 default_server; + + # OTA v0 static tree — devices fetch https://apps.f0xx.org/v0/ota/channel/stable.json + # Populate: builder auto_deploy, or rsync out/ota/v0/ → .../ota-artifacts/v0/ + location ^~ /v0/ota/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/ota-artifacts/v0/ota/; + add_header Cache-Control "public, max-age=60"; + } + + location = /app/androidcast_project { + return 301 /app/androidcast_project/; + } + + location = /app/androidcast_project/index.php { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/index.php; + } + + location = /app/androidcast_project/ { + rewrite ^ /app/androidcast_project/index.php last; + } + + location /app/androidcast_project/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/; + index index.php index.html; + try_files $uri $uri/ =404; + } + + location = /app/androidcast_project/crashes { + return 301 /app/androidcast_project/crashes/; + } + + location = /app/androidcast_project/graphs { + return 301 /app/androidcast_project/graphs/; + } + + location ^~ /app/androidcast_project/crashes/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location ^~ /app/androidcast_project/graphs/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/; + } + + location = /app/androidcast_project/crashes/api/upload.php { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/crashes/ { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/graphs/api/graphs.php { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/graphs/api/graph_upload.php { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location ^~ /app/androidcast_project/graphs/ { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 4m; + } + + location = /app/androidcast_project/build { + return 301 /app/androidcast_project/build/; + } + + location ^~ /app/androidcast_project/build/assets/ { + alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/assets/; + } + + location ^~ /app/androidcast_project/build/ { + include fastcgi_params; + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/build_console/backend/public/index.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/build/index.php; + fastcgi_param REQUEST_URI $request_uri; + client_max_body_size 512m; + fastcgi_read_timeout 1800s; + } + + location ~ \.php$ { + include /etc/nginx/fastcgi.conf; + fastcgi_pass 127.0.0.1:9000; + } +} diff --git a/sim/cluster0/populate_lab_setup.sh b/sim/cluster0/populate_lab_setup.sh new file mode 100644 index 0000000..4a66f63 --- /dev/null +++ b/sim/cluster0/populate_lab_setup.sh @@ -0,0 +1,136 @@ +#!/bin/sh +# Unattended cast cluster population — dev lab and staging template. +# Usage: +# sudo sh populate_lab_setup.sh # full setup on this node +# sudo sh populate_lab_setup.sh --coordinator # from jump host: all nodes in order +# sudo sh populate_lab_setup.sh --phase baseline|db|app|all +set -eu + +ROOT="$(cd "$(dirname "$0")" && pwd)" +export CAST_CLUSTER_ROOT="$ROOT" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +export JOURNAL_ACTOR=populate + +PHASE=all +COORDINATOR=0 + +while [ $# -gt 0 ]; do + case "$1" in + --coordinator) COORDINATOR=1; shift ;; + --phase) PHASE="${2:-all}"; shift 2 ;; + --help|-h) + echo "Usage: sudo sh populate_lab_setup.sh [--coordinator] [--phase baseline|db|app|all]" + exit 0 + ;; + *) die "unknown arg: $1" ;; + esac +done + +run_phase_baseline() { + JOURNAL_PHASE=baseline + journal_start phase_baseline "$(host_short)" + log "phase baseline on $(host_short)" + journal_start apk_upgrade + apk update + apk upgrade -U -a || apk upgrade -U || true + journal_ok apk_upgrade + run_script "$ROOT/scripts/baseline.sh" + journal_ok phase_baseline +} + +run_phase_db() { + JOURNAL_PHASE=db + journal_start phase_db "$(host_short)" + if is_primary_node; then + log "phase db primary on $(host_short)" + run_script "$ROOT/scripts/mariadb-primary.sh" + run_script "$ROOT/scripts/load-schemas.sh" + journal_ok phase_db "primary" + return + fi + if is_replica_node; then + log "phase db replica on $(host_short) — waiting for primary" + wait_for_primary_db + wait_for_tcp "$PRIMARY_DB_HOST" "$PRIMARY_DB_PORT" 300 + run_script "$ROOT/scripts/mariadb-replica.sh" + journal_ok phase_db "replica" + return + fi + journal_nok phase_db "unknown host" + die "unknown host for db phase: $(host_short)" +} + +run_phase_app() { + JOURNAL_PHASE=app + journal_start phase_app "$(host_short)" + log "phase app on $(host_short)" + if is_replica_node; then + wait_for_primary_db + fi + run_script "$ROOT/scripts/deploy-app.sh" + run_script "$ROOT/scripts/configure-nginx.sh" + rc-service php-fpm83 restart 2>/dev/null || true + rc-service nginx reload 2>/dev/null || rc-service nginx restart 2>/dev/null || true + if is_primary_node; then + rc-service mariadb restart 2>/dev/null || true + elif is_replica_node; then + rc-service mariadb restart 2>/dev/null || true + fi + journal_ok phase_app +} + +run_local() { + ensure_shared_mounted + journal_start populate_local "phase=${PHASE}" + case "$PHASE" in + baseline) run_phase_baseline ;; + db) run_phase_baseline; run_phase_db ;; + app) run_phase_app ;; + all) + run_phase_baseline || exit 1 + run_phase_db || exit 1 + run_phase_app || exit 1 + run_script "$ROOT/verify_lab_setup.sh" --local || exit 1 + ;; + *) journal_nok populate_local "unknown phase"; die "unknown phase: $PHASE" ;; + esac + journal_ok populate_local "phase=${PHASE}" +} + +run_coordinator() { + ensure_shared_mounted + journal_start populate_coordinator "nodes=${ALL_CAST_HOSTS}" + log "coordinator: ${CLUSTER_NAME} nodes=${ALL_CAST_HOSTS}" + + for H in $CAST01_HOST $CAST02_HOST $CAST03_HOST; do + ssh_node "$H" "sudo sh ${SHARED_MOUNT}/cluster/populate_lab_setup.sh --phase baseline" & + done + wait || die "baseline failed on one or more nodes" + + ssh_node "$CAST01_HOST" "sudo sh ${SHARED_MOUNT}/cluster/populate_lab_setup.sh --phase db" + + for H in $CAST02_HOST $CAST03_HOST; do + ssh_node "$H" "sudo sh ${SHARED_MOUNT}/cluster/populate_lab_setup.sh --phase db" & + done + wait + + for H in $CAST01_HOST $CAST02_HOST $CAST03_HOST; do + ssh_node "$H" "sudo sh ${SHARED_MOUNT}/cluster/populate_lab_setup.sh --phase app" & + done + wait + + sh "$ROOT/verify_lab_setup.sh" --cluster + journal_ok populate_coordinator + log "populate_coordinator_ok ${CLUSTER_NAME}" +} + +if [ "$(id -u)" -ne 0 ]; then + die "run as root (sudo sh populate_lab_setup.sh)" +fi + +if [ "$COORDINATOR" -eq 1 ]; then + run_coordinator +else + run_local +fi diff --git a/sim/cluster0/scripts/baseline.sh b/sim/cluster0/scripts/baseline.sh new file mode 100644 index 0000000..4967176 --- /dev/null +++ b/sim/cluster0/scripts/baseline.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# cast cluster baseline — packages, NFS, hosts, nginx/php smoke vhost. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +ensure_shared_mounted + +HOST="$(host_short)" +STAGE="$(mktemp -d /var/tmp/cast-cluster.XXXXXX)" +trap 'rm -rf "$STAGE"' EXIT + +log "baseline start on $HOST" + +grep -v "${FE_NFS_EXPORT_PATH}" /etc/fstab > "$STAGE/fstab.new" || true +echo "$FSTAB_NFS_LINE" >> "$STAGE/fstab.new" +mv "$STAGE/fstab.new" /etc/fstab +mkdir -p "$SHARED_MOUNT" +rc-update add nfsmount boot 2>/dev/null || true +mount "$SHARED_MOUNT" 2>/dev/null || mount -a 2>/dev/null || true + +grep -v '# cast-cluster' /etc/hosts | grep -v 'cast0[123]\.' | grep -v 'artc0\.' > "$STAGE/hosts.new" || cp /etc/hosts "$STAGE/hosts.new" +cat >> "$STAGE/hosts.new" </dev/null || true +rc-update add php-fpm83 default 2>/dev/null || true +run_script "$ROOT/scripts/configure-php-fpm.sh" +rc-service php-fpm83 restart 2>/dev/null || rc-service php-fpm83 start 2>/dev/null || true +rc-service nginx start 2>/dev/null || true + +if is_primary_node; then + grep -q '^skip-networking' /etc/my.cnf.d/mariadb-server.cnf 2>/dev/null && \ + sed -i 's/^skip-networking/#skip-networking/' /etc/my.cnf.d/mariadb-server.cnf || true + grep -q '^bind-address' /etc/my.cnf.d/mariadb-server.cnf 2>/dev/null || \ + printf '\n[mysqld]\nbind-address = 0.0.0.0\n' >> /etc/my.cnf.d/mariadb-server.cnf + rc-update add mariadb default 2>/dev/null || true + rc-service mariadb restart 2>/dev/null || rc-service mariadb start 2>/dev/null || true +fi + +mkdir -p /var/www/localhost/htdocs +echo "

${HOST}

${CLUSTER_NAME} node

" > /var/www/localhost/htdocs/index.html +chown -R nginx:nginx /var/www/localhost/htdocs 2>/dev/null || true + +mkdir -p "$ROOT" +IP="$(ip -4 -o addr show eth0 2>/dev/null | awk '{print $4}' | cut -d/ -f1)" +echo "$HOST $(date -Iseconds) ip=$IP cluster=$CLUSTER_NAME" >> "$ROOT/seen.log" +printf '{"host":"%s","ip":"%s","cluster":"%s","configured_at":"%s"}\n' \ + "$HOST" "$IP" "$CLUSTER_NAME" "$(date -Iseconds)" > "$ROOT/${HOST}.json" + +mount | grep -q " on ${SHARED_MOUNT} " || die "NFS not mounted" +curl -sS -o /dev/null -w "nginx_http=%{http_code}\n" http://127.0.0.1/ +log "baseline_ok $HOST" diff --git a/sim/cluster0/scripts/configure-nginx.sh b/sim/cluster0/scripts/configure-nginx.sh new file mode 100644 index 0000000..ff5a889 --- /dev/null +++ b/sim/cluster0/scripts/configure-nginx.sh @@ -0,0 +1,12 @@ +#!/bin/sh +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +ensure_shared_mounted + +install -D -m 644 "$ROOT/nginx/apps-port80.conf" /etc/nginx/http.d/androidcast.conf +rm -f /etc/nginx/http.d/default.conf /etc/nginx/http.d/cluster.conf 2>/dev/null || true +nginx -t +rc-service nginx reload +log "nginx androidcast vhost installed" diff --git a/sim/cluster0/scripts/configure-php-fpm.sh b/sim/cluster0/scripts/configure-php-fpm.sh new file mode 100644 index 0000000..dd08d37 --- /dev/null +++ b/sim/cluster0/scripts/configure-php-fpm.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +ensure_shared_mounted + +CONF=/etc/php83/php-fpm.d/www.conf +grep -q '^listen = 127.0.0.1:9000' "$CONF" 2>/dev/null || \ + sed -i 's|^listen = .*|listen = 127.0.0.1:9000|' "$CONF" +grep -q '^listen.allowed_clients' "$CONF" 2>/dev/null || \ + printf '\nlisten.allowed_clients = 127.0.0.1\n' >> "$CONF" +log "php-fpm83 listen 127.0.0.1:9000" diff --git a/sim/cluster0/scripts/deploy-app.sh b/sim/cluster0/scripts/deploy-app.sh new file mode 100644 index 0000000..1659085 --- /dev/null +++ b/sim/cluster0/scripts/deploy-app.sh @@ -0,0 +1,109 @@ +#!/bin/sh +# Deploy androidcast project tree + lab config.php (local disk, not NFS). +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +ensure_shared_mounted + +APP_PASS="$(read_cred mariadb_app password)" +DB_HOST="$PRIMARY_DB_HOST" +if is_primary_node; then + DB_HOST=127.0.0.1 +fi + +mkdir -p "$(dirname "$APP_ROOT")" +GIT_ROOT="${APP_ROOT}/android_cast" +BACKEND="${GIT_ROOT}/examples/crash_reporter/backend" +CONFIG="${BACKEND}/config/config.php" + +case "$APP_SOURCE" in + git) + if [ -d "${GIT_ROOT}/.git" ]; then + log "git pull ${GIT_BRANCH} in ${GIT_ROOT}" + git config --global --add safe.directory "$GIT_ROOT" 2>/dev/null || true + git -C "$GIT_ROOT" fetch origin + git -C "$GIT_ROOT" checkout "$GIT_BRANCH" + git -C "$GIT_ROOT" pull --ff-only origin "$GIT_BRANCH" + else + log "git clone ${GIT_ORIGIN} → ${GIT_ROOT}" + rm -rf "$GIT_ROOT" + git clone --branch "$GIT_BRANCH" --depth 1 "$GIT_ORIGIN" "$GIT_ROOT" + git config --global --add safe.directory "$GIT_ROOT" 2>/dev/null || true + fi + ;; + bundle) + BUNDLE="${ROOT}/release/current" + [ -d "$BUNDLE" ] || die "missing bundle dir $BUNDLE (APP_SOURCE=bundle)" + log "rsync bundle $BUNDLE → ${APP_ROOT}" + mkdir -p "$APP_ROOT" + rsync -a --delete "$BUNDLE/" "$APP_ROOT/" + ;; + *) + die "unknown APP_SOURCE=$APP_SOURCE" + ;; +esac + +mkdir -p "${BACKEND}/config" "${BACKEND}/data" "${BACKEND}/storage" +chown -R nginx:nginx "$APP_ROOT" 2>/dev/null || true + +if [ ! -f "$CONFIG" ] || [ "${FORCE_CONFIG:-1}" = "1" ]; then + log "writing lab config.php" + cat > "$CONFIG" < 'Android Cast Issues', + 'base_path' => '${APP_BASE_PATH}', + 'db' => [ + 'driver' => 'mariadb', + 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', + 'mysql' => [ + 'host' => '${DB_HOST}', + 'port' => ${PRIMARY_DB_PORT}, + 'socket' => '', + 'database' => 'androidcast_crashes', + 'username' => 'androidcast', + 'password' => '${APP_PASS}', + 'charset' => 'utf8mb4', + ], + ], + 'session_name' => 'ac_crash_sess', + 'session_cookie_path' => '${APP_HUB_PATH}', + 'debug' => true, + 'rbac' => [ + 'default_company_slug' => 'default', + 'default_company_id' => 1, + ], + 'analytics' => ['enabled' => false, 'measurement_id' => '', 'debug' => false], + 'remote_access' => [ + 'wg_endpoint' => '', + 'wg_server_public_key' => '', + 'provision_peers' => false, + 'require_wg_tools' => false, + ], + 'auth' => [ + 'encryption_key' => 'lab-cluster-dev-32-char-min-secret!!', + 'max_attempts' => 8, + 'lockout_window_minutes' => 15, + ], + 'url_shortener' => [ + 'enabled' => true, + 'public_base' => '${SHORT_LINKS_PUBLIC_BASE}', + 'db' => [ + 'mysql' => [ + 'host' => '${DB_HOST}', + 'port' => ${PRIMARY_DB_PORT}, + 'socket' => '', + 'database' => 'url_shortener', + 'username' => 'androidcast', + 'password' => '${APP_PASS}', + 'charset' => 'utf8mb4', + ], + ], + ], +]; +PHP + chown nginx:nginx "$CONFIG" 2>/dev/null || true +fi + +log "deploy-app_ok $(host_short)" diff --git a/sim/cluster0/scripts/journal-backfill-cluster0.sh b/sim/cluster0/scripts/journal-backfill-cluster0.sh new file mode 100644 index 0000000..6f6b67b --- /dev/null +++ b/sim/cluster0/scripts/journal-backfill-cluster0.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# One-time backfill of cluster0 journal from known-good state (2026-06-13 session). +# Safe to re-run: appends only if journal is empty. +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +ensure_shared_mounted +journal_init + +if [ -f "$JOURNAL_TSV" ] && [ "$(wc -l < "$JOURNAL_TSV")" -gt 1 ]; then + log "journal already has entries — skip backfill (see $JOURNAL_TSV)" + sh "$ROOT/scripts/journal-rebuild-summary.sh" + exit 0 +fi + +export JOURNAL_ACTOR=backfill +TS="2026-06-13T12:00:00+00:00" + +append() { + _host="$1" + _phase="$2" + _action="$3" + _status="$4" + _detail="$5" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$TS" "$_host" "$JOURNAL_ACTOR" "$_phase" "$_action" "$_status" "$_detail" >> "$JOURNAL_TSV" +} + +# Infrastructure (all nodes) +for H in cast01 cast02 cast03; do + append "$H" baseline nfs_mount OK "/shared from FE export" + append "$H" baseline packages OK "nginx php83-fpm mariadb-client nfs-utils" + append "$H" baseline hosts OK "cast-cluster /etc/hosts block" +done + +# MariaDB +append cast01 db mariadb-primary OK "GTID primary server-id=1" +append cast01 db load-schemas OK "16 crashes + 4 url_shortener tables" +append cast02 db mariadb-replica OK "GTID replica IO+SQL running" +append cast03 db mariadb-replica OK "GTID replica IO+SQL running" + +# App + nginx +for H in cast01 cast02 cast03; do + append "$H" app deploy-app OK "git next → android_cast/" + append "$H" app configure-nginx OK "apps-port80.conf php83:9000" +done + +# Verification +append cast01 verify verify_lab OK "mode=local" +append cast02 verify verify_lab OK "mode=local" +append cast03 verify verify_lab OK "mode=local" +append cast01 verify verify_lab OK "mode=cluster HTTP peers" +append cast01 verify verify_global START "pending DNS/FE cutover" + +sh "$ROOT/scripts/journal-rebuild-summary.sh" +log "backfill_ok → $JOURNAL_TSV" diff --git a/sim/cluster0/scripts/journal-rebuild-summary.sh b/sim/cluster0/scripts/journal-rebuild-summary.sh new file mode 100644 index 0000000..41cd5f3 --- /dev/null +++ b/sim/cluster0/scripts/journal-rebuild-summary.sh @@ -0,0 +1,35 @@ +#!/bin/sh +# Regenerate human-readable OK/NOK summary from journal TSV (safe on FE via NFS read). +set -eu +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +load_cluster_env +journal_init + +{ + echo "# ${CLUSTER_NAME} — setup journal summary" + echo + echo "Read raw log on FE: \`${JOURNAL_TSV}\`" + echo + echo "Last rebuilt: $(date -u -Iseconds 2>/dev/null || date -u)" + echo + echo "| UTC time | Host | Phase | Action | Status | Detail |" + echo "|----------|------|-------|--------|--------|--------|" + tail -n +2 "$JOURNAL_TSV" 2>/dev/null | while IFS=' ' read -r ts host actor phase action status detail; do + printf '| %s | %s | %s | %s | **%s** | %s |\n' \ + "$ts" "$host" "$phase" "$action" "$status" "$detail" + done + echo + echo "## Latest status per action/host" + echo + tail -n +2 "$JOURNAL_TSV" 2>/dev/null | awk -F' ' ' + { key=$2 SUBSEP $5 SUBSEP $4; line=$0; status=$6; if (status != "START") last[key]=line } + END { for (k in last) print last[k] }' | sort -t' ' -k1,1 | while IFS=' ' read -r ts host actor phase action status detail; do + printf -- '- **%s** `%s` / `%s`' "$status" "$host" "$action" + [ -n "$detail" ] && printf ' — %s' "$detail" + printf '\n' + done +} > "$JOURNAL_SUMMARY" + +echo "summary → $JOURNAL_SUMMARY" diff --git a/sim/cluster0/scripts/lib/common.sh b/sim/cluster0/scripts/lib/common.sh new file mode 100644 index 0000000..9262531 --- /dev/null +++ b/sim/cluster0/scripts/lib/common.sh @@ -0,0 +1,192 @@ +# shellcheck shell=sh +# Shared helpers for cast cluster scripts. + +log() { + printf '[%s] %s\n' "$(date -Iseconds 2>/dev/null || date)" "$*" +} + +die() { + log "ERROR: $*" + if [ -n "${JOURNAL_LAST_ACTION:-}" ]; then + journal_record NOK "$JOURNAL_LAST_ACTION" "$*" + fi + exit 1 +} + +cluster_root() { + if [ -n "${CAST_CLUSTER_ROOT:-}" ]; then + printf '%s\n' "$CAST_CLUSTER_ROOT" + return + fi + if [ -f /shared/cluster/cluster.env ]; then + printf '/shared/cluster\n' + return + fi + if [ -f "$(dirname "$0")/../cluster.env" ]; then + cd "$(dirname "$0")/.." && pwd + return + fi + die "cannot locate cluster root (is /shared mounted?)" +} + +load_cluster_env() { + ROOT="$(cluster_root)" + ENV_FILE="${1:-$ROOT/cluster.env}" + [ -f "$ENV_FILE" ] || die "missing $ENV_FILE" + # shellcheck disable=SC1090 + . "$ENV_FILE" + CAST_CLUSTER_ROOT="$ROOT" + FE_NFS="${FE_NFS_SERVER}:${FE_NFS_EXPORT_PATH}" + FSTAB_NFS_LINE="${FE_NFS} ${SHARED_MOUNT} nfs rw,_netdev,nofail,noatime,nodiratime 0 0" + ALL_CAST_HOSTS="${CAST01_HOST} ${CAST02_HOST} ${CAST03_HOST}" + JOURNAL_DIR="${SHARED_MOUNT}/journal" + JOURNAL_TSV="${JOURNAL_DIR}/${CLUSTER_NAME}.journal.tsv" + JOURNAL_SUMMARY="${JOURNAL_DIR}/${CLUSTER_NAME}.summary.md" +} + +journal_init() { + load_cluster_env 2>/dev/null || return 0 + mkdir -p "$JOURNAL_DIR" + if [ ! -f "$JOURNAL_TSV" ]; then + printf 'timestamp_utc\thost\tactor\tphase\taction\tstatus\tdetail\n' > "$JOURNAL_TSV" + fi +} + +journal_record() { + _status="$1" + _action="$2" + _detail="${3:-}" + _phase="${JOURNAL_PHASE:-}" + journal_init + _ts="$(date -u -Iseconds 2>/dev/null || date -u +%Y-%m-%dT%H:%M:%SZ)" + _host="$(host_short 2>/dev/null || echo unknown)" + _actor="${JOURNAL_ACTOR:-script}" + _line="${_ts} ${_host} ${_actor} ${_phase} ${_action} ${_status} ${_detail}" + printf '%s\n' "$_line" >> "$JOURNAL_TSV" + log "journal ${_status} ${_action}${_detail:+ — ${_detail}}" + if [ -x "${CAST_CLUSTER_ROOT}/scripts/journal-rebuild-summary.sh" ] 2>/dev/null; then + sh "${CAST_CLUSTER_ROOT}/scripts/journal-rebuild-summary.sh" 2>/dev/null || true + elif [ -f "${CAST_CLUSTER_ROOT}/scripts/journal-rebuild-summary.sh" ]; then + sh "${CAST_CLUSTER_ROOT}/scripts/journal-rebuild-summary.sh" 2>/dev/null || true + fi +} + +journal_start() { + JOURNAL_LAST_ACTION="$1" + journal_record START "$1" "${2:-}" +} + +journal_ok() { + journal_record OK "$1" "${2:-}" + JOURNAL_LAST_ACTION="" +} + +journal_nok() { + journal_record NOK "$1" "${2:-}" + JOURNAL_LAST_ACTION="" +} + +journal_warn() { + journal_record WARN "$1" "${2:-}" +} + +ensure_shared_mounted() { + load_cluster_env + if mount | grep -q " on ${SHARED_MOUNT} "; then + journal_init + return 0 + fi + if [ -f /etc/cast-cluster.env ]; then + # shellcheck disable=SC1091 + . /etc/cast-cluster.env + FE_NFS="${FE_NFS_SERVER}:${FE_NFS_EXPORT_PATH}" + FSTAB_NFS_LINE="${FE_NFS} ${SHARED_MOUNT} nfs rw,_netdev,nofail,noatime,nodiratime 0 0" + fi + log "mounting ${SHARED_MOUNT} from ${FE_NFS}" + journal_start nfs_mount "from ${FE_NFS}" + mkdir -p "$SHARED_MOUNT" + grep -v "${FE_NFS_EXPORT_PATH}" /etc/fstab > /var/tmp/cast-fstab.new 2>/dev/null || cp /etc/fstab /var/tmp/cast-fstab.new + echo "$FSTAB_NFS_LINE" >> /var/tmp/cast-fstab.new + mv /var/tmp/cast-fstab.new /etc/fstab + rc-update add nfsmount boot 2>/dev/null || true + mount "$SHARED_MOUNT" 2>/dev/null || mount -a + if mount | grep -q " on ${SHARED_MOUNT} "; then + load_cluster_env + journal_ok nfs_mount "${SHARED_MOUNT}" + else + journal_nok nfs_mount "mount failed" + die "NFS mount failed: ${SHARED_MOUNT}" + fi +} + +run_script() { + _script="$1" + _action="$(basename "$_script" .sh)" + journal_start "$_action" "$_script" + if sh "$_script"; then + journal_ok "$_action" "$_script" + return 0 + fi + journal_nok "$_action" "$_script" + return 1 +} + +host_short() { + hostname -s 2>/dev/null || hostname +} + +is_primary_node() { + [ "$(host_short)" = "$CAST01_HOST" ] +} + +is_replica_node() { + H="$(host_short)" + [ "$H" = "$CAST02_HOST" ] || [ "$H" = "$CAST03_HOST" ] +} + +wait_for_tcp() { + _host="$1" + _port="$2" + _timeout="${3:-300}" + _i=0 + while [ "$_i" -lt "$_timeout" ]; do + if nc -z "$_host" "$_port" 2>/dev/null; then + return 0 + fi + _i=$((_i + 1)) + sleep 1 + done + die "timeout waiting for ${_host}:${_port}" +} + +wait_for_primary_db() { + load_cluster_env + CREDS="${CAST_CLUSTER_ROOT}/credentials.txt" + APP_PASS="$(awk '/^\[mariadb_app\]/{f=1;next} /^\[/{f=0} f&&/^password=/{print substr($0,10); exit}' "$CREDS")" + _i=0 + while [ "$_i" -lt 300 ]; do + if mariadb -u androidcast -p"$APP_PASS" -h"$PRIMARY_DB_HOST" -N -e 'SELECT 1' >/dev/null 2>&1; then + return 0 + fi + _i=$((_i + 1)) + sleep 2 + done + die "primary DB not reachable at ${PRIMARY_DB_HOST}" +} + +read_cred() { + _section="$1" + _key="$2" + _file="${CAST_CLUSTER_ROOT}/credentials.txt" + awk -v s="[$_section]" -v k="$_key=" ' + $0 == s { f=1; next } + /^\[/ { f=0 } + f && index($0, k) == 1 { print substr($0, length(k)+1); exit } + ' "$_file" +} + +ssh_node() { + _host="$1" + shift + ssh -o BatchMode=yes -o StrictHostKeyChecking=no "${SSH_USER}@${_host}" "$@" +} diff --git a/sim/cluster0/scripts/load-schemas.sh b/sim/cluster0/scripts/load-schemas.sh new file mode 100644 index 0000000..d6a91e2 --- /dev/null +++ b/sim/cluster0/scripts/load-schemas.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# Load project schemas on cast01 primary only. +set -eu +SHARED=/shared/cluster/sql +[ "$(hostname -s)" = cast01 ] || { echo "run on cast01 only"; exit 1; } + +apply() { + f="$1" + [ -f "$f" ] || { echo "missing $f"; exit 1; } + echo "==> $f" + mariadb -u root < "$f" +} + +apply_db() { + db="$1" + f="$2" + echo "==> $db < $f" + mariadb -u root "$db" < "$f" +} + +apply "$SHARED/crashes/schema.mariadb.sql" + +# Migrations after base schema (003 is for legacy; base schema already includes tickets) +for m in \ + 004_ticket_workflow.sql \ + 005_ticket_attachments.sql \ + 006_graph_sessions.sql \ + 007_remote_access.sql \ + 008_graph_sessions_indexes.sql \ + 008_auth_email_2fa.sql \ + 009_rssh_sessions.sql +do + apply_db androidcast_crashes "$SHARED/crashes/migrations/$m" +done + +apply "$SHARED/url_shortener/schema.mariadb.sql" + +mariadb -u root -e "SHOW DATABASES; SELECT COUNT(*) AS crash_tables FROM information_schema.tables WHERE table_schema='androidcast_crashes'; SELECT COUNT(*) AS url_tables FROM information_schema.tables WHERE table_schema='url_shortener';" +echo "schemas_loaded_ok" diff --git a/sim/cluster0/scripts/mariadb-primary.sh b/sim/cluster0/scripts/mariadb-primary.sh new file mode 100644 index 0000000..2480dc4 --- /dev/null +++ b/sim/cluster0/scripts/mariadb-primary.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Configure cast01 as MariaDB GTID primary. Idempotent-ish. +set -eu +SHARED=/shared/cluster +CREDS="$SHARED/credentials.txt" +REPL_USER="$(awk '/^\[mariadb_replication\]/{f=1;next} /^\[/{f=0} f&&/^user=/{print substr($0,6); exit}' "$CREDS")" +REPL_PASS="$(awk '/^\[mariadb_replication\]/{f=1;next} /^\[/{f=0} f&&/^password=/{print substr($0,10); exit}' "$CREDS")" +APP_USER="$(awk '/^\[mariadb_app\]/{f=1;next} /^\[/{f=0} f&&/^user=/{print substr($0,6); exit}' "$CREDS")" +APP_PASS="$(awk '/^\[mariadb_app\]/{f=1;next} /^\[/{f=0} f&&/^password=/{print substr($0,10); exit}' "$CREDS")" + +[ "$(hostname -s)" = cast01 ] || { echo "run on cast01 only"; exit 1; } + +grep -q '^skip-networking' /etc/my.cnf.d/mariadb-server.cnf && \ + sed -i 's/^skip-networking/#skip-networking/' /etc/my.cnf.d/mariadb-server.cnf + +install -D -m 644 "$SHARED/mariadb/primary.cnf" /etc/my.cnf.d/lab-cluster-primary.cnf +rc-update add mariadb default 2>/dev/null || true +rc-service mariadb restart + +mariadb -u root </dev/null || { echo "FAIL: SQL thread not running"; mariadb -u root -e "SHOW REPLICA STATUS\G" | grep -E 'Last_SQL_Error'; exit 1; } +echo "replica_ok $HOST server_id=$SERVER_ID" diff --git a/sim/cluster0/scripts/verify-cluster.sh b/sim/cluster0/scripts/verify-cluster.sh new file mode 100644 index 0000000..2d7098c --- /dev/null +++ b/sim/cluster0/scripts/verify-cluster.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Quick health check for cast cluster. +set -eu +HOST="$(hostname -s)" +FAIL=0 +echo "=== $HOST ===" +mount | grep ' /shared ' || { echo "WARN: /shared not mounted"; FAIL=1; } +curl -sS -o /dev/null -w "nginx=%{http_code}\n" http://127.0.0.1/ || true +php83 -v | head -1 + +case "$HOST" in + cast01) + mariadb -u root -e "SELECT @@server_id AS server_id, @@read_only AS read_only, @@gtid_current_pos AS gtid;" + mariadb -u androidcast -pcast-cluster-dev -h127.0.0.1 -e \ + "SELECT table_schema, COUNT(*) AS tables FROM information_schema.tables WHERE table_schema IN ('androidcast_crashes','url_shortener') GROUP BY table_schema;" + ;; + cast02|cast03) + mariadb -u root -e "SELECT @@server_id AS server_id, @@read_only AS read_only;" 2>/dev/null || { echo "mariadb not running"; FAIL=1; } + RS="$(mariadb -u root -e 'SHOW REPLICA STATUS\G' 2>/dev/null || true)" + echo "$RS" | grep -E 'Master_Host|Slave_IO_Running|Slave_SQL_Running|Seconds_Behind_Master|Last_SQL_Error' || true + echo "$RS" | grep -E 'Slave_IO_Running: Yes' >/dev/null || { echo "FAIL: IO thread not running"; FAIL=1; } + echo "$RS" | grep -E 'Slave_SQL_Running: Yes' >/dev/null || { echo "FAIL: SQL thread not running"; FAIL=1; } + mariadb -u root -e \ + "SELECT table_schema, COUNT(*) AS tables FROM information_schema.tables WHERE table_schema IN ('androidcast_crashes','url_shortener') GROUP BY table_schema;" + mariadb -u androidcast -pcast-cluster-dev -h127.0.0.1 -e "SELECT 1 AS local_read_ok;" 2>/dev/null || echo "WARN: local app user read failed" + ;; +esac + +if [ "$FAIL" -eq 0 ]; then + echo OK +else + echo FAIL + exit 1 +fi diff --git a/sim/cluster0/verify_global_setup.sh b/sim/cluster0/verify_global_setup.sh new file mode 100644 index 0000000..cddcf0e --- /dev/null +++ b/sim/cluster0/verify_global_setup.sh @@ -0,0 +1,64 @@ +#!/bin/sh +# Post-DNS / post-FE verification — run after PUBLIC_ORIGIN points at this cluster. +set -eu + +ROOT="$(cd "$(dirname "$0")" && pwd)" +export CAST_CLUSTER_ROOT="$ROOT" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +export JOURNAL_ACTOR=verify_global +export JOURNAL_PHASE=verify_global +ensure_shared_mounted + +FAIL=0 +ORIGIN="${PUBLIC_ORIGIN%/}" + +check_url() { + _path="$1" + _expect="${2:-200}" + _url="${ORIGIN}${_path}" + _action="curl ${_path}" + journal_start "$_action" "$_url" + _code="$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "$_url" 2>/dev/null || echo 000)" + if [ "$_code" = "$_expect" ]; then + journal_ok "$_action" "http=${_code}" + log "OK $_code $_url" + else + journal_nok "$_action" "got=${_code} want=${_expect}" + log "FAIL got $_code want $_expect $_url" + FAIL=1 + fi +} + +journal_start verify_global "origin=${ORIGIN}" +log "verify_global_setup cluster=${CLUSTER_NAME} origin=${ORIGIN}" +[ -n "$ORIGIN" ] || die "set PUBLIC_ORIGIN in cluster.env" + +check_url "${APP_HUB_PATH}/" "200" +check_url "${APP_BASE_PATH}/" "200" +check_url "${APP_BASE_PATH}/assets/js/app.js" "200" +check_url "${APP_BASE_PATH}/api/diag.php" "200" + +# Short links UI (may redirect to login) +_code="$(curl -sS -o /dev/null -w '%{http_code}' -L --max-time 30 "${ORIGIN}${APP_BASE_PATH}/?view=short_links" 2>/dev/null || echo 000)" +case "$_code" in + 200|302) log "OK $_code short_links" ;; + *) log "FAIL short_links http=$_code"; FAIL=1 ;; +esac + +if [ -n "${FE_PROXY_TARGET:-}" ]; then + _fe_code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "http://${FE_PROXY_TARGET}${APP_BASE_PATH}/" 2>/dev/null || echo 000)" + case "$_fe_code" in + 200|302) log "OK upstream $_fe_code http://${FE_PROXY_TARGET}${APP_BASE_PATH}/" ;; + *) log "WARN upstream $_fe_code (check FE nginx after DNS cutover)" ;; + esac +fi + +if [ "$FAIL" -eq 0 ]; then + journal_ok verify_global + log "verify_global_setup_ok ${CLUSTER_NAME}" + exit 0 +fi +journal_nok verify_global +log "verify_global_setup_FAIL ${CLUSTER_NAME}" +exit 1 diff --git a/sim/cluster0/verify_lab_setup.sh b/sim/cluster0/verify_lab_setup.sh new file mode 100644 index 0000000..48b1f93 --- /dev/null +++ b/sim/cluster0/verify_lab_setup.sh @@ -0,0 +1,105 @@ +#!/bin/sh +# Internal cluster verification (before or after DNS). No external network required for --local. +set -eu + +ROOT="$(cd "$(dirname "$0")" && pwd)" +export CAST_CLUSTER_ROOT="$ROOT" +# shellcheck source=/dev/null +. "$ROOT/scripts/lib/common.sh" +export JOURNAL_ACTOR=verify_lab +export JOURNAL_PHASE=verify + +MODE=local +while [ $# -gt 0 ]; do + case "$1" in + --local|--local-only) MODE=local; shift ;; + --cluster) MODE=cluster; shift ;; + *) die "unknown arg: $1" ;; + esac +done + +FAIL=0 +check_local_node() { + H="$(host_short)" + log "verify local $H" + run_script "$ROOT/scripts/verify-cluster.sh" || FAIL=1 + + nginx -t >/dev/null 2>&1 || { log "FAIL nginx -t"; FAIL=1; } + rc-status nginx php-fpm83 2>/dev/null | grep -E 'nginx|php-fpm83' || true + if is_primary_node || is_replica_node; then + rc-service mariadb status 2>/dev/null | grep -q started || { log "FAIL mariadb not running"; FAIL=1; } + fi + + CODE="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1${APP_BASE_PATH}/" 2>/dev/null || echo 000)" + case "$CODE" in + 200|302) log "app_http=${CODE}" ;; + *) log "FAIL app_http=${CODE} for ${APP_BASE_PATH}/"; FAIL=1 ;; + esac + + ASSET="${APP_BASE_PATH}/assets/js/app.js" + ACODE="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1${ASSET}" 2>/dev/null || echo 000)" + case "$ACODE" in + 200) log "asset_http=${ACODE}" ;; + *) log "WARN asset_http=${ACODE} (${ASSET})" ;; + esac + + DIAG="$(curl -sS "http://127.0.0.1${APP_BASE_PATH}/api/diag.php" 2>/dev/null || true)" + echo "$DIAG" | grep -q '"ok":true' || { log "FAIL diag.php"; FAIL=1; } + + CRASH_N="$(mariadb -u androidcast -p"$(read_cred mariadb_app password)" -h127.0.0.1 -N -e \ + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='androidcast_crashes'" 2>/dev/null || echo 0)" + [ "$CRASH_N" = "$EXPECTED_CRASHES_TABLES" ] || { log "FAIL crashes tables=$CRASH_N want $EXPECTED_CRASHES_TABLES"; FAIL=1; } +} + +check_remote_node_http() { + _host="$1" + _ip="" + case "$_host" in + "$CAST01_HOST") _ip="$CAST01_IP" ;; + "$CAST02_HOST") _ip="$CAST02_IP" ;; + "$CAST03_HOST") _ip="$CAST03_IP" ;; + esac + [ -n "$_ip" ] || { log "FAIL unknown host $_host"; FAIL=1; return; } + log "verify http $_host ($_ip)" + _code="$(curl -sS -o /dev/null -w '%{http_code}' "http://${_ip}${APP_BASE_PATH}/" 2>/dev/null || echo 000)" + case "$_code" in + 200|302) log "OK $_host app_http=$_code" ;; + *) log "FAIL $_host app_http=$_code"; FAIL=1 ;; + esac + _diag="$(curl -sS "http://${_ip}${APP_BASE_PATH}/api/diag.php" 2>/dev/null || true)" + echo "$_diag" | grep -q '"ok":true' || { log "FAIL $_host diag.php"; FAIL=1; } +} + +check_remote_node_ssh() { + _host="$1" + log "verify ssh $_host" + ssh_node "$_host" "sudo sh ${SHARED_MOUNT}/cluster/verify_lab_setup.sh --local" || FAIL=1 +} + +ensure_shared_mounted +journal_start verify_lab "mode=${MODE}" + +if [ "$MODE" = cluster ]; then + for H in $CAST01_HOST $CAST02_HOST $CAST03_HOST; do + if [ "$(host_short)" = "$H" ]; then + check_local_node + else + if ssh -o BatchMode=yes -o ConnectTimeout=3 "${SSH_USER}@${H}" true 2>/dev/null; then + check_remote_node_ssh "$H" + else + check_remote_node_http "$H" + fi + fi + done +else + check_local_node +fi + +if [ "$FAIL" -eq 0 ]; then + journal_ok verify_lab "mode=${MODE}" + log "verify_lab_setup_ok ${CLUSTER_NAME} mode=$MODE" + exit 0 +fi +journal_nok verify_lab "mode=${MODE}" +log "verify_lab_setup_FAIL ${CLUSTER_NAME} mode=$MODE" +exit 1