This commit is contained in:
parent
51706d2d11
commit
853e99ca5f
21 changed files with 1402 additions and 77 deletions
173
scripts/bootstrap_ci_clone_key.py
Executable file
173
scripts/bootstrap_ci_clone_key.py
Executable file
|
|
@ -0,0 +1,173 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
DEFAULT_FORGEJO_BASE_URL = "https://aksal.cloud"
|
||||
DEFAULT_REPO = "Robot-U/robot-u-site"
|
||||
DEFAULT_KEY_TITLE = "robot-u-site-actions-clone"
|
||||
DEFAULT_SECRET_NAME = "CI_REPO_SSH_KEY"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root_dir = Path(__file__).resolve().parents[1]
|
||||
_load_env_file(root_dir / ".env")
|
||||
_load_env_file(root_dir / ".env.local")
|
||||
|
||||
token = os.getenv("FORGEJO_API_TOKEN") or os.getenv("FORGEJO_TOKEN")
|
||||
if not token:
|
||||
print(
|
||||
"Set FORGEJO_API_TOKEN to a Forgejo token that can manage repo keys/secrets.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
base_url = (os.getenv("FORGEJO_BASE_URL") or DEFAULT_FORGEJO_BASE_URL).rstrip("/")
|
||||
repo = os.getenv("FORGEJO_REPO") or DEFAULT_REPO
|
||||
key_title = os.getenv("CI_CLONE_KEY_TITLE") or DEFAULT_KEY_TITLE
|
||||
secret_name = os.getenv("CI_CLONE_SECRET_NAME") or DEFAULT_SECRET_NAME
|
||||
owner, repo_name = _repo_parts(repo)
|
||||
|
||||
ssh_keygen = shutil.which("ssh-keygen")
|
||||
if not ssh_keygen:
|
||||
print("ssh-keygen is required.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="robot-u-ci-key.") as temp_dir:
|
||||
key_path = Path(temp_dir) / "id_ed25519"
|
||||
subprocess.run(
|
||||
[
|
||||
ssh_keygen,
|
||||
"-t",
|
||||
"ed25519",
|
||||
"-N",
|
||||
"",
|
||||
"-C",
|
||||
key_title,
|
||||
"-f",
|
||||
str(key_path),
|
||||
],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
public_key = key_path.with_suffix(".pub").read_text().strip()
|
||||
private_key = key_path.read_text()
|
||||
|
||||
deploy_key = _create_deploy_key(
|
||||
base_url,
|
||||
token,
|
||||
owner,
|
||||
repo_name,
|
||||
key_title,
|
||||
public_key,
|
||||
)
|
||||
_put_actions_secret(base_url, token, owner, repo_name, secret_name, private_key)
|
||||
|
||||
print(f"Added read-only deploy key {deploy_key.get('id', '(unknown id)')} to {repo}.")
|
||||
print(f"Updated repository Actions secret {secret_name}.")
|
||||
print("Private key material was only held in memory and a temporary directory.")
|
||||
return 0
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip().removeprefix("export ").strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def _repo_parts(repo: str) -> tuple[str, str]:
|
||||
parts = repo.split("/", 1)
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise SystemExit("FORGEJO_REPO must use owner/repo format.")
|
||||
return parts[0], parts[1]
|
||||
|
||||
|
||||
def _create_deploy_key(
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
title: str,
|
||||
public_key: str,
|
||||
) -> dict[str, object]:
|
||||
path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/keys"
|
||||
payload = {
|
||||
"title": title,
|
||||
"key": public_key,
|
||||
"read_only": True,
|
||||
}
|
||||
return _request_json(base_url, token, "POST", path, payload)
|
||||
|
||||
|
||||
def _put_actions_secret(
|
||||
base_url: str,
|
||||
token: str,
|
||||
owner: str,
|
||||
repo: str,
|
||||
name: str,
|
||||
value: str,
|
||||
) -> None:
|
||||
path = f"/api/v1/repos/{quote(owner)}/{quote(repo)}/actions/secrets/{quote(name)}"
|
||||
_request_json(base_url, token, "PUT", path, {"data": value})
|
||||
|
||||
|
||||
def _request_json(
|
||||
base_url: str,
|
||||
token: str,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = Request(
|
||||
f"{base_url}{path}",
|
||||
data=data,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"token {token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
if response.status == 204:
|
||||
return {}
|
||||
raw_body = response.read()
|
||||
except HTTPError as error:
|
||||
detail = error.read().decode("utf-8", errors="replace")
|
||||
print(f"Forgejo API returned {error.code}: {detail}", file=sys.stderr)
|
||||
raise SystemExit(1) from error
|
||||
|
||||
if not raw_body:
|
||||
return {}
|
||||
|
||||
decoded = json.loads(raw_body.decode("utf-8"))
|
||||
if isinstance(decoded, dict):
|
||||
return decoded
|
||||
return {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
140
scripts/check_deploy_config.py
Executable file
140
scripts/check_deploy_config.py
Executable file
|
|
@ -0,0 +1,140 @@
|
|||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
PLACEHOLDER_VALUES = {
|
||||
"",
|
||||
"replace-with-a-random-32-byte-or-longer-secret",
|
||||
"your-forgejo-oauth-client-id",
|
||||
"your-forgejo-oauth-client-secret",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root_dir = Path(__file__).resolve().parents[1]
|
||||
_load_env_file(root_dir / ".env")
|
||||
_load_env_file(root_dir / ".env.local")
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
app_base_url = _required_env("APP_BASE_URL", errors)
|
||||
parsed_app_url = urlparse(app_base_url)
|
||||
if app_base_url and parsed_app_url.scheme not in {"http", "https"}:
|
||||
errors.append("APP_BASE_URL must start with http:// or https://.")
|
||||
if parsed_app_url.scheme == "http":
|
||||
warnings.append("APP_BASE_URL uses http://. Use https:// for public deployment.")
|
||||
|
||||
auth_secret = _required_env("AUTH_SECRET_KEY", errors)
|
||||
if auth_secret in PLACEHOLDER_VALUES or len(auth_secret) < 32:
|
||||
errors.append("AUTH_SECRET_KEY must be a real random secret at least 32 characters long.")
|
||||
|
||||
auth_cookie_secure = _env_bool("AUTH_COOKIE_SECURE")
|
||||
if parsed_app_url.scheme == "https" and not auth_cookie_secure:
|
||||
errors.append("AUTH_COOKIE_SECURE=true is required when APP_BASE_URL uses https://.")
|
||||
if parsed_app_url.scheme == "http" and auth_cookie_secure:
|
||||
warnings.append("AUTH_COOKIE_SECURE=true will prevent cookies over plain HTTP.")
|
||||
|
||||
_required_env("FORGEJO_BASE_URL", errors)
|
||||
_required_env("FORGEJO_OAUTH_CLIENT_ID", errors)
|
||||
_required_env("FORGEJO_OAUTH_CLIENT_SECRET", errors)
|
||||
|
||||
general_repo = _required_env("FORGEJO_GENERAL_DISCUSSION_REPO", errors)
|
||||
if general_repo and len(general_repo.split("/", 1)) != 2:
|
||||
errors.append("FORGEJO_GENERAL_DISCUSSION_REPO must use owner/repo format.")
|
||||
|
||||
cors_origins = _csv_env("CORS_ALLOW_ORIGINS")
|
||||
if not cors_origins:
|
||||
warnings.append("CORS_ALLOW_ORIGINS is not set. The app will default to APP_BASE_URL.")
|
||||
elif "*" in cors_origins:
|
||||
warnings.append("CORS_ALLOW_ORIGINS includes '*'. Avoid that for public deployment.")
|
||||
|
||||
if not os.getenv("FORGEJO_WEBHOOK_SECRET"):
|
||||
warnings.append(
|
||||
"FORGEJO_WEBHOOK_SECRET is not set. Webhook cache invalidation is unauthenticated."
|
||||
)
|
||||
|
||||
_positive_number_env("FORGEJO_REPO_SCAN_LIMIT", errors)
|
||||
_positive_number_env("FORGEJO_RECENT_ISSUE_LIMIT", errors)
|
||||
_positive_number_env("CALENDAR_EVENT_LIMIT", errors)
|
||||
_non_negative_number_env("FORGEJO_CACHE_TTL_SECONDS", errors)
|
||||
_positive_number_env("FORGEJO_REQUEST_TIMEOUT_SECONDS", errors)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"WARNING: {warning}", file=sys.stderr)
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("Deployment configuration looks usable.")
|
||||
return 0
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
|
||||
for raw_line in path.read_text().splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip().removeprefix("export ").strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def _required_env(name: str, errors: list[str]) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if value in PLACEHOLDER_VALUES:
|
||||
errors.append(f"{name} is required.")
|
||||
return value
|
||||
|
||||
|
||||
def _env_bool(name: str) -> bool:
|
||||
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _csv_env(name: str) -> tuple[str, ...]:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
return ()
|
||||
return tuple(entry.strip() for entry in value.replace("\n", ",").split(",") if entry.strip())
|
||||
|
||||
|
||||
def _positive_number_env(name: str, errors: list[str]) -> None:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
return
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
errors.append(f"{name} must be numeric.")
|
||||
return
|
||||
if parsed_value <= 0:
|
||||
errors.append(f"{name} must be greater than zero.")
|
||||
|
||||
|
||||
def _non_negative_number_env(name: str, errors: list[str]) -> None:
|
||||
value = os.getenv(name, "").strip()
|
||||
if not value:
|
||||
return
|
||||
try:
|
||||
parsed_value = float(value)
|
||||
except ValueError:
|
||||
errors.append(f"{name} must be numeric.")
|
||||
return
|
||||
if parsed_value < 0:
|
||||
errors.append(f"{name} must be zero or greater.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -11,6 +11,8 @@ python_files=(
|
|||
"live_prototype.py"
|
||||
"prototype_cache.py"
|
||||
"settings.py"
|
||||
"scripts/bootstrap_ci_clone_key.py"
|
||||
"scripts/check_deploy_config.py"
|
||||
"update_events.py"
|
||||
"tests"
|
||||
)
|
||||
|
|
@ -55,7 +57,7 @@ run_check \
|
|||
run_check \
|
||||
"Vulture" \
|
||||
uv run --with "vulture>=2.15,<3.0.0" \
|
||||
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py update_events.py tests --min-confidence 80
|
||||
vulture app.py auth.py calendar_feeds.py forgejo_client.py live_prototype.py prototype_cache.py settings.py scripts/bootstrap_ci_clone_key.py scripts/check_deploy_config.py update_events.py tests --min-confidence 80
|
||||
run_check \
|
||||
"Backend Tests" \
|
||||
"${python_cmd[@]}" -m unittest discover -s tests -p "test_*.py"
|
||||
|
|
|
|||
18
scripts/run_prod.sh
Executable file
18
scripts/run_prod.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${root_dir}"
|
||||
|
||||
host="${HOST:-0.0.0.0}"
|
||||
port="${PORT:-8000}"
|
||||
|
||||
if [[ ! -f "${root_dir}/frontend/dist/index.html" ]]; then
|
||||
echo "frontend/dist/index.html is missing. Build the frontend before starting production." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec python3 -m uvicorn app:app \
|
||||
--host "${host}" \
|
||||
--port "${port}" \
|
||||
--proxy-headers
|
||||
Loading…
Add table
Add a link
Reference in a new issue