#!/usr/bin/env bash # setup-web-linux.sh — install/upgrade the ci-orchestrator web dashboard (W7b) # # Repeatable / idempotent: safe to re-run any time (e.g. after `git pull` + # a code change — see CLAUDE.md: the production venv must be reinstalled # non-editable after every change). Every mutating step checks its own # state first and skips (with a message) when already satisfied. # # What it does: # 1. pip install '[web]' into /opt/ci/venv, non-editable, as ci-runner # 2. Render deploy/systemd/ci-web.service into /etc/systemd/system/, # substituting the LAN bind IP; write /etc/ci/web.env (CI_CONFIG) if the # config path was overridden # 3. Ensure a commented [web]/[web.oidc] template exists in config.toml # (never overwrites an existing [web] section) # 4. Prompt to store WebSessionSecret / WebOidcClientSecret in the keyring # (interactive prompt only — never accepts a secret as a CLI argument) # 5. Print the manual Gitea OIDC application registration steps # 6. Install deploy/sudoers.d/ci-web to /etc/sudoers.d/ci-web (mode 0440, # Stage D service-management panel's exact-command NOPASSWD grant), # validated with `visudo -cf` -- removed again if validation fails # (a broken sudoers.d file can lock out sudo, so this step never # leaves an unvalidated file in place) # 7. systemctl daemon-reload && enable (+ restart if already running) # # Usage: sudo bash setup-web-linux.sh [--host ] [options] # See --help for the full option list. set -euo pipefail # ── Defaults ───────────────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CI_USER="ci-runner" OPT_CI="/opt/ci" VENV="$OPT_CI/venv" REPO_DIR="$SCRIPT_DIR" UNIT_SRC="$REPO_DIR/deploy/systemd/ci-web.service" UNIT_DST="/etc/systemd/system/ci-web.service" SUDOERS_SRC="$REPO_DIR/deploy/sudoers.d/ci-web" SUDOERS_DST="/etc/sudoers.d/ci-web" ENV_FILE="/etc/ci/web.env" CONFIG_PATH="/var/lib/ci/config.toml" WEB_PORT=8800 WEB_HOST="" # resolved below (arg > prompt > 127.0.0.1) SKIP_PIP=false SKIP_SECRETS=false ASSUME_DEFAULT_HOST=false # ── Colours ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' info() { echo -e "${GREEN}==>${NC} $*"; } warn() { echo -e "${YELLOW} ! $*${NC}"; } die() { echo -e "${RED}ERROR: $*${NC}" >&2; exit 1; } usage() { cat < LAN IP the dashboard binds to (written into the unit and into the config.toml template). Default: prompt, falling back to 127.0.0.1 (loopback-only) on Enter. --config-path

