From 9268970972261692c068d8a63dbbacedbf042422 Mon Sep 17 00:00:00 2001 From: yoshifuminakamura Date: Wed, 5 Aug 2026 11:15:31 +0900 Subject: [PATCH] Add portal GitLab pipeline submit Signed-off-by: yoshifuminakamura --- .../portal-execution-profiles-handoff.md | 23 ++- result_server/routes/admin.py | 178 +++++++++++++++--- .../templates/admin_execution_profiles.html | 89 ++++++++- .../tests/test_execution_profiles.py | 143 ++++++++++++++ result_server/utils/gitlab_pipeline.py | 102 ++++++++++ 5 files changed, 502 insertions(+), 33 deletions(-) diff --git a/docs/guides/portal-execution-profiles-handoff.md b/docs/guides/portal-execution-profiles-handoff.md index 7cf756f..78cf5c9 100644 --- a/docs/guides/portal-execution-profiles-handoff.md +++ b/docs/guides/portal-execution-profiles-handoff.md @@ -30,7 +30,9 @@ Recommended order: 4. Add a dry-run submit view that resolves a profile and shows the GitLab Pipeline API payload without sending it. 5. Add the real GitLab Pipeline API trigger only after the dry-run path is - reviewed. + reviewed. Keep the trigger token in the site-local service environment as + `RESULT_SERVER_GITLAB_TOKEN`; do not store it in SQLite, logs, or the OSS + repository. 6. Index received benchmark and estimation JSON metadata into SQLite while keeping JSON/tgz artifacts as raw records. 7. Add environment snapshot storage after deciding which host/runtime metadata @@ -40,6 +42,25 @@ GitLab schedules should not be the primary governance point. The Portal should own periodic and event-triggered execution decisions, then trigger GitLab CI with resolved site-local variables. +## GitLab Pipeline API Configuration + +Dry-run payload rendering requires: + +```text +RESULT_SERVER_GITLAB_REPO=gitlab.example.org/group/project +``` + +Actual submission also requires: + +```text +RESULT_SERVER_GITLAB_TOKEN= +``` + +`RESULT_SERVER_GITLAB_REPO` is a scheme-less `host/path` value. The token must +have permission to create pipelines in that GitLab project. The Portal records +the request payload, GitLab response metadata, status, and errors in +`execution_requests`; it must not record the token value. + ## Compatibility Expectations Keep the existing `list.csv` and `queue.csv` paths working. Execution profiles diff --git a/result_server/routes/admin.py b/result_server/routes/admin.py index e5992e8..18f148e 100644 --- a/result_server/routes/admin.py +++ b/result_server/routes/admin.py @@ -26,7 +26,12 @@ load_execution_profiles, normalize_profile, ) -from utils.gitlab_pipeline import build_pipeline_plan, configured_gitlab_repo +from utils.gitlab_pipeline import ( + build_pipeline_plan, + configured_gitlab_repo, + configured_gitlab_token, + submit_pipeline_plan, +) from utils.rate_limit import rate_limited from utils.user_store import get_user_store @@ -127,6 +132,48 @@ def _parse_bool_form(name): return request.form.get(name) == "on" +def _build_execution_pipeline_plan(store): + """Resolve the submitted target and build a GitLab pipeline plan.""" + target_ref = request.form.get("target_ref", "").strip() or "develop" + profile_id = request.form.get("profile_id", "").strip() + code = request.form.get("code", "").strip() + system = request.form.get("system", "").strip() + exp = request.form.get("exp", "").strip() + app = request.form.get("app", "").strip() + benchpark = _parse_bool_form("benchpark") + park_only = _parse_bool_form("park_only") + park_send = _parse_bool_form("park_send") + + resolve_result = store.resolve_profile( + profile_id=profile_id, + code=code, + system=system, + exp=exp, + ) + profile = resolve_result.profile + plan = build_pipeline_plan( + gitlab_repo=configured_gitlab_repo(), + target_ref=target_ref, + code=code, + system=system, + app=app, + benchpark=benchpark, + park_only=park_only, + park_send=park_send, + scheduler_extra_args=resolve_result.scheduler_extra_args, + ) + return { + "target_ref": target_ref, + "profile_id": profile_id, + "code": code, + "system": system, + "exp": exp, + "profile": profile, + "plan": plan, + "errors": resolve_result.errors + plan.errors, + } + + def _user_affiliations(store, email): """Return the affiliations for a user, handling missing records uniformly.""" if hasattr(store, "get_user"): @@ -243,35 +290,15 @@ def dry_run_execution_profile_submit(): """Resolve an execution profile and render a GitLab Pipeline API dry run.""" db_path = current_app.config.get("EXECUTION_PROFILE_DB_PATH") store = ExecutionProfileStore(db_path) - target_ref = request.form.get("target_ref", "").strip() or "develop" - profile_id = request.form.get("profile_id", "").strip() - code = request.form.get("code", "").strip() - system = request.form.get("system", "").strip() - exp = request.form.get("exp", "").strip() - app = request.form.get("app", "").strip() - benchpark = _parse_bool_form("benchpark") - park_only = _parse_bool_form("park_only") - park_send = _parse_bool_form("park_send") - - resolve_result = store.resolve_profile( - profile_id=profile_id, - code=code, - system=system, - exp=exp, - ) - profile = resolve_result.profile - plan = build_pipeline_plan( - gitlab_repo=configured_gitlab_repo(), - target_ref=target_ref, - code=code, - system=system, - app=app, - benchpark=benchpark, - park_only=park_only, - park_send=park_send, - scheduler_extra_args=resolve_result.scheduler_extra_args, - ) - errors = resolve_result.errors + plan.errors + submit_plan = _build_execution_pipeline_plan(store) + target_ref = submit_plan["target_ref"] + profile_id = submit_plan["profile_id"] + code = submit_plan["code"] + system = submit_plan["system"] + exp = submit_plan["exp"] + profile = submit_plan["profile"] + plan = submit_plan["plan"] + errors = submit_plan["errors"] status = "dry_run_ready" if not errors else "dry_run_blocked" request_id = store.create_execution_request( request_type="gitlab_pipeline", @@ -315,6 +342,97 @@ def dry_run_execution_profile_submit(): "errors": errors, "warnings": plan.warnings, }, + submit_result=None, + ) + + +@admin_bp.route("/execution-profiles/submit", methods=["POST"]) +@admin_required +@rate_limited(max_per_minute=5, key_fn=_admin_rate_key, scope="admin_write") +def submit_execution_profile_pipeline(): + """Resolve an execution profile and submit a GitLab pipeline.""" + db_path = current_app.config.get("EXECUTION_PROFILE_DB_PATH") + store = ExecutionProfileStore(db_path) + submit_plan = _build_execution_pipeline_plan(store) + target_ref = submit_plan["target_ref"] + profile_id = submit_plan["profile_id"] + code = submit_plan["code"] + system = submit_plan["system"] + exp = submit_plan["exp"] + profile = submit_plan["profile"] + plan = submit_plan["plan"] + errors = list(submit_plan["errors"]) + submit_result = None + + if request.form.get("confirm_submit") != "on": + errors.append("confirm_submit is required") + + if not errors: + submit_result = submit_pipeline_plan( + plan, + token=configured_gitlab_token(), + ) + errors.extend(submit_result.errors) + + if submit_result and submit_result.ok: + status = "submitted" + elif submit_result: + status = "submit_failed" + else: + status = "submit_blocked" + payload = {"api_url": plan.api_url, "payload": plan.payload} + if submit_result is not None: + payload["submit"] = { + "status_code": submit_result.status_code, + "response": submit_result.response, + } + request_id = store.create_execution_request( + request_type="gitlab_pipeline", + status=status, + dry_run=False, + profile_id=profile["id"] if profile else profile_id, + target_ref=target_ref, + code=code, + system=system, + exp=exp, + payload=payload, + errors=errors, + actor=session.get("user_email", ""), + ) + + audit_event( + "admin_execution_profile_submit", + actor=session.get("user_email"), + target=profile["id"] if profile else profile_id, + result="success" if status == "submitted" else "failure", + details={ + "request_id": request_id, + "target_ref": target_ref, + "code": code, + "system": system, + "exp": exp, + "status": status, + "http_status": submit_result.status_code if submit_result else 0, + "errors": errors, + }, + ) + + profile_result = load_execution_profiles(db_path) + return render_template( + "admin_execution_profiles.html", + profile_result=profile_result, + dry_run_result=None, + submit_result={ + "request_id": request_id, + "status": status, + "profile": profile, + "api_url": plan.api_url, + "payload_json": json.dumps(plan.payload, indent=2, sort_keys=True), + "errors": errors, + "warnings": plan.warnings, + "response": submit_result.response if submit_result else {}, + "status_code": submit_result.status_code if submit_result else 0, + }, ) diff --git a/result_server/templates/admin_execution_profiles.html b/result_server/templates/admin_execution_profiles.html index be80333..2416f9f 100644 --- a/result_server/templates/admin_execution_profiles.html +++ b/result_server/templates/admin_execution_profiles.html @@ -249,10 +249,10 @@

