Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
"""GitLab Rotator module for the PAT Manager system.
|
||||
|
||||
Handles automatic token rotation for GitLab PATs using the
|
||||
GitLab Personal Access Tokens API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import RotationError
|
||||
from .models import RotationResult
|
||||
|
||||
|
||||
class GitLabRotator:
|
||||
"""Rotates GitLab Personal Access Tokens via the GitLab API.
|
||||
|
||||
Uses POST /api/v4/personal_access_tokens/{id}/rotate to obtain a new
|
||||
token with a fresh expiry date. Implements retry logic (one retry on
|
||||
failure) and skips rotation for already-expired tokens.
|
||||
|
||||
Attributes:
|
||||
timeout: HTTP request timeout in seconds.
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: int = 30) -> None:
|
||||
self.timeout = timeout
|
||||
self._gitlab_url = os.environ.get("GITLAB_URL", "https://gitlab.com")
|
||||
|
||||
async def rotate(
|
||||
self, token_id: str, current_token: str, expiry_date: str | None = None
|
||||
) -> RotationResult:
|
||||
"""Rotate a GitLab PAT to obtain a new token and expiry date.
|
||||
|
||||
If the token is already expired (based on the provided expiry_date),
|
||||
rotation is skipped and an error result is returned indicating manual
|
||||
intervention is needed.
|
||||
|
||||
On HTTP error or timeout, retries once. If the retry also fails,
|
||||
returns an error RotationResult.
|
||||
|
||||
Args:
|
||||
token_id: The GitLab personal access token ID (numeric string).
|
||||
current_token: The current token value used for authentication.
|
||||
expiry_date: Optional ISO 8601 date string (YYYY-MM-DD) of the
|
||||
token's current expiry. Used to skip expired tokens.
|
||||
|
||||
Returns:
|
||||
RotationResult with success=True and new token/expiry on success,
|
||||
or success=False with an error message on failure.
|
||||
"""
|
||||
# Skip rotation for expired tokens
|
||||
if expiry_date is not None:
|
||||
try:
|
||||
parsed_expiry = date.fromisoformat(expiry_date)
|
||||
if parsed_expiry < date.today():
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"Token {token_id} is expired (expiry: {expiry_date}). "
|
||||
"Manual intervention required — cannot rotate an expired token."
|
||||
),
|
||||
)
|
||||
except ValueError:
|
||||
# Invalid date format — proceed with rotation attempt
|
||||
pass
|
||||
|
||||
url = f"{self._gitlab_url}/api/v4/personal_access_tokens/{token_id}/rotate"
|
||||
headers = {"PRIVATE-TOKEN": current_token}
|
||||
|
||||
# First attempt
|
||||
result = await self._attempt_rotation(url, headers, token_id)
|
||||
if result.success:
|
||||
return result
|
||||
|
||||
# Retry once on failure
|
||||
result = await self._attempt_rotation(url, headers, token_id)
|
||||
return result
|
||||
|
||||
async def _attempt_rotation(
|
||||
self, url: str, headers: dict[str, str], token_id: str
|
||||
) -> RotationResult:
|
||||
"""Make a single rotation API call.
|
||||
|
||||
Args:
|
||||
url: The full rotation endpoint URL.
|
||||
headers: Request headers including PRIVATE-TOKEN.
|
||||
token_id: The token ID for error messages.
|
||||
|
||||
Returns:
|
||||
RotationResult indicating success or failure.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(url, headers=headers)
|
||||
except httpx.TimeoutException:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"Rotation request timed out for token {token_id}",
|
||||
)
|
||||
except httpx.ConnectError as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"Connection error during rotation for token {token_id}: {exc}",
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=f"HTTP error during rotation for token {token_id}: {exc}",
|
||||
)
|
||||
|
||||
if not (200 <= response.status_code < 300):
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"GitLab API returned HTTP {response.status_code} "
|
||||
f"for token {token_id} rotation"
|
||||
),
|
||||
)
|
||||
|
||||
# Parse successful response
|
||||
try:
|
||||
data = response.json()
|
||||
new_token = data["token"]
|
||||
new_expiry_date = data["expires_at"]
|
||||
except (KeyError, ValueError) as exc:
|
||||
return RotationResult(
|
||||
success=False,
|
||||
new_token=None,
|
||||
new_expiry_date=None,
|
||||
error_message=(
|
||||
f"Failed to parse rotation response for token {token_id}: {exc}"
|
||||
),
|
||||
)
|
||||
|
||||
return RotationResult(
|
||||
success=True,
|
||||
new_token=new_token,
|
||||
new_expiry_date=new_expiry_date,
|
||||
error_message=None,
|
||||
)
|
||||
Reference in New Issue
Block a user