Skip to content

feat: Implement System Model, Mock Source and Ingestion - #30

Merged
cschwartz merged 5 commits into
mainfrom
4-implement-system-model-mock-source-and-ingestion
Mar 14, 2026
Merged

feat: Implement System Model, Mock Source and Ingestion#30
cschwartz merged 5 commits into
mainfrom
4-implement-system-model-mock-source-and-ingestion

Conversation

@cschwartz

@cschwartz cschwartz commented Mar 9, 2026

Copy link
Copy Markdown
Owner

Implements #4

Summary by CodeRabbit

  • New Features

    • CMDB system inventory support with system↔asset relationship tracking
    • New systems API endpoints (list, lookup, retrieve)
    • Consolidated full ingestion endpoint (/ingest) for assets and systems
    • Mock CMDB service and improved mock utilities for local testing
  • Documentation

    • Pattern docs added for ingestion, models, routes, mock services, and error handling
  • Tests

    • New and updated unit and end-to-end tests covering CMDB and ingestion flows

@coderabbitai

coderabbitai Bot commented Mar 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@cschwartz has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 25 minutes and 22 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2dd581a1-2eaf-48bd-a6ce-7750163ecc31

📥 Commits

Reviewing files that changed from the base of the PR and between 188c966 and 369fff1.

📒 Files selected for processing (2)
  • verdict-backend/app/services/cmdb_ingestion.py
  • verdict-backend/devenv.nix
📝 Walkthrough

Walkthrough

Adds CMDB ingestion end-to-end: new System model and association table, CMDB mock and generic mock factory, CMDB ingestion service and routes (including POST /ingest and /systems), refactors assets to public models, updates queries/schemas, Alembic migration, tests, and pattern docs.

Changes

Cohort / File(s) Summary
Database & Models
verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py, verdict-backend/app/models/system.py, verdict-backend/app/models/asset.py, verdict-backend/app/models/base_model.py, verdict-backend/app/models/base.py, verdict-backend/app/models/timestamp.py
Adds system table and asset_system join table; introduces System* types and AssetBase/AssetCreate/AssetPublic; moves timestamps into BaseModel/PublicModel and removes TimestampMixin.
Queries & GoldSource
verdict-backend/app/models/gold_source.py, verdict-backend/app/queries.py
Extends GoldSourceType with CMDB and adds overloads + optional public_class to get_by_gold_source, get_by_id, get_paginated to return validated PublicModel projections.
Ingestion Services
verdict-backend/app/services/cmdb_ingestion.py, verdict-backend/app/services/asset_ingestion.py
Adds CMDB ingestion pipeline (fetch index/detail, convert, resolve assets, upsert, sync links) and updates asset ingestion to accept AssetCreate and return AssetPublic.
Routes & API
verdict-backend/app/routes/ingestion.py, verdict-backend/app/routes/systems.py, verdict-backend/app/routes/assets.py, verdict-backend/app/main.py
Adds POST /ingest (runs asset+system ingestion and commits), new /systems router (list, by-gold-source, get), refactors asset routes to return AssetPublic and removes asset-specific ingest endpoint; registers routers.
Schemas & Responses
verdict-backend/app/schemas/external/cmdb.py, verdict-backend/app/schemas/system.py, verdict-backend/app/schemas/ingestion.py, verdict-backend/app/schemas/asset.py, verdict-backend/app/schemas/base.py
Adds external CMDB schemas, FullIngestionResponse, SystemListResponse; replaces AssetResponse with AssetPublic in responses; removes schema mixins.
Mocks & Mock Helpers
mock-services/mock_helpers.py, mock-services/cmdb/app.py, mock-services/cmdb/models.py, mock-services/cmdb/data/systems.yaml, mock-services/asset_inventory/app.py, mock-services/justfile, mock-services/devenv.nix
Introduces YAML-backed generic mock factory (load_yaml/create_mock_app), creates CMDB mock and fixtures, refactors asset_inventory mock to use factory, updates justfile and devenv for CMDB mock.
Dev & CI config
verdict-backend/devenv.nix, verdict-backend/devenv.yaml, devenv.yaml, .github/workflows/verdict-backend.yaml, .gitignore, verdict-backend/justfile
Updates devenv to run two mock services and process manager, adds git-hooks input, augments CI to publish test results, ignores test-results/, adds dev-test recipe and adjusts test markers.
Tests & Factories
verdict-backend/tests/services/test_cmdb_ingestion.py, verdict-backend/tests/e2e/test_cmdb_ingestion.py, verdict-backend/tests/routes/test_ingestion.py, verdict-backend/tests/routes/test_systems.py, verdict-backend/tests/e2e/test_asset_ingestion.py, verdict-backend/tests/routes/test_assets.py, verdict-backend/tests/services/test_asset_ingestion.py, verdict-backend/tests/factories/system.py, verdict-backend/tests/models/mixin_test_model.py, verdict-backend/tests/e2e/conftest.py
Adds comprehensive unit/e2e tests for CMDB ingestion and routes, updates asset ingestion tests and factories, adds SystemFactory, and ensures DB truncation for e2e tests.
Documentation
docs/patterns/error-handling.md, docs/patterns/ingestion.md, docs/patterns/models.md, docs/patterns/routes.md, docs/patterns/mock-services.md, docs/patterns/backend.md
Adds pattern docs for error handling, ingestion, models, routes, and mock services; removes prior backend.md content.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/User
    participant Backend as Verdict Backend
    participant AssetMock as Asset Mock
    participant CMDBMock as CMDB Mock
    participant DB as Database

    Client->>Backend: POST /ingest
    activate Backend

    rect rgba(100, 200, 150, 0.5)
    Note over Backend,AssetMock: Asset ingestion phase
    Backend->>AssetMock: GET /assets
    AssetMock-->>Backend: [AssetIndexItem,...]
    loop per asset
        Backend->>AssetMock: GET /assets/{id}
        AssetMock-->>Backend: AssetDetail
        Backend->>Backend: validate & convert -> AssetCreate
    end
    Backend->>DB: upsert assets by gold_source
    DB-->>Backend: list[AssetPublic]
    end

    rect rgba(150, 150, 200, 0.5)
    Note over Backend,CMDBMock: CMDB ingestion phase
    Backend->>CMDBMock: GET /systems
    CMDBMock-->>Backend: [SystemIndexItem,...]
    loop per system
        Backend->>CMDBMock: GET /systems/{id}
        CMDBMock-->>Backend: SystemDetail
        Backend->>Backend: resolve asset_gold_source_ids -> asset IDs
        Backend->>Backend: validate & convert -> SystemCreate
    end
    Backend->>DB: upsert systems by gold_source
    DB-->>Backend: list[SystemPublic]
    Backend->>DB: sync asset_system links (diff)
    end

    Backend->>DB: commit
    DB-->>Backend: commit ok
    Backend-->>Client: FullIngestionResponse {assets_ingested, systems_ingested}
    deactivate Backend
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Issue #4: Implements System model, CMDB mock, cmdb_ingestion pipeline, routes, schemas, migration, and tests — directly aligns with this PR's objectives.

