fluxer/scripts/ci/workflows/deploy_app.py

216 lines
6.3 KiB
Python
Executable File

#!/usr/bin/env python3
import pathlib
import sys
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
from ci_steps import (
ADD_KNOWN_HOSTS_SCRIPT,
INSTALL_DOCKER_PUSSH_SCRIPT,
INSTALL_RCLONE_SCRIPT,
record_deploy_commit_script,
rclone_config_script,
set_build_timestamp_script,
)
from ci_workflow import parse_step_env_args
from ci_utils import run_step
STEPS: dict[str, str] = {
"install_dependencies": """
set -euo pipefail
cd fluxer_app
pnpm install --frozen-lockfile
""",
"run_lingui": """
set -euo pipefail
cd fluxer_app
pnpm lingui:extract
pnpm lingui:compile --strict
""",
"record_deploy_commit": record_deploy_commit_script(
include_env=True,
include_sentry=False,
),
"install_wasm_pack": """
set -euo pipefail
if ! command -v wasm-pack >/dev/null 2>&1; then
cargo install wasm-pack --version 0.13.1
fi
""",
"generate_wasm": """
set -euo pipefail
cd fluxer_app
pnpm wasm:codegen
""",
"add_known_hosts": ADD_KNOWN_HOSTS_SCRIPT,
"fetch_deployment_config": """
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
ssh "${SERVER}" "cat ${CONFIG_PATH}" > fluxer_app/config.json
""",
"build_application": """
set -euo pipefail
cd fluxer_app
pnpm build
node -e "const fs = require('fs'); const {execSync} = require('child_process'); const cfg = JSON.parse(fs.readFileSync(process.env.FLUXER_CONFIG, 'utf8')); const app = cfg.app_public || {}; let sha = app.build_sha || ''; if (!sha) { try { sha = execSync('git rev-parse --short HEAD', {stdio:['ignore','pipe','ignore']}).toString().trim(); } catch {} } const timestamp = Number(app.build_timestamp ?? Math.floor(Date.now() / 1000)); const buildNumber = Number(app.build_number ?? 0); const env = app.project_env ?? cfg.sentry?.release_channel ?? cfg.env ?? ''; const payload = { sha, buildNumber, timestamp, env }; fs.writeFileSync('dist/version.json', JSON.stringify(payload, null, 2));"
""",
"install_rclone": INSTALL_RCLONE_SCRIPT,
"upload_assets": rclone_config_script(
endpoint="https://s3.us-east-va.io.cloud.ovh.us",
acl="public-read",
expand_vars=True,
)
+ """
rclone copy fluxer_app/dist/assets ovh:fluxer-static/assets \
--transfers 32 \
--checkers 16 \
--size-only \
--fast-list \
--s3-upload-concurrency 8 \
--s3-chunk-size 16M \
-v
""",
"set_build_timestamp": set_build_timestamp_script(),
"install_docker_pussh": INSTALL_DOCKER_PUSSH_SCRIPT,
"push_and_deploy": """
set -euo pipefail
docker pussh "${IMAGE_TAG}" "${SERVER}"
ssh "${SERVER}" \
"IMAGE_TAG=${IMAGE_TAG} SERVICE_NAME=${SERVICE_NAME} COMPOSE_STACK=${COMPOSE_STACK} RELEASE_CHANNEL=${RELEASE_CHANNEL} APP_REPLICAS=${APP_REPLICAS} bash" << 'REMOTE_EOF'
set -euo pipefail
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
CONFIG_PATH="/etc/fluxer/config.canary.json"
else
CONFIG_PATH="/etc/fluxer/config.stable.json"
fi
read -r CADDY_APP_DOMAIN <<EOF
$(python3 - <<'PY' "${CONFIG_PATH}"
import sys, json
from urllib.parse import urlparse
path = sys.argv[1]
with open(path, 'r') as f:
cfg = json.load(f)
domain = cfg.get('domain', {})
overrides = cfg.get('endpoint_overrides', {})
def build_url(scheme, base_domain, port, path=''):
standard = (scheme == 'http' and port == 80) or (scheme == 'https' and port == 443) or (scheme == 'ws' and port == 80) or (scheme == 'wss' and port == 443)
port_part = f":{port}" if port and not standard else ""
return f"{scheme}://{base_domain}{port_part}{path}"
def derive_domain(key):
if key == 'cdn':
return domain.get('cdn_domain') or domain.get('base_domain')
if key == 'invite':
return domain.get('invite_domain') or domain.get('base_domain')
if key == 'gift':
return domain.get('gift_domain') or domain.get('base_domain')
return domain.get('base_domain')
public_scheme = domain.get('public_scheme', 'https')
public_port = domain.get('public_port', 443 if public_scheme == 'https' else 80)
derived_app = build_url(public_scheme, derive_domain('app'), public_port)
app_url = (overrides.get('app') or derived_app).strip()
parsed_app = urlparse(app_url)
app_host = parsed_app.netloc or parsed_app.path
print(app_host)
PY
)
EOF
if [[ "${RELEASE_CHANNEL}" == "canary" ]]; then
API_TARGET="fluxer-api-canary_app"
else
API_TARGET="fluxer-api_app"
fi
sudo mkdir -p "/opt/${SERVICE_NAME}"
sudo chown -R "${USER}:${USER}" "/opt/${SERVICE_NAME}"
cd "/opt/${SERVICE_NAME}"
cat > compose.yaml << COMPOSEEOF
x-deploy-base: &deploy_base
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
update_config:
parallelism: 1
delay: 10s
order: start-first
rollback_config:
parallelism: 1
delay: 10s
x-common-caddy-headers: &common_caddy_headers
caddy.header.Strict-Transport-Security: "max-age=31536000; includeSubDomains; preload"
caddy.header.X-Xss-Protection: "1; mode=block"
caddy.header.X-Content-Type-Options: "nosniff"
caddy.header.Referrer-Policy: "strict-origin-when-cross-origin"
caddy.header.X-Frame-Options: "DENY"
caddy.header.Cache-Control: "no-store, no-cache, must-revalidate"
caddy.header.Pragma: "no-cache"
caddy.header.Expires: "0"
x-env-base: &env_base
FLUXER_CONFIG: /etc/fluxer/config.json
x-healthcheck: &healthcheck
test: ['CMD', 'curl', '-f', 'http://localhost:8080/_health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
services:
app:
image: ${IMAGE_TAG}
volumes:
- ${CONFIG_PATH}:/etc/fluxer/config.json:ro
deploy:
<<: *deploy_base
replicas: ${APP_REPLICAS}
labels:
<<: *common_caddy_headers
caddy: ${CADDY_APP_DOMAIN}
caddy.redir: "/.well-known/fluxer /api/.well-known/fluxer 301"
caddy.handle_path_0: /api*
caddy.handle_path_0.reverse_proxy: "http://${API_TARGET}:8080"
caddy.reverse_proxy: "{{upstreams 8080}}"
environment:
<<: *env_base
networks: [fluxer-shared]
healthcheck: *healthcheck
networks:
fluxer-shared:
external: true
COMPOSEEOF
docker stack deploy \
--with-registry-auth \
--detach=false \
--resolve-image never \
-c compose.yaml \
"${COMPOSE_STACK}"
REMOTE_EOF
""",
}
def main() -> int:
args = parse_step_env_args(include_server_ip=True)
run_step(STEPS, args.step)
return 0
if __name__ == "__main__":
raise SystemExit(main())