Add Gitea CI workflow and harden credential generation
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 2s
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 2s
This commit is contained in:
74
.gitea/workflows/docker-publish.yml
Normal file
74
.gitea/workflows/docker-publish.yml
Normal file
@@ -0,0 +1,74 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: orchestration-platform
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
env:
|
||||
REGISTRY: gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu
|
||||
IMAGE_REPO: gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu/j.r/application-template-code-location
|
||||
steps:
|
||||
- name: Checkout repository (shell)
|
||||
run: |
|
||||
REPO_DIR="repo"
|
||||
REPO_CLONE_URL="https://gitea.dataprovider01.sandbox-cat-dat.simpl-europe.eu/j.r/application-template-code-location.git"
|
||||
CLONE_USER="${{ secrets.REGISTRY_USERNAME }}"
|
||||
CLONE_PASS="${{ secrets.REGISTRY_PASSWORD }}"
|
||||
REF_NAME="${GITHUB_REF_NAME}"
|
||||
if [ -z "${REF_NAME}" ]; then
|
||||
REF_NAME="${GITHUB_REF#refs/heads/}"
|
||||
fi
|
||||
|
||||
if [ -z "${CLONE_USER}" ] || [ -z "${CLONE_PASS}" ]; then
|
||||
echo "Missing REGISTRY_USERNAME or REGISTRY_PASSWORD secret"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "${REPO_DIR}"
|
||||
AUTH_HEADER="$(printf '%s:%s' "${CLONE_USER}" "${CLONE_PASS}" | base64 | tr -d '\n')"
|
||||
git clone --depth 1 --branch "${REF_NAME}" \
|
||||
-c "http.extraHeader=Authorization: Basic ${AUTH_HEADER}" \
|
||||
"${REPO_CLONE_URL}" \
|
||||
"${REPO_DIR}"
|
||||
|
||||
if [ ! -f "${REPO_DIR}/Dockerfile" ]; then
|
||||
echo "Dockerfile not found after clone"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate registry secrets
|
||||
run: |
|
||||
if [ -z "${{ secrets.REGISTRY_USERNAME }}" ] || [ -z "${{ secrets.REGISTRY_PASSWORD }}" ]; then
|
||||
echo "Missing REGISTRY_USERNAME or REGISTRY_PASSWORD secret"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Login to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "${REGISTRY}" \
|
||||
-u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
COMMIT_SHA="${GITHUB_SHA:-$GITEA_SHA}"
|
||||
SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)"
|
||||
cd repo
|
||||
docker build \
|
||||
-t "${IMAGE_REPO}:latest" \
|
||||
-t "${IMAGE_REPO}:${SHORT_SHA}" \
|
||||
.
|
||||
|
||||
- name: Push image tags
|
||||
run: |
|
||||
COMMIT_SHA="${GITHUB_SHA:-$GITEA_SHA}"
|
||||
SHORT_SHA="$(echo "${COMMIT_SHA}" | cut -c1-12)"
|
||||
docker push "${IMAGE_REPO}:latest"
|
||||
docker push "${IMAGE_REPO}:${SHORT_SHA}"
|
||||
@@ -81,7 +81,7 @@ dagster dev
|
||||
The workflow accepts the following high-level inputs:
|
||||
|
||||
- `repository_identifier`
|
||||
- `consumer_email` with default literal `${CONSUMER_EMAIL}`
|
||||
- `consumer_email` (required, must be a valid email address)
|
||||
- `access_level`
|
||||
|
||||
Email subject and body templates are read from environment variables:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
repository_identifier: simpl-open/platform-automation
|
||||
consumer_email: "${CONSUMER_EMAIL}"
|
||||
consumer_email: "consumer@example.local"
|
||||
access_level: read-only
|
||||
token_name_prefix: simpl-open
|
||||
token_expiration_days: 30
|
||||
@@ -31,7 +31,6 @@ simpl_open_gitea_workflow_config_mapping = config_mapping(
|
||||
),
|
||||
"consumer_email": Field(
|
||||
str,
|
||||
default_value="${CONSUMER_EMAIL}",
|
||||
description="Email address where credentials must be delivered",
|
||||
),
|
||||
"access_level": Field(
|
||||
|
||||
@@ -3,6 +3,7 @@ Generate Gitea credentials for a consumer request.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from dagster import Field, OpExecutionContext, Out, op # pylint: disable=import-error
|
||||
|
||||
@@ -10,6 +11,10 @@ from simpl_open_credential_delivery.ops.retry_policy import DEFAULT_RETRY_POLICY
|
||||
from simpl_open_credential_delivery.services import GiteaAPIClient
|
||||
|
||||
|
||||
def _is_valid_email(value: str) -> bool:
|
||||
return bool(re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", value))
|
||||
|
||||
|
||||
@op(
|
||||
config_schema={
|
||||
"repository_identifier": Field(
|
||||
@@ -54,6 +59,12 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
||||
"Missing required environment variable: GITEA_ADMIN_TOKEN"
|
||||
)
|
||||
|
||||
consumer_email = str(config["consumer_email"]).strip()
|
||||
if not _is_valid_email(consumer_email) or consumer_email.startswith("${"):
|
||||
raise ValueError(
|
||||
"Invalid consumer_email. Provide a real email address (for example, consumer@example.local)."
|
||||
)
|
||||
|
||||
context.log.info("Generating Gitea credentials for repository %s", config["repository_identifier"])
|
||||
client = GiteaAPIClient(
|
||||
base_url=base_url,
|
||||
@@ -64,7 +75,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
||||
credential_bundle = client.generate_credentials(
|
||||
repository_identifier=config["repository_identifier"],
|
||||
access_level=config["access_level"],
|
||||
consumer_email=config["consumer_email"],
|
||||
consumer_email=consumer_email,
|
||||
token_name_prefix=config["token_name_prefix"],
|
||||
token_expiration_days=config["token_expiration_days"],
|
||||
)
|
||||
@@ -77,7 +88,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
||||
|
||||
return {
|
||||
"repository_identifier": config["repository_identifier"],
|
||||
"consumer_email": config["consumer_email"],
|
||||
"consumer_email": consumer_email,
|
||||
"access_level": config["access_level"],
|
||||
"username": credential_bundle.username,
|
||||
"access_token": credential_bundle.access_token,
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -55,6 +56,29 @@ class GiteaAPIClient:
|
||||
)
|
||||
return response
|
||||
|
||||
def _request_as_user(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
username: str,
|
||||
password: str,
|
||||
expected_status: tuple[int, ...] = (200, 201, 204),
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
response = requests.request(
|
||||
method=method,
|
||||
url=f"{self.base_url}{path}",
|
||||
headers={"Accept": "application/json"},
|
||||
auth=(username, password),
|
||||
timeout=self.timeout_seconds,
|
||||
**kwargs,
|
||||
)
|
||||
if response.status_code not in expected_status:
|
||||
raise GiteaAPIError(
|
||||
f"Gitea API call to {path} failed with status {response.status_code}: {response.text}"
|
||||
)
|
||||
return response
|
||||
|
||||
def _resolve_repository_identifier(self, repository_identifier: str) -> str:
|
||||
if "/" in repository_identifier:
|
||||
return repository_identifier
|
||||
@@ -71,6 +95,40 @@ class GiteaAPIClient:
|
||||
return str(payload[key])
|
||||
raise GiteaAPIError("Gitea token response did not contain a token value")
|
||||
|
||||
def _create_user_token(
|
||||
self,
|
||||
username: str,
|
||||
password: str,
|
||||
token_name: str,
|
||||
token_expiration_days: int,
|
||||
) -> requests.Response:
|
||||
base_payload = {
|
||||
"name": token_name,
|
||||
"expires_in_days": token_expiration_days,
|
||||
}
|
||||
|
||||
# Newer Gitea versions require at least one scope for access tokens.
|
||||
try:
|
||||
return self._request_as_user(
|
||||
"POST",
|
||||
f"/api/v1/users/{username}/tokens",
|
||||
username=username,
|
||||
password=password,
|
||||
json={**base_payload, "scopes": ["all"]},
|
||||
)
|
||||
except GiteaAPIError as exc:
|
||||
# Backward compatibility for older versions that do not support scopes.
|
||||
if "unknown field" not in str(exc).lower() and "invalid character" not in str(exc).lower():
|
||||
raise
|
||||
|
||||
return self._request_as_user(
|
||||
"POST",
|
||||
f"/api/v1/users/{username}/tokens",
|
||||
username=username,
|
||||
password=password,
|
||||
json=base_payload,
|
||||
)
|
||||
|
||||
def generate_credentials(
|
||||
self,
|
||||
repository_identifier: str,
|
||||
@@ -84,38 +142,85 @@ class GiteaAPIClient:
|
||||
owner, repository_name = resolved_repository.split("/", 1)
|
||||
username_source = consumer_email.split("@", 1)[0] if "@" in consumer_email else consumer_email
|
||||
username = self._slugify(username_source)
|
||||
token_name = f"{token_name_prefix}-{self._slugify(repository_name)}-{username}"
|
||||
token_name = f"{token_name_prefix}-{self._slugify(repository_name)}-{username}-{int(time.time())}"
|
||||
temporary_password = f"{username}-temporary-password"
|
||||
|
||||
self._request(
|
||||
create_user_response = self._request(
|
||||
"POST",
|
||||
"/api/v1/admin/users",
|
||||
expected_status=(201, 409),
|
||||
expected_status=(201, 409, 422),
|
||||
json={
|
||||
"username": username,
|
||||
"email": consumer_email,
|
||||
"login_name": username,
|
||||
"password": f"{username}-temporary-password",
|
||||
"must_change_password": True,
|
||||
"password": temporary_password,
|
||||
"must_change_password": False,
|
||||
},
|
||||
)
|
||||
|
||||
token_response = self._request(
|
||||
"POST",
|
||||
f"/api/v1/users/{username}/tokens",
|
||||
# Gitea may return 422 for an existing user depending on version.
|
||||
if create_user_response.status_code == 422 and "already exists" not in create_user_response.text.lower():
|
||||
raise GiteaAPIError(
|
||||
"Gitea user creation failed with validation error: "
|
||||
f"{create_user_response.text}"
|
||||
)
|
||||
|
||||
try:
|
||||
token_response = self._create_user_token(
|
||||
username=username,
|
||||
password=temporary_password,
|
||||
token_name=token_name,
|
||||
token_expiration_days=token_expiration_days,
|
||||
)
|
||||
except GiteaAPIError as exc:
|
||||
# Existing users created by older runs may still have "must change password" set.
|
||||
# Reset the user password policy via admin API, then retry token creation once.
|
||||
if "must change your password" not in str(exc).lower():
|
||||
raise
|
||||
|
||||
self._request(
|
||||
"PATCH",
|
||||
f"/api/v1/admin/users/{username}",
|
||||
expected_status=(200, 204),
|
||||
json={
|
||||
"name": token_name,
|
||||
"expires_in_days": token_expiration_days,
|
||||
"password": temporary_password,
|
||||
"must_change_password": False,
|
||||
},
|
||||
)
|
||||
|
||||
token_response = self._create_user_token(
|
||||
username=username,
|
||||
password=temporary_password,
|
||||
token_name=token_name,
|
||||
token_expiration_days=token_expiration_days,
|
||||
)
|
||||
token_payload = token_response.json() if token_response.content else {}
|
||||
access_token = self._extract_token(token_payload)
|
||||
|
||||
self._request(
|
||||
"POST",
|
||||
f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}",
|
||||
expected_status=(201, 204),
|
||||
collaborator_path = f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}"
|
||||
try:
|
||||
collaborator_response = self._request(
|
||||
"PUT",
|
||||
collaborator_path,
|
||||
expected_status=(201, 204, 422),
|
||||
json={"permission": access_level},
|
||||
)
|
||||
except GiteaAPIError as exc:
|
||||
# Some deployments expose a POST variant; retry once for compatibility.
|
||||
if "status 405" not in str(exc):
|
||||
raise
|
||||
collaborator_response = self._request(
|
||||
"POST",
|
||||
collaborator_path,
|
||||
expected_status=(201, 204, 422),
|
||||
json={"permission": access_level},
|
||||
)
|
||||
|
||||
if collaborator_response.status_code == 422 and "already" not in collaborator_response.text.lower():
|
||||
raise GiteaAPIError(
|
||||
"Failed to grant repository collaborator access: "
|
||||
f"{collaborator_response.text}"
|
||||
)
|
||||
|
||||
return GiteaCredentialBundle(
|
||||
username=username,
|
||||
|
||||
Reference in New Issue
Block a user