Possibly related PRs

  • PR #26: Overlaps refactors around asset models, mock-services, and ingestion logic; strong code-level connection.

Poem

🐰 I hopped through YAML and tables with cheer,

Wired mocks and routes so data draws near,
Gold-source breadcrumbs led links to their peers,
Systems and assets now sing in the same ear,
I nibble on tests and deploy without fear. 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Implement System Model, Mock Source and Ingestion' accurately reflects the primary changes across the PR, which add system model definitions, a CMDB mock service, and comprehensive ingestion functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 4-implement-system-model-mock-source-and-ingestion
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py (2)

46-53: Consider adding an index on system_id for reverse lookups.

The composite primary key (asset_id, system_id) efficiently supports queries by asset_id, but queries that filter by system_id (e.g., "find all assets for a system") may benefit from an additional index.

💡 Optional: Add index for system_id lookups
         sa.ForeignKeyConstraint(["asset_id"], ["asset.id"]),
         sa.ForeignKeyConstraint(["system_id"], ["system.id"]),
         sa.PrimaryKeyConstraint("asset_id", "system_id"),
     )
+    op.create_index("ix_asset_system_system_id", "asset_system", ["system_id"])

And in downgrade:

 def downgrade() -> None:
     """Downgrade schema."""
+    op.drop_index("ix_asset_system_system_id", table_name="asset_system")
     op.drop_table("asset_system")
     op.drop_table("system")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py`
around lines 46 - 53, Add a non-unique index on the asset_system table's
system_id to speed reverse lookups: after the op.create_table call that defines
asset_system (and its columns asset_id and system_id) add an op.create_index for
system_id (name it something like ix_asset_system_system_id) and in the
downgrade add the corresponding op.drop_index before dropping the asset_system
table to keep migrations reversible.

50-51: Consider specifying ON DELETE behavior for foreign keys.

The foreign key constraints don't specify cascade behavior. If an asset or system is deleted, the join table rows will become orphaned or cause constraint violations depending on database defaults.

♻️ Proposed fix to add explicit cascade behavior
-        sa.ForeignKeyConstraint(["asset_id"], ["asset.id"]),
-        sa.ForeignKeyConstraint(["system_id"], ["system.id"]),
+        sa.ForeignKeyConstraint(["asset_id"], ["asset.id"], ondelete="CASCADE"),
+        sa.ForeignKeyConstraint(["system_id"], ["system.id"], ondelete="CASCADE"),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py`
around lines 50 - 51, The migration's ForeignKeyConstraint definitions for
asset_id -> asset.id and system_id -> system.id should include explicit ON
DELETE behavior to avoid orphaned join rows; update the sa.ForeignKeyConstraint
entries (the ones referencing "asset_id" -> "asset.id" and "system_id" ->
"system.id") to specify ondelete="CASCADE" (or another desired policy) so
deletes of Asset or System cascade to the join table rows.
docs/patterns/ingestion.md (1)

17-20: Document dependency order in the orchestrator section.

Line 19's reference-resolution rule only works if the orchestrator executes sources in dependency order, e.g. assets before systems. Making that explicit here will save future sources from following the pattern and then failing deterministically on missing references.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/patterns/ingestion.md` around lines 17 - 20, Clarify that the global
ingestion endpoint (POST /ingest) and the orchestrator must execute source
pipelines in dependency order so reference-resolution by gold source ID works
reliably; update the orchestrator section to state that the orchestrator
determines and enforces dependency order (e.g., ingest assets before systems)
and will schedule/sequence sources accordingly so that references can be
resolved during each pipeline run and the session commit/rollback semantics
remain intact.
verdict-backend/app/models/system.py (1)

