1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 00:58:20 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-23 12:21:14 +02:00
commit 7f3e4fea9f
53 changed files with 2725 additions and 0 deletions

18
.env.example Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
runtime/*
!runtime/db-init/
!runtime/db-init/.gitkeep

4
.gitmodules vendored Normal file
View File

@@ -0,0 +1,4 @@
[submodule "ac-scripts"]
path = ac-scripts
url = git://f0xx.org/ac/ac-scripts
branch = next

6
README.md Normal file
View File

@@ -0,0 +1,6 @@
# ac-deploy
Orchestration, cluster0 lab, Docker images, nginx seeds.
- **Remote:** git://f0xx.org/ac/ac-deploy
- **Submodules:** ac-scripts

1
ac-scripts Submodule

Submodule ac-scripts added at 3c197ed9dd

67
cleanup.sh Executable file
View File

@@ -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."

150
deploy.sh Executable file
View File

@@ -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" <<EOF
<?php
return [
'app_name' => '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" <<EOF
<?php
return [
'app_name' => '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

125
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -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

View File

@@ -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

3
nginx-seeds/README.md Normal file
View File

@@ -0,0 +1,3 @@
# nginx-seeds
Nginx fragments from monolith examples/crash_reporter/backend/.

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;
}

39
nginx/build-fe.conf Normal file
View File

@@ -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;
}
}

72
nginx/crashes-fe.conf Normal file
View File

@@ -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;
}
}

33
nginx/gitea-fe.conf Normal file
View File

@@ -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";
}
}

63
nginx/landing.conf Normal file
View File

@@ -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;
}
}

1
runtime/db-init/.gitkeep Normal file
View File

@@ -0,0 +1 @@

View File

@@ -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;

View File

@@ -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);

View File

@@ -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');

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

34
scripts/apply-hosts.sh Executable file
View File

@@ -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."

View File

@@ -0,0 +1,77 @@
# cast01cast03 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)

108
sim/cluster0/DEPLOY.md Normal file
View File

@@ -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 cast0103
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.

17
sim/cluster0/README.md Normal file
View File

@@ -0,0 +1,17 @@
# cast01cast03 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`.

View File

@@ -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

48
sim/cluster0/cluster.env Normal file
View File

@@ -0,0 +1,48 @@
# cluster0 — cast01cast03 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 + c1c3.acl0.f0xx.org → FE (cast0103 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"

View File

@@ -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

View File

@@ -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/`.

View File

@@ -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

View File

@@ -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

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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" <<EOF
# cast-cluster ${CLUSTER_NAME}
${CAST01_IP} ${CAST01_HOST} ${CAST01_HOST}.${CAST_DOMAIN}
${CAST02_IP} ${CAST02_HOST} ${CAST02_HOST}.${CAST_DOMAIN}
${CAST03_IP} ${CAST03_HOST} ${CAST03_HOST}.${CAST_DOMAIN}
${ARTC0_IP} ${ARTC0_HOST} ${ARTC0_HOST}.${CAST_DOMAIN} alpine-be
EOF
mv "$STAGE/hosts.new" /etc/hosts
apk update
PKGS="$APK_PACKAGES_BASE"
if is_primary_node; then
PKGS="$PKGS mariadb"
fi
# shellcheck disable=SC2086
apk add --no-cache $PKGS
rc-update add nginx default 2>/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 "<html><body><h1>${HOST}</h1><p>${CLUSTER_NAME} node</p></body></html>" > /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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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" <<PHP
<?php
return [
'app_name' => '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)"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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}" "$@"
}

View File

@@ -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"

View File

@@ -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 <<SQL
CREATE USER IF NOT EXISTS '${REPL_USER}'@'10.7.16.%' IDENTIFIED BY '${REPL_PASS}';
GRANT REPLICATION SLAVE, REPLICATION CLIENT, RELOAD, LOCK TABLES, SELECT, SHOW VIEW, EVENT, TRIGGER ON *.* TO '${REPL_USER}'@'10.7.16.%';
CREATE USER IF NOT EXISTS '${APP_USER}'@'10.7.16.%' IDENTIFIED BY '${APP_PASS}';
CREATE USER IF NOT EXISTS '${APP_USER}'@'localhost' IDENTIFIED BY '${APP_PASS}';
GRANT ALL PRIVILEGES ON androidcast_crashes.* TO '${APP_USER}'@'10.7.16.%';
GRANT ALL PRIVILEGES ON androidcast_crashes.* TO '${APP_USER}'@'localhost';
GRANT ALL PRIVILEGES ON url_shortener.* TO '${APP_USER}'@'10.7.16.%';
GRANT ALL PRIVILEGES ON url_shortener.* TO '${APP_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
echo "primary_ok server_id=$(mariadb -u root -N -e 'SELECT @@server_id')"
echo "gtid=$(mariadb -u root -N -e 'SELECT @@gtid_current_pos')"

View File

@@ -0,0 +1,73 @@
#!/bin/sh
# Bootstrap cast02 or cast03 as GTID replica of cast01.
set -eu
set -o pipefail
SHARED=/shared/cluster
CREDS="$SHARED/credentials.txt"
[ -f "$SHARED/cluster.env" ] && . "$SHARED/cluster.env"
HOST="$(hostname -s)"
case "$HOST" in
cast02) SERVER_ID=2 ;;
cast03) SERVER_ID=3 ;;
*) echo "run on cast02 or cast03"; exit 1 ;;
esac
REPL_PASS="$(awk '/^\[mariadb_replication\]/{f=1;next} /^\[/{f=0} f&&/^password=/{print substr($0,10); exit}' "$CREDS")"
REPL_USER="$(awk '/^\[mariadb_replication\]/{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")"
APP_USER="$(awk '/^\[mariadb_app\]/{f=1;next} /^\[/{f=0} f&&/^user=/{print substr($0,6); exit}' "$CREDS")"
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
apk add --no-cache mariadb
install -D -m 644 "$SHARED/mariadb/replica.cnf" /etc/my.cnf.d/lab-cluster-replica.cnf
grep -q '^server-id=' /etc/my.cnf.d/lab-cluster-replica.cnf && \
sed -i "s/^server-id=.*/server-id=${SERVER_ID}/" /etc/my.cnf.d/lab-cluster-replica.cnf || \
echo "server-id=${SERVER_ID}" >> /etc/my.cnf.d/lab-cluster-replica.cnf
rc-service mariadb stop 2>/dev/null || true
rm -rf /var/lib/mysql/*
/etc/init.d/mariadb setup
rc-update add mariadb default
rc-service mariadb restart
sleep 2
echo "=== app DB sync from cast01 (skip mysql.* — avoids help_topic dump failures) ==="
mariadb-dump --skip-ssl -h cast01 -u "${REPL_USER}" -p"${REPL_PASS}" \
--databases androidcast_crashes url_shortener \
--single-transaction --gtid --routines --events \
| mariadb -u root
GTID_POS="$(mariadb --skip-ssl -h cast01 -u "${REPL_USER}" -p"${REPL_PASS}" -N -e 'SELECT @@gtid_current_pos')"
[ -n "$GTID_POS" ] || { echo "FAIL: could not read gtid from cast01"; exit 1; }
mariadb -u root -e "SET GLOBAL gtid_slave_pos='${GTID_POS}'"
echo "gtid_slave_pos set to: ${GTID_POS}"
mariadb -u root <<SQL
CREATE USER IF NOT EXISTS '${APP_USER}'@'10.7.16.%' IDENTIFIED BY '${APP_PASS}';
CREATE USER IF NOT EXISTS '${APP_USER}'@'localhost' IDENTIFIED BY '${APP_PASS}';
GRANT ALL PRIVILEGES ON androidcast_crashes.* TO '${APP_USER}'@'10.7.16.%';
GRANT ALL PRIVILEGES ON androidcast_crashes.* TO '${APP_USER}'@'localhost';
GRANT ALL PRIVILEGES ON url_shortener.* TO '${APP_USER}'@'10.7.16.%';
GRANT ALL PRIVILEGES ON url_shortener.* TO '${APP_USER}'@'localhost';
FLUSH PRIVILEGES;
CHANGE MASTER TO
MASTER_HOST='cast01',
MASTER_USER='${REPL_USER}',
MASTER_PASSWORD='${REPL_PASS}',
MASTER_USE_GTID=slave_pos;
START REPLICA;
SQL
sleep 3
mariadb -u root -e "SHOW REPLICA STATUS\G" | grep -E 'Slave_IO_Running|Slave_SQL_Running|Last_Error|Last_SQL_Error|Seconds_Behind_Master'
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;"
URL_N="$(mariadb -u root -N -e 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='"'"'url_shortener'"'"'')"
CRASH_N="$(mariadb -u root -N -e 'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='"'"'androidcast_crashes'"'"'')"
EXP_CRASH="${EXPECTED_CRASHES_TABLES:-16}"
EXP_URL="${EXPECTED_URL_SHORTENER_TABLES:-4}"
[ "$CRASH_N" = "$EXP_CRASH" ] && [ "$URL_N" = "$EXP_URL" ] || { echo "FAIL: expected ${EXP_CRASH}+${EXP_URL} tables, got crashes=$CRASH_N url=$URL_N"; exit 1; }
mariadb -u root -e "SHOW REPLICA STATUS\G" | grep -E 'Slave_SQL_Running: Yes' >/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"

View File

@@ -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

View File

@@ -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

View File

@@ -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