From a98e7777e6655b0b33d52d0828ec754b49ff2700 Mon Sep 17 00:00:00 2001 From: Yoan Moscatelli Date: Thu, 21 May 2026 15:28:59 +0200 Subject: [PATCH 1/3] :sparkles: add automatic projet desactivation and retag --- .devcontainer/requirements.txt | 3 +- ...-autoTaggingAndStaleDeactivation.prompt.md | 97 +++++++++ .../workflows/deactivate-stale-projects.yaml | 47 ++++ .github/workflows/retag-projects.yaml | 45 ++++ README.md | 49 +++++ src/cli/commands.py | 189 ++++++++++++++++ src/domain/models.py | 2 + src/services/project.py | 137 ++++++++++++ src/services/sbom.py | 13 +- src/services/stale_projects.py | 125 +++++++++++ src/services/tagging.py | 93 ++++++++ tests/test_stale_projects.py | 202 ++++++++++++++++++ tests/test_tagging.py | 165 ++++++++++++++ 13 files changed, 1164 insertions(+), 3 deletions(-) create mode 100644 .github/prompts/plan-autoTaggingAndStaleDeactivation.prompt.md create mode 100644 .github/workflows/deactivate-stale-projects.yaml create mode 100644 .github/workflows/retag-projects.yaml create mode 100644 src/services/stale_projects.py create mode 100644 src/services/tagging.py create mode 100644 tests/test_stale_projects.py create mode 100644 tests/test_tagging.py diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index bd10e33..ae7b0d1 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -6,4 +6,5 @@ pre-commit==4.1.0 click==8.1.8 python-magic==0.4.27 pydantic==2.11.2 -pyyaml==6.0.2 \ No newline at end of file +pyyaml==6.0.2 +pytest==8.3.5 \ No newline at end of file diff --git a/.github/prompts/plan-autoTaggingAndStaleDeactivation.prompt.md b/.github/prompts/plan-autoTaggingAndStaleDeactivation.prompt.md new file mode 100644 index 0000000..4ae7533 --- /dev/null +++ b/.github/prompts/plan-autoTaggingAndStaleDeactivation.prompt.md @@ -0,0 +1,97 @@ +## Plan: Auto-tagging + cron deactivation of stale DT projects + +Two coordinated additions to the Dependency-Track tooling: + +1. **Auto-tagging** — every project gets four canonical, prefixed tags whenever it's created/updated by this tool: `name:`, `version:`, `parent:` (when applicable), and `lifecycle:` derived from the version string. Same logic powers a one-off remediation CLI to back-fill existing projects. +2. **Stale deactivation** — a daily cron workflow marks active projects inactive. Leaf projects are stale when `lastBomImport` is older than 15 days **or null** (never imported). Collection-parent projects (and any project with active children) are protected regardless of age. `lifecycle:GA` and `keep-active` tags protect any project. + +**Tagging rules** (single source of truth: `src/services/tagging.py`) +- `name:` — `n = name.lower().replace("-", "_")`. +- `version:` — `v = version.lower().replace("-", "_")` (only when version is set). +- `parent:

` — `p = parent_name.lower().replace("-", "_")` (only when project has a parent). +- `lifecycle:` — case-insensitive substring scan over the version string, first-match wins in this order: `alpha`, `beta`, `dev`, `preview`, `rc`. No match (or no version) → `lifecycle:GA`. +- Constants: `LIFECYCLE_PREFIXES = ("alpha", "beta", "dev", "preview", "rc")`, `GA_TAG = "lifecycle:GA"`. +- Pure helper signature: `compute_auto_tags(name: str, version: Optional[str], parent_name: Optional[str]) -> list[str]`. +- A second helper `merge_auto_tags(existing: list[str], auto: list[str]) -> list[str]` performs deduplication and lifecycle replacement: strip any existing `lifecycle:*`, `name:*`, `version:*`, `parent:*` and re-add the freshly computed ones; preserve every other tag. + +**Stale deactivation logic** (`src/services/stale_projects.py` + `src/cli/commands.py`) +- `is_stale` skip conditions (leaf projects only): already inactive · tags contain `lifecycle:GA` **or** `keep-active` · `lastBomImport` set and age ≤ threshold. **`isLatest=true` and `null lastBomImport` are NOT protected** — a never-imported project is always stale. +- Before deactivating any project (leaf or parent), `get_project_children` is called; projects with active children are skipped (`has_active_children`) — this prevents DT's 409. +- Two-pass deactivation: **pass 1** — leaf/NONE-logic projects that pass `is_stale` and have no active children; **pass 2** — collection parents with no active children (staleness check skipped for parents). +- Deactivation uses GET-then-PATCH: fetch full project payload, set `active=false`, PATCH — avoids 409 from partial-body rejection. +- 2-second pause between write operations to avoid overloading the API server. + +**Phases & Steps** + +Phase A — Pure tagging helper (no deps, easy to test) +1. New module `src/services/tagging.py` exposing `compute_auto_tags`, `merge_auto_tags`, and the lifecycle constants. +2. New tests in `tests/test_tagging.py` covering: GA fallback, each lifecycle token (alpha/beta/dev/preview/rc), version with multiple keywords (first-match wins), null version, parent vs no parent, dash normalization, idempotent merge (no duplicates), lifecycle replacement on re-run. + +Phase B — Auto-tag on upload (depends on A) +3. Extend `Project` dataclass in `src/domain/models.py` with optional non-API field `parent_name: Optional[str] = None` (excluded from `to_api_dict`). +4. In `src/services/project.py` `ProjectService.create_project`, just before the existing existence check, call `merge_auto_tags(project.tags, compute_auto_tags(project.name, project.version, project.parent_name))` and assign back to `project.tags`. This covers create AND update paths since both flow through this method. +5. Update the three `Project(...)` construction sites in `src/services/sbom.py` (lines ~285, ~313, ~458) to pass `parent_name=` where a parent exists. Also check `src/sbom_uploader/{singular,list,nested,directory}.py` for any direct `Project(...)` construction and pass `parent_name` there too. + +Phase C — Stale deactivation service layer (parallel with B) +6. In `src/services/project.py`: add `list_projects(exclude_inactive=True)` (paginated wrapper around `GET /project`), extract a public `get_project_children(uuid)` from `_get_single_project_hierarchy`, and add `deactivate_project(uuid)` which GETs the full project payload, sets `active=false`, and PATCHes (honors `dry_run`). + +Phase D — Staleness decision module (depends on A & C) +7. New `src/services/stale_projects.py` exposing `STALE_THRESHOLD_DAYS = 15`, `is_stale(project, now_ms, threshold_days) -> (bool, skip_reason)`, `partition_by_collection(projects)`, and `build_summary(...)`. Tag checks look for both `lifecycle:GA` and `keep-active` in the project's tags list (raw DT tag dicts → `{t["name"] for t in project.get("tags", [])}`). +8. Tests in `tests/test_stale_projects.py` for the full decision matrix. + +Phase E — CLI commands (depends on B & D) +9. In `src/cli/commands.py`: + - `deactivate-stale` with `--days` (default 15), `--dry-run`. Runs the two-pass logic and emits JSON summary + `GITHUB_STEP_SUMMARY` block. + - `retag-projects` with `--dry-run`. Lists ALL projects (including inactive), and for each computes desired auto-tags, merges with existing tags via `merge_auto_tags` (which strips old `lifecycle:*`, `name:*`, `version:*`, `parent:*` before re-adding fresh ones — preserves everything else). PATCHes only when the tag set actually differs. Parent name resolved from `project.get("parent", {}).get("name")` returned by the list endpoint, or via a follow-up GET if absent. + +Phase F — GitHub workflows (depends on E) +10. New `.github/workflows/deactivate-stale-projects.yaml`: + - `on.schedule: "0 2 * * *"` + `on.workflow_dispatch` with `dry-run` boolean input (default false). + - `permissions: contents: read`, `concurrency: group: deactivate-stale, cancel-in-progress: false`. + - Steps: checkout, setup Python 3.13, `pip install -r requirements.txt`, `python3 src/main.py deactivate-stale` with `INPUT_URL`/`INPUT_API_KEY` from secrets and `INPUT_DRY_RUN` from the dispatch input. +11. New `.github/workflows/retag-projects.yaml`: + - `on.workflow_dispatch` only (one-off remediation), with `dry-run` input (default true for safety). + - Otherwise identical structure to the deactivation workflow; runs `python3 src/main.py retag-projects`. + +Phase G — Docs (minimal) +12. Brief note in `README.md` describing the two new commands and the workflow_dispatch entry point for retagging. (No new docs files per repo convention.) + +**Relevant files** +- `src/services/tagging.py` — **new**: pure helpers (`compute_auto_tags`, `merge_auto_tags`, lifecycle constants). +- `src/services/stale_projects.py` — **new**: pure decision helpers. +- `src/services/project.py` — extend with `list_projects`, `get_project_children`, `deactivate_project`; inject auto-tag merge at start of `create_project` (line ~58). +- `src/domain/models.py` — add `parent_name: Optional[str] = None` to `Project` (line ~94), keep it out of `to_api_dict`. +- `src/services/sbom.py` — pass `parent_name` at the three `Project(...)` construction sites (~285, ~313, ~458). +- `src/sbom_uploader/{singular,list,nested,directory}.py` — pass `parent_name` wherever `Project(...)` is constructed (verify during impl). +- `src/cli/commands.py` — add `deactivate-stale` and `retag-projects` commands; reuse `@with_services()` decorator. +- `.github/workflows/deactivate-stale-projects.yaml` — **new** cron + dispatch. +- `.github/workflows/retag-projects.yaml` — **new** dispatch only. +- `tests/test_tagging.py` — **new**. +- `tests/test_stale_projects.py` — **new**. +- `README.md` — append short usage section. + +**Verification** +1. `pytest tests/test_tagging.py tests/test_stale_projects.py` — all unit cases pass. +2. Against local DT (`tests/docker/docker-compose.yml`): + - Upload `tests/single_sbom/nginx_12.9.1.json` and verify the resulting project carries `name:nginx`, `version:12.9.1`, `lifecycle:GA`. + - Upload one with a `*-rc.1` version and verify `lifecycle:rc`. + - Upload a nested hierarchy and verify children carry `parent:` and the parent does not. +3. Seed a project with stale `lastBomImport` plus `lifecycle:GA`, run `python3 src/main.py deactivate-stale --dry-run` — it must be reported as skipped (reason: GA). Remove the tag, rerun, and confirm it is now reported as stale. +4. Manually clear tags on an existing project, run `python3 src/main.py retag-projects --dry-run`, verify the diff shows the expected four tags added. +5. Re-run `retag-projects` without `--dry-run`; a third invocation must report zero changes (idempotency). +6. `actionlint .github/workflows/deactivate-stale-projects.yaml .github/workflows/retag-projects.yaml`. + +**Decisions** +- Tag format: prefixed (`name:`, `version:`, `parent:`, `lifecycle:`) to avoid collisions with arbitrary user tags. +- Tag value normalization: `.lower().replace("-", "_")` for name/version/parent — matches existing `file_discovery.py` convention. +- Lifecycle keywords: `alpha`, `beta`, `dev`, `preview`, `rc`. First-match wins in that order. Default → `GA`. +- Retag scope: preserve arbitrary user tags; only the four managed prefixes (`name:`, `version:`, `parent:`, `lifecycle:`) are replaced. +- Auto-tagging is applied inside `ProjectService.create_project`, so it runs for both new projects and updates of existing ones (back-fills tags on every upload). +- Stale deactivation skip list: `lifecycle:GA` **OR** `keep-active` tag, active children present. `isLatest=true` is **not** protected. `null lastBomImport` is **not** protected (treated as infinitely old → stale). +- Schedule: deactivation daily `0 2 * * *` UTC. Retag: manual dispatch only (one-off remediation; ongoing tagging happens at upload time). + +**Further Considerations** +1. **Secret names** — what are the existing repo-level secret names for the DT URL and API key? (`DT_URL`/`DT_API_KEY` vs. `DEPENDENCY_TRACK_URL`/`DEPENDENCY_TRACK_API_KEY` vs. something else) +2. **API key permissions** — reuse the upload key (must hold `PORTFOLIO_MANAGEMENT`) or use a dedicated maintenance key? +3. **Edge case `dev-preview`** — version `1.0.0-dev-preview` will land on `lifecycle:dev` under the chosen precedence. Confirm that's intended (vs. `preview`). +4. **Notifications** — should the deactivation workflow post to Slack / open an issue when projects are deactivated, or is the run log sufficient? diff --git a/.github/workflows/deactivate-stale-projects.yaml b/.github/workflows/deactivate-stale-projects.yaml new file mode 100644 index 0000000..f652e22 --- /dev/null +++ b/.github/workflows/deactivate-stale-projects.yaml @@ -0,0 +1,47 @@ +name: Deactivate Stale Projects + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + inputs: + dry-run: + description: "Dry run — report without making changes" + type: boolean + default: false + +concurrency: + group: deactivate-stale + cancel-in-progress: false + +permissions: + contents: read + +jobs: + deactivate-stale: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python 3.13 + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: "pip" + + - name: Install Python dependencies + run: pip install -r requirements.txt + + - name: Deactivate stale projects + env: + INPUT_URL: ${{ vars.DEPENDENCY_TRACK_HOSTNAME }} + INPUT_API_KEY: ${{ secrets.DEPENDENCY_TRACK_APIKEY }} + INPUT_DRY_RUN: ${{ inputs.dry-run || 'false' }} + run: | + DRY_RUN_FLAG="" + if [ "$INPUT_DRY_RUN" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + PYTHONPATH=src python3 src/main.py deactivate-stale $DRY_RUN_FLAG diff --git a/.github/workflows/retag-projects.yaml b/.github/workflows/retag-projects.yaml new file mode 100644 index 0000000..0c9aa3d --- /dev/null +++ b/.github/workflows/retag-projects.yaml @@ -0,0 +1,45 @@ +name: Retag Projects + +on: + workflow_dispatch: + inputs: + dry-run: + description: "Dry run — show changes without applying them" + type: boolean + default: true + +concurrency: + group: retag-projects + cancel-in-progress: false + +permissions: + contents: read + +jobs: + retag: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python 3.13 + uses: actions/setup-python@v6 + with: + python-version: "3.13" + cache: "pip" + + - name: Install Python dependencies + run: pip install -r requirements.txt + + - name: Retag all projects + env: + INPUT_URL: ${{ vars.DEPENDENCY_TRACK_HOSTNAME }} + INPUT_API_KEY: ${{ secrets.DEPENDENCY_TRACK_APIKEY }} + INPUT_DRY_RUN: ${{ inputs.dry-run || 'true' }} + run: | + DRY_RUN_FLAG="" + if [ "$INPUT_DRY_RUN" = "true" ]; then + DRY_RUN_FLAG="--dry-run" + fi + PYTHONPATH=src python3 src/main.py retag-projects $DRY_RUN_FLAG diff --git a/README.md b/README.md index f0c12bc..704e8e9 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,55 @@ export INPUT_DRY_RUN="true" PYTHONPATH=src python src/main.py upload ``` +## 🏷️ Auto-Tagging & Stale Project Maintenance + +Every project created or updated by this tool is automatically tagged with four +canonical prefixed tags: + +| Tag | Value | +|-----|-------| +| `name:` | `name.lower().replace("-","_")` | +| `version:` | `version.lower().replace("-","_")` (when set) | +| `parent:

` | `parent_name.lower().replace("-","_")` (when set) | +| `lifecycle:` | First match of `alpha`, `beta`, `dev`, `preview`, `rc` in the version string, or `GA` | + +User-defined tags are always preserved; only the four managed prefixes are +replaced on subsequent uploads. + +### Deactivate stale projects + +```bash +# Preview — no changes made +PYTHONPATH=src python3 src/main.py deactivate-stale --dry-run + +# Apply with a custom threshold (default: 15 days) +PYTHONPATH=src python3 src/main.py deactivate-stale --days 30 +``` + +Projects are **protected** from deactivation when they carry `lifecycle:GA` or +`keep-active`, or when they have active children (checked via the DT API before +every deactivation attempt, regardless of collection logic). +A project that has never received a BOM (`lastBomImport` is null) is treated as +infinitely stale and will be deactivated unless one of the above protections +applies. + +A daily scheduled workflow (`.github/workflows/deactivate-stale-projects.yaml`, +`0 2 * * *` UTC) runs this automatically. Trigger it manually via +`workflow_dispatch` with the `dry-run` input set to `true` for a safe preview. + +### Back-fill tags on existing projects + +```bash +# Preview diff (default behaviour) +PYTHONPATH=src python3 src/main.py retag-projects --dry-run + +# Apply +PYTHONPATH=src python3 src/main.py retag-projects +``` + +The one-off remediation workflow (`.github/workflows/retag-projects.yaml`) is +`workflow_dispatch`-only with `dry-run: true` as the safe default. + ## 🤝 Contributing 1. Fork the repository diff --git a/src/cli/commands.py b/src/cli/commands.py index 9eac32c..d69dbce 100644 --- a/src/cli/commands.py +++ b/src/cli/commands.py @@ -490,6 +490,195 @@ def _handle_action_upload(config_data: dict, output_file: str) -> None: raise click.ClickException("Hierarchy upload failed") +@cli.command("deactivate-stale") +@click.option( + "--days", + default=15, + show_default=True, + help="Inactivity threshold in days.", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Report without making any changes.", +) +@with_services() +def deactivate_stale(days: int, dry_run: bool, services: Services) -> None: + """Mark stale projects as inactive. + + Pass 1 — leaf projects: deactivated when lastBomImport is older than + --days and not protected by lifecycle:GA or keep-active. + + Pass 2 — collection parents: deactivated when they have no active + children, regardless of their own lastBomImport age. lifecycle:GA and + keep-active still protect them. + """ + import time + import datetime as _dt + from services.stale_projects import ( + is_stale, + partition_by_collection, + build_summary, + ) + + now_ms = int(_dt.datetime.now(_dt.timezone.utc).timestamp() * 1000) + effective_dry_run = dry_run or services.connection_service.dry_run + + click.echo( + f"Fetching active projects (threshold: {days} days, dry_run={effective_dry_run})..." + ) + projects = services.project_service.list_projects(exclude_inactive=True) + click.echo(f"Found {len(projects)} active projects.") + + leaves, parents = partition_by_collection(projects) + + deactivated: list = [] + skipped: list = [] + + # Pass 1: leaf projects — staleness check, then active-children guard + for project in leaves: + stale, reason = is_stale(project, now_ms, days) + if not stale: + skipped.append((project, reason)) + continue + + # A project may have children even with collectionLogic=NONE; DT + # returns 409 if we try to deactivate a parent with active children. + children = services.project_service.get_project_children(project["uuid"]) + active_children = [c for c in children if c.get("active", True)] + if active_children: + skipped.append((project, "has_active_children")) + continue + + ok = services.project_service.deactivate_project(project["uuid"]) + if ok: + deactivated.append(project) + else: + skipped.append((project, "deactivation_failed")) + time.sleep(2) + + # Pass 2: collection parents — deactivate when no active children remain, + # regardless of the parent's own lastBomImport age. + for project in parents: + tag_names = {t["name"] for t in project.get("tags", [])} + if "lifecycle:GA" in tag_names: + skipped.append((project, "lifecycle_GA")) + continue + if "keep-active" in tag_names: + skipped.append((project, "keep_active")) + continue + + children = services.project_service.get_project_children(project["uuid"]) + active_children = [c for c in children if c.get("active", True)] + if active_children: + skipped.append((project, "has_active_children")) + continue + + ok = services.project_service.deactivate_project(project["uuid"]) + if ok: + deactivated.append(project) + else: + skipped.append((project, "deactivation_failed")) + time.sleep(2) + + summary = build_summary(deactivated, skipped, dry_run=effective_dry_run) + summary_json = json.dumps(summary, indent=2) + click.echo(summary_json) + + github_step_summary = os.getenv("GITHUB_STEP_SUMMARY") + if github_step_summary: + with open(github_step_summary, "a", encoding="utf-8") as fh: + fh.write(f"## Stale Project Deactivation\n\n```json\n{summary_json}\n```\n") + + +@cli.command("retag-projects") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show what would change without applying it.", +) +@with_services() +def retag_projects(dry_run: bool, services: Services) -> None: + """Back-fill canonical auto-tags on all projects. + + Iterates every project (including inactive), computes the four managed + tags (name:, version:, parent:, lifecycle:), and PATCHes only those + whose tag set has actually changed. User-defined tags are preserved. + Running this command twice in succession reports zero changes (idempotent). + """ + import time + from services.tagging import compute_auto_tags, merge_auto_tags + + effective_dry_run = dry_run or services.connection_service.dry_run + + click.echo( + f"Fetching all projects including inactive (dry_run={effective_dry_run})..." + ) + projects = services.project_service.list_projects(exclude_inactive=False) + click.echo(f"Found {len(projects)} projects.") + + changed = 0 + unchanged = 0 + + for project in projects: + name = project.get("name", "") + version = project.get("version") + parent_obj = project.get("parent") or {} + p_name = parent_obj.get("name") + + # Follow-up GET when parent is referenced by UUID only + if parent_obj.get("uuid") and not p_name: + resp = services.connection_service.make_request( + method="GET", endpoint=f"/project/{parent_obj['uuid']}" + ) + if resp and resp.status_code == 200: + try: + p_name = resp.json().get("name") + except Exception: # pylint: disable=broad-except + pass + + current_tags = [t["name"] for t in project.get("tags", [])] + desired_tags = merge_auto_tags( + current_tags, compute_auto_tags(name, version, p_name) + ) + + if set(current_tags) == set(desired_tags): + unchanged += 1 + continue + + click.echo( + f" {name} ({version or 'no version'}): " + f"{current_tags} → {desired_tags}" + ) + changed += 1 + + uuid = project.get("uuid") + if uuid: + # Fetch the full project payload and replace only the tags field, + # so that collectionLogic, classifier, description and other fields + # are not reset to DT defaults by a partial PATCH body. + full_resp = services.connection_service.make_request( + method="GET", endpoint=f"/project/{uuid}" + ) + if full_resp and full_resp.status_code == 200: + try: + full_payload = full_resp.json() + full_payload["tags"] = [{"name": t} for t in desired_tags] + services.connection_service.make_request( + method="PATCH", + endpoint=f"/project/{uuid}", + json=full_payload, + ) + except Exception: # pylint: disable=broad-except + logger.warning("Failed to retag project %s", uuid) + time.sleep(2) + + action = "Would update" if effective_dry_run else "Updated" + click.echo(f"\n{action} {changed} project(s), {unchanged} unchanged.") + + def _count_projects_in_hierarchy(config_data: dict) -> int: """ Recursively count all projects in a hierarchy configuration. diff --git a/src/domain/models.py b/src/domain/models.py index 1a47ab5..e0f3093 100644 --- a/src/domain/models.py +++ b/src/domain/models.py @@ -95,6 +95,8 @@ class Project: # pylint: disable=too-many-instance-attributes description: Optional[str] = None active: bool = True is_latest: bool = False + # Local-only field — not serialised to the DT API + parent_name: Optional[str] = None def to_api_dict(self) -> Dict[str, Any]: """Convert to dictionary for API calls.""" diff --git a/src/services/project.py b/src/services/project.py index c967951..e9707a7 100644 --- a/src/services/project.py +++ b/src/services/project.py @@ -12,6 +12,7 @@ ) from domain.models import Project, CollectionLogic from domain.version import is_latest_version, get_latest_version +from services.tagging import compute_auto_tags, merge_auto_tags from services.connection import ConnectionService from services.response_handler import APIResponseHandler @@ -57,6 +58,11 @@ def create_project( """ logger.info("Creating/updating project: %s", project.name) + project.tags = merge_auto_tags( + project.tags, + compute_auto_tags(project.name, project.version, project.parent_name), + ) + delete_on_suffix = ( delete_if_version_matches if delete_if_version_matches is not None @@ -700,6 +706,137 @@ def _get_parent_version(self, project: Project) -> Optional[str]: return None + def list_projects(self, exclude_inactive: bool = True) -> List[Dict[str, Any]]: + """Return all projects from Dependency Track using paginated requests. + + Args: + exclude_inactive: When True only active projects are returned. + + Returns: + Flat list of project dicts from the API. + """ + all_projects: List[Dict[str, Any]] = [] + page = 1 + page_size = 100 + + while True: + params: Dict[str, Any] = {"pageSize": page_size, "page": page} + if exclude_inactive: + params["excludeInactive"] = "true" + + response = self.connection.make_request( + method="GET", endpoint="/project", params=params + ) + + if not response or response.status_code != 200: + break + + try: + batch = response.json() + except Exception: # pylint: disable=broad-except + break + + if not isinstance(batch, list) or not batch: + break + + all_projects.extend(batch) + + # Stop when we have received everything reported by the server + total_header = response.headers.get("X-Total-Count") + if total_header and len(all_projects) >= int(total_header): + break + + if len(batch) < page_size: + break + + page += 1 + + return all_projects + + def get_project_children(self, uuid: str) -> List[Dict[str, Any]]: + """Return the direct children of a project. + + Args: + uuid: Project UUID. + + Returns: + List of child project dicts, or an empty list on error. + """ + response = self.connection.make_request( + method="GET", endpoint=f"/project/{uuid}/children" + ) + if response and response.status_code == 200: + try: + return response.json() + except Exception: # pylint: disable=broad-except + pass + return [] + + def deactivate_project(self, uuid: str) -> bool: + """Mark a project as inactive via PATCH. + + Fetches the full project payload first so DT's PATCH endpoint receives + a complete object (partial-body PATCHes return 409 in most DT versions). + Honors dry-run mode. + + Args: + uuid: Project UUID to deactivate. + + Returns: + True when the project was (or would be) deactivated, False on error. + """ + if self.connection.dry_run: + logger.info("[DRY RUN] Would deactivate project %s", uuid) + return True + + # Fetch the current project data so we can send a complete PATCH body + get_response = self.connection.make_request( + method="GET", endpoint=f"/project/{uuid}" + ) + if not get_response or get_response.status_code != HTTPStatus.OK.value: + logger.warning( + "Could not fetch project %s before deactivation (status %s)", + uuid, + get_response.status_code if get_response else "no response", + ) + return False + + try: + project_data = get_response.json() + except Exception: # pylint: disable=broad-except + logger.warning("Could not parse project %s response", uuid) + return False + + project_data["active"] = False + + patch_response = self.connection.make_request( + method="PATCH", + endpoint=f"/project/{uuid}", + json=project_data, + ) + + if patch_response is None: + return True + + if patch_response.status_code == HTTPStatus.OK.value: + logger.info("Deactivated project %s", uuid) + return True + + if patch_response.status_code == HTTPStatus.CONFLICT.value: + logger.warning( + "409 conflict deactivating project %s — " + "project likely has active children; skipping", + uuid, + ) + return False + + logger.warning( + "Unexpected status %s deactivating project %s", + patch_response.status_code, + uuid, + ) + return False + def _remove_latest_flag(self, project_uuid: str) -> None: """ Remove the latest flag from a specific project. diff --git a/src/services/sbom.py b/src/services/sbom.py index 1f6bb31..08fea1c 100644 --- a/src/services/sbom.py +++ b/src/services/sbom.py @@ -314,6 +314,7 @@ def upload_nested_hierarchy( # pylint: disable=too-many-locals, too-many-branch name=child_project_name, version=metadata.version, parent_uuid=created_parent.uuid, + parent_name=parent_name, ) created_child = self.project_service.create_project( @@ -432,7 +433,11 @@ def upload_from_hierarchy_config(self, config_file: Path) -> UploadResult: return UploadResult.failure_result(str(error)) def _process_hierarchy_project( - self, project_name: str, project_config: Dict[str, Any], parent_uuid: str = None + self, + project_name: str, + project_config: Dict[str, Any], + parent_uuid: str = None, + parent_name: Optional[str] = None, ) -> UploadResult: """ Process a single project in the hierarchy configuration. @@ -463,6 +468,7 @@ def _process_hierarchy_project( parent_uuid=parent_uuid, tags=hierarchy_config.tags, is_latest=hierarchy_config.is_latest, + parent_name=parent_name, ) created_project = self.project_service.create_project( @@ -499,7 +505,10 @@ def _process_hierarchy_project( child_name = child_config.get("name") if child_name: child_result = self._process_hierarchy_project( - child_name, child_config, created_project.uuid + child_name, + child_config, + created_project.uuid, + parent_name=project_name, ) if not child_result.success: logger.error("Failed to process child project: %s", child_name) diff --git a/src/services/stale_projects.py b/src/services/stale_projects.py new file mode 100644 index 0000000..afa5b9b --- /dev/null +++ b/src/services/stale_projects.py @@ -0,0 +1,125 @@ +"""Staleness decision helpers for Dependency Track projects. + +All functions are pure (no I/O) so they are easy to unit-test in isolation. +""" + +from typing import Dict, Any, List, Optional, Tuple + +STALE_THRESHOLD_DAYS = 15 +_MILLIS_PER_DAY = 86_400_000 + +_GA_TAG = "lifecycle:GA" +_KEEP_ACTIVE_TAG = "keep-active" + + +def is_stale( + project: Dict[str, Any], + now_ms: int, + threshold_days: int = STALE_THRESHOLD_DAYS, +) -> Tuple[bool, str]: + """Determine whether a project should be deactivated. + + Skip conditions for **leaf** projects (in evaluation order): + 1. Already inactive + 2. Tags contain ``lifecycle:GA`` + 3. Tags contain ``keep-active`` + 4. Age ≤ threshold (projects with a null ``lastBomImport`` are considered + infinitely old and therefore always stale) + + Note: collection parents use a different rule (no active children) and are + not evaluated through this function. + + Args: + project: Raw DT project dict from the API. + now_ms: Current time as Unix epoch milliseconds. + threshold_days: Days without a BOM import to be considered stale. + + Returns: + ``(stale, skip_reason)`` — when *stale* is ``False``, *skip_reason* + is a short string explaining why; when *stale* is ``True``, + *skip_reason* is an empty string. + """ + if not project.get("active", True): + return False, "already_inactive" + + tag_names = {t["name"] for t in project.get("tags", [])} + if _GA_TAG in tag_names: + return False, "lifecycle_GA" + if _KEEP_ACTIVE_TAG in tag_names: + return False, "keep_active" + + last_bom_import = project.get("lastBomImport") + if last_bom_import is not None: + age_days = (now_ms - last_bom_import) / _MILLIS_PER_DAY + if age_days <= threshold_days: + return False, "not_stale" + + # null lastBomImport → never imported → always stale + return True, "" + + +def partition_by_collection( + projects: List[Dict[str, Any]], +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """Separate leaf projects from collection parents. + + A project is a *collection parent* when its ``collectionLogic`` field is + set to a value other than ``"NONE"`` (or absent / null). + + Args: + projects: List of raw DT project dicts. + + Returns: + ``(leaves, parents)`` where *parents* have a non-NONE collectionLogic. + """ + leaves: List[Dict[str, Any]] = [] + parents: List[Dict[str, Any]] = [] + for proj in projects: + logic = proj.get("collectionLogic") or "NONE" + if logic == "NONE": + leaves.append(proj) + else: + parents.append(proj) + return leaves, parents + + +def build_summary( + deactivated: List[Dict[str, Any]], + skipped: List[Tuple[Dict[str, Any], str]], + dry_run: bool, +) -> Dict[str, Any]: + """Build a JSON-serialisable summary of the deactivation run. + + Args: + deactivated: Projects that were (or would be) deactivated. + skipped: ``(project, reason)`` pairs for skipped projects. + dry_run: Whether this was a dry-run. + + Returns: + Summary dict with ``dry_run``, ``deactivated``, ``skipped``, + and ``counts`` keys. + """ + return { + "dry_run": dry_run, + "deactivated": [ + { + "uuid": p.get("uuid"), + "name": p.get("name"), + "version": p.get("version"), + } + for p in deactivated + ], + "skipped": [ + { + "uuid": p.get("uuid"), + "name": p.get("name"), + "version": p.get("version"), + "reason": reason, + } + for p, reason in skipped + ], + "counts": { + "deactivated": len(deactivated), + "skipped": len(skipped), + }, + } diff --git a/src/services/tagging.py b/src/services/tagging.py new file mode 100644 index 0000000..d29fd7b --- /dev/null +++ b/src/services/tagging.py @@ -0,0 +1,93 @@ +"""Auto-tagging helpers for Dependency Track projects. + +Single source of truth for the four canonical managed tag prefixes +(``name:``, ``version:``, ``parent:``, ``lifecycle:``). +""" + +from typing import Optional, List + +LIFECYCLE_PREFIXES = ("alpha", "beta", "dev", "preview", "rc") +GA_TAG = "lifecycle:GA" + +_MANAGED_PREFIXES = ("name:", "version:", "parent:", "lifecycle:") + + +def compute_auto_tags( + name: str, + version: Optional[str], + parent_name: Optional[str], +) -> List[str]: + """Compute the four canonical auto-tags for a project. + + Args: + name: Project name. + version: Project version, or None. + parent_name: Parent project name, or None. + + Returns: + List of tag strings — always includes ``name:`` and ``lifecycle:``, + plus ``version:`` and ``parent:`` when those values are provided. + """ + tags: List[str] = [] + tags.append(f"name:{_normalize(name)}") + if version: + tags.append(f"version:{_normalize(version)}") + if parent_name: + tags.append(f"parent:{_normalize(parent_name)}") + tags.append(_lifecycle_tag(version)) + return tags + + +def merge_auto_tags(existing: List[str], auto: List[str]) -> List[str]: + """Merge computed auto-tags into an existing tag list. + + Strips any existing managed-prefix tags (``name:``, ``version:``, + ``parent:``, ``lifecycle:``) and appends the freshly computed ones, + preserving every other tag. The result is deduplicated while + preserving insertion order. + + Args: + existing: Current list of tag strings on the project. + auto: Tags produced by :func:`compute_auto_tags`. + + Returns: + Deduplicated merged tag list. + """ + preserved = [t for t in existing if not _is_managed(t)] + merged = preserved + auto + seen: set = set() + result: List[str] = [] + for tag in merged: + if tag not in seen: + seen.add(tag) + result.append(tag) + return result + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _normalize(value: str) -> str: + """Lowercase and replace hyphens with underscores.""" + return value.lower().replace("-", "_") + + +def _lifecycle_tag(version: Optional[str]) -> str: + """Return the appropriate ``lifecycle:`` tag for *version*. + + Performs a case-insensitive substring scan in LIFECYCLE_PREFIXES order; + first match wins. Returns ``lifecycle:GA`` when no keyword is found or + when *version* is None. + """ + if version: + lower = version.lower() + for prefix in LIFECYCLE_PREFIXES: + if prefix in lower: + return f"lifecycle:{prefix}" + return GA_TAG + + +def _is_managed(tag: str) -> bool: + """Return True when *tag* belongs to one of the four managed prefixes.""" + return any(tag.startswith(p) for p in _MANAGED_PREFIXES) diff --git a/tests/test_stale_projects.py b/tests/test_stale_projects.py new file mode 100644 index 0000000..3778aa7 --- /dev/null +++ b/tests/test_stale_projects.py @@ -0,0 +1,202 @@ +"""Unit tests for src/services/stale_projects.py""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import pytest +from services.stale_projects import ( + is_stale, + partition_by_collection, + build_summary, + STALE_THRESHOLD_DAYS, +) + +_DAY_MS = 86_400_000 +_NOW_MS = 1_700_000_000_000 # fixed reference timestamp + + +def _project(**kwargs): + """Minimal active project dict with sensible defaults.""" + base = { + "uuid": "test-uuid", + "name": "test-project", + "version": "1.0.0", + "active": True, + "isLatest": False, + "lastBomImport": _NOW_MS - 20 * _DAY_MS, # 20 days old → stale by default + "tags": [], + "collectionLogic": "NONE", + } + base.update(kwargs) + return base + + +# --------------------------------------------------------------------------- +# is_stale — skip conditions +# --------------------------------------------------------------------------- + + +def test_skip_already_inactive(): + stale, reason = is_stale(_project(active=False), _NOW_MS) + assert stale is False + assert reason == "already_inactive" + + +def test_is_latest_is_still_stale(): + """isLatest=true no longer protects a project from being deactivated.""" + stale, reason = is_stale(_project(isLatest=True), _NOW_MS) + assert stale is True + assert reason == "" + + +def test_null_bom_import_is_stale(): + """A project that was never imported has no protection — always stale.""" + stale, reason = is_stale(_project(lastBomImport=None), _NOW_MS) + assert stale is True + assert reason == "" + + +def test_skip_lifecycle_ga_tag(): + proj = _project(tags=[{"name": "lifecycle:GA"}]) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "lifecycle_GA" + + +def test_skip_keep_active_tag(): + proj = _project(tags=[{"name": "keep-active"}]) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "keep_active" + + +def test_skip_not_stale_recent(): + proj = _project(lastBomImport=_NOW_MS - 10 * _DAY_MS) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "not_stale" + + +def test_skip_exactly_on_threshold(): + """Age == threshold is NOT stale (strict >).""" + proj = _project(lastBomImport=_NOW_MS - STALE_THRESHOLD_DAYS * _DAY_MS) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "not_stale" + + +def test_stale_old_project(): + stale, reason = is_stale(_project(), _NOW_MS) + assert stale is True + assert reason == "" + + +def test_custom_threshold(): + proj = _project(lastBomImport=_NOW_MS - 20 * _DAY_MS) + # With threshold=30, a 20-day-old project is NOT stale + stale, reason = is_stale(proj, _NOW_MS, threshold_days=30) + assert stale is False + assert reason == "not_stale" + # With threshold=10, it IS stale + stale2, _ = is_stale(proj, _NOW_MS, threshold_days=10) + assert stale2 is True + + +def test_ga_tag_takes_priority_over_staleness(): + """lifecycle:GA overrides even a very old project.""" + proj = _project( + lastBomImport=_NOW_MS - 365 * _DAY_MS, + tags=[{"name": "lifecycle:GA"}, {"name": "extra"}], + ) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "lifecycle_GA" + + +def test_ga_tag_takes_priority_over_null_bom_import(): + """lifecycle:GA protects a project even when lastBomImport is null.""" + proj = _project(lastBomImport=None, tags=[{"name": "lifecycle:GA"}]) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "lifecycle_GA" + + +def test_keep_active_takes_priority_over_null_bom_import(): + proj = _project(lastBomImport=None, tags=[{"name": "keep-active"}]) + stale, reason = is_stale(proj, _NOW_MS) + assert stale is False + assert reason == "keep_active" + + +def test_skip_order_inactive_checked_first(): + """already_inactive is checked before anything else.""" + proj = _project(active=False, isLatest=True, tags=[{"name": "lifecycle:GA"}]) + _, reason = is_stale(proj, _NOW_MS) + assert reason == "already_inactive" + + +# --------------------------------------------------------------------------- +# partition_by_collection +# --------------------------------------------------------------------------- + + +def test_partition_leaves_have_none_logic(): + projects = [ + _project(uuid="a", collectionLogic="NONE"), + _project(uuid="b", collectionLogic="AGGREGATE_DIRECT_CHILDREN"), + _project(uuid="c", collectionLogic="AGGREGATE_LATEST_VERSION_CHILDREN"), + _project(uuid="d"), # missing key → treated as NONE + ] + leaves, parents = partition_by_collection(projects) + leaf_uuids = {p["uuid"] for p in leaves} + parent_uuids = {p["uuid"] for p in parents} + assert leaf_uuids == {"a", "d"} + assert parent_uuids == {"b", "c"} + + +def test_partition_null_collection_logic(): + """collectionLogic=None is treated the same as 'NONE'.""" + proj = _project(collectionLogic=None) + leaves, parents = partition_by_collection([proj]) + assert len(leaves) == 1 + assert len(parents) == 0 + + +def test_partition_empty_list(): + leaves, parents = partition_by_collection([]) + assert leaves == [] + assert parents == [] + + +# --------------------------------------------------------------------------- +# build_summary +# --------------------------------------------------------------------------- + + +def test_build_summary_structure(): + deactivated = [_project(uuid="u1", name="p1", version="1.0")] + skipped = [(_project(uuid="u2", name="p2"), "is_latest")] + summary = build_summary(deactivated, skipped, dry_run=False) + + assert summary["dry_run"] is False + assert summary["counts"]["deactivated"] == 1 + assert summary["counts"]["skipped"] == 1 + assert summary["deactivated"][0]["uuid"] == "u1" + assert summary["skipped"][0]["reason"] == "is_latest" + + +def test_build_summary_dry_run_flag(): + summary = build_summary([], [], dry_run=True) + assert summary["dry_run"] is True + assert summary["counts"]["deactivated"] == 0 + assert summary["counts"]["skipped"] == 0 + + +def test_build_summary_preserves_name_version(): + deactivated = [{"uuid": "x", "name": "svc", "version": "2.0"}] + summary = build_summary(deactivated, [], dry_run=False) + entry = summary["deactivated"][0] + assert entry["name"] == "svc" + assert entry["version"] == "2.0" diff --git a/tests/test_tagging.py b/tests/test_tagging.py new file mode 100644 index 0000000..c15e3e6 --- /dev/null +++ b/tests/test_tagging.py @@ -0,0 +1,165 @@ +"""Unit tests for src/services/tagging.py""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +import pytest +from services.tagging import ( + compute_auto_tags, + merge_auto_tags, + GA_TAG, + LIFECYCLE_PREFIXES, +) + + +# --------------------------------------------------------------------------- +# compute_auto_tags — lifecycle detection +# --------------------------------------------------------------------------- + + +def test_lifecycle_ga_no_version(): + tags = compute_auto_tags("myapp", None, None) + assert "lifecycle:GA" in tags + assert not any(t.startswith("version:") for t in tags) + + +def test_lifecycle_ga_plain_version(): + tags = compute_auto_tags("myapp", "1.2.3", None) + assert "lifecycle:GA" in tags + + +@pytest.mark.parametrize("keyword", list(LIFECYCLE_PREFIXES)) +def test_lifecycle_keyword(keyword): + tags = compute_auto_tags("svc", f"1.0.0-{keyword}.1", None) + assert f"lifecycle:{keyword}" in tags + assert "lifecycle:GA" not in tags + + +def test_lifecycle_first_match_wins_alpha_before_beta(): + """alpha appears before beta in LIFECYCLE_PREFIXES — alpha must win.""" + tags = compute_auto_tags("svc", "1.0.0-alpha-beta", None) + assert "lifecycle:alpha" in tags + assert "lifecycle:beta" not in tags + + +def test_lifecycle_first_match_wins_dev_before_preview(): + """dev appears before preview in LIFECYCLE_PREFIXES — dev must win.""" + tags = compute_auto_tags("svc", "1.0.0-dev-preview", None) + assert "lifecycle:dev" in tags + assert "lifecycle:preview" not in tags + + +def test_lifecycle_case_insensitive(): + tags = compute_auto_tags("svc", "1.0.0-RC.1", None) + assert "lifecycle:rc" in tags + + +# --------------------------------------------------------------------------- +# compute_auto_tags — name / version / parent normalization +# --------------------------------------------------------------------------- + + +def test_name_tag_present(): + tags = compute_auto_tags("my-app", "1.0.0", None) + assert "name:my_app" in tags + + +def test_version_tag_dash_normalization(): + tags = compute_auto_tags("svc", "1.0.0-rc.1", None) + assert "version:1.0.0_rc.1" in tags + + +def test_version_tag_absent_when_no_version(): + tags = compute_auto_tags("svc", None, None) + assert not any(t.startswith("version:") for t in tags) + + +def test_parent_tag_present(): + tags = compute_auto_tags("child", "1.0.0", "my-parent") + assert "parent:my_parent" in tags + + +def test_parent_tag_absent_when_no_parent(): + tags = compute_auto_tags("child", "1.0.0", None) + assert not any(t.startswith("parent:") for t in tags) + + +def test_parent_tag_normalization(): + tags = compute_auto_tags("svc", "1.0", "Big-Parent") + assert "parent:big_parent" in tags + + +def test_all_four_tags_when_fully_specified(): + tags = compute_auto_tags("my-svc", "2.0.0-beta.1", "my-parent") + prefixes = {"name:", "version:", "parent:", "lifecycle:"} + found = {t.split(":")[0] + ":" for t in tags} + assert prefixes == found + + +# --------------------------------------------------------------------------- +# merge_auto_tags — deduplication & replacement +# --------------------------------------------------------------------------- + + +def test_merge_replaces_lifecycle(): + existing = ["lifecycle:alpha", "custom-tag"] + auto = compute_auto_tags("svc", "1.0.0", None) # lifecycle:GA + result = merge_auto_tags(existing, auto) + assert "lifecycle:GA" in result + assert "lifecycle:alpha" not in result + + +def test_merge_replaces_name(): + existing = ["name:old_name", "team:backend"] + auto = compute_auto_tags("new-name", "1.0.0", None) + result = merge_auto_tags(existing, auto) + assert "name:new_name" in result + assert "name:old_name" not in result + + +def test_merge_preserves_user_tags(): + existing = ["team:backend", "env:prod", "lifecycle:alpha"] + auto = compute_auto_tags("svc", "1.0.0", None) + result = merge_auto_tags(existing, auto) + assert "team:backend" in result + assert "env:prod" in result + + +def test_merge_no_duplicates(): + auto = compute_auto_tags("svc", "1.0.0", None) + # Merge same auto tags twice + result = merge_auto_tags(auto, auto) + assert len(result) == len(set(result)) + + +def test_merge_idempotent(): + """Running merge twice should produce the same result.""" + existing = ["custom", "lifecycle:beta"] + auto = compute_auto_tags("svc", "2.0.0", "parent-svc") + first = merge_auto_tags(existing, auto) + second = merge_auto_tags(first, auto) + assert first == second + + +def test_merge_removes_version_tag_when_rerun(): + existing = ["version:1.0.0", "lifecycle:GA", "name:svc"] + auto = compute_auto_tags("svc", "2.0.0", None) + result = merge_auto_tags(existing, auto) + assert "version:1.0.0" not in result + assert "version:2.0.0" in result + + +def test_merge_removes_parent_tag_when_rerun(): + existing = ["parent:old_parent"] + auto = compute_auto_tags("svc", "1.0.0", "new-parent") + result = merge_auto_tags(existing, auto) + assert "parent:old_parent" not in result + assert "parent:new_parent" in result + + +def test_merge_empty_existing(): + result = merge_auto_tags([], compute_auto_tags("svc", "1.0.0", None)) + assert "name:svc" in result + assert "lifecycle:GA" in result From 10a1ee59ef192d4ebd4a425fa666b44471c69ce0 Mon Sep 17 00:00:00 2001 From: Yoan Moscatelli Date: Thu, 11 Jun 2026 06:51:44 +0000 Subject: [PATCH 2/3] :hammer: patch was not included in dry run --- src/services/connection.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/src/services/connection.py b/src/services/connection.py index cbdb846..2e4b7ee 100644 --- a/src/services/connection.py +++ b/src/services/connection.py @@ -94,9 +94,13 @@ def make_request( APIConnectionError: If the request fails AuthenticationError: If authentication fails """ - logger.debug("make_request called: method=%s, endpoint=%s, dry_run=%s", - method, endpoint, self.dry_run) - if self.dry_run and method.upper() in ["POST", "PUT", "DELETE"]: + logger.debug( + "make_request called: method=%s, endpoint=%s, dry_run=%s", + method, + endpoint, + self.dry_run, + ) + if self.dry_run and method.upper() in ["POST", "PUT", "PATCH", "DELETE"]: logger.info("[DRY RUN] Would %s to %s", method.upper(), endpoint) return None @@ -111,10 +115,15 @@ def make_request( try: # Use tuple form of timeout: (connect_timeout, read_timeout) # This ensures both connection and data reading have proper timeouts - timeout_tuple = (30, self.timeout) # 30s to connect, configured timeout to read + timeout_tuple = ( + 30, + self.timeout, + ) # 30s to connect, configured timeout to read logger.debug( "Making %s request to %s with timeout: connect=30s, read=%ss", - method, endpoint, self.timeout + method, + endpoint, + self.timeout, ) response = requests.request( method=method, url=url, headers=headers, timeout=timeout_tuple, **kwargs @@ -126,8 +135,11 @@ def make_request( if response.status_code == HTTPStatus.FORBIDDEN: raise AuthenticationError("API access forbidden") - logger.debug("Request successful: status=%s, endpoint=%s", - response.status_code, endpoint) + logger.debug( + "Request successful: status=%s, endpoint=%s", + response.status_code, + endpoint, + ) return response except ( # pylint: disable=try-except-raise From 065b18f4a581974c7a8bfd9b5141194784af4c74 Mon Sep 17 00:00:00 2001 From: Yoan Moscatelli Date: Thu, 11 Jun 2026 07:55:24 +0000 Subject: [PATCH 3/3] :green_heart: update docker compose --- tests/docker/docker-compose.yml | 149 +++++--------------------------- 1 file changed, 21 insertions(+), 128 deletions(-) diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml index 405daff..ea40a1a 100644 --- a/tests/docker/docker-compose.yml +++ b/tests/docker/docker-compose.yml @@ -1,160 +1,53 @@ -##################################################### -# This Docker Compose file contains two services -# Dependency-Track API Server -# Dependency-Track FrontEnd -##################################################### +name: Dependency-Track services: apiserver: - image: dependencytrack/apiserver + image: dependencytrack/apiserver:4.14.2 depends_on: postgres: condition: service_healthy - # The Dependency-Track container can be configured using any of the - # available configuration properties defined in: - # https://docs.dependencytrack.org/getting-started/configuration/ - # All properties are upper case with periods replaced by underscores. - # - # Database Properties - # ALPINE_DATABASE_MODE: "external" - # ALPINE_DATABASE_URL: "jdbc:postgresql://postgres10:5432/dtrack" - # ALPINE_DATABASE_DRIVER: "org.postgresql.Driver" - # ALPINE_DATABASE_USERNAME: "dtrack" - # ALPINE_DATABASE_PASSWORD: "changeme" - # ALPINE_DATABASE_POOL_ENABLED: "true" - # ALPINE_DATABASE_POOL_MAX_SIZE: "20" - # ALPINE_DATABASE_POOL_MIN_IDLE: "10" - # ALPINE_DATABASE_POOL_IDLE_TIMEOUT: "300000" - # ALPINE_DATABASE_POOL_MAX_LIFETIME: "600000" - # - # Optional LDAP Properties - # ALPINE_LDAP_ENABLED: "true" - # ALPINE_LDAP_SERVER_URL: "ldap://ldap.example.com:389" - # ALPINE_LDAP_BASEDN: "dc=example,dc=com" - # ALPINE_LDAP_SECURITY_AUTH: "simple" - # ALPINE_LDAP_BIND_USERNAME: "" - # ALPINE_LDAP_BIND_PASSWORD: "" - # ALPINE_LDAP_AUTH_USERNAME_FORMAT: "%s@example.com" - # ALPINE_LDAP_ATTRIBUTE_NAME: "userPrincipalName" - # ALPINE_LDAP_ATTRIBUTE_MAIL: "mail" - # ALPINE_LDAP_GROUPS_FILTER: "(&(objectClass=group)(objectCategory=Group))" - # ALPINE_LDAP_USER_GROUPS_FILTER: "(member:1.2.840.113556.1.4.1941:={USER_DN})" - # ALPINE_LDAP_GROUPS_SEARCH_FILTER: "(&(objectClass=group)(objectCategory=Group)(cn=*{SEARCH_TERM}*))" - # ALPINE_LDAP_USERS_SEARCH_FILTER: "(&(objectClass=user)(objectCategory=Person)(cn=*{SEARCH_TERM}*))" - # ALPINE_LDAP_USER_PROVISIONING: "false" - # ALPINE_LDAP_TEAM_SYNCHRONIZATION: "false" - # - # Optional OpenID Connect (OIDC) Properties - # ALPINE_OIDC_ENABLED: "true" - # ALPINE_OIDC_ISSUER: "https://auth.example.com/auth/realms/example" - # ALPINE_OIDC_CLIENT_ID: "" - # ALPINE_OIDC_USERNAME_CLAIM: "preferred_username" - # ALPINE_OIDC_TEAMS_CLAIM: "groups" - # ALPINE_OIDC_USER_PROVISIONING: "true" - # ALPINE_OIDC_TEAM_SYNCHRONIZATION: "true" - # - # Optional HTTP Proxy Settings - # ALPINE_HTTP_PROXY_ADDRESS: "proxy.example.com" - # ALPINE_HTTP_PROXY_PORT: "8888" - # ALPINE_HTTP_PROXY_USERNAME: "" - # ALPINE_HTTP_PROXY_PASSWORD: "" - # ALPINE_NO_PROXY: "" - # - # Optional HTTP Outbound Connection Timeout Settings. All values are in seconds. - # ALPINE_HTTP_TIMEOUT_CONNECTION: "30" - # ALPINE_HTTP_TIMEOUT_SOCKET: "30" - # ALPINE_HTTP_TIMEOUT_POOL: "60" - # - # Optional Cross-Origin Resource Sharing (CORS) Headers - # ALPINE_CORS_ENABLED: "true" - # ALPINE_CORS_ALLOW_ORIGIN: "*" - # ALPINE_CORS_ALLOW_METHODS: "GET, POST, PUT, DELETE, OPTIONS" - # ALPINE_CORS_ALLOW_HEADERS: "Origin, Content-Type, Authorization, X-Requested-With, Content-Length, Accept, Origin, X-Api-Key, X-Total-Count, *" - # ALPINE_CORS_EXPOSE_HEADERS: "Origin, Content-Type, Authorization, X-Requested-With, Content-Length, Accept, Origin, X-Api-Key, X-Total-Count" - # ALPINE_CORS_ALLOW_CREDENTIALS: "true" - # ALPINE_CORS_MAX_AGE: "3600" - # - # Optional logging configuration - # LOGGING_LEVEL: "INFO" - # LOGGING_CONFIG_PATH: "logback.xml" - # - # Optional metrics properties - # ALPINE_METRICS_ENABLED: "true" - # ALPINE_METRICS_AUTH_USERNAME: "" - # ALPINE_METRICS_AUTH_PASSWORD: "" - # - # Optional environmental variables to enable default notification publisher templates override and set the base directory to search for templates - # DEFAULT_TEMPLATES_OVERRIDE_ENABLED: "false" - # DEFAULT_TEMPLATES_OVERRIDE_BASE_DIRECTORY: "/data" - # - # Optional configuration for the Snyk analyzer - # SNYK_THREAD_BATCH_SIZE: "10" - # - # Optional environmental variables to provide more JVM arguments to the API Server JVM, i.e. "-XX:ActiveProcessorCount=8" - # EXTRA_JAVA_OPTIONS: "" - environment: - ALPINE_DATABASE_MODE: "external" - ALPINE_DATABASE_URL: "jdbc:postgresql://postgres:5432/dtrack" - ALPINE_DATABASE_DRIVER: "org.postgresql.Driver" - ALPINE_DATABASE_USERNAME: "dtrack" - ALPINE_DATABASE_PASSWORD: "dtrack" deploy: resources: limits: - memory: 12288m - reservations: - memory: 8192m - restart_policy: - condition: on-failure - ports: - - '8081:8080' - volumes: - - 'dtrack-data:/data' + memory: 2g + environment: + DT_DATASOURCE_URL: "jdbc:postgresql://postgres:5432/dtrack" + DT_DATASOURCE_USERNAME: "dtrack" + DT_DATASOURCE_PASSWORD: "dtrack" healthcheck: - test: [ "CMD-SHELL", "wget -t 1 -T 3 --no-proxy -q -O /dev/null http://127.0.0.1:8080$${CONTEXT}health || exit 1" ] - interval: 30s + test: ["CMD-SHELL", "curl -sf http://localhost:8080/api/version >/dev/null || exit 1"] + interval: 15s + timeout: 5s + retries: 8 start_period: 60s - timeout: 3s + ports: + - "127.0.0.1:8081:8080" + volumes: + - "apiserver-data:/data" restart: unless-stopped frontend: - image: dependencytrack/frontend - depends_on: - apiserver: - condition: service_healthy + image: dependencytrack/frontend:4.14.2 environment: - # The base URL of the API server. - # NOTE: - # * This URL must be reachable by the browsers of your users. - # * The frontend container itself does NOT communicate with the API server directly, it just serves static files. - # * When deploying to dedicated servers, please use the external IP or domain of the API server. API_BASE_URL: "http://localhost:8081" - # OIDC_ISSUER: "" - # OIDC_CLIENT_ID: "" - # OIDC_SCOPE: "" - # OIDC_FLOW: "" - # OIDC_LOGIN_BUTTON_TEXT: "" - # volumes: - # - "/host/path/to/config.json:/app/static/config.json" ports: - - "8080:8080" + - "127.0.0.1:8082:8080" restart: unless-stopped postgres: - image: postgres:17-alpine + image: postgres:18-alpine environment: POSTGRES_DB: "dtrack" POSTGRES_USER: "dtrack" POSTGRES_PASSWORD: "dtrack" healthcheck: - test: [ "CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}" ] + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 5s timeout: 3s retries: 3 volumes: - - "postgres-data:/var/lib/postgresql/data" - restart: unless-stopped + - "postgres-data:/var/lib/postgresql" volumes: - dtrack-data: {} + apiserver-data: {} postgres-data: {} \ No newline at end of file