35-36: Use Field(default_factory=list) for the mutable default.

While Pydantic handles mutable defaults safely in most cases, using default_factory is explicit and avoids any potential confusion or edge cases.

♻️ Suggested fix
-class SystemPublic(SystemBase, PublicModel):
-    asset_ids: list[int] = []
+class SystemPublic(SystemBase, PublicModel):
+    asset_ids: list[int] = Field(default_factory=list)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/models/system.py` around lines 35 - 36, The mutable
default for asset_ids in class SystemPublic should use Pydantic's
default_factory: update the SystemPublic model (symbol: SystemPublic, attribute:
asset_ids) to declare asset_ids with Field(default_factory=list) instead of
assigning [] directly, and add the necessary import of Field from pydantic so
the model uses a fresh list per instance.
verdict-backend/app/services/cmdb_ingestion.py (1)

75-99: Consider batching asset resolution for better performance at scale.

The current implementation performs one DB query per gold source ID. While acceptable for the current synchronous ingestion model, this could become a bottleneck with large datasets.

♻️ Potential optimization using a single query
def _resolve_asset_ids(
    session: Session,
    gold_source_ids: list[str],
) -> Result[list[int], ValidationError | DBError]:
    if not gold_source_ids:
        return Ok([])
    
    try:
        assets = session.exec(
            select(Asset).where(
                Asset.gold_source_type == GoldSourceType.ASSET_INVENTORY,
                Asset.gold_source_id.in_(gold_source_ids),
            )
        ).all()
    except OperationalError as e:
        return Err(db_error_from(e))
    
    found_ids = {a.gold_source_id: a.id for a in assets}
    missing = set(gold_source_ids) - found_ids.keys()
    if missing:
        return Err(ValidationError(raw=f"unresolvable asset references: {missing}"))
    
    return Ok([found_ids[gs_id] for gs_id in gold_source_ids])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/services/cmdb_ingestion.py` around lines 75 - 99, The
loop in _resolve_asset_ids issues one DB query per gold_source_id; replace it
with a single batched query using
session.exec(select(Asset).where(Asset.gold_source_type ==
GoldSourceType.ASSET_INVENTORY, Asset.gold_source_id.in_(gold_source_ids))) to
fetch all matching assets at once, catch DB exceptions and convert them to the
existing DBError (e.g., via db_error_from), build a mapping from
asset.gold_source_id to asset.id, detect any missing gold_source_ids and return
a ValidationError listing them, and finally return Ok of ids in the same order
as the input gold_source_ids; also handle the empty gold_source_ids case by
returning Ok([]) immediately.
verdict-backend/app/services/asset_ingestion.py (1)

95-99: Consider removing redundant flush.

Since _upsert_asset now flushes after each operation (lines 122 and 130), this final flush at line 96 is effectively a no-op. All database changes have already been flushed by the time the loop completes.

♻️ Proposed simplification
     assets.append(upsert_result.value)

-    try:
-        session.flush()
-    except OperationalError as e:
-        return Err(db_error_from(e))
     return Ok(assets)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/services/asset_ingestion.py` around lines 95 - 99, The
final try/except around session.flush() is redundant because _upsert_asset
already flushes per operation (see calls inside _upsert_asset at lines where it
flushes after insert/update), so remove the outer try/except and the
session.flush() call in asset_ingestion.py and leave the function returning
Ok(assets); ensure any error handling remains via the existing db_error_from
handling inside _upsert_asset rather than duplicating the flush/error block.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@verdict-backend/app/services/cmdb_ingestion.py`:
- Around line 135-157: The _sync_asset_links function currently performs
session.execute calls for sa.delete(asset_system) and sa.insert(asset_system)
without handling DB errors; wrap the delete and insert operations (the
session.execute calls that use sa.delete(asset_system) and
sa.insert(asset_system)) in a try/except that catches
sqlalchemy.exc.OperationalError (or the DB OperationalError alias you use) and
on exception log the error and return False, otherwise return True after all ops
succeed; update the caller to check the boolean result from _sync_asset_links
and handle failures accordingly.

In `@verdict-backend/devenv.nix`:
- Around line 161-180: The curl readiness probes using the patterns "until curl
-sf http://localhost:4010/assets" and "until curl -sf
http://localhost:4011/systems" need an explicit timeout so the retry loop can
progress; update both curl invocations to include a short per-request timeout
option (e.g., --max-time and/or --connect-timeout) so each probe fails fast and
the existing retries variable/30-retry logic can trigger, and apply the same
timeout change to the similar verdict app probe mentioned later so all readiness
checks are bounded.

In `@verdict-backend/tests/routes/test_ingestion.py`:
- Around line 47-60: The test sets app.dependency_overrides[get_http_client] =
_mock_http_client but does not guarantee cleanup on failures; wrap the request
and assertions in a try/finally (or move the override into a fixture) so that
app.dependency_overrides.pop(get_http_client, None) always runs; locate the
override assignment and ensure cleanup for get_http_client (and similarly for
the other occurrences around lines 80-90) by placing the pop(...) in the finally
block so the override is removed even if app_client.post("/ingest") or
subsequent asserts fail.
- Around line 64-88: The test test_full_ingest_rolls_back_on_system_failure
currently only asserts a 502 response but doesn't verify that the DB rollback
occurred; after awaiting response = await app_client.post("/ingest") use the
provided db_session to query the relevant tables/entities (e.g., assets and
systems created by the ingest flow, referencing ASSET_INDEX/ASSET_DETAIL and
SYSTEM_INDEX) and assert that no new rows were inserted (counts are zero or
unchanged), or alternatively assert specific entities (by IDs from
ASSET_INDEX/ASSET_DETAIL) are absent; update the test to fail if any of those
persisted so it guarantees the transaction was rolled back.