Target config.toml (default: $CONFIG_PATH). --skip-pip Skip step 1 (pip install '[web]' extra). --skip-secrets Skip step 4 (interactive keyring secret prompts). -h, --help Show this help. Never binds 0.0.0.0. Binding a non-loopback IP without [web.oidc] configured is refused at runtime by 'ci web serve' itself (open mode is localhost-only) -- see docs/WEB-UI.md. Examples: sudo bash setup-web-linux.sh --host 127.0.0.1 sudo bash setup-web-linux.sh --host 192.168.1.50 sudo bash setup-web-linux.sh --skip-pip --skip-secrets # re-render unit only EOF exit 1 } # ── Parse args ─────────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --host) WEB_HOST="$2"; shift 2 ;; --config-path) CONFIG_PATH="$2"; shift 2 ;; --skip-pip) SKIP_PIP=true; shift ;; --skip-secrets) SKIP_SECRETS=true; shift ;; -h|--help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac done [[ $EUID -eq 0 ]] || die "run as root (sudo bash $(basename "$0") ...)" [[ -f "$UNIT_SRC" ]] || die "unit template not found: $UNIT_SRC (run from the repo root)" [[ -f "$SUDOERS_SRC" ]] || die "sudoers template not found: $SUDOERS_SRC (run from the repo root)" id "$CI_USER" &>/dev/null || die "user '$CI_USER' does not exist -- run setup-host-linux.sh first" [[ -x "$VENV/bin/python" ]] || die "$VENV/bin/python not found -- run setup-host-linux.sh first" if [[ -z "$WEB_HOST" ]]; then read -r -p "LAN IP to bind the dashboard to [127.0.0.1]: " WEB_HOST WEB_HOST="${WEB_HOST:-127.0.0.1}" fi echo "" echo "╔══════════════════════════════════════════════════════╗" echo "║ ci-orchestrator web dashboard — installer ║" echo "╚══════════════════════════════════════════════════════╝" echo " Bind : ${WEB_HOST}:${WEB_PORT}" echo " ci_user : $CI_USER" echo " venv : $VENV" echo " config.toml : $CONFIG_PATH" echo "" # ── Step 1: pip install '[web]' extra (non-editable, per CLAUDE.md) ───────── if [[ "$SKIP_PIP" == false ]]; then info "[1/7] Installing ci_orchestrator[web] into $VENV (non-editable)" sudo -u "$CI_USER" "$VENV/bin/pip" install -q "${REPO_DIR}[web]" verify_out=$(sudo -u "$CI_USER" "$VENV/bin/python" -m ci_orchestrator web serve --help 2>&1) \ || die "'ci web serve --help' failed after install -- see output above" echo " Verify OK: ${verify_out%%$'\n'*}" else info "[1/7] pip install -- SKIPPED (--skip-pip)" fi # ── Step 2: render the systemd unit + optional CI_CONFIG env file ─────────── info "[2/7] Rendering ci-web.service (bind host: $WEB_HOST)" RENDERED="$(mktemp)" trap 'rm -f "$RENDERED"' EXIT sed "s#__CI_WEB_BIND_HOST__#${WEB_HOST}#" "$UNIT_SRC" > "$RENDERED" if [[ -f "$UNIT_DST" ]] && cmp -s "$RENDERED" "$UNIT_DST"; then warn "$UNIT_DST already up to date -- skip" UNIT_CHANGED=false else install -m 644 "$RENDERED" "$UNIT_DST" echo " Wrote $UNIT_DST" UNIT_CHANGED=true fi mkdir -p /etc/ci if [[ "$CONFIG_PATH" != "/var/lib/ci/config.toml" ]]; then if [[ -f "$ENV_FILE" ]] && grep -q '^CI_CONFIG=' "$ENV_FILE"; then warn "$ENV_FILE already sets CI_CONFIG -- skip (edit manually to change it)" else echo "CI_CONFIG=$CONFIG_PATH" >> "$ENV_FILE" chmod 640 "$ENV_FILE" chown "root:$CI_USER" "$ENV_FILE" 2>/dev/null || true echo " Wrote CI_CONFIG=$CONFIG_PATH to $ENV_FILE" fi else echo " Using the auto-discovered default config.toml path -- no $ENV_FILE needed" fi # ── Step 3: ensure a [web]/[web.oidc] template exists in config.toml ──────── info "[3/7] config.toml [web] / [web.oidc] template" WEB_MARKER="# --- appended by setup-web-linux.sh ---" if [[ -f "$CONFIG_PATH" ]] && { grep -qF "$WEB_MARKER" "$CONFIG_PATH" || grep -q '^\[web\]' "$CONFIG_PATH"; }; then warn "[web] section (or a previously appended template) already present in $CONFIG_PATH -- skip, not overwriting" else [[ -f "$CONFIG_PATH" ]] || die "$CONFIG_PATH does not exist -- run setup-host-linux.sh first (it creates the base config.toml)" cat >> "$CONFIG_PATH" </dev/null } _set_secret_if_needed() { local name="$1" label="$2" if _secret_present "$name"; then warn "Keyring key '$name' already set -- skip" return fi read -r -p " Set keyring key '$name' now? [Y/n] " ans case "$ans" in [nN]*) warn "Skipped -- set it later with:" \ "sudo -u $CI_USER $VENV/bin/python -m ci_orchestrator creds set --target $name --user $label" ;; *) # Delegates straight to the CLI's own interactive prompt # (hide_input + confirmation) -- the secret never touches # this script's variables, argv, or any log. sudo -u "$CI_USER" "$VENV/bin/python" -m ci_orchestrator creds set \ --target "$name" --user "$label" ;; esac } _set_secret_if_needed "WebSessionSecret" "web-session" _set_secret_if_needed "WebOidcClientSecret" "web-oidc-client" fi # ── Step 5: manual Gitea OIDC app registration instructions ───────────────── info "[5/7] Gitea OIDC application registration (manual step)" cat < Applications -> "Manage OAuth2 Applications" -> Create Application name : ci-web Redirect URI : http://${WEB_HOST}:${WEB_PORT}/auth/callback (must match [web.oidc].redirect_url EXACTLY -- host, port and path, or login will loop) After creating it, Gitea shows a Client ID and Client Secret ONCE: 1. Client ID -> set [web.oidc].client_id in $CONFIG_PATH 2. Client Secret -> store in the keyring (never in config.toml): sudo -u $CI_USER $VENV/bin/python -m ci_orchestrator creds set \\ --target WebOidcClientSecret --user web-oidc-client 3. Add your Gitea username(s) to [web].allowed_users in $CONFIG_PATH 4. Uncomment [web] and [web.oidc] in $CONFIG_PATH EOF # ── Step 6: sudoers.d/ci-web (Stage D service-panel NOPASSWD grant) ───────── info "[6/7] Installing sudoers.d/ci-web (service-panel NOPASSWD grant)" if [[ -f "$SUDOERS_DST" ]] && cmp -s "$SUDOERS_SRC" "$SUDOERS_DST"; then warn "$SUDOERS_DST already up to date -- skip" else install -m 0440 -o root -g root "$SUDOERS_SRC" "$SUDOERS_DST" if visudo -cf "$SUDOERS_DST"; then echo " Wrote $SUDOERS_DST (validated: visudo -cf OK)" else rm -f "$SUDOERS_DST" die "visudo -cf rejected $SUDOERS_DST -- REMOVED it (a broken" \ "sudoers.d file can lock out sudo, so this step never leaves an" \ "unvalidated file in place). Fix deploy/sudoers.d/ci-web and" \ "re-run this installer." fi fi warn "Verify systemctl/systemd-run absolute paths on THIS host match the" \ "sudoers file: command -v systemctl systemd-run -- edit both" \ "deploy/sudoers.d/ci-web and $SUDOERS_DST if either is not /usr/bin." # ── Step 7: enable + (re)start the service ─────────────────────────────────── info "[7/7] systemctl daemon-reload + enable/restart ci-web.service" systemctl daemon-reload if systemctl is-active --quiet ci-web.service; then systemctl restart ci-web.service echo " ci-web.service was already running -- restarted to pick up any changes" else systemctl enable --now ci-web.service echo " ci-web.service enabled and started" fi echo "" echo "╔══════════════════════════════════════════════════════╗" echo "║ Install complete ║" echo "╚══════════════════════════════════════════════════════╝" echo "" echo " Status : systemctl status ci-web.service" echo " Logs : journalctl -u ci-web.service -f" if [[ "$WEB_HOST" == "127.0.0.1" || "$WEB_HOST" == "localhost" ]]; then echo " URL : http://${WEB_HOST}:${WEB_PORT}/ (loopback only -- open mode works without OIDC)" else echo " URL : http://${WEB_HOST}:${WEB_PORT}/" echo " NOTE : a LAN bind refuses to start until [web.oidc] is configured" echo " and enabled in $CONFIG_PATH (see step 5 above) -- this" echo " matches the same guard 'ci web serve' enforces itself." fi echo " Docs : $REPO_DIR/docs/WEB-UI.md" echo ""