feat!: full-access Gitea MCP server (1.0.0): full API coverage, stdio + streamable HTTP, admin and per-user OAuth #71
No files matched your search
@@ -0,0 +1,35 @@
|
|||||||
|
# Keep the build context small and free of secrets. The Dockerfile only needs
|
||||||
|
# pyproject.toml, uv.lock, README.md, LICENSE and src/.
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.claude
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
env
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
tests
|
||||||
|
docs
|
||||||
|
spec
|
||||||
|
scripts
|
||||||
|
docker
|
||||||
|
docker-compose.yml
|
||||||
|
security
|
||||||
|
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
.ruff_cache
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
name: ci
|
||||||
|
|
||||||
|
# One pipeline, eight jobs:
|
||||||
|
#
|
||||||
|
# gitleaks secret scan over the full history
|
||||||
|
# lint ruff check, ruff format --check, mypy
|
||||||
|
# test-unit pytest (coverage gate from pyproject.toml)
|
||||||
|
# test-integration pytest against a throwaway Gitea in Docker
|
||||||
|
# test-mcp MCP smoke tests, including against the freshly built image
|
||||||
|
# build-and-push build the image; push it on push events (needs lint + test-unit)
|
||||||
|
# docs-check docs drift check
|
||||||
|
# security security test suite, pip-audit, ruff bandit rules
|
||||||
|
#
|
||||||
|
# Docker hygiene on the shared runner: every resource this workflow creates has
|
||||||
|
# a per-run name (aegis-ci-<run id>-...) and is removed by an `if: always()`
|
||||||
|
# step that deletes exactly those names. Nothing here ever prunes.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
- main
|
||||||
|
|
||||||
|
env:
|
||||||
|
UV_PYTHON_DOWNLOADS: never
|
||||||
|
UV_NO_PROGRESS: "1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Secret scan: pinned gitleaks binary (sha256 verified), full history.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
gitleaks:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install gitleaks
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
bin_dir="${RUNNER_TEMP:-/tmp}/gitleaks-bin"
|
||||||
|
bash scripts/ci/install_gitleaks.sh "${bin_dir}"
|
||||||
|
echo "${bin_dir}" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Scan history
|
||||||
|
shell: bash
|
||||||
|
run: gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Lint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Ruff check
|
||||||
|
run: uv run ruff check .
|
||||||
|
- name: Ruff format
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
- name: Mypy
|
||||||
|
run: uv run mypy src
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Unit tests (integration tests are skipped without AEGIS_INTEGRATION=1).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
test-unit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Run unit tests
|
||||||
|
run: uv run pytest --cov-report=term-missing --cov-report=xml:coverage.xml
|
||||||
|
- name: Upload coverage report
|
||||||
|
# Best effort: some act_runner versions do not support the v4 artifact
|
||||||
|
# backend, and the coverage gate itself already ran above.
|
||||||
|
continue-on-error: true
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-${{ github.run_id }}
|
||||||
|
path: coverage.xml
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Integration tests against a throwaway Gitea 28.1.0 in Docker. The job
|
||||||
|
# container is joined to a per-run network (container mode), see
|
||||||
|
# tests/integration/README.md. The harness removes what it creates; the
|
||||||
|
# cleanup step is the safety net for cancelled runs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
test-integration:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Run integration tests
|
||||||
|
env:
|
||||||
|
AEGIS_INTEGRATION: "1"
|
||||||
|
AEGIS_IT_NETWORK_MODE: container
|
||||||
|
run: uv run pytest tests/integration -m integration --no-cov
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/ci/cleanup_job_resources.sh
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. MCP smoke tests. Builds its own copy of the image (same Dockerfile, same
|
||||||
|
# commit) so the tests can also exercise the container, and passes the
|
||||||
|
# reference through AEGIS_MCP_IMAGE. The image cannot be shared with
|
||||||
|
# build-and-push: pull requests never push, and jobs may land on different
|
||||||
|
# runners.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
test-mcp:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
env:
|
||||||
|
MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Build image
|
||||||
|
run: docker build -f docker/Dockerfile -t "${MCP_IMAGE}" .
|
||||||
|
- name: Run MCP smoke tests
|
||||||
|
env:
|
||||||
|
AEGIS_MCP_IMAGE: ${{ env.MCP_IMAGE }}
|
||||||
|
# Smoke tests that start a throwaway Gitea use the same harness as the
|
||||||
|
# integration tests.
|
||||||
|
AEGIS_INTEGRATION: "1"
|
||||||
|
AEGIS_IT_NETWORK_MODE: container
|
||||||
|
run: uv run pytest tests/mcp -m mcp_smoke --no-cov
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: bash scripts/ci/cleanup_job_resources.sh "${MCP_IMAGE}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Build the image from the checked-out commit. On push events it is also
|
||||||
|
# pushed:
|
||||||
|
# :sha-<12> every branch (immutable, traceable to the commit; lets any
|
||||||
|
# feature branch image be pulled and tested)
|
||||||
|
# :dev additionally on dev
|
||||||
|
# :latest and :main additionally on main
|
||||||
|
# Pull requests only build. The pushed digest is recorded in the job
|
||||||
|
# outputs and the job summary.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build-and-push:
|
||||||
|
needs: [lint, test-unit]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
outputs:
|
||||||
|
image: ${{ steps.meta.outputs.sha_tag }}
|
||||||
|
digest: ${{ steps.digest.outputs.digest }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compute image name and tags
|
||||||
|
id: meta
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
|
||||||
|
SHA_TAG="${IMAGE}:sha-${GITHUB_SHA::12}"
|
||||||
|
EXTRA_TAGS=""
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||||
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
|
EXTRA_TAGS="${IMAGE}:latest ${IMAGE}:main"
|
||||||
|
elif [ "${GITHUB_REF_NAME}" = "dev" ]; then
|
||||||
|
EXTRA_TAGS="${IMAGE}:dev"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "image=${IMAGE}"
|
||||||
|
echo "sha_tag=${SHA_TAG}"
|
||||||
|
echo "extra_tags=${EXTRA_TAGS}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker build -f docker/Dockerfile \
|
||||||
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||||
|
--label "org.opencontainers.image.source=https://git.hiddenden.cafe/${GITHUB_REPOSITORY}" \
|
||||||
|
-t "${{ steps.meta.outputs.sha_tag }}" .
|
||||||
|
|
||||||
|
- name: Smoke-test image
|
||||||
|
shell: bash
|
||||||
|
run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version
|
||||||
|
|
||||||
|
- name: Log in to the registry
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.hiddenden.cafe
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
# PAT with write:package scope. The built-in token lacks package-write
|
||||||
|
# on this instance.
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Tag and push
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
for tag in ${{ steps.meta.outputs.extra_tags }}; do
|
||||||
|
docker tag "${{ steps.meta.outputs.sha_tag }}" "${tag}"
|
||||||
|
done
|
||||||
|
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
|
||||||
|
docker push "${tag}"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Record digest
|
||||||
|
id: digest
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||||
|
DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "${{ steps.meta.outputs.sha_tag }}")"
|
||||||
|
else
|
||||||
|
DIGEST="not pushed (pull request build)"
|
||||||
|
fi
|
||||||
|
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
|
||||||
|
{
|
||||||
|
echo "### Image"
|
||||||
|
echo
|
||||||
|
echo "- tag: \`${{ steps.meta.outputs.sha_tag }}\`"
|
||||||
|
echo "- digest: \`${DIGEST}\`"
|
||||||
|
if [ -n "${{ steps.meta.outputs.extra_tags }}" ]; then
|
||||||
|
echo "- also tagged: \`${{ steps.meta.outputs.extra_tags }}\`"
|
||||||
|
fi
|
||||||
|
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||||
|
echo "image digest: ${DIGEST}"
|
||||||
|
|
||||||
|
# Removes exactly what this run created, even on failure. No prune.
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker logout git.hiddenden.cafe || true
|
||||||
|
bash scripts/ci/cleanup_job_resources.sh \
|
||||||
|
"${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Docs drift check
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
docs-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Check docs against code and spec
|
||||||
|
run: uv run python scripts/check_docs.py
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Security: test suite, dependency audit, bandit rules via ruff.
|
||||||
|
# Waivers live in docs/security.md; advisory ignores in
|
||||||
|
# security/pip-audit-ignore.txt.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --locked
|
||||||
|
- name: Security tests
|
||||||
|
run: uv run pytest tests/security --no-cov
|
||||||
|
- name: Dependency audit
|
||||||
|
run: bash scripts/ci/pip_audit.sh
|
||||||
|
- name: Ruff security rules
|
||||||
|
run: uv run ruff check --select S .
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
name: docker
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Test on every branch push; registry push is gated per-step to main/dev.
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- dev
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 1. Lint: ruff + black + mypy.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
- name: Run lint
|
|
||||||
run: |
|
|
||||||
ruff check src tests
|
|
||||||
ruff format --check src tests
|
|
||||||
black --check src tests
|
|
||||||
mypy src
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 2. Test: pytest with coverage gate.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
- name: Run tests
|
|
||||||
run: pytest --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 3. Build the Docker image, smoke-test it, push to Gitea (push events to
|
|
||||||
# main/dev only), then clean up so nothing lingers on the self-hosted
|
|
||||||
# runner.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
docker:
|
|
||||||
needs: [lint, test]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Compute image name & tags
|
|
||||||
id: meta
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
|
|
||||||
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha_tag=${IMAGE}:sha-${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
|
||||||
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
|
||||||
# Production: stable :latest + :main
|
|
||||||
echo "branch_tags=${IMAGE}:latest ${IMAGE}:main" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
# dev (and any other branch): tag with the branch name
|
|
||||||
echo "branch_tags=${IMAGE}:${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
shell: bash
|
|
||||||
run: docker build -f docker/Dockerfile -t "${{ steps.meta.outputs.sha_tag }}" .
|
|
||||||
|
|
||||||
- name: Smoke-test image
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
docker run --rm --entrypoint python "${{ steps.meta.outputs.sha_tag }}" \
|
|
||||||
-c "import aegis_gitea_mcp"
|
|
||||||
echo "Image imports cleanly."
|
|
||||||
|
|
||||||
- name: Log in to Gitea Container Registry
|
|
||||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.hiddenden.cafe
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
# PAT with write:package scope, stored as the REGISTRY_TOKEN secret.
|
|
||||||
# The auto-provided GITEA_TOKEN lacks package-write permission on
|
|
||||||
# this instance, so we use a dedicated token here.
|
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Tag & push
|
|
||||||
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
for tag in ${{ steps.meta.outputs.branch_tags }} ${{ steps.meta.outputs.sha_tag }}; do
|
|
||||||
docker tag "${{ steps.meta.outputs.sha_tag }}" "$tag"
|
|
||||||
docker push "$tag"
|
|
||||||
echo "Pushed $tag"
|
|
||||||
done
|
|
||||||
|
|
||||||
# Always runs — removes exactly what this run created, even on failure.
|
|
||||||
# Scoped on purpose: if the runner shares the host Docker daemon, a global
|
|
||||||
# prune would also wipe other homelab services. We never create volumes
|
|
||||||
# here, so only dangling images + build cache are swept.
|
|
||||||
- name: Cleanup
|
|
||||||
if: always()
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
docker rmi -f ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.branch_tags }} || true
|
|
||||||
docker image prune -f || true
|
|
||||||
docker builder prune -f || true
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: lint
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run Ruff (fail on any diagnostics)
|
|
||||||
run: ruff check src tests
|
|
||||||
|
|
||||||
- name: Enforce formatting
|
|
||||||
run: |
|
|
||||||
ruff format --check src tests
|
|
||||||
black --check src tests
|
|
||||||
|
|
||||||
- name: Run mypy
|
|
||||||
run: mypy src
|
|
||||||
@@ -3,7 +3,7 @@ name: publish
|
|||||||
# Build the Python package with uv and publish it to the self-hosted Gitea PyPI
|
# Build the Python package with uv and publish it to the self-hosted Gitea PyPI
|
||||||
# registry on merge. dev -> a dev package (aegis-gitea-mcp-dev, .devN versions);
|
# registry on merge. dev -> a dev package (aegis-gitea-mcp-dev, .devN versions);
|
||||||
# main -> the stable package (aegis-gitea-mcp). Gated on lint + tests so a release
|
# main -> the stable package (aegis-gitea-mcp). Gated on lint + tests so a release
|
||||||
# can never ship red. Reuses the REGISTRY_TOKEN package secret (same one docker.yml
|
# can never ship red. Reuses the REGISTRY_TOKEN package secret (same one ci.yml
|
||||||
# uses); if it is absent the job fails loudly instead of publishing anonymously.
|
# uses); if it is absent the job fails loudly instead of publishing anonymously.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -13,7 +13,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 1. Lint: ruff + black + mypy (same gate as the other workflows).
|
# 1. Lint (same gate as ci.yml, via uv and the lockfile).
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -24,19 +24,21 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --locked
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: |
|
run: |
|
||||||
ruff check src tests
|
uv run ruff check .
|
||||||
ruff format --check src tests
|
uv run ruff format --check .
|
||||||
black --check src tests
|
uv run mypy src
|
||||||
mypy src
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2. Test: pytest with coverage gate.
|
# 2. Test: unit tests with the coverage gate from pyproject.toml.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -47,12 +49,15 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
- name: Set up uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: uv sync --locked
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: pytest --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80
|
run: uv run pytest
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 3. Build with uv and publish to the Gitea PyPI registry.
|
# 3. Build with uv and publish to the Gitea PyPI registry.
|
||||||
@@ -78,6 +83,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up uv
|
- name: Set up uv
|
||||||
uses: astral-sh/setup-uv@v5
|
uses: astral-sh/setup-uv@v5
|
||||||
|
with:
|
||||||
|
version: "0.12.5"
|
||||||
|
enable-cache: false
|
||||||
|
|
||||||
- name: Require publish credentials
|
- name: Require publish credentials
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -100,7 +108,9 @@ jobs:
|
|||||||
CHANNEL="stable"
|
CHANNEL="stable"
|
||||||
else
|
else
|
||||||
PKG_NAME="aegis-gitea-mcp-dev"
|
PKG_NAME="aegis-gitea-mcp-dev"
|
||||||
PKG_VERSION="${BASE}.dev${GITHUB_RUN_NUMBER}"
|
# Drop an existing .devN suffix (the committed version may be a
|
||||||
|
# development version) so the result has exactly one dev segment.
|
||||||
|
PKG_VERSION="${BASE%%.dev*}.dev${GITHUB_RUN_NUMBER}"
|
||||||
CHANNEL="dev"
|
CHANNEL="dev"
|
||||||
fi
|
fi
|
||||||
echo "pkg_name=${PKG_NAME}" >> "$GITHUB_OUTPUT"
|
echo "pkg_name=${PKG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -133,7 +143,7 @@ jobs:
|
|||||||
- name: Publish to Gitea PyPI registry
|
- name: Publish to Gitea PyPI registry
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
# Reuse the existing package secret (same one docker.yml uses). The
|
# Reuse the existing package secret (same one ci.yml uses). The
|
||||||
# token authenticates as its owning Gitea user, so GITHUB_ACTOR is the
|
# token authenticates as its owning Gitea user, so GITHUB_ACTOR is the
|
||||||
# username and the token is the password.
|
# username and the token is the password.
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
name: test
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.12'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements-dev.txt
|
|
||||||
|
|
||||||
- name: Run lint
|
|
||||||
run: |
|
|
||||||
ruff check src tests
|
|
||||||
ruff format --check src tests
|
|
||||||
black --check src tests
|
|
||||||
|
|
||||||
- name: Run tests with coverage gate
|
|
||||||
run: |
|
|
||||||
pytest --cov=aegis_gitea_mcp --cov-report=term-missing --cov-fail-under=80
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Package: build with uv and smoke-test both install profiles so packaging
|
|
||||||
# regressions (broken console scripts, dependency split) are caught in CI.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
package:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
|
|
||||||
- name: Set up uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
|
|
||||||
- name: Build sdist + wheel
|
|
||||||
run: uv build
|
|
||||||
|
|
||||||
- name: Smoke-test the core (stdio) install
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
WHEEL="$(echo dist/*.whl)"
|
|
||||||
python -m venv /tmp/core
|
|
||||||
/tmp/core/bin/pip install --quiet "$WHEEL"
|
|
||||||
# The core install must NOT pull in the web stack.
|
|
||||||
if /tmp/core/bin/python -c "import importlib.util,sys; sys.exit(0 if importlib.util.find_spec('fastapi') else 1)"; then
|
|
||||||
echo "::error::core install unexpectedly includes fastapi" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
# The stdio console script exists and exits 2 with a clear error when
|
|
||||||
# required env vars are missing (no traceback).
|
|
||||||
set +e
|
|
||||||
GITEA_URL= GITEA_TOKEN= /tmp/core/bin/aegis-gitea-mcp >/dev/null 2>/tmp/core_err.txt
|
|
||||||
rc=$?
|
|
||||||
set -e
|
|
||||||
test "$rc" = "2" || { echo "::error::stdio entry exit $rc (expected 2)"; cat /tmp/core_err.txt; exit 1; }
|
|
||||||
grep -q "GITEA_URL" /tmp/core_err.txt
|
|
||||||
echo "core stdio entry OK (exit 2, no fastapi)"
|
|
||||||
|
|
||||||
- name: Smoke-test the [server] install
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
WHEEL="$(echo dist/*.whl)"
|
|
||||||
python -m venv /tmp/server
|
|
||||||
/tmp/server/bin/pip install --quiet "${WHEEL}[server]"
|
|
||||||
/tmp/server/bin/python -c "import fastapi, uvicorn, aegis_gitea_mcp.server_entry; print('server extra import OK')"
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Gitleaks configuration. Extends the default ruleset; only the findings below
|
||||||
|
# are allowlisted. Every one of them was reviewed and is a fake or a placeholder
|
||||||
|
# (documentation examples, test fixtures, and a variable name that matched the
|
||||||
|
# generic key rule). Real credential rules (cloud keys, private keys, provider
|
||||||
|
# token formats) stay active everywhere.
|
||||||
|
title = "AegisGitea-MCP gitleaks config"
|
||||||
|
|
||||||
|
[extend]
|
||||||
|
useDefault = true
|
||||||
|
|
||||||
|
# Fake tokens in tests and placeholder values in docs. Only the two noisy
|
||||||
|
# generic rules are silenced, and only in these directories.
|
||||||
|
[[allowlists]]
|
||||||
|
description = "fake fixtures in tests and placeholder examples in docs"
|
||||||
|
targetRules = ["generic-api-key", "curl-auth-header"]
|
||||||
|
paths = [
|
||||||
|
'''^tests/''',
|
||||||
|
'''^docs/''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Legacy top-level guides that no longer exist in the tree (they live only in
|
||||||
|
# history). They contained placeholder keys such as YOUR_API_KEY and
|
||||||
|
# a1b2c3d4e5f6....
|
||||||
|
[[allowlists]]
|
||||||
|
description = "removed legacy docs with placeholder keys"
|
||||||
|
targetRules = ["generic-api-key", "curl-auth-header"]
|
||||||
|
paths = [
|
||||||
|
'''^(AUTH_SETUP|AUTHENTICATION_IMPLEMENTATION_SUMMARY|CHATGPT_SETUP|DEPLOYMENT|KEY_ROTATION|TESTING)\.md$''',
|
||||||
|
]
|
||||||
|
|
||||||
|
# Removed module: a cache-size constant name matched the generic key rule.
|
||||||
|
[[allowlists]]
|
||||||
|
description = "false positive in a removed source file"
|
||||||
|
targetRules = ["generic-api-key"]
|
||||||
|
paths = [
|
||||||
|
'''^src/aegis_gitea_mcp/server\.py$''',
|
||||||
|
]
|
||||||
+35
-58
@@ -1,82 +1,59 @@
|
|||||||
|
# Production deployment of the HTTP transport.
|
||||||
|
#
|
||||||
|
# cp .env.example .env # set GITEA_URL, MCP_BEARER_TOKEN and/or OAuth settings
|
||||||
|
# docker compose up -d
|
||||||
|
#
|
||||||
|
# The container binds 0.0.0.0 internally; the server refuses to start unless
|
||||||
|
# authentication is configured, and the port is published on loopback only.
|
||||||
|
# Put a TLS reverse proxy in front of 127.0.0.1:8080 (and set PUBLIC_BASE_URL).
|
||||||
services:
|
services:
|
||||||
aegis-mcp:
|
aegis-mcp:
|
||||||
profiles: ["prod"]
|
image: git.hiddenden.cafe/hiddenden/aegisgitea-mcp:${AEGIS_MCP_TAG:-latest}
|
||||||
build:
|
# To build locally instead of pulling the published image:
|
||||||
context: .
|
# build:
|
||||||
dockerfile: docker/Dockerfile
|
# context: .
|
||||||
|
# dockerfile: docker/Dockerfile
|
||||||
container_name: aegis-gitea-mcp
|
container_name: aegis-gitea-mcp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
ENVIRONMENT: production
|
# Fixed by the image layout; do not override from .env.
|
||||||
MCP_HOST: ${MCP_HOST:-127.0.0.1}
|
MCP_TRANSPORT: http
|
||||||
ALLOW_INSECURE_BIND: ${ALLOW_INSECURE_BIND:-false}
|
MCP_HOST: 0.0.0.0
|
||||||
expose:
|
MCP_PORT: "8080"
|
||||||
- "8080"
|
DATA_DIR: /var/lib/aegis-mcp
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
|
||||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
- aegis-mcp-data:/var/lib/aegis-mcp
|
||||||
- ./policy.yaml:/app/policy.yaml:ro
|
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp
|
- /tmp
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
user: "1000:1000"
|
user: "1000:1000"
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
- CMD
|
||||||
"CMD",
|
- python
|
||||||
"python",
|
- -c
|
||||||
"-c",
|
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5)"
|
||||||
"import httpx; httpx.get('http://127.0.0.1:8080/health', timeout=5)",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 15s
|
||||||
|
# Optional: join an external reverse proxy network instead of publishing a
|
||||||
aegis-mcp-dev:
|
# port. Uncomment this and the `networks:` block at the bottom, and drop
|
||||||
profiles: ["dev"]
|
# the `ports:` entry above.
|
||||||
build:
|
# networks:
|
||||||
context: .
|
# - proxy
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
container_name: aegis-gitea-mcp-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
ENVIRONMENT: development
|
|
||||||
MCP_HOST: 127.0.0.1
|
|
||||||
ALLOW_INSECURE_BIND: false
|
|
||||||
LOG_LEVEL: DEBUG
|
|
||||||
EXPOSE_ERROR_DETAILS: true
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${MCP_PORT:-8080}:8080"
|
|
||||||
volumes:
|
|
||||||
- ./src:/app/src:ro
|
|
||||||
- ./policy.yaml:/app/policy.yaml:ro
|
|
||||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
|
||||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
user: "1000:1000"
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
aegis-mcp-logs:
|
|
||||||
driver: local
|
|
||||||
aegis-mcp-data:
|
aegis-mcp-data:
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
# networks:
|
||||||
proxy:
|
# proxy:
|
||||||
external: true
|
# external: true
|
||||||
+51
-34
@@ -1,51 +1,68 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Build stage
|
# Both stages use the same python:3.12-slim image, pinned by digest so a build
|
||||||
FROM python:3.12-slim AS builder
|
# is reproducible. Refresh the digest deliberately (docker pull, then
|
||||||
|
# docker inspect --format '{{index .RepoDigests 0}}' python:3.12-slim).
|
||||||
|
ARG PYTHON_IMAGE=python:3.12-slim@sha256:a6e34c598f2467ed0e9a8d349809fcd8b5c603269512df273a0bb1784edc11b1
|
||||||
|
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.5@sha256:e85be844203885286c60ffad8a858d48afb6c5a5c237ca0e67f12e74b8f174b1
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
FROM ${UV_IMAGE} AS uv
|
||||||
ENV PYTHONUNBUFFERED=1
|
|
||||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
# ---------------------------------------------------------------------------
|
||||||
|
# Builder: resolve the locked, non-dev dependencies and install the project
|
||||||
|
# (non-editable) into /app/.venv. Nothing from this stage except the venv is
|
||||||
|
# copied into the runtime image.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ${PYTHON_IMAGE} AS builder
|
||||||
|
|
||||||
|
COPY --from=uv /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_PYTHON_DOWNLOADS=never \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
COPY pyproject.toml uv.lock README.md LICENSE ./
|
||||||
&& apt-get install -y --no-install-recommends gcc \
|
COPY src ./src
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
RUN uv sync --locked --no-dev --no-editable
|
||||||
|
|
||||||
COPY requirements.txt ./
|
# ---------------------------------------------------------------------------
|
||||||
RUN pip install --no-cache-dir --user -r requirements.txt
|
# Runtime: slim image, only the venv, non-root user.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
FROM ${PYTHON_IMAGE}
|
||||||
|
|
||||||
# Runtime stage
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
FROM python:3.12-slim
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PATH=/app/.venv/bin:$PATH
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
# Non-root runtime user (uid 1000). DATA_DIR holds the token store and the
|
||||||
ENV PYTHONUNBUFFERED=1
|
# audit log; mount a persistent volume there.
|
||||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
RUN useradd --create-home --uid 1000 --shell /usr/sbin/nologin aegis \
|
||||||
ENV NODE_ENV=production
|
&& mkdir -p /var/lib/aegis-mcp \
|
||||||
ENV ENVIRONMENT=production
|
&& chown aegis:aegis /var/lib/aegis-mcp
|
||||||
ENV PATH=/home/aegis/.local/bin:$PATH
|
|
||||||
ENV PYTHONPATH=/app/src:$PYTHONPATH
|
|
||||||
|
|
||||||
WORKDIR /app
|
COPY --from=builder --chown=root:root /app/.venv /app/.venv
|
||||||
|
|
||||||
# Non-root runtime user
|
# Binding 0.0.0.0 is required for the published port to work inside a
|
||||||
RUN useradd -m -u 1000 -s /usr/sbin/nologin aegis
|
# container. It is safe by default: the server refuses to start (exit code 2)
|
||||||
|
# on a non-loopback bind unless authentication is configured (MCP_BEARER_TOKEN
|
||||||
COPY --from=builder --chown=aegis:aegis /root/.local /home/aegis/.local
|
# or OAUTH_ENABLED). Still publish the port only on 127.0.0.1 or behind a
|
||||||
COPY --chown=aegis:aegis src/ ./src/
|
# reverse proxy (see docker-compose.yml).
|
||||||
COPY --chown=aegis:aegis scripts/ ./scripts/
|
ENV DATA_DIR=/var/lib/aegis-mcp \
|
||||||
|
MCP_TRANSPORT=http \
|
||||||
# /var/log/aegis-mcp -> audit log (mount a writable volume)
|
MCP_HOST=0.0.0.0 \
|
||||||
# /var/lib/aegis-mcp -> dynamic client registration store (mount a writable, persistent volume)
|
MCP_PORT=8080
|
||||||
RUN mkdir -p /var/log/aegis-mcp /var/lib/aegis-mcp /tmp/aegis-mcp \
|
|
||||||
&& chown -R aegis:aegis /var/log/aegis-mcp /var/lib/aegis-mcp /tmp/aegis-mcp
|
|
||||||
|
|
||||||
USER aegis
|
USER aegis
|
||||||
|
WORKDIR /home/aegis
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
# Python stdlib only (the slim image has no curl). Probes loopback on MCP_PORT.
|
||||||
CMD python -c "import httpx; httpx.get('http://127.0.0.1:8080/health', timeout=5)" || exit 1
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:%s/health' % os.environ.get('MCP_PORT', '8080'), timeout=5)"]
|
||||||
|
|
||||||
CMD ["python", "-m", "aegis_gitea_mcp.server"]
|
ENTRYPOINT ["aegis-gitea-mcp"]
|
||||||
|
CMD ["serve"]
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
services:
|
|
||||||
aegis-mcp:
|
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: docker/Dockerfile
|
|
||||||
container_name: aegis-gitea-mcp
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- ../.env
|
|
||||||
environment:
|
|
||||||
ENVIRONMENT: production
|
|
||||||
MCP_HOST: ${MCP_HOST:-127.0.0.1}
|
|
||||||
ALLOW_INSECURE_BIND: ${ALLOW_INSECURE_BIND:-false}
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:${MCP_PORT:-8080}:8080"
|
|
||||||
volumes:
|
|
||||||
- aegis-mcp-logs:/var/log/aegis-mcp
|
|
||||||
- aegis-mcp-data:/var/lib/aegis-mcp
|
|
||||||
- ../policy.yaml:/app/policy.yaml:ro
|
|
||||||
read_only: true
|
|
||||||
tmpfs:
|
|
||||||
- /tmp
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
user: "1000:1000"
|
|
||||||
networks:
|
|
||||||
- proxy
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"python",
|
|
||||||
"-c",
|
|
||||||
"import httpx; httpx.get('http://127.0.0.1:8080/health', timeout=5)",
|
|
||||||
]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 10s
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
aegis-mcp-logs:
|
|
||||||
driver: local
|
|
||||||
aegis-mcp-data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
proxy:
|
|
||||||
external: true
|
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Remove the Docker resources that THIS job created, and nothing else.
|
||||||
|
#
|
||||||
|
# Usage: scripts/ci/cleanup_job_resources.sh [image-ref ...]
|
||||||
|
#
|
||||||
|
# The runner shares one Docker daemon with other jobs and other services, so
|
||||||
|
# this script never prunes. It only removes:
|
||||||
|
# * per-run networks named aegis-it-* that the job container is attached to
|
||||||
|
# (the integration harness joins the job container to its own per-run
|
||||||
|
# network only), plus the containers still attached to those networks;
|
||||||
|
# * containers started from the exact image refs given as arguments;
|
||||||
|
# * the exact image refs given as arguments.
|
||||||
|
# Everything is best effort (a failing removal never fails the job).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
job_ref=""
|
||||||
|
if command -v uv > /dev/null 2>&1 && [ -f tests/integration/harness.py ]; then
|
||||||
|
job_ref="$(uv run python -c \
|
||||||
|
'from tests.integration.harness import job_container_ref; print(job_container_ref())' \
|
||||||
|
2> /dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "${job_ref}" ]; then
|
||||||
|
job_ref="${HOSTNAME:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${job_ref}" ] && docker container inspect "${job_ref}" > /dev/null 2>&1; then
|
||||||
|
job_id="$(docker container inspect --format '{{.Id}}' "${job_ref}")"
|
||||||
|
networks="$(docker container inspect \
|
||||||
|
--format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' \
|
||||||
|
"${job_ref}" | grep -E '^aegis-it-[0-9a-f]+$' || true)"
|
||||||
|
for net in ${networks}; do
|
||||||
|
for cid in $(docker network inspect --format '{{range $id, $_ := .Containers}}{{$id}}{{"\n"}}{{end}}' "${net}" 2> /dev/null); do
|
||||||
|
if [ "${cid}" != "${job_id}" ]; then
|
||||||
|
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
docker network disconnect -f "${net}" "${job_id}" > /dev/null 2>&1 || true
|
||||||
|
docker network rm "${net}" > /dev/null 2>&1 || true
|
||||||
|
echo "removed network ${net}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
for image in "$@"; do
|
||||||
|
for cid in $(docker ps -aq --filter "ancestor=${image}" 2> /dev/null); do
|
||||||
|
docker rm -f -v "${cid}" > /dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
docker rmi "${image}" > /dev/null 2>&1 || true
|
||||||
|
echo "removed image ${image}"
|
||||||
|
done
|
||||||
|
|
||||||
|
exit 0
|
||||||
Executable
+46
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install a pinned gitleaks release into a directory, verifying its sha256.
|
||||||
|
#
|
||||||
|
# Usage: scripts/ci/install_gitleaks.sh <dest-dir>
|
||||||
|
#
|
||||||
|
# The binary ends up at <dest-dir>/gitleaks. To upgrade, bump GITLEAKS_VERSION
|
||||||
|
# and both digests from the release's gitleaks_<version>_checksums.txt.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITLEAKS_VERSION="8.30.1"
|
||||||
|
SHA256_X64="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb"
|
||||||
|
SHA256_ARM64="e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080"
|
||||||
|
|
||||||
|
dest="${1:?usage: install_gitleaks.sh <dest-dir>}"
|
||||||
|
|
||||||
|
case "$(uname -m)" in
|
||||||
|
x86_64 | amd64)
|
||||||
|
arch="x64"
|
||||||
|
expected="${SHA256_X64}"
|
||||||
|
;;
|
||||||
|
aarch64 | arm64)
|
||||||
|
arch="arm64"
|
||||||
|
expected="${SHA256_ARM64}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unsupported architecture: $(uname -m)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
archive="gitleaks_${GITLEAKS_VERSION}_linux_${arch}.tar.gz"
|
||||||
|
url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${archive}"
|
||||||
|
|
||||||
|
work="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${work}"' EXIT
|
||||||
|
|
||||||
|
curl --fail --silent --show-error --location --retry 3 --output "${work}/${archive}" "${url}"
|
||||||
|
echo "${expected} ${work}/${archive}" | sha256sum --check --status || {
|
||||||
|
echo "sha256 mismatch for ${archive}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "${dest}"
|
||||||
|
tar -xzf "${work}/${archive}" -C "${dest}" gitleaks
|
||||||
|
chmod 0755 "${dest}/gitleaks"
|
||||||
|
"${dest}/gitleaks" version
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Audit the locked dependency set with pip-audit.
|
||||||
|
#
|
||||||
|
# Ignored advisories (documented waivers, see docs/security.md) are read from
|
||||||
|
# security/pip-audit-ignore.txt: one advisory id per line, '#' starts a comment.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ignore_file="security/pip-audit-ignore.txt"
|
||||||
|
req="$(mktemp)"
|
||||||
|
trap 'rm -f "${req}"' EXIT
|
||||||
|
|
||||||
|
uv export --locked --no-hashes --no-emit-project --format requirements-txt > "${req}"
|
||||||
|
|
||||||
|
args=(-r "${req}" --strict --no-deps --disable-pip)
|
||||||
|
if [ -f "${ignore_file}" ]; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
id="${line%%#*}"
|
||||||
|
id="$(echo "${id}" | tr -d '[:space:]')"
|
||||||
|
if [ -n "${id}" ]; then
|
||||||
|
args+=(--ignore-vuln "${id}")
|
||||||
|
fi
|
||||||
|
done < "${ignore_file}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
uv run pip-audit "${args[@]}"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Advisories ignored by `scripts/ci/pip_audit.sh`.
|
||||||
|
# One vulnerability id per line (GHSA-..., PYSEC-..., CVE-...). '#' starts a comment.
|
||||||
|
# Every entry needs a justification and an expiry here and a matching waiver in
|
||||||
|
# docs/security.md. Keep this file empty unless a waiver is documented.
|
||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
import atexit
|
import atexit
|
||||||
import contextlib
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
@@ -21,7 +22,9 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
from types import FrameType
|
from types import FrameType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -76,6 +79,61 @@ def run_docker(
|
|||||||
return proc
|
return proc
|
||||||
|
|
||||||
|
|
||||||
|
_MOUNTINFO_ID_RE = re.compile(r"/containers/([0-9a-f]{64})/")
|
||||||
|
_CGROUP_ID_RE = re.compile(r"(?<![0-9a-f])([0-9a-f]{64})(?![0-9a-f])")
|
||||||
|
|
||||||
|
|
||||||
|
def container_id_candidates(
|
||||||
|
environ: Mapping[str, str] | None = None,
|
||||||
|
proc_root: Path = Path("/"),
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return references that may name the container this process runs in.
|
||||||
|
|
||||||
|
Order: ``$HOSTNAME``, ``/etc/hostname``, then 64-hex container ids found in
|
||||||
|
``/proc/self/mountinfo`` and ``/proc/self/cgroup``. On act_runner the hostname
|
||||||
|
is normally the container id, but a runner may set another hostname or use host
|
||||||
|
networking settings, so the other sources act as fallbacks.
|
||||||
|
"""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
candidates: list[str] = []
|
||||||
|
|
||||||
|
def add(value: str) -> None:
|
||||||
|
value = value.strip()
|
||||||
|
if value and value not in candidates:
|
||||||
|
candidates.append(value)
|
||||||
|
|
||||||
|
add(env.get("HOSTNAME", ""))
|
||||||
|
for rel in ("etc/hostname",):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
add((proc_root / rel).read_text(encoding="utf-8"))
|
||||||
|
for rel, pattern in (
|
||||||
|
("proc/self/mountinfo", _MOUNTINFO_ID_RE),
|
||||||
|
("proc/self/cgroup", _CGROUP_ID_RE),
|
||||||
|
):
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
for match in pattern.finditer((proc_root / rel).read_text(encoding="utf-8")):
|
||||||
|
add(match.group(1))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def job_container_ref() -> str:
|
||||||
|
"""Return a reference docker accepts for the container running the tests.
|
||||||
|
|
||||||
|
Each candidate from ``container_id_candidates`` is checked with
|
||||||
|
``docker inspect``; the first one docker resolves wins.
|
||||||
|
"""
|
||||||
|
for candidate in container_id_candidates():
|
||||||
|
proc = run_docker(
|
||||||
|
"container", "inspect", "--format", "{{.Id}}", candidate, check=False, timeout=30
|
||||||
|
)
|
||||||
|
if proc.returncode == 0 and proc.stdout.strip():
|
||||||
|
return candidate
|
||||||
|
raise HarnessError(
|
||||||
|
"AEGIS_IT_NETWORK_MODE=container: could not identify the container running the "
|
||||||
|
"tests ($HOSTNAME, /etc/hostname and /proc/self did not resolve to a container)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def require_docker() -> None:
|
def require_docker() -> None:
|
||||||
"""Fail with a clear message when the docker daemon is not usable."""
|
"""Fail with a clear message when the docker daemon is not usable."""
|
||||||
try:
|
try:
|
||||||
@@ -417,9 +475,7 @@ def start_gitea() -> GiteaInstance:
|
|||||||
try:
|
try:
|
||||||
base_url = _start_container(res, container_mode)
|
base_url = _start_container(res, container_mode)
|
||||||
if container_mode:
|
if container_mode:
|
||||||
job_container = os.environ.get("HOSTNAME")
|
job_container = job_container_ref()
|
||||||
if not job_container:
|
|
||||||
raise HarnessError("AEGIS_IT_NETWORK_MODE=container requires $HOSTNAME")
|
|
||||||
run_docker("network", "connect", res.network, job_container)
|
run_docker("network", "connect", res.network, job_container)
|
||||||
res.attached_container = job_container
|
res.attached_container = job_container
|
||||||
_wait_ready(base_url, res.container)
|
_wait_ready(base_url, res.container)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tests.integration import harness
|
from tests.integration import harness
|
||||||
@@ -138,3 +140,20 @@ def test_missing_docker_cli_gives_clear_error(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
monkeypatch.setenv("PATH", "")
|
monkeypatch.setenv("PATH", "")
|
||||||
with pytest.raises(HarnessError, match="Docker is unavailable"):
|
with pytest.raises(HarnessError, match="Docker is unavailable"):
|
||||||
harness.require_docker()
|
harness.require_docker()
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_id_candidates_fall_back_to_proc(tmp_path: Path) -> None:
|
||||||
|
cid = "ab" * 32
|
||||||
|
(tmp_path / "etc").mkdir()
|
||||||
|
(tmp_path / "proc/self").mkdir(parents=True)
|
||||||
|
(tmp_path / "etc/hostname").write_text("custom-host\n")
|
||||||
|
(tmp_path / "proc/self/mountinfo").write_text(
|
||||||
|
f"1 2 0:1 /var/lib/docker/containers/{cid}/hostname /etc/hostname rw - ext4 /dev/x rw\n"
|
||||||
|
)
|
||||||
|
(tmp_path / "proc/self/cgroup").write_text(f"0::/docker/{cid}\n")
|
||||||
|
candidates = harness.container_id_candidates({"HOSTNAME": "job-host"}, proc_root=tmp_path)
|
||||||
|
assert candidates == ["job-host", "custom-host", cid]
|
||||||
|
|
||||||
|
|
||||||
|
def test_container_id_candidates_tolerate_missing_files(tmp_path: Path) -> None:
|
||||||
|
assert harness.container_id_candidates({}, proc_root=tmp_path) == []
|
||||||
Reference in new issue
Block a user