---

Nitpick comments:
In `@docs/patterns/ingestion.md`:
- Around line 17-20: Clarify that the global ingestion endpoint (POST /ingest)
and the orchestrator must execute source pipelines in dependency order so
reference-resolution by gold source ID works reliably; update the orchestrator
section to state that the orchestrator determines and enforces dependency order
(e.g., ingest assets before systems) and will schedule/sequence sources
accordingly so that references can be resolved during each pipeline run and the
session commit/rollback semantics remain intact.

In
`@verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py`:
- Around line 46-53: Add a non-unique index on the asset_system table's
system_id to speed reverse lookups: after the op.create_table call that defines
asset_system (and its columns asset_id and system_id) add an op.create_index for
system_id (name it something like ix_asset_system_system_id) and in the
downgrade add the corresponding op.drop_index before dropping the asset_system
table to keep migrations reversible.
- Around line 50-51: The migration's ForeignKeyConstraint definitions for
asset_id -> asset.id and system_id -> system.id should include explicit ON
DELETE behavior to avoid orphaned join rows; update the sa.ForeignKeyConstraint
entries (the ones referencing "asset_id" -> "asset.id" and "system_id" ->
"system.id") to specify ondelete="CASCADE" (or another desired policy) so
deletes of Asset or System cascade to the join table rows.

In `@verdict-backend/app/models/system.py`:
- Around line 35-36: The mutable default for asset_ids in class SystemPublic
should use Pydantic's default_factory: update the SystemPublic model (symbol:
SystemPublic, attribute: asset_ids) to declare asset_ids with
Field(default_factory=list) instead of assigning [] directly, and add the
necessary import of Field from pydantic so the model uses a fresh list per
instance.

In `@verdict-backend/app/services/asset_ingestion.py`:
- Around line 95-99: The final try/except around session.flush() is redundant
because _upsert_asset already flushes per operation (see calls inside
_upsert_asset at lines where it flushes after insert/update), so remove the
outer try/except and the session.flush() call in asset_ingestion.py and leave
the function returning Ok(assets); ensure any error handling remains via the
existing db_error_from handling inside _upsert_asset rather than duplicating the
flush/error block.

In `@verdict-backend/app/services/cmdb_ingestion.py`:
- Around line 75-99: The loop in _resolve_asset_ids issues one DB query per
gold_source_id; replace it with a single batched query using
session.exec(select(Asset).where(Asset.gold_source_type ==
GoldSourceType.ASSET_INVENTORY, Asset.gold_source_id.in_(gold_source_ids))) to
fetch all matching assets at once, catch DB exceptions and convert them to the
existing DBError (e.g., via db_error_from), build a mapping from
asset.gold_source_id to asset.id, detect any missing gold_source_ids and return
a ValidationError listing them, and finally return Ok of ids in the same order
as the input gold_source_ids; also handle the empty gold_source_ids case by
returning Ok([]) immediately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4e53066c-3f75-4625-ae24-8b725dd8e6a8

📥 Commits

Reviewing files that changed from the base of the PR and between 99bcd50 and 8427584.

⛔ Files ignored due to path filters (2)
  • devenv.lock is excluded by !**/*.lock
  • verdict-backend/devenv.lock is excluded by !**/*.lock
📒 Files selected for processing (47)
  • devenv.yaml
  • docs/patterns/backend.md
  • docs/patterns/error-handling.md
  • docs/patterns/ingestion.md
  • docs/patterns/mock-services.md
  • docs/patterns/models.md
  • docs/patterns/routes.md
  • mock-services/asset_inventory/app.py
  • mock-services/cmdb/__init__.py
  • mock-services/cmdb/app.py
  • mock-services/cmdb/data/systems.yaml
  • mock-services/cmdb/models.py
  • mock-services/devenv.nix
  • mock-services/justfile
  • mock-services/mock_helpers.py
  • verdict-backend/alembic/versions/3bfa8f122226_create_system_and_asset_system_tables.py
  • verdict-backend/app/config.py
  • verdict-backend/app/main.py
  • verdict-backend/app/models/__init__.py
  • verdict-backend/app/models/asset.py
  • verdict-backend/app/models/base.py
  • verdict-backend/app/models/base_model.py
  • verdict-backend/app/models/gold_source.py
  • verdict-backend/app/models/system.py
  • verdict-backend/app/models/timestamp.py
  • verdict-backend/app/queries.py
  • verdict-backend/app/routes/assets.py
  • verdict-backend/app/routes/ingestion.py
  • verdict-backend/app/routes/systems.py
  • verdict-backend/app/schemas/asset.py
  • verdict-backend/app/schemas/base.py
  • verdict-backend/app/schemas/external/cmdb.py
  • verdict-backend/app/schemas/ingestion.py
  • verdict-backend/app/schemas/system.py
  • verdict-backend/app/services/asset_ingestion.py
  • verdict-backend/app/services/cmdb_ingestion.py
  • verdict-backend/devenv.nix
  • verdict-backend/devenv.yaml
  • verdict-backend/tests/e2e/test_asset_ingestion.py
  • verdict-backend/tests/e2e/test_cmdb_ingestion.py
  • verdict-backend/tests/factories/system.py
  • verdict-backend/tests/models/mixin_test_model.py
  • verdict-backend/tests/routes/test_assets.py
  • verdict-backend/tests/routes/test_ingestion.py
  • verdict-backend/tests/routes/test_systems.py
  • verdict-backend/tests/services/test_asset_ingestion.py
  • verdict-backend/tests/services/test_cmdb_ingestion.py
