306 lines
11 KiB
Bash
Executable File
306 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
umask 0077
|
|
|
|
SOURCE_ROOT="${SOURCE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/aios-orchestrator-gateway}"
|
|
CONFIG_ROOT="${CONFIG_ROOT:-/etc/aios-orchestrator-gateway}"
|
|
STATE_ROOT="${STATE_ROOT:-/var/lib/aios-orchestrator-gateway}"
|
|
UNIT_ROOT="${UNIT_ROOT:-/etc/systemd/system}"
|
|
SYSUSERS_ROOT="${SYSUSERS_ROOT:-/etc/sysusers.d}"
|
|
TMPFILES_ROOT="${TMPFILES_ROOT:-/etc/tmpfiles.d}"
|
|
DRY_RUN=0
|
|
|
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
|
DRY_RUN=1
|
|
fi
|
|
|
|
fail() {
|
|
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=%s\n' "$1" >&2
|
|
exit 2
|
|
}
|
|
|
|
[[ "$(id -u)" -eq 0 ]] || fail OWNER_INSTALL_REQUIRED
|
|
|
|
for cmd in git python3 systemd-analyze systemctl systemd-sysusers systemd-tmpfiles sha256sum find sort xargs; do
|
|
command -v "$cmd" >/dev/null 2>&1 || fail "MISSING_COMMAND_${cmd}"
|
|
done
|
|
|
|
if [[ -f "$SOURCE_ROOT/SHA256SUMS" ]]; then
|
|
(cd "$SOURCE_ROOT" && sha256sum --check SHA256SUMS)
|
|
fi
|
|
|
|
for path in \
|
|
"$SOURCE_ROOT/services/aios_orchestrator_gateway" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
|
|
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json"; do
|
|
[[ -e "$path" ]] || fail "MISSING_SOURCE_${path}"
|
|
done
|
|
|
|
PYCACHE="/tmp/aios-orchestrator-install-pycache-$$"
|
|
rm -rf "$PYCACHE"
|
|
PYTHONPYCACHEPREFIX="$PYCACHE" python3 -m compileall -q "$SOURCE_ROOT/services" "$SOURCE_ROOT/scripts"
|
|
PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$SOURCE_ROOT" \
|
|
python3 -m unittest -v tests.test_aios_orchestrator_gateway
|
|
rm -rf "$PYCACHE"
|
|
for script in "$SOURCE_ROOT"/scripts/*.sh; do bash -n "$script"; done
|
|
python3 -m json.tool "$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" >/dev/null
|
|
systemd-analyze verify \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
|
"$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer"
|
|
|
|
SOURCE_FILES=(
|
|
README-AIOS-ORCHESTRATOR-GATEWAY.md
|
|
config/aios-orchestrator-gateway.example.json
|
|
schemas/decision.schema.json
|
|
schemas/event.schema.json
|
|
scripts/bootstrap_orchestration_loop.py
|
|
scripts/install_aios_orchestrator_gateway.sh
|
|
scripts/report_orchestrator_event.py
|
|
scripts/rollback_aios_orchestrator_gateway.sh
|
|
scripts/verify_aios_orchestrator_gateway.sh
|
|
systemd/aios-orchestrator-gateway-poll.service
|
|
systemd/aios-orchestrator-gateway-poll.timer
|
|
systemd/aios-orchestrator-gateway.service
|
|
systemd/aios-orchestrator-gateway.sysusers
|
|
systemd/aios-orchestrator-gateway.tmpfiles
|
|
tests/test_aios_orchestrator_gateway.py
|
|
tests/test_aios_orchestrator_gateway_rollback.sh
|
|
workspace-agent/CENTRAL_AGENT_INSTRUCTIONS.md
|
|
workspace-agent/orchestrator-decision-action.openapi.yaml
|
|
openapi/aios-orchestrator-gateway.openapi.yaml
|
|
)
|
|
while IFS= read -r path; do SOURCE_FILES+=("$path"); done < <(
|
|
cd "$SOURCE_ROOT"
|
|
find services/aios_orchestrator_gateway -type f ! -path '*/__pycache__/*' ! -name '*.pyc' -print | sort
|
|
)
|
|
for path in "${SOURCE_FILES[@]}"; do
|
|
[[ -f "$SOURCE_ROOT/$path" && ! -L "$SOURCE_ROOT/$path" ]] || fail "INVALID_SOURCE_FILE_${path}"
|
|
done
|
|
|
|
SOURCE_DIGEST="$(
|
|
cd "$SOURCE_ROOT"
|
|
printf '%s\0' "${SOURCE_FILES[@]}" | sort -z | xargs -0 sha256sum \
|
|
| sha256sum \
|
|
| awk '{print $1}'
|
|
)"
|
|
[[ "$SOURCE_DIGEST" =~ ^[0-9a-f]{64}$ ]] || fail SOURCE_DIGEST_INVALID
|
|
RELEASE_DIR="$INSTALL_ROOT/releases/$SOURCE_DIGEST"
|
|
|
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
|
printf 'AIOS_ORCHESTRATOR_INSTALL_DRY_RUN=PASS digest=%s release=%s\n' \
|
|
"$SOURCE_DIGEST" "$RELEASE_DIR"
|
|
exit 0
|
|
fi
|
|
|
|
systemd-sysusers "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers"
|
|
|
|
install -d -m 0755 "$INSTALL_ROOT/releases"
|
|
install -d -o root -g aios-orchestrator -m 0750 "$CONFIG_ROOT"
|
|
install -d -m 0700 "$STATE_ROOT"
|
|
|
|
systemd-tmpfiles --create "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles"
|
|
|
|
if [[ ! -d "$RELEASE_DIR" ]]; then
|
|
TMP_RELEASE="$INSTALL_ROOT/releases/.tmp-$SOURCE_DIGEST-$$"
|
|
install -d -m 0755 "$TMP_RELEASE"
|
|
for path in "${SOURCE_FILES[@]}"; do
|
|
install -d -m 0755 "$TMP_RELEASE/$(dirname "$path")"
|
|
cp -a "$SOURCE_ROOT/$path" "$TMP_RELEASE/$path"
|
|
done
|
|
printf '%s\n' "$SOURCE_DIGEST" > "$TMP_RELEASE/SOURCE_DIGEST"
|
|
chmod -R go-w "$TMP_RELEASE"
|
|
mv "$TMP_RELEASE" "$RELEASE_DIR"
|
|
fi
|
|
|
|
PREVIOUS_TARGET=""
|
|
if [[ -L "$INSTALL_ROOT/current" ]]; then
|
|
PREVIOUS_TARGET="$(readlink -f "$INSTALL_ROOT/current")"
|
|
fi
|
|
|
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.service" \
|
|
"$UNIT_ROOT/aios-orchestrator-gateway.service"
|
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.service" \
|
|
"$UNIT_ROOT/aios-orchestrator-gateway-poll.service"
|
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway-poll.timer" \
|
|
"$UNIT_ROOT/aios-orchestrator-gateway-poll.timer"
|
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.sysusers" \
|
|
"$SYSUSERS_ROOT/aios-orchestrator-gateway.conf"
|
|
install -m 0644 "$SOURCE_ROOT/systemd/aios-orchestrator-gateway.tmpfiles" \
|
|
"$TMPFILES_ROOT/aios-orchestrator-gateway.conf"
|
|
|
|
if [[ ! -f "$CONFIG_ROOT/inbound.token" ]]; then
|
|
python3 - <<'PY' > "$CONFIG_ROOT/inbound.token"
|
|
import secrets, sys
|
|
sys.stdout.write(secrets.token_urlsafe(48))
|
|
PY
|
|
fi
|
|
[[ ! -L "$CONFIG_ROOT/inbound.token" ]] || fail INBOUND_TOKEN_SYMLINK
|
|
chown root:aios-orchestrator "$CONFIG_ROOT/inbound.token"
|
|
chmod 0640 "$CONFIG_ROOT/inbound.token"
|
|
|
|
if [[ ! -f "$CONFIG_ROOT/callback.token" ]]; then
|
|
python3 - <<'PY' > "$CONFIG_ROOT/callback.token"
|
|
import secrets, sys
|
|
sys.stdout.write(secrets.token_urlsafe(48))
|
|
PY
|
|
fi
|
|
[[ ! -L "$CONFIG_ROOT/callback.token" ]] || fail CALLBACK_TOKEN_SYMLINK
|
|
chown root:aios-orchestrator "$CONFIG_ROOT/callback.token"
|
|
chmod 0640 "$CONFIG_ROOT/callback.token"
|
|
|
|
# Historical installers appended one LF. Remove only that exact legacy byte in
|
|
# place; reject every other newline form without ever copying or printing data.
|
|
python3 - "$CONFIG_ROOT/config.json" "$CONFIG_ROOT/inbound.token" "$CONFIG_ROOT/callback.token" <<'PY'
|
|
import json, os, pathlib, stat, sys
|
|
config_path = pathlib.Path(sys.argv[1])
|
|
names = list(sys.argv[2:])
|
|
if config_path.is_file() and not config_path.is_symlink():
|
|
config = json.loads(config_path.read_text())
|
|
candidates = [
|
|
config.get("inbound_token_file"),
|
|
config.get("callback_token_file"),
|
|
(config.get("workspace_agent") or {}).get("access_token_file"),
|
|
(config.get("responses_fallback") or {}).get("api_key_file"),
|
|
(config.get("openclaw_sync") or {}).get("bearer_token_file"),
|
|
]
|
|
names.extend(name for name in candidates if isinstance(name, str) and name)
|
|
for name in dict.fromkeys(names):
|
|
path = pathlib.Path(name)
|
|
if not path.is_absolute() or path.is_symlink() or path.resolve() != path:
|
|
raise SystemExit("invalid token path")
|
|
fd = os.open(name, os.O_RDWR | getattr(os, "O_NOFOLLOW", 0))
|
|
try:
|
|
info = os.fstat(fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0 or info.st_size > 16384:
|
|
raise SystemExit("invalid token metadata")
|
|
data = os.read(fd, info.st_size)
|
|
if data.endswith(b"\n") and b"\n" not in data[:-1] and b"\r" not in data:
|
|
os.ftruncate(fd, info.st_size - 1)
|
|
os.fsync(fd)
|
|
elif b"\n" in data or b"\r" in data:
|
|
raise SystemExit("invalid token format")
|
|
finally:
|
|
os.close(fd)
|
|
PY
|
|
|
|
if [[ ! -f "$CONFIG_ROOT/config.json" ]]; then
|
|
install -m 0600 \
|
|
"$SOURCE_ROOT/config/aios-orchestrator-gateway.example.json" \
|
|
"$CONFIG_ROOT/config.json.example"
|
|
printf 'AIOS_ORCHESTRATOR_INSTALL=BLOCKED reason=CONFIGURATION_REQUIRED digest=%s\n' \
|
|
"$SOURCE_DIGEST"
|
|
exit 2
|
|
fi
|
|
|
|
chown root:aios-orchestrator "$CONFIG_ROOT/config.json"
|
|
chmod 0640 "$CONFIG_ROOT/config.json"
|
|
|
|
python3 -m json.tool "$CONFIG_ROOT/config.json" >/dev/null
|
|
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
|
|
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
|
|
PYTHONPATH="$RELEASE_DIR" \
|
|
python3 - "$CONFIG_ROOT/config.json" <<'PY'
|
|
import sys
|
|
from pathlib import Path
|
|
from services.aios_orchestrator_gateway.config import Config
|
|
cfg = Config.from_file(Path(sys.argv[1]))
|
|
cfg.inbound_token()
|
|
cfg.callback_token()
|
|
if cfg.workspace_trigger_id:
|
|
cfg.workspace_token()
|
|
if cfg.responses_api_key_file:
|
|
cfg.responses_api_key()
|
|
if cfg.openclaw_sync_endpoint:
|
|
cfg.openclaw_sync_token()
|
|
print("CONFIG_VALIDATE=PASS")
|
|
PY
|
|
|
|
# Commit the release pointer only after every candidate/config/secret check has
|
|
# succeeded. A failed validation therefore leaves the running release intact.
|
|
ln -s "$RELEASE_DIR" "$INSTALL_ROOT/.current-new-$$"
|
|
mv -Tf "$INSTALL_ROOT/.current-new-$$" "$INSTALL_ROOT/current"
|
|
|
|
python3 - "$STATE_ROOT/install-receipt.json" "$SOURCE_DIGEST" "$RELEASE_DIR" "$PREVIOUS_TARGET" <<'PY'
|
|
import json, os, sys, tempfile
|
|
from pathlib import Path
|
|
path = Path(sys.argv[1])
|
|
data = {
|
|
"schema_version": 1,
|
|
"source_digest": sys.argv[2],
|
|
"current_release": sys.argv[3],
|
|
"previous_release": sys.argv[4] or None,
|
|
"last_rollback": None,
|
|
}
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fd, temp = tempfile.mkstemp(prefix=".install-receipt-", dir=path.parent)
|
|
try:
|
|
with os.fdopen(fd, "w") as handle:
|
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
handle.write("\n")
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.chmod(temp, 0o600)
|
|
os.replace(temp, path)
|
|
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(directory)
|
|
finally:
|
|
os.close(directory)
|
|
finally:
|
|
try:
|
|
os.unlink(temp)
|
|
except FileNotFoundError:
|
|
pass
|
|
PY
|
|
chown root:root "$STATE_ROOT/install-receipt.json"
|
|
chmod 0600 "$STATE_ROOT/install-receipt.json"
|
|
|
|
systemctl daemon-reload
|
|
systemctl enable aios-orchestrator-gateway.service
|
|
systemctl enable aios-orchestrator-gateway-poll.timer
|
|
systemctl stop aios-orchestrator-gateway-poll.timer || true
|
|
systemctl stop aios-orchestrator-gateway-poll.service || true
|
|
|
|
rollback_activation() {
|
|
if [[ -n "$PREVIOUS_TARGET" && -d "$PREVIOUS_TARGET" ]]; then
|
|
bash "$RELEASE_DIR/scripts/rollback_aios_orchestrator_gateway.sh" || true
|
|
else
|
|
systemctl stop aios-orchestrator-gateway.service || true
|
|
fi
|
|
fail ACTIVATION_FAILED_ROLLED_BACK
|
|
}
|
|
|
|
systemctl restart aios-orchestrator-gateway.service || rollback_activation
|
|
python3 - <<'PY' || rollback_activation
|
|
import json, time, urllib.request
|
|
for _ in range(30):
|
|
try:
|
|
with urllib.request.urlopen("http://127.0.0.1:18920/healthz", timeout=1) as response:
|
|
body = json.loads(response.read(65536))
|
|
if response.status == 200 and body.get("status") == "active":
|
|
raise SystemExit(0)
|
|
except Exception:
|
|
pass
|
|
time.sleep(0.5)
|
|
raise SystemExit(1)
|
|
PY
|
|
systemctl start aios-orchestrator-gateway-poll.timer || rollback_activation
|
|
systemctl is-active --quiet aios-orchestrator-gateway.service
|
|
systemctl is-active --quiet aios-orchestrator-gateway-poll.timer
|
|
|
|
AIOS_ORCHESTRATOR_CONFIG_OWNER_UID=0 \
|
|
AIOS_ORCHESTRATOR_SECRET_OWNER_UID=0 \
|
|
PYTHONPATH="$INSTALL_ROOT/current" \
|
|
python3 "$INSTALL_ROOT/current/scripts/bootstrap_orchestration_loop.py" \
|
|
--config "$CONFIG_ROOT/config.json"
|
|
systemctl start aios-orchestrator-gateway-poll.service || rollback_activation
|
|
|
|
printf 'AIOS_ORCHESTRATOR_INSTALL=PASS digest=%s previous=%s\n' \
|
|
"$SOURCE_DIGEST" "${PREVIOUS_TARGET:-NONE}"
|