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:
|
The workflow accepts the following high-level inputs:
|
||||||
|
|
||||||
- `repository_identifier`
|
- `repository_identifier`
|
||||||
- `consumer_email` with default literal `${CONSUMER_EMAIL}`
|
- `consumer_email` (required, must be a valid email address)
|
||||||
- `access_level`
|
- `access_level`
|
||||||
|
|
||||||
Email subject and body templates are read from environment variables:
|
Email subject and body templates are read from environment variables:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
repository_identifier: simpl-open/platform-automation
|
repository_identifier: simpl-open/platform-automation
|
||||||
consumer_email: "${CONSUMER_EMAIL}"
|
consumer_email: "consumer@example.local"
|
||||||
access_level: read-only
|
access_level: read-only
|
||||||
token_name_prefix: simpl-open
|
token_name_prefix: simpl-open
|
||||||
token_expiration_days: 30
|
token_expiration_days: 30
|
||||||
@@ -31,7 +31,6 @@ simpl_open_gitea_workflow_config_mapping = config_mapping(
|
|||||||
),
|
),
|
||||||
"consumer_email": Field(
|
"consumer_email": Field(
|
||||||
str,
|
str,
|
||||||
default_value="${CONSUMER_EMAIL}",
|
|
||||||
description="Email address where credentials must be delivered",
|
description="Email address where credentials must be delivered",
|
||||||
),
|
),
|
||||||
"access_level": Field(
|
"access_level": Field(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Generate Gitea credentials for a consumer request.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
from dagster import Field, OpExecutionContext, Out, op # pylint: disable=import-error
|
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
|
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(
|
@op(
|
||||||
config_schema={
|
config_schema={
|
||||||
"repository_identifier": Field(
|
"repository_identifier": Field(
|
||||||
@@ -54,6 +59,12 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
|||||||
"Missing required environment variable: GITEA_ADMIN_TOKEN"
|
"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"])
|
context.log.info("Generating Gitea credentials for repository %s", config["repository_identifier"])
|
||||||
client = GiteaAPIClient(
|
client = GiteaAPIClient(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
@@ -64,7 +75,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
|||||||
credential_bundle = client.generate_credentials(
|
credential_bundle = client.generate_credentials(
|
||||||
repository_identifier=config["repository_identifier"],
|
repository_identifier=config["repository_identifier"],
|
||||||
access_level=config["access_level"],
|
access_level=config["access_level"],
|
||||||
consumer_email=config["consumer_email"],
|
consumer_email=consumer_email,
|
||||||
token_name_prefix=config["token_name_prefix"],
|
token_name_prefix=config["token_name_prefix"],
|
||||||
token_expiration_days=config["token_expiration_days"],
|
token_expiration_days=config["token_expiration_days"],
|
||||||
)
|
)
|
||||||
@@ -77,7 +88,7 @@ def generate_gitea_credentials(context: OpExecutionContext) -> dict:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"repository_identifier": config["repository_identifier"],
|
"repository_identifier": config["repository_identifier"],
|
||||||
"consumer_email": config["consumer_email"],
|
"consumer_email": consumer_email,
|
||||||
"access_level": config["access_level"],
|
"access_level": config["access_level"],
|
||||||
"username": credential_bundle.username,
|
"username": credential_bundle.username,
|
||||||
"access_token": credential_bundle.access_token,
|
"access_token": credential_bundle.access_token,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -55,6 +56,29 @@ class GiteaAPIClient:
|
|||||||
)
|
)
|
||||||
return response
|
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:
|
def _resolve_repository_identifier(self, repository_identifier: str) -> str:
|
||||||
if "/" in repository_identifier:
|
if "/" in repository_identifier:
|
||||||
return repository_identifier
|
return repository_identifier
|
||||||
@@ -71,6 +95,40 @@ class GiteaAPIClient:
|
|||||||
return str(payload[key])
|
return str(payload[key])
|
||||||
raise GiteaAPIError("Gitea token response did not contain a token value")
|
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(
|
def generate_credentials(
|
||||||
self,
|
self,
|
||||||
repository_identifier: str,
|
repository_identifier: str,
|
||||||
@@ -84,38 +142,85 @@ class GiteaAPIClient:
|
|||||||
owner, repository_name = resolved_repository.split("/", 1)
|
owner, repository_name = resolved_repository.split("/", 1)
|
||||||
username_source = consumer_email.split("@", 1)[0] if "@" in consumer_email else consumer_email
|
username_source = consumer_email.split("@", 1)[0] if "@" in consumer_email else consumer_email
|
||||||
username = self._slugify(username_source)
|
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",
|
"POST",
|
||||||
"/api/v1/admin/users",
|
"/api/v1/admin/users",
|
||||||
expected_status=(201, 409),
|
expected_status=(201, 409, 422),
|
||||||
json={
|
json={
|
||||||
"username": username,
|
"username": username,
|
||||||
"email": consumer_email,
|
"email": consumer_email,
|
||||||
"login_name": username,
|
"login_name": username,
|
||||||
"password": f"{username}-temporary-password",
|
"password": temporary_password,
|
||||||
"must_change_password": True,
|
"must_change_password": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
token_response = self._request(
|
# Gitea may return 422 for an existing user depending on version.
|
||||||
"POST",
|
if create_user_response.status_code == 422 and "already exists" not in create_user_response.text.lower():
|
||||||
f"/api/v1/users/{username}/tokens",
|
raise GiteaAPIError(
|
||||||
json={
|
"Gitea user creation failed with validation error: "
|
||||||
"name": token_name,
|
f"{create_user_response.text}"
|
||||||
"expires_in_days": token_expiration_days,
|
)
|
||||||
},
|
|
||||||
)
|
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={
|
||||||
|
"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 {}
|
token_payload = token_response.json() if token_response.content else {}
|
||||||
access_token = self._extract_token(token_payload)
|
access_token = self._extract_token(token_payload)
|
||||||
|
|
||||||
self._request(
|
collaborator_path = f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}"
|
||||||
"POST",
|
try:
|
||||||
f"/api/v1/repos/{owner}/{repository_name}/collaborators/{username}",
|
collaborator_response = self._request(
|
||||||
expected_status=(201, 204),
|
"PUT",
|
||||||
json={"permission": access_level},
|
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(
|
return GiteaCredentialBundle(
|
||||||
username=username,
|
username=username,
|
||||||
|
|||||||
Reference in New Issue
Block a user