💤 Files with no reviewable changes (4)
  • docs/patterns/backend.md
  • verdict-backend/app/schemas/base.py
  • verdict-backend/app/models/timestamp.py
  • verdict-backend/tests/routes/test_assets.py

Comment thread verdict-backend/app/services/cmdb_ingestion.py Outdated
Comment on lines 161 to +180
until curl -sf http://localhost:4010/assets > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: Mock service failed to start"
echo "ERROR: Asset inventory mock failed to start"
exit 1
fi
sleep 1
done
echo "Asset inventory mock ready"

retries=0
until curl -sf http://localhost:4011/systems > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: CMDB mock failed to start"
exit 1
fi
sleep 1
done
echo "Mock service ready"
echo "CMDB mock ready"

@coderabbitai coderabbitai Bot Mar 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add timeouts to the mock readiness curls.

curl -sf has no response timeout, so a half-started local service can hang a probe indefinitely and the 30-retry cap never fires. Please bound these calls, then mirror the same change in the verdict app probe below.

⏱️ Proposed fix
-    until curl -sf http://localhost:4010/assets > /dev/null 2>&1; do
+    until curl -sf --connect-timeout 1 --max-time 2 http://localhost:4010/assets > /dev/null 2>&1; do

-    until curl -sf http://localhost:4011/systems > /dev/null 2>&1; do
+    until curl -sf --connect-timeout 1 --max-time 2 http://localhost:4011/systems > /dev/null 2>&1; do
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
until curl -sf http://localhost:4010/assets > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: Mock service failed to start"
echo "ERROR: Asset inventory mock failed to start"
exit 1
fi
sleep 1
done
echo "Asset inventory mock ready"
retries=0
until curl -sf http://localhost:4011/systems > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: CMDB mock failed to start"
exit 1
fi
sleep 1
done
echo "Mock service ready"
echo "CMDB mock ready"
until curl -sf --connect-timeout 1 --max-time 2 http://localhost:4010/assets > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: Asset inventory mock failed to start"
exit 1
fi
sleep 1
done
echo "Asset inventory mock ready"
retries=0
until curl -sf --connect-timeout 1 --max-time 2 http://localhost:4011/systems > /dev/null 2>&1; do
retries=$((retries + 1))
if [ $retries -ge 30 ]; then
echo "ERROR: CMDB mock failed to start"
exit 1
fi
sleep 1
done
echo "CMDB mock ready"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/devenv.nix` around lines 161 - 180, The curl readiness probes
using the patterns "until curl -sf http://localhost:4010/assets" and "until curl
-sf http://localhost:4011/systems" need an explicit timeout so the retry loop
can progress; update both curl invocations to include a short per-request
timeout option (e.g., --max-time and/or --connect-timeout) so each probe fails
fast and the existing retries variable/30-retry logic can trigger, and apply the
same timeout change to the similar verdict app probe mentioned later so all
readiness checks are bounded.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, the test harness seems brittle. We'll do something smarter here. Just not in this ticket.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cschwartz, understood — happy to defer this to a future ticket. Would you like me to open a GitHub issue to track the test harness improvements (bounded curl timeouts and any other hardening you have in mind)?

Comment thread verdict-backend/tests/routes/test_ingestion.py Outdated
Comment thread verdict-backend/tests/routes/test_ingestion.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
verdict-backend/tests/e2e/conftest.py (1)

14-19: Isolate DB state per test instead of per session.

At Line 14, scope="session" means data created by one test can leak into subsequent tests until the whole session ends. Prefer function-scoped cleanup for test independence.

Proposed change
-@pytest.fixture(scope="session", autouse=True)
+@pytest.fixture(autouse=True)
 def _clean_db() -> Generator[None, None, None]:
     """Truncate all tables before and after the e2e session."""
     _truncate()
     yield
     _truncate()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/tests/e2e/conftest.py` around lines 14 - 19, The _clean_db
fixture currently uses scope="session" which allows DB state to persist across
tests; change its scope to "function" (or remove scope to use default function
scope) so that _truncate() runs before and after each individual test, ensuring
isolation; locate the _clean_db fixture and adjust its scope attribute while
keeping the existing calls to _truncate() in the setup and teardown logic
(referencing _clean_db and the _truncate() helper).
verdict-backend/tests/routes/test_ingestion.py (1)

58-67: Consider using or removing db_session.

