feat(drone): enroll drone + gitea SCM dep (M1 implementation)
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- tests/gitea/recipe_meta.py: gitea as install-time dep provider; sqlite3
overlay EXTRA_ENV, health path /api/healthz, relaxed access for CI use
- tests/drone/recipe_meta.py: DEPS=["gitea"]; health /healthz; 600s timeout
- tests/drone/install_steps.sh: wires GITEA_CLIENT_ID + GITEA_DOMAIN +
client_secret Docker secret + DRONE_USER_CREATE before single drone deploy
- tests/drone/functional/test_scm_configured.py: Playwright-free SCM test —
follows /login redirect, asserts final URL is gitea dep's OAuth2 authorize
endpoint with matching client_id (per Adversary pre-probe REVIEW-drone.md)
- tests/drone/PARITY.md: backup structural-skip justified (no backupbot labels)
- runner/harness/sso.py: setup_gitea_oauth() — creates gitea admin user via
CLI + OAuth2 app via API, returns {admin_user, admin_password, client_id,
client_secret} for install_steps.sh consumption
- runner/run_recipe_ci.py: _enrich_deps_with_sso now handles gitea dep (calls
setup_gitea_oauth; keycloak path unchanged)
- tests/unit/test_gitea_dep.py: unit tests for gitea dep path — meta loading,
SSO routing, SCM redirect assertion logic (parametrized)
- machine-docs: STATUS/JOURNAL/BACKLOG-drone.md phase state files initialized
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@ -365,3 +365,127 @@ def load_sso_creds() -> dict | None:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea OAuth2 setup — drone dep provider (phase drone)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _gitea_api(
|
||||
provider_domain: str,
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
body=None,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> tuple[int, object]:
|
||||
"""Call the gitea REST API (basic-auth). Returns (status, body_json_or_None)."""
|
||||
import base64
|
||||
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
|
||||
headers: dict[str, str] = {"Authorization": f"Basic {auth}"}
|
||||
if data:
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(
|
||||
f"https://{provider_domain}/api/v1{path}",
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30, context=_CTX) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
parsed = None
|
||||
return e.code, parsed
|
||||
|
||||
|
||||
def setup_gitea_oauth(provider_domain: str, parent_domain: str) -> dict:
|
||||
"""Create a gitea admin user + OAuth2 application for a drone dep.
|
||||
|
||||
Steps:
|
||||
1. Create admin user via `gitea admin user create` CLI inside the container.
|
||||
2. Create an OAuth2 app via the gitea REST API (basic auth as the new admin).
|
||||
3. Return a creds dict: {admin_user, admin_password, client_id, client_secret}.
|
||||
|
||||
The caller (orchestrator) stores creds in $CCCI_DEPS_FILE so drone's install_steps.sh
|
||||
can wire DRONE_GITEA_CLIENT_ID + the client_secret Docker secret before the first deploy.
|
||||
Per plan §4.4-B, the client_secret is class-B run-scoped and destroyed on teardown.
|
||||
"""
|
||||
admin_user = "ci_admin"
|
||||
# 32-char alphanumeric password — safe to pass as a CLI arg (no shell metacharacters)
|
||||
admin_password = secrets.token_hex(16)
|
||||
admin_email = "ci@ci.local"
|
||||
|
||||
# 1. Create admin user via gitea CLI inside the running container.
|
||||
# The rootless gitea image has GITEA_WORK_DIR + GITEA_CUSTOM set as ENV; docker exec
|
||||
# inherits those (image ENV is part of the container config). The gitea binary is in PATH.
|
||||
print(f" gitea dep: creating admin user {admin_user!r} on {provider_domain}", flush=True)
|
||||
try:
|
||||
out = lifecycle.exec_in_app(
|
||||
provider_domain,
|
||||
[
|
||||
"gitea",
|
||||
"admin",
|
||||
"user",
|
||||
"create",
|
||||
"--admin",
|
||||
"--username",
|
||||
admin_user,
|
||||
"--password",
|
||||
admin_password,
|
||||
"--email",
|
||||
admin_email,
|
||||
"--must-change-password",
|
||||
"false",
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
print(f" gitea dep: admin user created: {out.strip()[:120]}", flush=True)
|
||||
except RuntimeError as e:
|
||||
msg = str(e)
|
||||
if "already exists" in msg.lower() or "user already exists" in msg.lower():
|
||||
print(f" gitea dep: admin user {admin_user!r} already exists — continuing", flush=True)
|
||||
else:
|
||||
raise
|
||||
|
||||
# 2. Create OAuth2 application via gitea API.
|
||||
oauth_app_name = f"drone-{parent_domain[:8]}"
|
||||
redirect_uri = f"https://{parent_domain}/login"
|
||||
status, resp = _gitea_api(
|
||||
provider_domain,
|
||||
"/user/applications/oauth2",
|
||||
method="POST",
|
||||
body={
|
||||
"name": oauth_app_name,
|
||||
"redirect_uris": [redirect_uri],
|
||||
"confidential_client": True,
|
||||
},
|
||||
username=admin_user,
|
||||
password=admin_password,
|
||||
)
|
||||
if status not in (201, 200):
|
||||
raise RuntimeError(
|
||||
f"gitea OAuth2 app create failed: HTTP {status} — {resp!r}"
|
||||
)
|
||||
client_id = resp["client_id"]
|
||||
client_secret = resp["client_secret"]
|
||||
print(
|
||||
f" gitea dep: OAuth2 app {oauth_app_name!r} created (client_id={client_id})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"admin_user": admin_user,
|
||||
"admin_password": admin_password,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}
|
||||
|
||||
@ -474,8 +474,9 @@ def _enrich_deps_with_sso(parent_recipe: str, parent_domain: str, deps_list) ->
|
||||
setup function, then return a recipe→entry dict carrying domain + admin + realm/client/user
|
||||
info — the shape the `install_steps.sh` hook (and dependent tests) read.
|
||||
|
||||
Provider routing: today only `keycloak` is supported. authentik will need a parallel
|
||||
`setup_authentik_realm` when an authentik-dep recipe enrolls (DEFERRED.md #9).
|
||||
Provider routing: keycloak (OIDC realm/client) and gitea (OAuth2 app for drone) are
|
||||
supported. authentik will need a parallel `setup_authentik_realm` when an authentik-dep
|
||||
recipe enrolls (DEFERRED.md #9).
|
||||
"""
|
||||
from harness import sso, warm # local import — sso may not be needed for dep-less runs
|
||||
|
||||
@ -485,6 +486,19 @@ def _enrich_deps_with_sso(parent_recipe: str, parent_domain: str, deps_list) ->
|
||||
dep_domain = entry.get("domain")
|
||||
if not dep_recipe or not dep_domain:
|
||||
continue
|
||||
if dep_recipe == "gitea":
|
||||
# Gitea dep provider (phase drone): create admin user + OAuth2 app so the
|
||||
# dependent recipe's install_steps.sh can wire DRONE_GITEA_* before deploy.
|
||||
creds = sso.setup_gitea_oauth(dep_domain, parent_domain)
|
||||
out[dep_recipe] = {
|
||||
"recipe": dep_recipe,
|
||||
"domain": dep_domain,
|
||||
"admin_user": creds["admin_user"],
|
||||
"admin_password": creds["admin_password"],
|
||||
"client_id": creds["client_id"],
|
||||
"client_secret": creds["client_secret"],
|
||||
}
|
||||
continue
|
||||
if dep_recipe != "keycloak":
|
||||
# Provider not yet supported — record bare entry; install_steps.sh / tests will
|
||||
# raise if they need realm/client info they don't see.
|
||||
|
||||
Reference in New Issue
Block a user