Create / Update Profile

-

GitLab Pipeline Dry Run

+

GitLab Pipeline Submit

Resolve an approved profile for a target scope and preview the GitLab - Pipeline API request. This does not submit a pipeline. + Pipeline API request before submitting it.

{% if csrf_token is defined %}{% endif %} @@ -328,6 +328,91 @@

GitLab Pipeline Dry Run

API URL: {{ dry_run_result.api_url or 'not configured' }}

{{ dry_run_result.payload_json }}
{% endif %} + + + {% if csrf_token is defined %}{% endif %} + + + + + + +
+ + + + + +
+
+ + {% if submit_result %} +
+ Submit request #{{ submit_result.request_id }}: + {{ submit_result.status }} + {% if submit_result.status_code %} + HTTP {{ submit_result.status_code }} + {% endif %} + {% if submit_result.profile %} + using profile {{ submit_result.profile.id }} + {% endif %} +
+ {% if submit_result.errors %} +
+ Submit blockers: +
    + {% for error in submit_result.errors %} +
  • {{ error }}
  • + {% endfor %} +
+
+ {% endif %} + {% if submit_result.warnings %} +
+ Submit warnings: +
    + {% for warning in submit_result.warnings %} +
  • {{ warning }}
  • + {% endfor %} +
+
+ {% endif %} +