The db_session fixture is injected but unused. If it's solely for test isolation, that's fine—but adding a DB assertion (e.g., verifying one Asset and one System were persisted) would strengthen the test.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/tests/routes/test_ingestion.py` around lines 58 - 67, The
test_full_ingest_endpoint currently accepts the db_session fixture but never
uses it; either remove db_session from the test signature or add assertions that
verify persistence (e.g., query the DB via db_session to assert one Asset and
one System exist after calling app_client.post("/ingest")). Locate
test_full_ingest_endpoint and either drop the db_session parameter or use
db_session to query the Asset and System models (or repository methods) to
assert counts/records match the expected assets_ingested and systems_ingested.
verdict-backend/app/services/cmdb_ingestion.py (2)

75-99: Resolve asset IDs from Asset in bulk here.

This helper does one lookup and one AssetPublic validation per reference, and ingest_systems() repeats that for every system. A single SELECT Asset.gold_source_id, Asset.id ... IN (...) for the unique gold-source IDs would remove the N+1 query pattern and keep this path decoupled from API-model validation.

Also applies to: 180-186

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/services/cmdb_ingestion.py` around lines 75 - 99, The
_resolve_asset_ids helper currently does N+1 lookups and API-model validation
per gold_source_id; replace that with a single bulk query that selects
Asset.gold_source_id and Asset.id WHERE Asset.gold_source_id IN (unique
gold_source_ids) using the Session (referencing _resolve_asset_ids,
GoldSourceMixin.get_by_gold_source should not be used here), build a map from
gold_source_id to id, and then iterate the original gold_source_ids to produce
asset_ids in the same order, returning a ValidationError via Err if any
gold_source_id is missing; apply the same bulk-query pattern to the analogous
logic referenced at lines ~180-186.

102-132: Delay SystemPublic validation until after link sync.

_upsert_system() only needs to hand the caller something with an id, but it validates SystemPublic before _sync_asset_links() runs. Returning System or system_id here and building SystemPublic once after the asset links are synchronized keeps the write path cleaner and avoids returning pre-link state.

