Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.
Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.
deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.
Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
292 lines
13 KiB
Bash
Executable File
292 lines
13 KiB
Bash
Executable File
#!/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 '<repo>[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 <lan-ip>] [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 <<EOF
|
|
Usage: sudo bash $(basename "$0") [options]
|
|
|
|
Options:
|
|
--host <ip> 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 <p> 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" <<EOF
|
|
|
|
$WEB_MARKER
|
|
# Uncomment and fill in [web.oidc] once you have registered the Gitea
|
|
# OAuth2 application (see the installer's step 5 output / docs/WEB-UI.md).
|
|
# session_secret_cred / client_secret_cred are KEYRING KEY NAMES only --
|
|
# the secrets themselves are never written to this file.
|
|
#
|
|
# [web]
|
|
# enabled = true
|
|
# bind_host = "${WEB_HOST}" # never 0.0.0.0
|
|
# bind_port = ${WEB_PORT}
|
|
# audit_log = "/var/lib/ci/logs/web-audit.jsonl"
|
|
# allowed_users = ["CHANGE-ME"] # Gitea usernames allowed to log in
|
|
# session_secret_cred = "WebSessionSecret"
|
|
#
|
|
# [web.oidc]
|
|
# issuer = "https://CHANGE-ME.example"
|
|
# client_id = "CHANGE-ME"
|
|
# redirect_url = "http://${WEB_HOST}:${WEB_PORT}/auth/callback"
|
|
# client_secret_cred = "WebOidcClientSecret"
|
|
# scopes = ["openid", "profile"]
|
|
EOF
|
|
echo " Appended a commented [web]/[web.oidc] template to $CONFIG_PATH"
|
|
fi
|
|
|
|
# ── Step 4: keyring secrets (interactive prompt only, never argv) ───────────
|
|
info "[4/7] Keyring secrets (WebSessionSecret, WebOidcClientSecret)"
|
|
if [[ "$SKIP_SECRETS" == true ]]; then
|
|
echo " SKIPPED (--skip-secrets)"
|
|
else
|
|
_secret_present() {
|
|
sudo -u "$CI_USER" "$VENV/bin/python" -c "
|
|
from ci_orchestrator.credentials import KeyringCredentialStore
|
|
KeyringCredentialStore().get('$1')
|
|
" &>/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 <<EOF
|
|
|
|
In Gitea: Settings -> 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 ""
|