API URL: {{ submit_result.api_url or 'not configured' }}

+
{{ submit_result.payload_json }}
+ {% if submit_result.response %} +
{{ submit_result.response | tojson(indent=2) }}
+ {% endif %} + {% endif %}
diff --git a/result_server/tests/test_execution_profiles.py b/result_server/tests/test_execution_profiles.py index 5214e35..a80f619 100644 --- a/result_server/tests/test_execution_profiles.py +++ b/result_server/tests/test_execution_profiles.py @@ -21,6 +21,11 @@ load_execution_profiles, normalize_profile, ) +from utils.gitlab_pipeline import ( # noqa: E402 + GitLabPipelineSubmitResult, + build_pipeline_plan, + submit_pipeline_plan, +) class _Store: @@ -435,3 +440,141 @@ def test_admin_execution_profiles_dry_run_blocks_without_matching_profile( assert "no approved execution profile matches target" in html finally: _cleanup(temp_dirs) + + +def test_gitlab_pipeline_submit_posts_private_token_without_storing_it(monkeypatch): + plan = build_pipeline_plan( + gitlab_repo="gitlab.example.org/group/benchkit.git", + target_ref="develop", + code="qws", + ) + captured = {} + + class _Response: + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def getcode(self): + return 201 + + def read(self): + return b'{"id":123,"web_url":"https://gitlab.example.org/p/123"}' + + def fake_urlopen(request, timeout): + captured["url"] = request.full_url + captured["headers"] = dict(request.header_items()) + captured["data"] = request.data.decode() + captured["timeout"] = timeout + return _Response() + + result = submit_pipeline_plan(plan, token="secret-token", urlopen=fake_urlopen) + + assert result.ok is True + assert result.status_code == 201 + assert result.response["id"] == 123 + assert captured["url"] == "https://gitlab.example.org/api/v4/projects/group%2Fbenchkit/pipeline" + assert captured["headers"]["Private-token"] == "secret-token" + assert json.loads(captured["data"])["ref"] == "develop" + + +def test_gitlab_pipeline_submit_blocks_without_token(): + plan = build_pipeline_plan( + gitlab_repo="gitlab.example.org/group/benchkit.git", + target_ref="develop", + code="qws", + ) + + result = submit_pipeline_plan(plan, token="") + + assert result.ok is False + assert result.status_code == 0 + assert result.errors == ["RESULT_SERVER_GITLAB_TOKEN is not set"] + + +def test_admin_execution_profiles_submit_posts_pipeline_and_records_request( + tmp_path, + monkeypatch, +): + monkeypatch.setenv("RESULT_SERVER_GITLAB_REPO", "gitlab.example.org/group/benchkit.git") + monkeypatch.setenv("RESULT_SERVER_GITLAB_TOKEN", "secret-token") + db_path = tmp_path / "cx_portal.sqlite3" + ExecutionProfileStore(str(db_path)).upsert_profile(_profile(), actor="admin") + app, temp_dirs = _admin_app(db_path) + + def fake_submit(plan, *, token): + assert token == "secret-token" + assert plan.api_url == "https://gitlab.example.org/api/v4/projects/group%2Fbenchkit/pipeline" + return GitLabPipelineSubmitResult( + status_code=201, + response={"id": 123, "web_url": "https://gitlab.example.org/p/123"}, + errors=[], + ) + + monkeypatch.setattr("routes.admin.submit_pipeline_plan", fake_submit) + try: + with app.test_client() as client: + _login_admin(client) + resp = client.post( + "/admin/execution-profiles/submit", + data={ + "target_ref": "develop", + "code": "qws", + "system": "RIKYU", + "exp": "case0", + "confirm_submit": "on", + }, + ) + + html = resp.data.decode() + assert resp.status_code == 200 + assert "Submit request #1" in html + assert "submitted" in html + assert "HTTP 201" in html + assert "secret-token" not in html + assert "https://gitlab.example.org/p/123" in html + + with sqlite3.connect(db_path) as conn: + row = conn.execute( + """ + SELECT status, dry_run, profile_id, payload_json, errors_json + FROM execution_requests + """ + ).fetchone() + assert row[:3] == ("submitted", 0, "rikyu-qws-nightly") + assert json.loads(row[4]) == [] + payload_record = json.loads(row[3]) + assert payload_record["submit"]["status_code"] == 201 + assert payload_record["submit"]["response"]["id"] == 123 + assert "secret-token" not in row[3] + finally: + _cleanup(temp_dirs) + + +def test_admin_execution_profiles_submit_requires_confirmation(tmp_path, monkeypatch): + monkeypatch.setenv("RESULT_SERVER_GITLAB_REPO", "gitlab.example.org/group/benchkit.git") + monkeypatch.setenv("RESULT_SERVER_GITLAB_TOKEN", "secret-token") + db_path = tmp_path / "cx_portal.sqlite3" + ExecutionProfileStore(str(db_path)).upsert_profile(_profile(), actor="admin") + app, temp_dirs = _admin_app(db_path) + + def fail_submit(*_args, **_kwargs): + raise AssertionError("submit should not be called without confirmation") + + monkeypatch.setattr("routes.admin.submit_pipeline_plan", fail_submit) + try: + with app.test_client() as client: + _login_admin(client) + resp = client.post( + "/admin/execution-profiles/submit", + data={"target_ref": "develop", "code": "qws", "system": "RIKYU"}, + ) + + html = resp.data.decode() + assert resp.status_code == 200 + assert "submit_blocked" in html + assert "confirm_submit is required" in html + finally: + _cleanup(temp_dirs) diff --git a/result_server/utils/gitlab_pipeline.py b/result_server/utils/gitlab_pipeline.py index d2ac219..ca524e3 100644 --- a/result_server/utils/gitlab_pipeline.py +++ b/result_server/utils/gitlab_pipeline.py @@ -4,7 +4,9 @@ import os import re +import urllib.error import urllib.parse +import urllib.request from dataclasses import dataclass from typing import Any @@ -22,6 +24,19 @@ class GitLabPipelinePlan: warnings: list[str] +@dataclass(frozen=True) +class GitLabPipelineSubmitResult: + """Result of submitting a GitLab Pipeline API request.""" + + status_code: int + response: dict[str, Any] + errors: list[str] + + @property + def ok(self) -> bool: + return 200 <= self.status_code < 300 and not self.errors + + def configured_gitlab_repo(env: dict[str, str] | None = None) -> str: """Return the configured host/path GitLab repo for Portal submits.""" source = env if env is not None else os.environ @@ -31,6 +46,12 @@ def configured_gitlab_repo(env: dict[str, str] | None = None) -> str: ) +def configured_gitlab_token(env: dict[str, str] | None = None) -> str: + """Return the configured GitLab API token for Portal submits.""" + source = env if env is not None else os.environ + return source.get("RESULT_SERVER_GITLAB_TOKEN", "").strip() + + def _split_gitlab_repo(repo: str) -> tuple[str, str] | None: if not repo or "://" in repo or not GITLAB_REPO_RE.match(repo): return None @@ -106,3 +127,84 @@ def build_pipeline_plan( errors=errors, warnings=warnings, ) + + +def submit_pipeline_plan( + plan: GitLabPipelinePlan, + *, + token: str, + timeout: float = 20.0, + urlopen=urllib.request.urlopen, +) -> GitLabPipelineSubmitResult: + """Submit a planned GitLab Pipeline API request.""" + errors = list(plan.errors) + if not token: + errors.append("RESULT_SERVER_GITLAB_TOKEN is not set") + if not plan.api_url: + errors.append("GitLab Pipeline API URL is not configured") + if errors: + return GitLabPipelineSubmitResult(status_code=0, response={}, errors=errors) + + body = json_dumps_bytes(plan.payload) + request = urllib.request.Request( + plan.api_url, + data=body, + headers={ + "PRIVATE-TOKEN": token, + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + try: + with urlopen(request, timeout=timeout) as response: + status_code = int(response.getcode()) + payload = _decode_json_response(response.read()) + except urllib.error.HTTPError as exc: + status_code = int(exc.code) + payload = _decode_json_response(exc.read()) + return GitLabPipelineSubmitResult( + status_code=status_code, + response=payload, + errors=[f"GitLab Pipeline API returned HTTP {status_code}"], + ) + except urllib.error.URLError as exc: + return GitLabPipelineSubmitResult( + status_code=0, + response={}, + errors=[f"GitLab Pipeline API request failed: {exc.reason}"], + ) + except TimeoutError: + return GitLabPipelineSubmitResult( + status_code=0, + response={}, + errors=["GitLab Pipeline API request timed out"], + ) + + errors = [] + if not 200 <= status_code < 300: + errors.append(f"GitLab Pipeline API returned HTTP {status_code}") + return GitLabPipelineSubmitResult( + status_code=status_code, + response=payload, + errors=errors, + ) + + +def json_dumps_bytes(payload: dict[str, Any]) -> bytes: + """Encode a JSON payload for urllib.""" + import json + + return json.dumps(payload).encode("utf-8") + + +def _decode_json_response(raw: bytes) -> dict[str, Any]: + if not raw: + return {} + import json + + try: + decoded = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return {"raw": raw.decode("utf-8", errors="replace")} + return decoded if isinstance(decoded, dict) else {"response": decoded}