151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Call the private Atlas Gitea API through a runtime-only token boundary."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
DEFAULT_BASE_URL = "https://scm.bstein.dev"
|
|
DEFAULT_TOKEN_FILE = Path("/runtime-access/gitea-token")
|
|
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE")
|
|
|
|
|
|
def read_token(path: Path = DEFAULT_TOKEN_FILE) -> str:
|
|
"""Read and validate the token from its in-memory Vault projection."""
|
|
token = path.read_text(encoding="utf-8").strip()
|
|
if not token:
|
|
raise ValueError(f"runtime credential is empty: {path}")
|
|
return token
|
|
|
|
|
|
def api_url(base_url: str, path: str) -> str:
|
|
"""Return a same-origin Gitea API URL for a validated API path."""
|
|
base = urllib.parse.urlsplit(base_url.rstrip("/"))
|
|
target = urllib.parse.urlsplit(path)
|
|
if base.scheme not in {"http", "https"} or not base.netloc:
|
|
raise ValueError("GITEA_BASE_URL must be an absolute HTTP(S) URL")
|
|
if target.scheme or target.netloc or target.fragment:
|
|
raise ValueError("API path must be relative to the configured Gitea origin")
|
|
if not target.path.startswith("/api/v1/"):
|
|
raise ValueError("API path must start with /api/v1/")
|
|
return urllib.parse.urlunsplit(
|
|
(base.scheme, base.netloc, target.path, target.query, "")
|
|
)
|
|
|
|
|
|
def build_request(
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
base_url: str,
|
|
token: str,
|
|
data: object | None = None,
|
|
) -> urllib.request.Request:
|
|
"""Build one authenticated request without placing the token in its URL."""
|
|
payload = None
|
|
if data is not None:
|
|
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
return urllib.request.Request(
|
|
api_url(base_url, path),
|
|
data=payload,
|
|
method=method,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "hermes-atlas-operator/1",
|
|
},
|
|
)
|
|
|
|
|
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
"""Parse a bounded method, API path, and optional JSON request body."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Call the Atlas Gitea API using the runtime Vault token."
|
|
)
|
|
parser.add_argument("method", choices=ALLOWED_METHODS)
|
|
parser.add_argument("path", help="Gitea path beginning with /api/v1/")
|
|
data_group = parser.add_mutually_exclusive_group()
|
|
data_group.add_argument("--data-json", help="JSON object/array request body")
|
|
data_group.add_argument(
|
|
"--data-file", type=Path, help="path to a JSON request body"
|
|
)
|
|
data_group.add_argument(
|
|
"--field",
|
|
action="append",
|
|
metavar="KEY=VALUE",
|
|
help=(
|
|
"repeatable JSON field; bare text remains a string while true, false, "
|
|
"null, numbers, objects, and arrays are decoded as JSON"
|
|
),
|
|
)
|
|
return parser.parse_args(argv)
|
|
|
|
|
|
def parse_fields(values: list[str]) -> dict[str, object]:
|
|
"""Build a JSON object from shell-safe, repeatable key/value arguments."""
|
|
result: dict[str, object] = {}
|
|
for value in values:
|
|
key, separator, raw = value.partition("=")
|
|
if not separator or not key or any(char.isspace() for char in key):
|
|
raise ValueError("each --field must be KEY=VALUE with a non-space key")
|
|
try:
|
|
result[key] = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
result[key] = raw
|
|
return result
|
|
|
|
|
|
def load_data(args: argparse.Namespace) -> object | None:
|
|
"""Decode the optional JSON body without involving a shell expansion."""
|
|
if args.data_json is not None:
|
|
return json.loads(args.data_json)
|
|
if args.data_file is not None:
|
|
return json.loads(args.data_file.read_text(encoding="utf-8"))
|
|
if args.field is not None:
|
|
return parse_fields(args.field)
|
|
return None
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
"""Execute the request, print only its response body, and return HTTP status."""
|
|
args = parse_args(argv)
|
|
try:
|
|
request = build_request(
|
|
args.method,
|
|
args.path,
|
|
base_url=os.environ.get("GITEA_BASE_URL", DEFAULT_BASE_URL),
|
|
token=read_token(),
|
|
data=load_data(args),
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
body = response.read()
|
|
if body:
|
|
sys.stdout.buffer.write(body)
|
|
if not body.endswith(b"\n"):
|
|
sys.stdout.buffer.write(b"\n")
|
|
return 0
|
|
except urllib.error.HTTPError as exc:
|
|
body = exc.read(65536)
|
|
print(f"Gitea API returned HTTP {exc.code}", file=sys.stderr)
|
|
if body:
|
|
sys.stderr.buffer.write(body)
|
|
if not body.endswith(b"\n"):
|
|
sys.stderr.buffer.write(b"\n")
|
|
return 1
|
|
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
print(f"Gitea API request failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|