Also applies to: 193-200

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/services/cmdb_ingestion.py` around lines 102 - 132,
_upsert_system currently validates and returns SystemPublic before asset links
are synchronized; change it to return the persisted System instance (or its id)
instead of calling SystemPublic.model_validate so that validation is deferred
until after _sync_asset_links runs. Concretely, in _upsert_system replace the
Ok(SystemPublic.model_validate(...)) returns with Ok(existing) or Ok(new) (or
Ok(existing.id)/Ok(new.id) if the caller only needs an id), remove/avoid
SystemPublic.model_validate calls here, and update the corresponding call sites
(including the similar block around the 193-200 range) so they call
SystemPublic.model_validate only after _sync_asset_links has completed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@verdict-backend/app/services/cmdb_ingestion.py`:
- Around line 140-142: The initial select against asset_system in
_sync_asset_links (the
session.execute(sa.select(asset_system.c.asset_id).where(asset_system.c.system_id
== system_id)).all() call) must be wrapped in the same OperationalError/DBError
handling as the subsequent writes: catch sqlalchemy.exc.OperationalError (or the
project's DBError translation), convert it to the Result[None, DBError] failure
and return early instead of letting the raw exception escape; apply the same
pattern to the later read blocks around lines 149-160 so all DB reads in
_sync_asset_links return the unified Result[None, DBError] on failure.

In `@verdict-backend/tests/routes/test_ingestion.py`:
- Around line 98-107: The test
test_full_ingest_does_not_persist_on_system_failure currently only asserts that
System rows are rolled back; add a parallel assertion that no Asset rows were
persisted to verify transaction atomicity. After the existing systems query
(db_session.exec(select(System)).all()), query assets with
db_session.exec(select(Asset)).all() and assert its length is 0 (or assert not
assets) so the test ensures both System and Asset records are rolled back when
_mock_system_failure() is triggered during app_client.post("/ingest").

---

Nitpick comments:
In `@verdict-backend/app/services/cmdb_ingestion.py`:
- Around line 75-99: The _resolve_asset_ids helper currently does N+1 lookups
and API-model validation per gold_source_id; replace that with a single bulk
query that selects Asset.gold_source_id and Asset.id WHERE Asset.gold_source_id
IN (unique gold_source_ids) using the Session (referencing _resolve_asset_ids,
GoldSourceMixin.get_by_gold_source should not be used here), build a map from
gold_source_id to id, and then iterate the original gold_source_ids to produce
asset_ids in the same order, returning a ValidationError via Err if any
gold_source_id is missing; apply the same bulk-query pattern to the analogous
logic referenced at lines ~180-186.
- Around line 102-132: _upsert_system currently validates and returns
SystemPublic before asset links are synchronized; change it to return the
persisted System instance (or its id) instead of calling
SystemPublic.model_validate so that validation is deferred until after
_sync_asset_links runs. Concretely, in _upsert_system replace the
Ok(SystemPublic.model_validate(...)) returns with Ok(existing) or Ok(new) (or
Ok(existing.id)/Ok(new.id) if the caller only needs an id), remove/avoid
SystemPublic.model_validate calls here, and update the corresponding call sites
(including the similar block around the 193-200 range) so they call
SystemPublic.model_validate only after _sync_asset_links has completed.

In `@verdict-backend/tests/e2e/conftest.py`:
- Around line 14-19: The _clean_db fixture currently uses scope="session" which
allows DB state to persist across tests; change its scope to "function" (or
remove scope to use default function scope) so that _truncate() runs before and
after each individual test, ensuring isolation; locate the _clean_db fixture and
adjust its scope attribute while keeping the existing calls to _truncate() in
the setup and teardown logic (referencing _clean_db and the _truncate() helper).

In `@verdict-backend/tests/routes/test_ingestion.py`:
- Around line 58-67: The test_full_ingest_endpoint currently accepts the
db_session fixture but never uses it; either remove db_session from the test
signature or add assertions that verify persistence (e.g., query the DB via
db_session to assert one Asset and one System exist after calling
app_client.post("/ingest")). Locate test_full_ingest_endpoint and either drop
the db_session parameter or use db_session to query the Asset and System models
(or repository methods) to assert counts/records match the expected
assets_ingested and systems_ingested.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3c83454d-b077-45ef-8e03-f0c376922268

📥 Commits

Reviewing files that changed from the base of the PR and between 8427584 and 410d2c2.

⛔ Files ignored due to path filters (1)
  • mock-services/devenv.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • .github/workflows/verdict-backend.yaml
  • .gitignore
  • verdict-backend/app/services/cmdb_ingestion.py
  • verdict-backend/devenv.nix
  • verdict-backend/justfile
  • verdict-backend/tests/e2e/conftest.py
  • verdict-backend/tests/routes/test_ingestion.py
✅ Files skipped from review due to trivial changes (1)
  • .gitignore
🚧 Files skipped from review as they are similar to previous changes (1)
  • verdict-backend/devenv.nix

Comment on lines +140 to +142
existing_rows = session.execute(
sa.select(asset_system.c.asset_id).where(asset_system.c.system_id == system_id)
).all()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle read-path DB failures in _sync_asset_links().

The initial lookup is still outside the OperationalError guard, so a failing read here bypasses the Result[None, DBError] contract and leaks a raw DB exception out of ingestion.

🛡️ Proposed fix
 def _sync_asset_links(
     session: Session,
     system_id: int,
     desired_asset_ids: list[int],
 ) -> Result[None, DBError]:
-    existing_rows = session.execute(
-        sa.select(asset_system.c.asset_id).where(asset_system.c.system_id == system_id)
-    ).all()
+    try:
+        existing_rows = session.execute(
+            sa.select(asset_system.c.asset_id).where(asset_system.c.system_id == system_id)
+        ).all()
+    except OperationalError as e:
+        return Err(db_error_from(e))
     existing_ids = {row.asset_id for row in existing_rows}
     desired_ids = set(desired_asset_ids)
 
     to_remove = existing_ids - desired_ids
     to_add = desired_ids - existing_ids

Also applies to: 149-160

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/app/services/cmdb_ingestion.py` around lines 140 - 142, The
initial select against asset_system in _sync_asset_links (the
session.execute(sa.select(asset_system.c.asset_id).where(asset_system.c.system_id
== system_id)).all() call) must be wrapped in the same OperationalError/DBError
handling as the subsequent writes: catch sqlalchemy.exc.OperationalError (or the
project's DBError translation), convert it to the Result[None, DBError] failure
and return early instead of letting the raw exception escape; apply the same
pattern to the later read blocks around lines 149-160 so all DB reads in
_sync_asset_links return the unified Result[None, DBError] on failure.

Comment on lines +98 to +107
@respx.mock
async def test_full_ingest_does_not_persist_on_system_failure(
app_client: AsyncClient, db_session: Session, mock_http_client
):
_mock_system_failure()

await app_client.post("/ingest")

systems = db_session.exec(select(System)).all()
assert len(systems) == 0

@coderabbitai coderabbitai Bot Mar 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify Asset model exists and can be imported
ast-grep --pattern 'class Asset'

Repository: cschwartz/verdict

Length of output: 792


🏁 Script executed:

# First, let's see the full test file and endpoint implementation
cat -n verdict-backend/tests/routes/test_ingestion.py | head -120

Repository: cschwartz/verdict

Length of output: 3935


🏁 Script executed:

# Find the ingest endpoint
find verdict-backend -name "*.py" -type f | xargs rg -l "POST.*ingest|@.*\.post.*ingest" | head -5

Repository: cschwartz/verdict

Length of output: 100


🏁 Script executed:

# Search for the ingest route definition
rg -A 20 "async def.*ingest|@.*route.*ingest" verdict-backend/app/routes/ -t py

Repository: cschwartz/verdict

Length of output: 1384


🏁 Script executed:

# Get full ingestion.py to understand transaction handling
cat -n verdict-backend/app/routes/ingestion.py

Repository: cschwartz/verdict

Length of output: 1324


🏁 Script executed:

# Find unwrap_or_raise implementation
rg -B 5 -A 10 "def unwrap_or_raise" verdict-backend/ -t py

Repository: cschwartz/verdict

Length of output: 1022


🏁 Script executed:

# Check ingest_systems to see if it validates asset references
rg -B 5 -A 30 "def ingest_systems" verdict-backend/ -t py

Repository: cschwartz/verdict

Length of output: 3006


🏁 Script executed:

# Check how get_session handles exceptions and rollback
cat -n verdict-backend/app/db.py

Repository: cschwartz/verdict

Length of output: 695


🏁 Script executed:

# Verify the asset ingestion process writes to database
rg -B 5 -A 30 "def ingest_assets" verdict-backend/app/services/ -t py

Repository: cschwartz/verdict

Length of output: 2813


🏁 Script executed:

# Confirm _resolve_asset_ids validates asset existence
rg -B 2 -A 10 "_resolve_asset_ids" verdict-backend/app/services/cmdb_ingestion.py -t py

Repository: cschwartz/verdict

Length of output: 956


Add assertion verifying Asset rollback.

The test validates that System records don't persist on system failure, but assets are ingested and committed to the same transaction first. To fully verify the atomicity guarantee, also assert that no Asset records persist.

💚 Proposed enhancement
 from app.models.system import System
+from app.models.asset import Asset


 `@respx.mock`
 async def test_full_ingest_does_not_persist_on_system_failure(
     app_client: AsyncClient, db_session: Session, mock_http_client
 ):
     _mock_system_failure()

     await app_client.post("/ingest")

     systems = db_session.exec(select(System)).all()
     assert len(systems) == 0
+    assets = db_session.exec(select(Asset)).all()
+    assert len(assets) == 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/tests/routes/test_ingestion.py` around lines 98 - 107, The
test test_full_ingest_does_not_persist_on_system_failure currently only asserts
that System rows are rolled back; add a parallel assertion that no Asset rows
were persisted to verify transaction atomicity. After the existing systems query
(db_session.exec(select(System)).all()), query assets with
db_session.exec(select(Asset)).all() and assert its length is 0 (or assert not
assets) so the test ensures both System and Asset records are rolled back when
_mock_system_failure() is triggered during app_client.post("/ingest").

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is tested in another test, we only do one assertion (but maybe with multiple assert statements) per test^

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cschwartz, understood — one logical concept per test, with atomicity of Asset rollback covered elsewhere. I'll keep this in mind for future reviews.


✏️ Learnings added
Learnt from: cschwartz
Repo: cschwartz/verdict PR: 30
File: verdict-backend/tests/routes/test_ingestion.py:98-107
Timestamp: 2026-03-14T09:28:07.512Z
Learning: In cschwartz/verdict, the testing convention is one logical assertion (one concept) per test, even if that means multiple assert statements. Separate concerns (e.g., Asset rollback vs. System rollback) belong in separate tests rather than being combined into a single test.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: cschwartz
Repo: cschwartz/verdict PR: 26
File: verdict-backend/app/services/asset_ingestion.py:111-129
Timestamp: 2026-03-01T07:49:07.243Z
Learning: In verdict-backend, asset ingestion runs synchronously via a single POST endpoint at /assets/ingest, with no concurrent ingestion. Database transactions serialize requests, so TOCTOU race conditions in the upsert logic (get_by_gold_source → session.add) are not a current concern. Conflict handling (INSERT...ON CONFLICT or retry logic) is deferred until background workers or parallel ingestion are introduced.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@verdict-backend/devenv.nix`:
- Line 153: The pytest invocation that writes a JUnit XML (the line starting
with "DATABASE_NAME=${database_name}_test uv run pytest
--disable-plugin-autoload -p asyncio -m 'not e2e'
--junit-xml=test-results/unit.xml") assumes test-results/ exists; modify the
build/script to ensure the test-results directory is created (e.g., create the
directory if missing) before running that pytest command, and apply the same
change to the other occurrence of this pytest invocation elsewhere in the file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4896847b-5fa6-4aa5-ac93-f10e188d2af3

📥 Commits

Reviewing files that changed from the base of the PR and between 410d2c2 and 188c966.

📒 Files selected for processing (2)
  • .github/workflows/verdict-backend.yaml
  • verdict-backend/devenv.nix

DATABASE_NAME=${database_name}_test just db-migrate
just check
DATABASE_NAME=${database_name}_test uv run pytest --disable-plugin-autoload -p asyncio -m 'not e2e'
DATABASE_NAME=${database_name}_test uv run pytest --disable-plugin-autoload -p asyncio -m 'not e2e' --junit-xml=test-results/unit.xml

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Create test-results/ before writing JUnit XML.

The current commands assume test-results already exists. If it doesn’t, XML report creation can fail and downstream reporting breaks.

🛠️ Proposed fix
     just db-test-reset
     DATABASE_NAME=${database_name}_test just db-migrate
     just check
+    mkdir -p test-results
     DATABASE_NAME=${database_name}_test uv run pytest --disable-plugin-autoload -p asyncio -m 'not e2e' --junit-xml=test-results/unit.xml
@@
     just db-test-reset
     DATABASE_NAME=${database_name}_test just db-migrate
     DATABASE_NAME=${database_name}_test uv run pytest --disable-plugin-autoload -p asyncio -m e2e --junit-xml=test-results/e2e.xml

Also applies to: 218-218

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@verdict-backend/devenv.nix` at line 153, The pytest invocation that writes a
JUnit XML (the line starting with "DATABASE_NAME=${database_name}_test uv run
pytest --disable-plugin-autoload -p asyncio -m 'not e2e'
--junit-xml=test-results/unit.xml") assumes test-results/ exists; modify the
build/script to ensure the test-results directory is created (e.g., create the
directory if missing) before running that pytest command, and apply the same
change to the other occurrence of this pytest invocation elsewhere in the file.

@cschwartz

Copy link
Copy Markdown
Owner Author

Build fail due to failed psycopg build in devenv-nixpkgs/rolling

@cschwartz
cschwartz merged commit df2b999 into main Mar 14, 2026
1 of 3 checks passed
@cschwartz
cschwartz deleted the 4-implement-system-model-mock-source-and-ingestion branch March 14, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant