diff --git a/.kiro/specs/adjust-revision-stale-read-fix/.config.kiro b/.kiro/specs/adjust-revision-stale-read-fix/.config.kiro new file mode 100644 index 00000000..4fd7cf0d --- /dev/null +++ b/.kiro/specs/adjust-revision-stale-read-fix/.config.kiro @@ -0,0 +1 @@ +{"specId": "473d5f24-2501-429d-a053-62d3153df110", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/adjust-revision-stale-read-fix/bugfix.md b/.kiro/specs/adjust-revision-stale-read-fix/bugfix.md new file mode 100644 index 00000000..2b03b391 --- /dev/null +++ b/.kiro/specs/adjust-revision-stale-read-fix/bugfix.md @@ -0,0 +1,43 @@ +# Bugfix Requirements Document + +## Introduction + +The "adjust revision for this platform" action (delivered by the imported-plugin-revision-adjustment-fix spec) lets a user apply a compatible source revision to an imported plugin's platform and re-run its build. In practice, the FIRST build started right after applying the adjustment ALWAYS compiles the ORIGINAL revision's source tree and fails (e.g. meson requires `gstreamer-1.0 >= 1.19.0` while the build image ships 1.16.3); manually re-running the build then compiles the adjusted revision's tree and succeeds. + +The root cause is a read-after-write race. The adjustment paths write the record's revision mapping (`arch_revisions` / `fetches`) with an `update_item`, then immediately auto-start the queued builds. That auto-start re-reads the record with an eventually-consistent DynamoDB `get_item` (no `ConsistentRead`), which can return the pre-write item. With the stale item, the build's source prefix resolution finds no adjusted mapping and falls back to the original revision's tree, so the CodeBuild `sourceLocationOverride` points at the wrong source. The race exists on both adjustment paths: the fetch-success result handler and the reuse path that maps an architecture to an already-fetched revision. + +The fix must ensure a build auto-started immediately after an adjustment write always resolves its source from the post-write record state, without changing any other read path or build flow. + +## Bug Analysis + +### Current Behavior (Defect) + +A queued per-arch build is auto-started within the same invocation immediately after a write that changed the record's revision mapping, and the auto-start's eventually-consistent re-read returns the pre-write item: + +1.1 WHEN an adjustment fetch succeeds and the auto-started build's re-read of the record returns the pre-write item THEN the system resolves the architecture's source prefix without the just-written arch_revisions mapping and submits the build from the original revision's source tree, which fails + +1.2 WHEN an adjustment reuses an already-fetched revision and the auto-started build's re-read of the record returns the pre-write item THEN the system resolves the architecture's source prefix without the just-written arch_revisions mapping and submits the build from the original revision's source tree, which fails + +1.3 WHEN the first build after an adjustment fails this way and the user manually retries THEN the system reads fresh state, resolves the adjusted revision's source prefix, and the build succeeds — masking the defect as a transient failure + +### Expected Behavior (Correct) + +2.1 WHEN an adjustment fetch succeeds and the queued builds are auto-started THEN the system SHALL resolve each pending architecture's source prefix from the post-write record state so the build compiles the adjusted revision's source tree + +2.2 WHEN an adjustment reuses an already-fetched revision and the queued build is auto-started THEN the system SHALL resolve the architecture's source prefix from the post-write record state so the build compiles the adjusted revision's source tree + +2.3 WHEN a build is auto-started immediately after any write that changed the record's revision mapping THEN the system SHALL submit the CodeBuild sourceLocationOverride with a prefix that reflects the just-written mapping, regardless of DynamoDB read consistency timing + +### Unchanged Behavior (Regression Prevention) + +3.1 WHEN a build is auto-started for an architecture whose revision mapping was not changed by the adjustment THEN the system SHALL CONTINUE TO resolve its source prefix exactly as today (per-arch mapping if present, otherwise the flat source_s3_prefix) + +3.2 WHEN a single-revision record has no per-architecture revision mappings THEN the system SHALL CONTINUE TO build from the flat source_s3_prefix layout + +3.3 WHEN queued builds are auto-started after an import-time fetch settles (the original import flow, no adjustment involved) THEN the system SHALL CONTINUE TO start them with the same idempotency guarantees: already-started architectures are left alone, unconfigured architectures stay queued, and a StartBuild failure is recorded without raising to the caller + +3.4 WHEN the user manually retries a failed build THEN the system SHALL CONTINUE TO re-submit it from the platform's currently recorded source tree + +3.5 WHEN other callers read the plugin record (detail page, listing, source inspection, build-result handling) THEN the system SHALL CONTINUE TO behave as today, unaffected by the fix + +3.6 WHEN an adjustment fetch fails THEN the system SHALL CONTINUE TO surface the failure on the affected platform's entry without starting builds or altering other platforms diff --git a/.kiro/specs/adjust-revision-stale-read-fix/design.md b/.kiro/specs/adjust-revision-stale-read-fix/design.md new file mode 100644 index 00000000..b9676478 --- /dev/null +++ b/.kiro/specs/adjust-revision-stale-read-fix/design.md @@ -0,0 +1,200 @@ +# Adjust-Revision Stale Read Bugfix Design + +## Overview + +The imported-plugin revision adjustment feature (imported-plugin-revision-adjustment-fix spec) writes a platform's revision mapping (`arch_revisions` / `fetches`) with a DynamoDB `update_item` and then, within the same Lambda invocation, auto-starts the queued builds via `plugin_builds.start_queued_builds`. That auto-start re-reads the record through `plugin_records.get_version_item`, which issues a plain (eventually-consistent) `get_item`. When the read lands on a replica that has not yet applied the write, the returned item lacks the just-written `arch_revisions` mapping, `arch_source_prefix` falls back to the flat `source_s3_prefix` (the original revision's tree), and CodeBuild's `sourceLocationOverride` points at the wrong source. The first post-adjustment build therefore compiles the original tree and fails deterministically (e.g. meson requires `gstreamer-1.0 >= 1.19.0` while the JetPack 4 image ships 1.16.3); a manual retry reads fresh state and succeeds, masking the defect as a transient failure. + +The fix is a targeted read-your-own-write correction: add an opt-in `consistent_read` parameter to `plugin_records.get_version_item` (defaulting to `False`, so every existing caller is bit-for-bit unchanged) and have `start_queued_builds` — the single function through which every auto-start flows — request `ConsistentRead=True`. This follows the precedent already in the codebase: `plugin_importer._handle_multi_fetch_result` performs its post-write settlement check with `ConsistentRead=True` for exactly this reason. Both adjustment race sites (the fetch-success result handler and the reuse path) funnel through `start_queued_builds`, so one change fixes both; the import-time fetch-settle path also flows through it and gains the same (strictly safer) guarantee for its own same-invocation read-after-write. + +## Glossary + +- **Bug_Condition (C)**: A queued per-arch build is auto-started in the same invocation immediately after an `update_item` that changed the record's revision mapping, and the auto-start's eventually-consistent re-read returns the pre-write item — so the build's source prefix resolves without the just-written mapping. +- **Property (P)**: The auto-started build's source prefix reflects the post-write record state: `arch_source_prefix` resolves through the just-written `arch_revisions[arch] -> fetches[slug].source_prefix`, and the CodeBuild `sourceLocationOverride` names the adjusted revision's tree. +- **Preservation**: Every other read path (`get_version_item` callers across `plugin_records.py`, `plugin_builds.py`, `plugin_importer.py`, `plugin_components.py`, `custom_node_types.py`, `workflow_packaging.py`), the auto-start's idempotency/never-raise semantics, manual retries, and flat single-revision source resolution must all remain unchanged. +- **get_version_item**: `plugin_records.py` (~line 262) — fetches one Plugin_Record version item via `plugin_table().get_item(Key=...)`; today it never sets `ConsistentRead`, so every read is eventually consistent. +- **start_queued_builds**: `plugin_builds.py` (~line 655) — re-reads the record via `get_version_item`, StartBuilds every requested architecture whose artifact entry is `queued`, and never raises to the caller. Called from the import-time fetch-settle path and (as `_start_queued_builds`, a lazy-import wrapper) from both adjustment paths. +- **arch_source_prefix**: `plugin_builds.py` (~line 266) — pure resolver `arch_revisions[arch] -> fetches[slug].source_prefix`, falling back to the flat `source_s3_prefix` when the item carries no mapping for the arch. It is correct; the bug is that it can be handed a stale item. +- **Race sites**: `plugin_importer._handle_adjustment_fetch_result` (fetch-success path, ~2040–2145: conditional `update_item` writing `arch_revisions` then `_start_queued_builds`) and `plugin_importer.adjust_revision` ADJUST_REUSE branch (~2340–2445: `update_item` writing `arch_revisions` + re-queue then `_start_queued_builds`). +- **ConsistentRead precedent**: `plugin_importer.py` ~line 1971 — the multi-fetch settlement check already reads with `ConsistentRead=True` so concurrent slug deliveries never all see an unsettled map. + +## Bug Details + +### Bug Condition + +The bug manifests when a build is auto-started immediately after an adjustment write in the same invocation. DynamoDB `GetItem` without `ConsistentRead` may return data that does not reflect a recently completed write; because the write and the read are milliseconds apart in the same invocation, the stale window is hit reliably in practice. The stale item lacks the `arch_revisions[arch]` mapping (or the updated `fetches[slug]` entry), so `arch_source_prefix(item, arch)` falls back to `source_s3_prefix` and the build compiles the original revision's tree. + +**Formal Specification:** +``` +FUNCTION isBugCondition(input) + INPUT: input of type (write: RevisionMappingWrite, autoStart: StartQueuedBuildsCall) + OUTPUT: boolean + + RETURN autoStart follows write within the same invocation + AND write changed arch_revisions[arch] (adjustment fetch-success + OR adjustment reuse path) + AND autoStart.reRead is eventually consistent + AND autoStart.reRead returns the pre-write item + -- consequence on unfixed code: + -- arch_source_prefix(staleItem, arch) == source_s3_prefix + -- != fetches[arch_revisions[arch]].source_prefix +END FUNCTION +``` + +### Examples + +- **Adjustment fetch success**: gst-plugins-good arm64_jp5 is adjusted from `main` to `1.16`. The fetch succeeds, `_handle_adjustment_fetch_result` writes `arch_revisions.arm64_jp5 = '1-16'` and calls `_start_queued_builds`. Expected: the build's `sourceLocationOverride` names `.../rev-1-16/`. Actual: the re-read returns the pre-write item, the prefix falls back to the flat `main` tree, and the build fails on `gstreamer-1.0 >= 1.19.0` vs 1.16.3. +- **Reuse path**: arm64_jp4 is adjusted to `1.16`, whose tree an earlier adjustment already fetched. `adjust_revision` writes `arch_revisions.arm64_jp4 = '1-16'` + re-queue and immediately auto-starts. Expected: build from `rev-1-16/`. Actual: stale re-read, flat-prefix fallback, deterministic first-build failure. +- **Manual retry masks it**: the user clicks "Retry build" seconds later; the read now reflects the write, `arch_source_prefix` resolves the adjusted tree, and the build succeeds — the defect looks like a flaky build (requirement 1.3). +- **Edge case — import-time auto-start**: the original import flow's fetch-settle path writes `import_status`/artifacts then calls `start_queued_builds` in the same invocation. The same theoretical stale window exists (a stale item could show entries not yet `queued`, silently starting nothing); a consistent read there is strictly safer and behavior-preserving. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** +- Architectures whose mapping was not changed by an adjustment resolve their source exactly as today: per-arch mapping if present, otherwise the flat `source_s3_prefix` (3.1); single-revision flat-layout records keep building from `source_s3_prefix` (3.2). +- `start_queued_builds` semantics beyond read consistency: already-started architectures are left alone, unconfigured architectures stay queued, StartBuild failures are recorded without raising to the caller, and the import-time fetch-settle auto-start keeps working (3.3). +- Manual retry (POST .../build) re-submits from the platform's currently recorded source tree (3.4). +- Every other `get_version_item` caller — detail/listing/source-inspection handlers in `plugin_records.py`, build views and build-result handling in `plugin_builds.py`, importer handlers, component packaging, custom node types, workflow packaging — keeps issuing eventually-consistent reads and behaves as today (3.5). +- A failed adjustment fetch still surfaces on the affected platform's entry only, without starting builds or touching other platforms (3.6). + +**Scope:** +All reads that do NOT flow through `start_queued_builds` are completely unaffected by this fix. This includes: +- Every `get_version_item` call that does not pass the new parameter (it defaults to `False` — identical `get_item` call, no `ConsistentRead` key) +- All query/list paths (`query_versions`, GSI queries), which are unrelated to `get_item` consistency +- All write paths (`update_item`, `set_arch_entry`, conditional writes), which are untouched + +## Hypothesized Root Cause + +This root cause is confirmed by code inspection rather than hypothesized from symptoms alone: + +1. **Eventually-consistent re-read after a same-invocation write (confirmed primary cause)**: `plugin_records.get_version_item` issues `plugin_table().get_item(Key=...)` with no `ConsistentRead`. `start_queued_builds` calls it immediately after the adjustment `update_item` in the same invocation, well inside DynamoDB's eventual-consistency window. + +2. **Silent flat-prefix fallback amplifies the staleness (contributing, by design)**: `arch_source_prefix` deliberately falls back to `source_s3_prefix` for unmapped architectures (single-revision records rely on this). Handed a stale item, the fallback produces a *valid-looking* wrong prefix instead of an error, so the submission proceeds and the failure surfaces only inside the CodeBuild log. + +3. **Both adjustment paths share the race shape**: `_handle_adjustment_fetch_result` (write mapping → `_start_queued_builds`) and `adjust_revision` ADJUST_REUSE (write mapping + re-queue → `_start_queued_builds`) both write then immediately re-read through the same helper — which is also why one fix at the re-read site covers both. + +4. **Not a write problem**: the `update_item`s are correct and (on the fetch-result path) conditional; the manual-retry success proves the written state is right. Only the read timing is wrong. + +## Correctness Properties + +Property 1: Bug Condition - Auto-Started Builds Resolve the Post-Write Source Tree + +_For any_ build auto-started via start_queued_builds immediately after a same-invocation write that changed the record's revision mapping — whether from the adjustment fetch-success handler or the adjustment reuse path (isBugCondition returns true) — the fixed system SHALL re-read the record with a strongly consistent read, so the item handed to arch_source_prefix carries the just-written arch_revisions mapping and the CodeBuild sourceLocationOverride names the adjusted revision's source prefix (fetches[arch_revisions[arch]].source_prefix), regardless of eventual-consistency timing. + +**Validates: Requirements 2.1, 2.2, 2.3** + +Property 2: Preservation - All Other Reads and Auto-Start Semantics Are Unchanged + +_For any_ input where the bug condition does NOT hold (isBugCondition returns false) — get_version_item calls from every other caller (which do not pass consistent_read and therefore issue the identical eventually-consistent get_item as before), source resolution for architectures not touched by an adjustment, flat single-revision records, manual retries, the import-time auto-start's idempotency and never-raise guarantees, and fetch-failure handling — the fixed system SHALL produce the same result as the original system, preserving all existing read behavior and build flows bit-for-bit. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6** + +## Fix Implementation + +### Chosen Approach and Rationale + +**Option 1 — opt-in strong consistency on the re-read (chosen)**: add `consistent_read: bool = False` to `plugin_records.get_version_item`; `plugin_builds.start_queued_builds` passes `consistent_read=True`. + +- Minimal: two files, a parameter and one call-site flag; no data-flow or signature changes anywhere else. +- Matches the existing precedent (`_handle_multi_fetch_result`'s `ConsistentRead=True` settlement check at plugin_importer.py ~1971). +- Fixes both race sites at once, because both adjustment paths flow through `start_queued_builds`; the import-time fetch-settle path (which also writes then auto-starts in one invocation) is covered by the same change, closing its identical theoretical race with no behavior change. +- Default `False` guarantees every other caller's `get_item` call is byte-identical (the `ConsistentRead` key is only added when requested), making preservation trivially auditable. + +**Option 2 — pass the known post-write item into start_queued_builds (rejected)**: threading the post-write state through `_start_queued_builds` and its callers is more invasive (call-site signature changes in `plugin_importer.py` and `plugin_builds.py`), risks drift between the passed snapshot and table state (the write is an `update_item`, so callers would have to reconstruct the merged item), and the import-time path would need the same reconstruction treatment. The cost of a consistent `GetItem` on this rare, non-latency-sensitive path is negligible by comparison. + +### Changes Required + +**File**: `edge-cv-portal/backend/functions/plugin_records.py` + +**Function**: `get_version_item` (~line 262) + +1. **Add opt-in consistency parameter**: signature becomes `get_version_item(plugin_id: str, version: int, consistent_read: bool = False)`. When `consistent_read` is true, pass `ConsistentRead=True` to `plugin_table().get_item(...)`; when false, issue exactly today's call (do not pass the key at all, so stubs and moto behavior for existing tests are unchanged). +2. **Document the intent**: docstring notes the parameter exists for same-invocation read-your-own-write callers (auto-start after an adjustment/fetch-settle write), mirroring the `_handle_multi_fetch_result` precedent. + +**File**: `edge-cv-portal/backend/functions/plugin_builds.py` + +**Function**: `start_queued_builds` (~line 673) + +3. **Request the consistent re-read**: `item = get_version_item(plugin_id, version, consistent_read=True)`. Everything else in the function (queued-arch selection, `submit_arch_builds`, `set_arch_entry` persistence, never-raise wrapper, audit logging) is untouched, preserving 3.3. + +**No other changes**: `arch_source_prefix`, `submit_arch_builds`, both adjustment paths in `plugin_importer.py`, and every other `get_version_item` call site are not modified. The fix is entirely in how the auto-start obtains its item. + +## Testing Strategy + +### Validation Approach + +Two-phase: first surface counterexamples on the UNFIXED code demonstrating the stale-read → wrong-source submission (confirming the root cause), then verify the fix satisfies Property 1 and preserve-check Property 2. Tests live in `edge-cv-portal/backend/tests/` (pytest + Hypothesis; venv at `/home/ubuntu/backend-test-venv`), reusing `test_plugin_importer.py`'s existing stub/moto patterns for the plugin table and CodeBuild. The stale read is deterministic to simulate: stub the table's `get_item` to return the pre-write item for plain (non-`ConsistentRead`) reads and the post-write item when `ConsistentRead=True` is passed. Property tests run ≥100 iterations and are tagged `Feature: adjust-revision-stale-read-fix, Property {n}: {title}`. + +### Exploratory Bug Condition Checking + +**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm or refute the root cause analysis. If we refute, we will need to re-hypothesize. + +**Test Plan**: Drive each adjustment path against a stubbed plugin table whose `get_item` returns the stale (pre-write) item unless `ConsistentRead=True` is requested, with CodeBuild's `start_build` captured. Assert the submitted `sourceLocationOverride` names the adjusted revision's `rev-{slug}/` prefix. On unfixed code these assertions fail with the flat prefix, reproducing the field failure exactly. + +**Test Cases**: +1. **Fetch-success stale read**: settle an adjustment fetch via `_handle_adjustment_fetch_result` with the stale-read stub; assert the auto-started build's source names the adjusted `rev-{slug}/` prefix (will fail on unfixed code — flat prefix submitted) +2. **Reuse-path stale read**: `adjust_revision` ADJUST_REUSE with the stale-read stub; assert the auto-started build's source names the reused entry's prefix (will fail on unfixed code) +3. **No ConsistentRead requested**: assert `start_queued_builds`'s re-read passes `ConsistentRead=True` to `get_item` (will fail on unfixed code — the parameter is absent) +4. **Retry succeeds after first failure** (edge, confirms 1.3): after the stale-read submission, a manual retry with fresh reads resolves the adjusted prefix (passes on unfixed code — documents the masking behavior) + +**Expected Counterexamples**: +- `start_build` called with `sourceLocationOverride = {bucket}/{source_s3_prefix}` instead of `{bucket}/{fetches[slug].source_prefix}` on both adjustment paths +- Possible causes confirmed: eventually-consistent `get_item` in `get_version_item`, silent flat-prefix fallback in `arch_source_prefix`, same-invocation write-then-read in both paths + +### Fix Checking + +**Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior. + +**Pseudocode:** +``` +FOR ALL (record, arch, slug) WHERE isBugCondition(write(record, arch, slug), + autoStart) DO + submissions := start_queued_builds_fixed(plugin_id, version) + -- with get_item stubbed stale-unless-ConsistentRead + ASSERT reRead used ConsistentRead=True + ASSERT submissions[arch].sourceLocationOverride + ends with fetches[slug].source_prefix +END FOR +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function. + +**Pseudocode:** +``` +FOR ALL input WHERE NOT isBugCondition(input) DO + ASSERT originalBehavior(input) = fixedBehavior(input) +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for preservation checking because: +- It generates many test cases automatically across the input domain (record shapes with/without `arch_revisions`/`fetches`, arch subsets, artifact statuses) +- It catches edge cases that manual unit tests might miss (partially mapped records, unconfigured architectures, missing items) +- It provides strong guarantees that behavior is unchanged for all non-buggy inputs + +**Test Plan**: Observe behavior on UNFIXED code first for non-adjustment reads and auto-start flows, then write property-based tests capturing that behavior and re-run them on the fixed code. + +**Test Cases**: +1. **Default-read preservation**: `get_version_item(plugin_id, version)` without the parameter issues a `get_item` call with no `ConsistentRead` key and returns the same decoded item as before the fix (3.5) +2. **Auto-start semantics preservation**: for randomly generated records, `start_queued_builds` starts exactly the queued+configured architectures, leaves non-queued and unconfigured entries alone, records StartBuild failures without raising, and returns `{}` for missing records — identical to unfixed behavior apart from the read consistency (3.3) +3. **Source-resolution preservation**: for randomly generated items, `arch_source_prefix(item, arch)` is byte-identical before and after the fix for every arch — mapped, unmapped, and flat (3.1, 3.2) +4. **Retry and failure-path preservation**: manual retry submissions and adjustment fetch-failure handling (per-arch logTail, no builds started, other platforms untouched) are unchanged (3.4, 3.6) + +### Unit Tests + +- `get_version_item`: passes `ConsistentRead=True` exactly when `consistent_read=True`; omits the key entirely by default; returns `None` for missing items in both modes +- `start_queued_builds`: re-reads with `consistent_read=True`; with the stale-read stub, submits the adjusted prefix; idempotency cases (already-building arch untouched, unconfigured arch left queued, StartBuild exception recorded as failed entry, missing record returns `{}`) +- Both adjustment paths end-to-end against the stale-read stub: fetch-success settles then builds from `rev-{slug}/`; ADJUST_REUSE maps then builds from the reused prefix + +### Property-Based Tests + +- **Property 1 (fix)**: Hypothesis-generated records × adjusted archs × slugs under the stale-unless-consistent stub — the auto-started submission's source always reflects the post-write mapping (≥100 iterations, tagged `Feature: adjust-revision-stale-read-fix, Property 1: Auto-Started Builds Resolve the Post-Write Source Tree`) +- **Property 2 (preservation)**: Hypothesis-generated records and non-bug-condition operations — default `get_version_item` call shape, `arch_source_prefix` resolution, and `start_queued_builds` outcomes are identical to the original implementation's (≥100 iterations, tagged `Feature: adjust-revision-stale-read-fix, Property 2: All Other Reads and Auto-Start Semantics Are Unchanged`) + +### Integration Tests + +- Full adjustment-fetch flow: adjust → simulate fetch SUCCEEDED via `handle_fetch_result` with the stale-read table stub → assert the arch's StartBuild used the adjusted `rev-{slug}/` prefix and the arch entry advanced queued → building +- Full reuse flow: adjust to an already-fetched revision → assert the immediate auto-start used the reused entry's prefix +- Import-time flow: original import fetch-settle → `start_queued_builds` still starts all queued architectures from the flat prefix with unchanged idempotency (3.2, 3.3) diff --git a/.kiro/specs/adjust-revision-stale-read-fix/tasks.md b/.kiro/specs/adjust-revision-stale-read-fix/tasks.md new file mode 100644 index 00000000..c5d2df3f --- /dev/null +++ b/.kiro/specs/adjust-revision-stale-read-fix/tasks.md @@ -0,0 +1,123 @@ +# Implementation Plan + +## Overview + +Fix the adjust-revision stale-read race using the exploratory bugfix workflow: write bug condition exploration tests (Property 1) and preservation property tests (Property 2) against the UNFIXED code first, then implement the targeted read-your-own-write fix (design Option 1: opt-in `consistent_read` on `plugin_records.get_version_item`, requested by `plugin_builds.start_queued_builds`), then verify the fix with the same tests plus unit coverage. Backend only — no frontend changes. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "wave": 1, "description": "Run on UNFIXED code: surface the stale-read wrong-source counterexamples (task 1 FAILS - Property 1) and capture preservation baselines (task 2 PASSES - Property 2). Independent of each other.", "tasks": ["1", "2"] }, + { "wave": 2, "description": "Implement the fix: consistent_read parameter on get_version_item, requested by start_queued_builds.", "tasks": ["3.1"] }, + { "wave": 3, "description": "Verify the fix: re-run task 1 tests (now PASS) then task 2 tests (still PASS).", "tasks": ["3.2", "3.3"] }, + { "wave": 4, "description": "Unit tests for fix specifics.", "tasks": ["4"] }, + { "wave": 5, "description": "Checkpoint: full backend suite passes.", "tasks": ["5"] } + ] +} +``` + +```mermaid +graph TD + T1[Task 1: Bug condition exploration tests - Property 1] + T2[Task 2: Preservation property tests - Property 2] + T31[Task 3.1: consistent_read parameter + start_queued_builds call site] + T32[Task 3.2: Verify Property 1 passes] + T33[Task 3.3: Verify Property 2 passes] + T4[Task 4: Unit tests] + T5[Task 5: Checkpoint] + + T1 --> T31 + T2 --> T31 + T31 --> T32 + T32 --> T33 + T33 --> T4 + T4 --> T5 +``` + +## Tasks + +- [x] 1. Write bug condition exploration tests + - **Property 1: Bug Condition** - Auto-Started Builds Resolve the Post-Write Source Tree + - **CRITICAL**: These tests MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the tests or the code when they fail** + - **NOTE**: These tests encode the expected behavior - they will validate the fix when they pass after implementation + - **GOAL**: Surface counterexamples that demonstrate the stale-read race exists and confirm the root cause analysis (eventually-consistent `get_item` in `get_version_item`, silent flat-prefix fallback in `arch_source_prefix`, same-invocation write-then-read on both adjustment paths) + - **Scoped PBT Approach**: The stale read is deterministic to simulate - stub the plugin table's `get_item` to return the stale (pre-write) item for plain reads and the post-write item only when `ConsistentRead=True` is passed (isBugCondition from design: auto-start follows a revision-mapping write in the same invocation, and the eventually-consistent re-read returns the pre-write item) + - Tests in `edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py` (pytest + Hypothesis, reusing `test_plugin_importer.py`'s stub/moto patterns for the plugin table and CodeBuild; capture `start_build` calls): + - **Fetch-success stale read**: settle an adjustment fetch via `_handle_adjustment_fetch_result` with the stale-read stub - assert the auto-started build's `sourceLocationOverride` names the adjusted `rev-{slug}/` prefix (unfixed code submits the flat `source_s3_prefix`) + - **Reuse-path stale read**: `adjust_revision` ADJUST_REUSE against an already-fetched revision with the stale-read stub - assert the auto-started build's source names the reused entry's prefix (unfixed code submits the flat prefix) + - **ConsistentRead assertion**: assert `start_queued_builds`'s re-read passes `ConsistentRead=True` to `get_item` (unfixed code never sets the key) + - **Property 1 (fix)**: Hypothesis-generated records × adjusted archs × slugs under the stale-unless-consistent stub - the auto-started submission's source always reflects the post-write mapping (≥100 iterations, tagged `Feature: adjust-revision-stale-read-fix, Property 1: Auto-Started Builds Resolve the Post-Write Source Tree`) + - **Retry-masks-it edge (documents 1.3)**: after the stale-read submission, a manual retry with fresh reads resolves the adjusted prefix (passes on unfixed code - documents the masking behavior) + - Run tests on UNFIXED code + - **EXPECTED OUTCOME**: Tests FAIL (this is correct - it proves the first post-adjustment build compiles the original tree) + - Document counterexamples found (e.g., `start_build` called with `sourceLocationOverride = {bucket}/{source_s3_prefix}` instead of `{bucket}/{fetches[slug].source_prefix}` on both adjustment paths) + - Mark task complete when tests are written, run, and failure is documented + - _Requirements: 1.1, 1.2, 1.3_ + +- [x] 2. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - All Other Reads and Auto-Start Semantics Are Unchanged + - **IMPORTANT**: Follow observation-first methodology + - Observe behavior on UNFIXED code for non-bug-condition inputs, then write Hypothesis property-based tests in `edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py` capturing the observed behavior patterns from the design's Preservation Requirements: + - **Default-read call shape**: `get_version_item(plugin_id, version)` without the parameter issues a `get_item` call with NO `ConsistentRead` key and returns the same decoded item as today; returns `None` for missing items (3.5) + - **Auto-start semantics**: for Hypothesis-generated records, `start_queued_builds` starts exactly the queued+configured architectures, leaves already-started and non-queued entries alone, leaves unconfigured architectures queued, records StartBuild failures without raising to the caller, and returns `{}` for missing records - identical to unfixed behavior apart from read consistency (3.3) + - **`arch_source_prefix` resolution**: for Hypothesis-generated items, resolution is byte-identical for every arch - mapped (`arch_revisions[arch] -> fetches[slug].source_prefix`), unmapped (flat fallback), and flat single-revision records (3.1, 3.2) + - **Retry and failure paths**: manual retry (POST .../build) re-submits from the platform's currently recorded source tree; adjustment fetch-failure handling records the fetch-failure logTail on the affected arch only, starts no builds, and leaves other platforms and `arch_revisions` untouched (3.4, 3.6) + - Property-based testing generates many test cases for stronger guarantees (record shapes with/without `arch_revisions`/`fetches`, arch subsets, artifact statuses, partially mapped records) + - Property tests run ≥100 iterations, tagged `Feature: adjust-revision-stale-read-fix, Property 2: All Other Reads and Auto-Start Semantics Are Unchanged` + - Run tests on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + +- [x] 3. Fix the adjust-revision stale read + + - [x] 3.1 Add opt-in consistent_read to get_version_item and request it from start_queued_builds + - `edge-cv-portal/backend/functions/plugin_records.py`, `get_version_item` (~line 262): signature becomes `get_version_item(plugin_id: str, version: int, consistent_read: bool = False)`; pass `ConsistentRead=True` to `plugin_table().get_item(...)` ONLY when `consistent_read` is true - when false, issue exactly today's call (do NOT pass the key at all, so stubs and moto behavior for existing tests are unchanged) + - Docstring notes the parameter exists for same-invocation read-your-own-write callers (auto-start after an adjustment/fetch-settle write), mirroring the `_handle_multi_fetch_result` `ConsistentRead=True` precedent at plugin_importer.py ~line 1971 + - `edge-cv-portal/backend/functions/plugin_builds.py`, `start_queued_builds` (~line 673): `item = get_version_item(plugin_id, version, consistent_read=True)` - everything else in the function (queued-arch selection, `submit_arch_builds`, `set_arch_entry` persistence, never-raise wrapper, audit logging) is untouched + - No other changes: `arch_source_prefix`, `submit_arch_builds`, both adjustment paths in `plugin_importer.py`, and every other `get_version_item` call site are not modified + - One fix covers both race sites (fetch-success handler and ADJUST_REUSE both funnel through `start_queued_builds`) and the import-time fetch-settle auto-start gains the same strictly-safer guarantee + - _Bug_Condition: isBugCondition(write, autoStart) from design - auto-start follows a revision-mapping write in the same invocation and the eventually-consistent re-read returns the pre-write item_ + - _Expected_Behavior: the auto-start re-reads with ConsistentRead=True so arch_source_prefix resolves the just-written arch_revisions[arch] -> fetches[slug].source_prefix and the CodeBuild sourceLocationOverride names the adjusted tree (Property 1 from design)_ + - _Preservation: Preservation Requirements from design - every other get_version_item caller issues the identical eventually-consistent get_item; auto-start idempotency/never-raise semantics, manual retries, flat single-revision resolution, and fetch-failure handling unchanged_ + - _Requirements: 2.1, 2.2, 2.3, 3.3, 3.5_ + + - [x] 3.2 Verify bug condition exploration tests now pass + - **Property 1: Expected Behavior** - Auto-Started Builds Resolve the Post-Write Source Tree + - **IMPORTANT**: Re-run the SAME tests from task 1 - do NOT write new tests + - The tests from task 1 encode the expected behavior + - When these tests pass, it confirms the expected behavior is satisfied: both adjustment paths auto-start builds whose `sourceLocationOverride` names the adjusted revision's `rev-{slug}/` prefix, and the re-read requests `ConsistentRead=True` + - Run with pytest (venv at `/home/ubuntu/backend-test-venv`) + - **EXPECTED OUTCOME**: Tests PASS (confirms bug is fixed) + - _Requirements: 2.1, 2.2, 2.3_ + + - [x] 3.3 Verify preservation tests still pass + - **Property 2: Preservation** - All Other Reads and Auto-Start Semantics Are Unchanged + - **IMPORTANT**: Re-run the SAME tests from task 2 - do NOT write new tests + - Run preservation property tests from task 2 on the fixed code + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - Confirm all tests still pass after fix (no regressions) + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + +- [x] 4. Write unit tests for the fix specifics + - `get_version_item` parameter behavior: passes `ConsistentRead=True` exactly when `consistent_read=True`; omits the key entirely by default; returns the decoded item in both modes; returns `None` for missing items in both modes + - `start_queued_builds`: re-reads with `consistent_read=True`; with the stale-read stub, submits the adjusted prefix; idempotency cases - already-building arch untouched, unconfigured arch left queued, StartBuild exception recorded as a failed entry without raising, missing record returns `{}` + - Both adjustment paths end-to-end against the stale-read stub: fetch-success settles via `handle_fetch_result` then builds from `rev-{slug}/` with the arch entry advancing queued → building; ADJUST_REUSE maps the arch then builds from the reused entry's prefix + - Import-time flow: original import fetch-settle → `start_queued_builds` still starts all queued architectures from the flat prefix with unchanged idempotency + - _Requirements: 2.1, 2.2, 2.3, 3.2, 3.3_ + +- [x] 5. Checkpoint - Ensure all tests pass + - Run the full backend suite with pytest (venv at `/home/ubuntu/backend-test-venv`) + - Ensure all tests pass, ask the user if questions arise + +## Notes + +- Task 1 tests MUST FAIL on unfixed code (confirms the bug); task 2 tests MUST PASS on unfixed code (confirms the baseline). Do not "fix" either before implementing task 3.1. +- Backend only - no frontend changes in this fix. Tests live in `edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py` (pytest + Hypothesis), reusing `test_plugin_importer.py`'s stub/moto patterns. +- The stale read is deterministic to simulate: stub `get_item` to return the pre-write item unless `ConsistentRead=True` is passed. +- Tasks 3.2 and 3.3 re-run the SAME tests from tasks 1 and 2 - no new tests are written there. +- Property tests run ≥100 iterations and are tagged `Feature: adjust-revision-stale-read-fix, Property {n}: {title}`. +- The fix must omit the `ConsistentRead` key entirely when `consistent_read=False` so every existing caller's `get_item` call is byte-identical (preservation requirement 3.5). diff --git a/.kiro/specs/aravis-camera-input/.config.kiro b/.kiro/specs/aravis-camera-input/.config.kiro new file mode 100644 index 00000000..b8d6c7e2 --- /dev/null +++ b/.kiro/specs/aravis-camera-input/.config.kiro @@ -0,0 +1 @@ +{"specId": "d107061e-32ed-4bda-a91c-f96a823ff1e7", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/aravis-camera-input/design.md b/.kiro/specs/aravis-camera-input/design.md new file mode 100644 index 00000000..6288e325 --- /dev/null +++ b/.kiro/specs/aravis-camera-input/design.md @@ -0,0 +1,403 @@ +# Design Document: Aravis Camera Input + +## Overview + +This feature gives the flow designer a camera input node that speaks the edge application's native camera language. On the edge, camera inputs are Aravis (GenICam) devices: `aravis_functions.getCameras()` enumerates the bus, `utils/camera_manager.py` connects and grabs frames by camera id, and `Camera`-type Image_Sources reference an Aravis camera through their `cameraId` field. The designer's only camera node, `camera_source`, is V4L2-path-shaped, and the camera-registry-sync feature discovers only V4L2 hardware — the Aravis inventory the edge actually manages never reaches the Portal, and the designer cannot express an Aravis-typed input. + +The design threads a new `aravis_camera_source` node type through every existing seam the camera-registry-sync feature already built, changing no wire protocols and adding no new transport: + +1. **Catalog** — a new `ARAVIS_CAMERA_SOURCE` NodeTypeDescriptor in the shared workflow_core catalog (both mirrored copies), with `camera_id` / `gain` / `exposure` parameters and appsrc-headed mappings on all device architectures (Requirement 1). +2. **Edge discovery** — an injectable Aravis enumeration layer added to `camera_discovery`, feeding `build_inventory` with `AravisDiscovered` Camera_Sources merged with configured `Camera`-type Image_Sources by camera id, reported through the unchanged `dda-camera-registry` shadow (Requirement 2). +3. **Designer picker** — the existing Camera_Picker extended to the new node's `camera_id` parameter, offering only Aravis-compatible registry entries and recording the standard `cameraBindingHint` (Requirement 3). +4. **Packaging + deployment** — the Component_Packager treats the node as a Camera_Input_Node emitting `aravisBinding: true` binding points; `validate_camera_bindings` gains the Aravis type-compatibility rule (Requirements 4, 5). +5. **Edge resolution + execution** — `resolve_bindings` produces Aravis assignments; the WorkflowExecutor gains a resolution provider (wired to the watcher's existing `binding_resolution` accessor) and an Aravis frame feed that grabs a frame through the Camera_Manager and pushes it into the compiled pipeline's appsrc via `GstPipelineManager.run_pipeline`'s existing `frame_data` mechanism (Requirement 6). + +### Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| New node type vs. discriminator on `camera_source` | A separate `aravis_camera_source` node type | `camera_source` compiles to `v4l2src device={device}` on x86 — a fundamentally different transport with a different identity parameter. A discriminator parameter would fork every mapping, slot, and picker rule inside one descriptor; a second descriptor reuses all generic catalog machinery unchanged and keeps `camera_source` byte-identical (Requirement 7.4) | +| Aravis node identity parameter | `camera_id` (required string), matching the edge Image_Source's `cameraId` and `camera_manager.connect_camera(camera_id)` | The Aravis camera id (e.g. `Aravis-Fake-GV01`, `Basler-12345678`) is the one identifier every edge camera path already keys on | +| Device-arch mapping | `appsrc name=appsrc_{nodeId} ! videoconvert` on every physical architecture; sim uses the shared dataset-fed stub | Aravis acquisition happens in the LocalServer process via Camera_Manager (a GStreamer `aravissrc` element is not shipped in the DDA images); the appsrc + Frame_Feed path is exactly how classic `Camera`-type pipelines run today (`GstPipelineManager.run_pipeline(pipeline_str, frame_data)`) | +| Aravis discovery placement | New `camera_discovery/aravis.py` with an injectable enumerator defaulting to a lazy import of `aravis_functions.getCameras()` | Mirrors the V4L2 module's injectable-ioctl pattern so tests supply fake buses; the lazy import keeps `camera_discovery` importable where the `gi`/Aravis stack is absent (Requirement 2.6, 2.7) | +| Aravis stable id | `arv-{sha1(vendor + "|" + model + "|" + serial)[:12]}` | Vendor/model/serial are the bus-stable GenICam identity; the Aravis runtime id can embed transport addresses that change across reconnects. Deterministic and collision-resistant per Requirement 2.2 | +| Merge rule | A configured `Camera`-type Image_Source whose `cameraId` equals a discovered Aravis camera's `id` merges into ONE entry under `cfg-{imageSourceId}` (configured params + discovered identity metadata), exactly like the existing device-path merge for V4L2 | Same shape as the proven `build_inventory` path merge (Requirement 2.4); bindings keep referencing the configured id | +| Discovered-only type | `AravisDiscovered` | Parallel to the existing `V4L2Discovered` naming; the sync reducer, registry table, and frontend display are type-string generic, so no reducer or storage change is needed (Requirement 7.3) | +| Binding point marker | `aravisBinding: true`, empty slots, on every device architecture | Parallel to the existing `adapterBinding` (JP4/5) and `csiSensorBinding` (JP6) markers: the binding selects which camera the executor's feed connects, never an element argument | +| Type compatibility | `aravis_camera_source` ↔ {`Camera`, `AravisDiscovered`}; `AravisDiscovered` also added to `camera_source`'s compatible set | An Aravis node must bind to an Aravis-backed source (Requirement 5.2); conversely a registered GenICam camera is a legitimate camera-backed source for the generic camera node on the adapter-fed architectures (Requirement 5.3) | +| Executor resolution wiring | `WorkflowExecutor` gains an injectable `binding_resolution_provider(registration_id)` wired to the watcher's existing `binding_resolution()` accessor | The watcher already computes and caches `ResolutionResult` (substituted document + assignments) per registration; the executor currently reloads the raw document from disk and never consumes it. Wiring the provider closes that gap for Aravis assignments (and slot-substituted documents) without new state | +| Frame feed | Executor pre-grabs one frame per Aravis node via `camera_manager.get_camera_frame(camera_id, config)` and pushes it through `run_pipeline`'s existing `frame_data` appsrc feed | This is byte-for-byte the classic `Camera`-type execution model (`gst_pipeline_executor` → `run_pipeline(pipeline_str, frame_data)`), reusing the cached-connection Camera_Manager path with its existing locking, timeout, and status handling | + +## Architecture + +### System Context + +```mermaid +graph TB + subgraph Portal + CAT1[workflow_core catalog - portal layer
+ ARAVIS_CAMERA_SOURCE] + NCP[NodeConfigPanel + cameraReference.ts
Aravis camera picker] + PKG[workflow_packaging.py
+ aravisBinding points] + DEP[deployments.py
+ Aravis type compatibility] + REG[(dda-portal-camera-registry
+ AravisDiscovered entries)] + end + subgraph Edge Device + CAT2[workflow_core catalog - vendor mirror
+ ARAVIS_CAMERA_SOURCE] + ARV[aravis_functions.getCameras] + DISC[camera_discovery
+ aravis.py enumeration] + INV[camera_sync build_inventory
+ cameraId merge] + ESA[Edge_Sync_Agent
unchanged transport] + RES[camera_binding.resolve_bindings
+ aravis assignments] + EXE[WorkflowExecutor
+ resolution provider + Aravis frame feed] + CM[camera_manager
get_camera_frame] + end + + ARV --> DISC --> INV --> ESA + ESA -.dda-camera-registry shadow.-> REG + REG --> NCP + NCP -->|cameraBindingHint| PKG + PKG -->|compiled_pipeline.json + bindingPoints| RES + DEP -.dda-camera-bindings shadow.-> RES + RES --> EXE --> CM + CAT1 -.byte-identical mirror.- CAT2 +``` + +### Aravis node end-to-end flow + +```mermaid +sequenceDiagram + participant WB as Workflow_Builder + participant PKG as Component_Packager + participant DEP as Deployment_Service + participant WE as Workflow_Engine (watcher) + participant EX as WorkflowExecutor + participant CM as Camera_Manager + + WB->>WB: user adds aravis_camera_source,
picker offers Aravis-compatible registry entries (3.2) + WB->>WB: selection populates camera_id/gain/exposure,
records cameraBindingHint (3.3) + WB->>PKG: package workflow version + PKG->>PKG: emit bindingPoints entry per aravis node:
aravisBinding true, empty slots, rendered params (4.1, 4.2) + DEP->>DEP: validate bindings: aravis node ↔
{Camera, AravisDiscovered} only (5.2) + DEP->>WE: dda-camera-bindings shadow (unchanged, 5.5) + WE->>WE: resolve_bindings: cameraSourceId -> local inventory
-> aravis assignment {camera_id, gain, exposure} (6.1) + Note over WE: missing id -> registration invalid,
triggers rejected (6.3) + EX->>WE: binding_resolution(registration_id) + EX->>EX: effective camera id = assignment else rendered params (6.4) + EX->>CM: get_camera_frame(camera_id, {gain, exposure}) + CM-->>EX: {data, height, width} + EX->>EX: run_pipeline(launch_string, frame_data) — appsrc push (6.4) +``` + +## Components and Interfaces + +### 1. Node_Catalog: `ARAVIS_CAMERA_SOURCE` (both catalog copies) + +Added to `workflow_core/catalog/nodes.py` in `edge-cv-portal/backend/layers/workflow_core/python/workflow_core/` and mirrored byte-identically to `src/backend/workflow_engine/vendor/workflow_core/`: + +```python +ARAVIS_CAMERA_SOURCE = NodeTypeDescriptor( + type_id="aravis_camera_source", + category=CATEGORY_INPUT, + display_name="Aravis Camera Source", + inputs=[], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("camera_id", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Aravis (GenICam) camera identifier as " + "enumerated on the edge device, e.g. " + "Aravis-Fake-GV01 or Basler-12345678.", + examples=["Aravis-Fake-GV01", "Basler-12345678"]), + ParameterDescriptor("gain", "int", required=False, default=4, + constraints={"min": 0, "max": 100}, ...), + ParameterDescriptor("exposure", "int", required=False, default=5000000, + constraints={"min": 0}, ...), + ], + mappings=_same_on_device_archs( + element_chain=[ + _element("appsrc", name="appsrc_{nodeId}"), + _element("videoconvert"), + ], + plugin_dependencies=["app", "videoconvertscale"], + ) + [_dataset_fed_sim_source()], + hardware_dependent=True, +) +``` + +- Appended to `NODE_CATALOG` next to `CAMERA_SOURCE`. Every generic consumer — validator, compiler, serializer, the `/workflows/node-catalog` route, the Node_Palette, `merged_catalog`, the test sandbox — picks the node up from the descriptor with no special-casing (Requirement 1.5). +- The appsrc element name is compile-time-rendered per node (the compiler resolves `{nodeId}` the same way other templates resolve node parameters) so multi-camera documents stay addressable; the executor's Frame_Feed locates the appsrc by the binding point's node id. +- The `camera_id` parameter never appears in any `args_template`, so `binding_point_slots` naturally yields no slots — consistent with the `aravisBinding` marker. +- The sim mapping is the shared `_dataset_fed_sim_source()` stub, exactly like `camera_source`, so designer test runs feed the node from the Test_Dataset (Requirement 1.4). +- Mirror discipline: the change is made in the portal layer copy and copied verbatim to the vendor mirror; the existing baseline expectation is `diff -r` cleanliness of the two source trees (Requirement 1.6). + +### 2. Edge Aravis discovery (`src/backend/camera_discovery/aravis.py`) + +```python +@dataclass(frozen=True) +class DiscoveredAravisCamera: + stable_id: str # arv-{sha1(vendor|model|serial)[:12]} + camera_id: str # the Aravis runtime id camera_manager connects by + model: str + address: str + physical_id: str + protocol: str # "GigEVision" | "USB3Vision" | "Fake" + serial: str + vendor: str + +@dataclass(frozen=True) +class AravisDiscoveryResult: + cameras: list[DiscoveredAravisCamera] + failures: list[dict] # [{"error": str}] — enumeration-level failures + +def enumerate_aravis(enumerator=None) -> AravisDiscoveryResult +``` + +- `enumerator` is injectable (tests pass fakes); the default lazily imports `edge_ml1_p_camera_management.aravis_functions` and calls `getCameras()`, mapping each returned `model.Camera` object to a `DiscoveredAravisCamera`. Import failure (no `gi`/Aravis stack) or enumeration failure yields `AravisDiscoveryResult([], [{"error": ...}])` — never an exception (Requirements 2.6, 2.7). +- `CameraDiscovery` (`discovery.py`) gains the Aravis enumerator alongside the V4L2 layer: each periodic pass runs both, and the tracked-snapshot diff (present/absent, `absent_since`, `on_change` only on change) treats Aravis stable ids identically to V4L2 stable ids — same absence semantics, same cadence, no second timer (Requirements 2.2, 2.5). The `InventorySnapshot` entries carry the camera object; downstream code distinguishes families by the entry type. +- Stable id derivation is a pure function `aravis_stable_id(vendor, model, serial)`; when serial is empty it falls back to including `physical_id` so two serial-less cameras of the same model do not collide. + +### 3. Inventory merge extension (`src/backend/camera_sync/inventory.py`) + +`build_inventory` gains the Aravis branch, structurally parallel to the existing device-path merge: + +- **Configured merge (2.4)**: an Image_Source of type `Camera` whose `cameraId` equals a tracked Aravis camera's `camera_id` yields ONE entry — id `cfg-{imageSourceId}`, name/type/params from the configured record (params already include `cameraId`, `gain`, `exposure` via the existing `_configured_params`), plus `capabilities.aravis = {model, address, physicalId, protocol, serial, vendor}` from discovery, `discovered: True`, and the tracked absent state. Each tracked Aravis camera contributes to exactly one entry. +- **Discovered-only (2.1, 2.3)**: an unmerged Aravis camera yields `CameraSourceState(camera_source_id=stable_id, name=f"{vendor} {model}", type="AravisDiscovered", origin="edge-discovered", params={"cameraId": camera_id, "serial": serial, "protocol": protocol, "address": address}, capabilities={"aravis": {...}}, discovered=True, absent=..., absent_since=...)`. +- The function stays pure and deterministic (sorted output); on inputs containing no Aravis cameras its output is unchanged from today (Requirement 7.2). The Edge_Sync_Agent, shadow document shape, sync reducer, and registry storage need no changes — `AravisDiscovered` flows through as one more type string (Requirement 7.3). + +### 4. Workflow_Builder Aravis picker (frontend `cameraReference.ts` + `NodeConfigPanel.tsx`) + +Pure extensions in `cameraReference.ts`: + +```typescript +// Control selection: aravis_camera_source's camera_id joins the rule (3.1) +export function isCameraReferenceParameter(typeId, parameterName): boolean; +// -> true for ('camera_source','device') and ('aravis_camera_source','camera_id') + +// Aravis compatibility filter for the picker's option list (3.2) +export function isAravisCompatibleCamera(camera: CameraSourceEntry): boolean; +// type === 'AravisDiscovered', or type === 'Camera' with a non-empty +// string params.cameraId + +// The camera id a Camera_Source resolves to (3.3, 3.5 display) +export function cameraIdValue(camera: CameraSourceEntry): string | null; + +// Selection application for the Aravis node (3.3; mirrors applyCameraSelection) +export function applyAravisCameraSelection( + parameters, camera, sourceDeviceId +): CameraSelectionResult; +// populates camera_id from cameraIdValue(camera), copies numeric +// gain/exposure when present, returns the standard CameraBindingHint +``` + +- `NodeConfigPanel` keys the control off the extended `isCameraReferenceParameter`; for `aravis_camera_source` it filters the fetched device cameras through `isAravisCompatibleCamera`, displays name, type, camera id, sync status, and staleness badge per option (3.5), applies selections through `applyAravisCameraSelection`, and retains the manual-entry toggle exactly as the existing control does (3.4). The hint storage (`data.cameraBindingHint`), advisory semantics, and `defaultManualEntry` logic are reused unchanged. +- `camera_source`'s picker path is untouched (Requirement 7.4). + +### 5. Component_Packager extension (`workflow_packaging.py`) + +```python +ARAVIS_CAMERA_SOURCE_TYPE_ID = 'aravis_camera_source' +``` + +- `gather_camera_input_nodes` includes nodes of type `aravis_camera_source` (Requirement 4.1); the version item's `camera_input_nodes` records them with `has_binding_points: true` through the existing recording path. +- `build_binding_points`: an `aravis_camera_source` node's entry carries `'aravisBinding': True` with empty slots on every physical device architecture (the sim document is never packaged), and `parameters` holds the rendered `camera_id` / `gain` / `exposure` defaults-overlaid values (Requirement 4.2). All other node types' entries are produced exactly as before. +- Workflows without Aravis nodes serialize byte-identically to pre-feature output — guaranteed structurally because the only behavior change is gated on the new type id (Requirement 4.3). + +### 6. Deployment_Service extension (`deployments.py`) + +```python +_CAMERA_COMPATIBLE_SOURCE_TYPES = { + 'camera_source': frozenset({'Camera', 'ICam', 'NvidiaCSI', + 'V4L2Discovered', 'AravisDiscovered'}), # 5.3 + 'aravis_camera_source': frozenset({'Camera', 'AravisDiscovered'}), # 5.2 +} +``` + +- `validate_camera_bindings` needs no other change: unbound-node errors, missing-source errors, degraded-source warnings, override constraint checking (now resolving the `aravis_camera_source` descriptor from the catalog for 5.4), hint pre-selection, and never-synced handling all apply to the new node through the existing generic code paths (Requirement 5.1). +- The binding-context endpoint and `CameraBindingMatrix` already carry `node_type` per node; the matrix's option list for an `aravis_camera_source` row is filtered through the same Aravis compatibility predicate (shared from `cameraReference.ts`) so users are not offered bindings the validator would reject. +- Binding delivery over `dda-camera-bindings` is unchanged (Requirement 5.5). + +### 7. Workflow_Engine resolution extension (`src/backend/workflow_engine/camera_binding.py`) + +- `resolve_bindings` treats `aravisBinding: true` binding points the same way it treats `adapterBinding` points — no slot substitution; a resolved binding contributes an assignment — but records them in a dedicated `aravis_assignments: Dict[str, Dict]` field on `ResolutionResult` (`{node_id: {"cameraSourceId": ..., "params": {...}}}`), keeping JP4/5 camera-adapter assignments and Aravis feed assignments distinct for the executor (Requirements 6.1, 6.2). +- A `cameraSourceId` binding resolves through the local inventory exactly as today; the resolved params for an Aravis-backed entry carry `cameraId` (from `build_inventory`), which the feed planner maps to the node's `camera_id`. `_PARAM_ALIASES` gains `"cameraId" -> "camera_id"` so overrides and resolved values line up with the descriptor's parameter name. +- Missing ids follow the existing path: `missing` entry + `missing camera source {csid}` error → watcher marks the registration invalid → triggers rejected → re-resolution on discovery change / shadow delta flips it back (Requirement 6.3). No watcher changes are needed beyond the enlarged `ResolutionResult`. + +### 8. WorkflowExecutor Aravis frame feed (`src/backend/workflow_engine/`) + +New pure module `src/backend/workflow_engine/aravis_feed.py`: + +```python +@dataclass(frozen=True) +class AravisFeed: + node_id: str + camera_id: str + config: dict # {"gain": int, "exposure": int} when present + +def plan_aravis_feeds(document: dict, + resolution: Optional[ResolutionResult]) -> list[AravisFeed] +``` + +- Pure over its inputs: for each binding point with `aravisBinding: true`, the effective values are the resolution's `aravis_assignments[node_id]["params"]` when present, else the binding point's rendered `parameters` (Requirement 6.4). A feed without a non-empty `camera_id` value is a planning error surfaced as an execution failure attributed to the node. +- `WorkflowExecutor` changes: + - Gains an injectable `binding_resolution_provider: Callable[[str], Optional[ResolutionResult]]`, wired at engine startup to the watcher's existing `binding_resolution()` accessor. When the provider returns a resolution, the executor runs the resolution's substituted document (closing the existing gap where slot-substituted documents were computed but never executed) — otherwise the disk document, as today. + - Before starting the pipeline, for each planned `AravisFeed` the executor grabs one frame through `camera_manager.get_camera_frame(feed.camera_id, feed.config)` (lazily imported, exactly like `GstPipelineManager`). The grab happens before `run_pipeline` so a camera failure fails fast with `failing_node_id = feed.node_id` (Requirement 6.5). + - The frame is pushed through the existing Frame_Feed: `run_pipeline(launch_string, frame_data)` locates the appsrc, wraps the buffer, pushes, and sends EOS — the classic `Camera`-type execution model. The appsrc caps are set from the grabbed frame's width/height, mirroring the caps handling the classic camera processing pipeline performs. + - Documents with no Aravis binding points plan zero feeds and take the exact pre-feature call path (`run_pipeline(launch_string, latency_metrics=...)`, no frame_data) (Requirement 6.6). Initial scope executes one Aravis feed per run (matching the single-appsrc Frame_Feed contract); a document with multiple Aravis nodes fails registration-side validation with a clear reason rather than undefined behavior. + +## Data Models + +### Catalog descriptor (both workflow_core copies) + +New `NodeTypeDescriptor` as specified in Component 1; `NODE_CATALOG` tuple gains one member. No changes to `models.py` shapes, the serializer schema, or the compiler. + +### Camera_Source inventory / registry entry (extended values only, no schema change) + +| Field | Aravis discovered-only entry | Configured `Camera` merged entry | +|---|---|---| +| `camera_source_id` | `arv-{sha1(vendor\|model\|serial)[:12]}` | `cfg-{imageSourceId}` (unchanged) | +| `type` | `AravisDiscovered` (new value) | `Camera` (unchanged) | +| `origin` | `edge-discovered` | `edge-configured` | +| `params` | `{cameraId, serial, protocol, address}` | existing configured params (already include `cameraId`, `gain`, `exposure`) | +| `capabilities` | `{aravis: {model, address, physicalId, protocol, serial, vendor}}` | existing + `aravis: {...}` | +| `discovered` / `absent` / `absent_since` | standard tracking | standard tracking | + +The `dda-camera-registry` shadow document, `reduce_report`, the DynamoDB registry table, and the camera routes carry these entries with zero changes — types and params are opaque strings/maps to all of them. + +### Binding point entry (compiled_pipeline.json) + +```jsonc +{ + "nodeId": "n2", + "nodeType": "aravis_camera_source", + "bindingHint": { "cameraSourceId": "arv-3fe9c0d21ab4", ... }, // when present + "parameters": { "camera_id": "Aravis-Fake-GV01", "gain": 4, "exposure": 5000000 }, + "slots": [], + "aravisBinding": true +} +``` + +### ResolutionResult (edge) + +Gains `aravis_assignments: Dict[str, Dict[str, Any]] = field(default_factory=dict)` — same shape as `adapter_assignments`. Existing consumers are unaffected (dataclass field addition with a default). + +### Camera_Binding shadow / deployment record + +Unchanged: `{node_id: {"cameraSourceId": id} | {"override": {"camera_id": ..., "gain": ..., "exposure": ...}}}` — the override keys are just the new node's parameter names. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Aravis node definitions round-trip and compile through generic catalog paths + +*For any* valid workflow definition containing `aravis_camera_source` nodes, serializing then parsing the definition SHALL produce an equivalent graph, and validating then compiling it for a device architecture SHALL succeed and render the node's appsrc-headed element chain. + +**Validates: Requirements 1.5** + +### Property 2: Aravis discovery enumeration completeness with identity capture + +*For any* set of enumerated Aravis cameras, every camera SHALL contribute to exactly one inventory entry, and every discovered-only entry SHALL have type `AravisDiscovered`, origin `edge-discovered`, and parameters/capabilities carrying all of the camera's identity fields (id, model, address, physical id, protocol, serial, vendor). + +**Validates: Requirements 2.1, 2.3** + +### Property 3: Aravis stable id determinism + +*For any* Aravis camera identity (vendor, model, serial), the derived stable id SHALL be a pure function of those fields — invariant under bus enumeration order, runtime id changes, and address changes — and distinct identities within an enumeration SHALL derive distinct stable ids. + +**Validates: Requirements 2.2** + +### Property 4: Configured/discovered Aravis merge by camera id + +*For any* combination of configured Image_Sources and discovered Aravis cameras, each `Camera`-type Image_Source whose `cameraId` equals a discovered camera's id SHALL yield exactly one inventory entry under the configured identifier combining configured parameters with discovered identity metadata, and no discovered Aravis camera SHALL contribute to more than one entry. + +**Validates: Requirements 2.4** + +### Property 5: Aravis absence marking on re-enumeration + +*For any* sequence of Aravis enumeration results, a stable id present in an earlier result and missing from a later one SHALL be marked absent with an absence timestamp and SHALL never be dropped from the tracked inventory. + +**Validates: Requirements 2.5** + +### Property 6: Aravis failure isolation and no-Aravis identity + +*For any* configured and V4L2-discovered inventory, building the inventory with a failing or unavailable Aravis enumerator SHALL produce entries identical to the pre-feature output for the same inputs, record the failure, and raise no exception. + +**Validates: Requirements 2.6, 7.2** + +### Property 7: Aravis picker compatibility filter + +*For any* list of Camera_Registry entries, the Aravis picker option list SHALL contain exactly the entries that are Aravis-compatible (type `AravisDiscovered`, or type `Camera` carrying a non-empty camera id parameter) — no incompatible entry offered, no compatible entry omitted. + +**Validates: Requirements 3.2** + +### Property 8: Aravis selection populates the node and records the hint + +*For any* Aravis-compatible Camera_Source and any prior parameter record, applying the selection SHALL set `camera_id` to the source's camera id, copy `gain` and `exposure` exactly when the source's params carry them as numbers, leave all other parameters untouched, and produce a binding hint carrying the source id, display name, and reference device id. + +**Validates: Requirements 3.3** + +### Property 9: Packaging emits Aravis binding points + +*For any* workflow definition containing `aravis_camera_source` nodes, packaging SHALL emit exactly one `bindingPoints` entry per Aravis node per architecture carrying `aravisBinding: true`, empty slots, and the node's rendered `camera_id`/`gain`/`exposure` parameter values, and SHALL record each node in the version item's `camera_input_nodes` with `has_binding_points: true`. + +**Validates: Requirements 4.1, 4.2** + +### Property 10: Aravis-free packaging identity + +*For any* workflow definition containing no `aravis_camera_source` node, the packaged compiled document SHALL be byte-identical to the pre-feature packaging output for the same definition. + +**Validates: Requirements 4.3** + +### Property 11: Aravis type-compatibility validation + +*For any* workflow version with Camera_Input_Nodes, target registry snapshot, and binding set, `validate_camera_bindings` SHALL produce a type-incompatibility error for a binding exactly when the bound Camera_Source's type is outside the node type's declared compatible set — `{Camera, AravisDiscovered}` for `aravis_camera_source`, the existing set plus `AravisDiscovered` for `camera_source`. + +**Validates: Requirements 5.2, 5.3** + +### Property 12: Aravis override constraint validation + +*For any* manual override submitted for an `aravis_camera_source` node, validation SHALL accept the override exactly when every value satisfies the descriptor's declared constraints (non-empty string `camera_id`, `gain` within 0–100, `exposure` non-negative, no undeclared parameter names). + +**Validates: Requirements 5.4** + +### Property 13: Device-side Aravis binding resolution + +*For any* compiled document with Aravis binding points, binding map, and local inventory: a `cameraSourceId` binding matching an inventory entry SHALL yield an Aravis assignment whose `camera_id` is the entry's camera id and SHALL leave the document's segments unchanged; a constraint-valid override binding SHALL yield an assignment from the override values; a `cameraSourceId` with no inventory entry SHALL mark the resolution invalid recording the missing id; and a constraint-violating override SHALL mark the resolution invalid with a reason. + +**Validates: Requirements 6.1, 6.2, 6.3** + +### Property 14: Aravis feed plan precedence + +*For any* compiled document with Aravis binding points and any optional resolution result, the planned feed for each node SHALL use the resolution's Aravis assignment values when present and the binding point's rendered parameters otherwise. + +**Validates: Requirements 6.4** + +### Property 15: Aravis-free execution identity + +*For any* compiled document containing no Aravis binding point (including legacy documents without `bindingPoints`), the feed planner SHALL plan zero feeds and the executor SHALL invoke the pipeline run without a frame feed, exactly as before this feature. + +**Validates: Requirements 6.6** + +## Error Handling + +| Failure | Handling | +|---|---| +| Aravis runtime (`gi`/Aravis) unavailable or `getCameras()` raises | `enumerate_aravis` returns an empty result with a failure record; `build_inventory` output equals the pre-feature output; discovery loop and Edge_Sync_Agent continue untouched (Requirements 2.6, 7.2) | +| Individual Aravis camera with missing/empty identity fields | Stable id derivation falls back to including `physical_id`; entries with no usable identity are recorded in the discovery failures list and skipped, never crashing the pass | +| Registry read fails while the picker is open | Existing Camera_Picker error handling unchanged: the control surfaces the fetch error and the manual-entry path remains usable (Requirement 3.4) | +| Binding submitted against a non-Aravis source type | `validate_camera_bindings` rejects with the existing `CAMERA_TYPE_INCOMPATIBLE` error naming the node, device, and source type (Requirement 5.2) | +| Aravis `cameraSourceId` missing from device inventory at resolution time | Resolution invalid with `missing camera source {csid}`; registration invalid, triggers rejected; re-resolution on discovery change / bindings delta flips it back (Requirement 6.3) | +| Multiple Aravis binding points in one document | Registration-side validation marks the registration invalid with a clear reason (single Frame_Feed contract) rather than undefined multi-appsrc behavior | +| `get_camera_frame` raises or returns no frame during a run | Execution marked failed with `failing_node_id` set to the Aravis node and the camera error message; nothing propagates (existing contained-failure discipline, Requirement 6.5) | +| Binding resolution provider unavailable/raises at execution time | Executor falls back to the disk document and rendered binding-point parameters (the unbound behavior), logged — a provider failure never takes a run down for documents that don't need it | + +## Testing Strategy + +**Dual approach**: property-based tests for the pure cores (catalog round trip, discovery/merge, id derivation, picker filter/application, packaging binding points, binding validation, resolution, feed planning) and example-based unit/component tests for static catalog content, UI wiring, and failure paths. Integration behavior (shadow transport, GStreamer, real Aravis hardware) is exercised through the existing injectable fakes; no test requires a physical camera. + +- **Python property tests** use `hypothesis` with no hardcoded `max_examples` (project default ≥100 iterations), tagged `**Feature: aravis-camera-input, Property {number}: {property_text}**`: + - Portal backend: `edge-cv-portal/backend/tests/test_property_*.py` against the moto-backed conftest stack (Properties 1, 9, 10, 11, 12). + - Edge LocalServer: `test/backend-test/camera_discovery`, `test/backend-test/camera_sync`, and `test/backend-test/workflow_engine` run with `PYTHONPATH=src/backend:test/backend-test` (Properties 2–6, 13, 14, 15). +- **TypeScript property tests** use `fast-check` (`numRuns: 100`) beside the existing picker property test in `edge-cv-portal/frontend/src/pages/workflows/` (Properties 7, 8). +- **Unit/component tests**: catalog content assertions mirroring `test_catalog_content.py` (1.1–1.4), catalog mirror diff check (1.6), injectable-enumerator construction (2.7), NodeConfigPanel control rendering / manual entry / display fields (3.1, 3.4, 3.5), binding matrix option filtering and hint pre-selection for Aravis rows (5.1), binding delivery with an Aravis node (5.5), executor grab/push with fake camera manager and fake pipeline manager including the failure path (6.4, 6.5), reducer ingestion of `AravisDiscovered` reports (7.3), and `camera_source` descriptor unchanged (7.4). +- **Baselines that must stay green**: portal backend pytest suite, frontend vitest + `npm run build`, and the edge `test/backend-test` workflow_engine / camera_discovery / camera_sync suites that pass on this host — pre-feature behavior identity (Requirement 7.1) is enforced by Properties 6, 10, 15 plus these unchanged baselines. diff --git a/.kiro/specs/aravis-camera-input/requirements.md b/.kiro/specs/aravis-camera-input/requirements.md new file mode 100644 index 00000000..30f17ec6 --- /dev/null +++ b/.kiro/specs/aravis-camera-input/requirements.md @@ -0,0 +1,119 @@ +# Requirements Document + +## Introduction + +The DDA edge application's primary camera input sources are Aravis (GenICam / GigE Vision / USB3 Vision) cameras: the LocalServer enumerates the Aravis bus through `edge_ml1_p_camera_management/aravis_functions.py` (each camera carrying an id, model, address, physical id, protocol, serial, and vendor), acquires frames through the camera manager (`utils/camera_manager.py`), and stores camera-backed Image_Sources of type `Camera` whose `cameraId` references an Aravis device. The Edge CV Portal's flow designer, however, has no node that speaks this language. Its only camera input node, `camera_source`, is shaped around a V4L2 device path (`device=/dev/video0`), and the camera-registry-sync feature that synchronizes edge camera inventory to the Portal discovers only V4L2 hardware — the Aravis bus inventory that the edge app and camera manager actually work with never reaches the Portal's Camera_Registry, and a designer user cannot express "this workflow's input is that GenICam camera". + +This feature adds an Aravis camera input sub-type to the flow designer that stays in sync with the edge. It introduces an `aravis_camera_source` node type in the shared workflow_core catalog whose parameters mirror the edge's Aravis camera identity (camera id, plus gain/exposure acquisition settings), extends the edge's camera discovery and sync agent to report Aravis bus cameras (merged with configured `Camera`-type Image_Sources by camera id) into the existing Camera_Registry, and wires the node through the whole existing binding pipeline: the Workflow_Builder camera picker offers the synced Aravis inventory for the node's camera id parameter, the Component_Packager emits Aravis-typed binding points, deploy-time validation enforces Aravis type compatibility, and the LocalServer resolves bindings to a local Aravis camera and executes the node by feeding frames grabbed through the existing camera manager into the compiled pipeline's appsrc. The workflow_core catalog exists in two mirrored copies (the portal layer and the edge vendor mirror), and both must stay identical. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend, DynamoDB storage) used to manage DDA use cases, models, workflows, deployments, and devices. +- **LocalServer**: The Greengrass component running on an Edge_Device. It owns the device-local Image_Source configuration, the Aravis camera stack, and executes GStreamer pipelines and deployed Workflow_Components. +- **Edge_Device**: A Greengrass core device registered in the Portal's devices table that runs LocalServer. +- **Aravis_Camera**: A GenICam-protocol camera (GigE Vision, USB3 Vision, or the Aravis Fake interface) visible to LocalServer through the Aravis library, identified by the identity fields the Aravis bus enumeration reports: id, model, address, physical id, protocol, serial number, and vendor. +- **Aravis_Enumeration**: The existing LocalServer capability (`aravis_functions.getCameras()` / `rescan_cameras()`) that lists the Aravis_Cameras currently visible on the device's GenICam bus. +- **Camera_Manager**: The existing LocalServer subsystem (`utils/camera_manager.py`) that connects, configures (gain, exposure, GenICam features), and grabs frames from Aravis_Cameras by camera id. +- **Image_Source**: The device-local input-source record managed by LocalServer's image_source DAO and accessors. An Image_Source of type `Camera` is Aravis-backed: its `cameraId` field references an Aravis_Camera and Camera_Manager acquires its frames. +- **Camera_Registry**: The existing Portal-side store and API (`dda-portal-camera-registry` table, `camera_registry.py` Lambda) holding, per Edge_Device, the Camera_Sources known for that device, introduced by the camera-registry-sync feature. +- **Camera_Source**: A per-device entry in the Camera_Registry (and in the edge-reported inventory) carrying a stable identifier, name, type, parameters, capability metadata, origin, version, and sync metadata. +- **Camera_Discovery**: The existing LocalServer subsystem (`src/backend/camera_discovery/`) that enumerates physical capture hardware for the inventory. Today it enumerates V4L2 devices only. +- **Edge_Sync_Agent**: The existing LocalServer component (`src/backend/camera_sync/`) that merges configured Image_Sources with Camera_Discovery results (`build_inventory`) and reports the inventory to the Portal over the `dda-camera-registry` named IoT shadow. +- **Aravis_Camera_Source**: A Camera_Source entry representing an Aravis_Camera: either a discovered-only bus camera (type `AravisDiscovered`, origin edge-discovered) or a configured `Camera`-type Image_Source merged with its discovered bus identity. +- **Node_Catalog**: The shared workflow_core node type catalog (`workflow_core/catalog/nodes.py`), maintained as two mirrored copies: the Portal layer (`edge-cv-portal/backend/layers/workflow_core/python/workflow_core/`) and the edge vendor mirror (`src/backend/workflow_engine/vendor/workflow_core/`). +- **Aravis_Camera_Source_Node**: The new input node type (`aravis_camera_source`) added to the Node_Catalog by this feature, whose parameters carry an Aravis camera id and acquisition settings and whose output port emits VideoFrames. +- **Workflow_Builder**: The graphical canvas UI within the Portal where users compose workflow definitions from Node_Catalog nodes. +- **Camera_Picker**: The existing Workflow_Builder camera reference control (`cameraReference.ts` + NodeConfigPanel) that offers a device's Camera_Registry entries for a Camera_Input_Node parameter and records a `cameraBindingHint` on the node. +- **Component_Packager**: The existing Portal packaging Lambda (`workflow_packaging.py`) that compiles workflow definitions into per-architecture `compiled_pipeline.json` documents and emits `bindingPoints` entries for Camera_Input_Nodes. +- **Camera_Input_Node**: A workflow node whose frames come from a device camera; today the `camera_source` node type and camera-backed custom node types, extended by this feature to include the Aravis_Camera_Source_Node. +- **Camera_Binding**: The existing per-deployment, per-device mapping from a Camera_Input_Node to a Camera_Source identifier or manual override, validated by `validate_camera_bindings` in `deployments.py` and delivered over the `dda-camera-bindings` shadow. +- **Workflow_Engine**: The LocalServer subsystem (`src/backend/workflow_engine/`) that discovers deployed Workflow_Components, resolves Camera_Bindings (`camera_binding.resolve_bindings`), and executes triggered workflow runs (`pipeline_executor.WorkflowExecutor`). +- **Workflow_Executor**: The Workflow_Engine component that runs a registered workflow's compiled pipeline through a GstPipelineManager instance. +- **Frame_Feed**: The existing single-frame appsrc feed mechanism of `GstPipelineManager.run_pipeline` (its `frame_data` argument), used today by classic Camera-type pipelines to push a Camera_Manager frame into an appsrc-headed pipeline. + +## Requirements + +### Requirement 1: Aravis Camera Source Node Type in the Catalog + +**User Story:** As a computer vision engineer building a workflow, I want a dedicated Aravis camera input node in the flow designer, so that I can declare a GenICam camera input with the same identity the edge application uses instead of faking it with a V4L2 device path. + +#### Acceptance Criteria + +1. THE Node_Catalog SHALL include an Aravis_Camera_Source_Node with type id `aravis_camera_source`, category input, display name "Aravis Camera Source", no input ports, and exactly one output port of type VideoFrames. +2. THE Aravis_Camera_Source_Node SHALL declare a required string parameter `camera_id` with a minimum length of 1, carrying the Aravis camera identifier the Camera_Manager connects by, with a description and at least one working example. +3. THE Aravis_Camera_Source_Node SHALL declare optional parameters `gain` (int, constraints min 0 and max 100, default 4) and `exposure` (int, constraint min 0, default 5000000) matching the acquisition settings the Camera_Manager applies, each with a description and at least one working example. +4. THE Aravis_Camera_Source_Node SHALL be marked hardware dependent, and its architecture mappings SHALL render an appsrc-headed element chain (appsrc followed by videoconvert, with the app and videoconvertscale plugin dependencies) on every physical device architecture, and the shared dataset-fed simulation stub on the sim architecture. +5. WHEN a workflow definition containing an Aravis_Camera_Source_Node is validated, compiled, or serialized through workflow_core, THE Node_Catalog SHALL process the node through the same generic descriptor-driven paths as existing catalog nodes, with parsing then serializing a definition containing the node producing an equivalent definition. +6. THE Node_Catalog SHALL carry the Aravis_Camera_Source_Node identically in both copies (the Portal layer and the edge vendor mirror), with the two catalog source trees remaining byte-identical. + +### Requirement 2: Edge Aravis Camera Discovery in the Inventory + +**User Story:** As an operator, I want the edge device's Aravis bus cameras to appear in the Portal's camera registry, so that the Portal inventory reflects the GenICam cameras the edge application actually manages. + +#### Acceptance Criteria + +1. WHEN the Edge_Sync_Agent builds the device inventory, THE Camera_Discovery SHALL include the Aravis_Cameras reported by Aravis_Enumeration, each as a Camera_Source with type `AravisDiscovered` and origin edge-discovered. +2. THE Camera_Discovery SHALL derive each discovered Aravis_Camera's stable Camera_Source identifier deterministically from bus-stable identity fields (vendor, model, and serial number), remaining stable across device reboots and bus re-enumerations. +3. WHEN Camera_Discovery records a discovered Aravis_Camera, THE Camera_Discovery SHALL capture the Aravis identity fields (id, model, address, physical id, protocol, serial number, vendor) as the Camera_Source's parameters and capability metadata. +4. WHEN a configured Image_Source of type `Camera` carries a `cameraId` equal to a discovered Aravis_Camera's id, THE Edge_Sync_Agent SHALL report the configured Image_Source and the discovered Aravis_Camera as one Camera_Source under the configured identifier, combining the configured parameters with the discovered identity metadata. +5. WHEN a re-enumeration detects that a previously discovered Aravis_Camera is no longer present on the bus, THE Camera_Discovery SHALL mark the corresponding Camera_Source as absent rather than deleting the record. +6. IF Aravis_Enumeration fails or the Aravis runtime is unavailable, THEN THE Camera_Discovery SHALL record the failure, report the remaining (V4L2 and configured) inventory unchanged, and continue operation. +7. THE Camera_Discovery SHALL enumerate the Aravis bus through an injectable enumeration layer, leaving the existing V4L2 enumeration, absence tracking, and reporting cadence unchanged for V4L2 Camera_Sources. + +### Requirement 3: Aravis Camera Picker in the Workflow Builder + +**User Story:** As a computer vision engineer, I want to pick an Aravis camera from a device's synced registry when configuring the Aravis camera input node, so that the node's camera identity matches a camera that actually exists on the edge. + +#### Acceptance Criteria + +1. WHEN a user configures an Aravis_Camera_Source_Node's `camera_id` parameter in the Workflow_Builder, THE Camera_Picker SHALL render the camera reference control for that parameter. +2. WHEN the Camera_Picker presents Camera_Sources for an Aravis_Camera_Source_Node, THE Camera_Picker SHALL offer only Aravis-compatible Camera_Sources (type `AravisDiscovered`, or type `Camera` entries carrying a camera id parameter) from the selected reference device's Camera_Registry entries. +3. WHEN a user selects an Aravis_Camera_Source for an Aravis_Camera_Source_Node, THE Camera_Picker SHALL populate the node's `camera_id` parameter from the Camera_Source's camera id parameter, populate `gain` and `exposure` when the Camera_Source's parameters carry them, and record the selection as a `cameraBindingHint` on the node's advisory data. +4. WHERE a user prefers manual entry, THE Camera_Picker SHALL continue to accept a directly typed camera id value for the Aravis_Camera_Source_Node. +5. WHEN the Camera_Picker presents Aravis_Camera_Sources, THE Camera_Picker SHALL display each entry's name, type, camera id, sync status, and staleness indication. + +### Requirement 4: Packaging Aravis Binding Points + +**User Story:** As an operator, I want workflows containing the Aravis camera node to package with binding points, so that deploy-time camera binding works for Aravis inputs the same way it works for existing camera inputs. + +#### Acceptance Criteria + +1. WHEN the Component_Packager packages a workflow containing an Aravis_Camera_Source_Node, THE Component_Packager SHALL treat the node as a Camera_Input_Node: it SHALL emit a `bindingPoints` entry for the node in every architecture's compiled document and record the node in the version item's `camera_input_nodes` with `has_binding_points: true`. +2. THE Component_Packager SHALL mark each Aravis_Camera_Source_Node binding point as adapter-fed on every physical device architecture (carrying `aravisBinding: true` with empty slots), with the binding point's parameters carrying the node's rendered `camera_id`, `gain`, and `exposure` values. +3. WHEN the Component_Packager packages a workflow containing no Aravis_Camera_Source_Node, THE Component_Packager SHALL produce output byte-identical to its pre-feature output for that workflow. + +### Requirement 5: Deploy-Time Binding for Aravis Nodes + +**User Story:** As an operator deploying a workflow with an Aravis camera input, I want to bind the node to an Aravis camera registered on each target device with type mismatches rejected, so that the deployed workflow references a GenICam camera the device actually has. + +#### Acceptance Criteria + +1. WHEN a deployment of a workflow containing an Aravis_Camera_Source_Node is created, THE Portal SHALL present the target device's Aravis-compatible Camera_Sources as binding options for that node in the existing binding matrix, pre-selecting an entry matching the node's `cameraBindingHint` when present. +2. WHEN a Camera_Binding for an Aravis_Camera_Source_Node references a Camera_Source whose type is neither `AravisDiscovered` nor `Camera`, THE Portal SHALL reject the binding with a message identifying the type mismatch. +3. WHEN a Camera_Binding for a `camera_source` node references a Camera_Source of type `AravisDiscovered`, THE Portal SHALL accept the binding under the existing camera-type compatibility rule for camera-backed sources. +4. WHERE a user chooses manual override for an Aravis_Camera_Source_Node, THE Portal SHALL validate the override values against the Aravis_Camera_Source_Node's declared parameter constraints and record the override as the Camera_Binding. +5. WHEN a deployment containing Aravis_Camera_Source_Node bindings is submitted successfully, THE Portal SHALL deliver the bindings to target devices through the existing `dda-camera-bindings` shadow mechanism, leaving the packaged artifact unchanged. + +### Requirement 6: Device-Side Resolution and Execution + +**User Story:** As an operator, I want the edge device to resolve an Aravis node's binding to a local Aravis camera and execute the workflow by grabbing frames from that camera, so that the deployed workflow runs against the bound GenICam camera and fails visibly when the camera is missing. + +#### Acceptance Criteria + +1. WHEN the Workflow_Engine resolves Camera_Bindings for a document containing an Aravis binding point, THE Workflow_Engine SHALL resolve a `cameraSourceId` binding against the device-local inventory and produce an Aravis assignment (node id mapped to the resolved camera id and acquisition parameters) instead of substituting element arguments. +2. WHEN an Aravis binding point carries a manual override, THE Workflow_Engine SHALL produce the Aravis assignment from the override values after constraint-checking them against the vendored catalog descriptor. +3. IF an Aravis binding's `cameraSourceId` has no matching entry in the device-local inventory at resolution time, THEN THE Workflow_Engine SHALL mark the registration invalid with a reason identifying the missing Camera_Source, and the existing invalid-registration path SHALL reject trigger requests until re-resolution succeeds. +4. WHEN the Workflow_Executor runs a registered workflow whose document contains an Aravis binding point, THE Workflow_Executor SHALL determine the effective camera id and acquisition parameters (the resolved Aravis assignment when bindings were applied, otherwise the binding point's rendered parameters), grab a frame through the Camera_Manager for that camera id, and push the frame into the compiled pipeline's appsrc through the Frame_Feed. +5. IF the Camera_Manager frame grab fails for an Aravis_Camera_Source_Node during a workflow run, THEN THE Workflow_Executor SHALL mark the execution failed with the failing node id set to the Aravis_Camera_Source_Node and an error describing the camera failure. +6. WHEN the Workflow_Executor runs a workflow containing no Aravis binding point, THE Workflow_Executor SHALL execute it exactly as before this feature. + +### Requirement 7: Backward Compatibility + +**User Story:** As an operator, I want existing workflows, registry entries, and deployments to keep working exactly as they do today, so that adding the Aravis node type does not disrupt anything in production. + +#### Acceptance Criteria + +1. WHEN a workflow definition, compiled document, or deployment created before this feature is processed by the Portal or the LocalServer, THE Portal and LocalServer SHALL validate, compile, package, register, bind, and execute it with behavior identical to before this feature. +2. WHEN the Edge_Sync_Agent reports an inventory on a device with no Aravis_Cameras and no Aravis runtime, THE Edge_Sync_Agent SHALL produce a report identical in shape and content to its pre-feature report for the same configured and V4L2-discovered sources. +3. THE Camera_Registry SHALL accept and store `AravisDiscovered` Camera_Sources through the existing sync reducer without changes to the handling of existing Camera_Source types. +4. THE existing `camera_source` node type SHALL keep its current parameters, mappings, compatibility rules, and Camera_Picker behavior unchanged. diff --git a/.kiro/specs/aravis-camera-input/tasks.md b/.kiro/specs/aravis-camera-input/tasks.md new file mode 100644 index 00000000..4ccc383d --- /dev/null +++ b/.kiro/specs/aravis-camera-input/tasks.md @@ -0,0 +1,214 @@ +# Implementation Plan: Aravis Camera Input + +## Overview + +Implementation follows the data flow the design threads through the existing camera-registry-sync seams: the catalog descriptor first (both mirrored workflow_core copies — everything downstream consumes it), then the edge Aravis discovery and inventory merge (pure cores with injectable enumeration), then the Portal surfaces (picker, packaging, deployment validation), and finally the edge resolution and executor frame feed. Property tests sit directly beside the code they validate. + +Test baselines that must stay green throughout: portal backend pytest under `edge-cv-portal/backend/tests` (moto-backed conftest stack), frontend vitest + `npm run build` under `edge-cv-portal/frontend`, and the edge LocalServer suites that pass on this host, run with `PYTHONPATH=src/backend:test/backend-test` scoped to `test/backend-test/workflow_engine test/backend-test/camera_discovery test/backend-test/camera_sync`. Python property tests use `hypothesis` (no hardcoded `max_examples`; the project default provides ≥100 iterations) as `test_property_*.py`; TypeScript property tests use `fast-check` with `numRuns: 100`. Each property test is tagged `**Feature: aravis-camera-input, Property {number}: {property_text}**`. + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. Catalog: ARAVIS_CAMERA_SOURCE in both copies] --> T2[2. Checkpoint] + T2 --> T3[3. Edge Aravis discovery] + T2 --> T6[6. Workflow_Builder Aravis picker] + T2 --> T7[7. Component_Packager binding points] + T3 --> T4[4. Inventory merge + sync compatibility] + T4 --> T5[5. Checkpoint - edge inventory] + T7 --> T8[8. Deployment validation + binding matrix] + T6 --> T9[9. Checkpoint - portal] + T8 --> T9 + T5 --> T10[10. Edge resolution + executor frame feed] + T9 --> T10 + T10 --> T11[11. Final checkpoint] +``` + +```json +{ + "waves": [ + { "wave": 1, "tasks": ["1"], "description": "Catalog descriptor in the portal workflow_core layer, mirrored byte-identically to the edge vendor copy, with content tests and the definition round-trip/compile property" }, + { "wave": 2, "tasks": ["2"], "description": "Checkpoint: catalog tests pass in both trees" }, + { "wave": 3, "tasks": ["3", "6", "7"], "description": "Independent consumers of the descriptor: edge Aravis enumeration, the frontend picker, and packaging binding points (can proceed in parallel)" }, + { "wave": 4, "tasks": ["4", "8"], "description": "Inventory merge on the edge; deployment type-compatibility validation and binding matrix on the portal" }, + { "wave": 5, "tasks": ["5", "9"], "description": "Checkpoints: edge inventory suites and portal suites pass" }, + { "wave": 6, "tasks": ["10"], "description": "Edge binding resolution (aravis assignments), feed planning, and the WorkflowExecutor frame feed wiring" }, + { "wave": 7, "tasks": ["11"], "description": "Final checkpoint: all baselines pass" } + ] +} +``` + +## Tasks + +- [x] 1. Add the `aravis_camera_source` node type to the Node_Catalog + - [x] 1.1 Add the `ARAVIS_CAMERA_SOURCE` descriptor to the portal workflow_core catalog + - In `edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py`: type id `aravis_camera_source`, category input, display name "Aravis Camera Source", no inputs, one `out` port of type VideoFrames; parameters `camera_id` (string, required, `min_length: 1`, description, examples), `gain` (int, optional, default 4, min 0 max 100), `exposure` (int, optional, default 5000000, min 0), each with description and examples; `hardware_dependent=True`; device-arch mappings `appsrc name=appsrc_{nodeId} ! videoconvert` with plugin dependencies `app`, `videoconvertscale`; sim mapping `_dataset_fed_sim_source()`; appended to `NODE_CATALOG` + - Ensure the compiler resolves the `{nodeId}` token in the appsrc name (reuse or extend the existing template resolution so multi-node documents render unique appsrc names) + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + + - [x] 1.2 Mirror the catalog change to the edge vendor copy + - Copy the modified catalog source verbatim to `src/backend/workflow_engine/vendor/workflow_core/` so the two source trees stay byte-identical (`diff -r` clean, ignoring `__pycache__`) + - _Requirements: 1.6_ + + - [x]* 1.3 Write catalog content unit tests + - Extend `edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_content.py` conventions: descriptor identity (type id, category, display name, ports), parameter declarations and constraints, hardware_dependent flag, per-arch appsrc element chains and plugin dependencies, sim mapping equal to `camera_source`'s sim stub, `EXPECTED_TYPE_IDS` updated; assert `camera_source`'s descriptor is unchanged; add a mirror-equality check diffing the two catalog files + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6, 7.4_ + + - [x]* 1.4 Write property test for Aravis node definition round trip and compilation + - **Feature: aravis-camera-input, Property 1: Aravis node definitions round-trip and compile through generic catalog paths** + - **Validates: Requirements 1.5** + - hypothesis over generated definitions containing `aravis_camera_source` nodes (extend `tests/generators.py` video-input types): serialize → parse equivalence; validate + compile succeed per device arch with the node's appsrc chain rendered + +- [x] 2. Checkpoint - catalog complete in both trees + - Ensure the workflow_core test suite and the edge-scoped suites pass, ask the user if questions arise. + +- [x] 3. Implement edge Aravis discovery (`src/backend/camera_discovery/aravis.py`) + - [x] 3.1 Implement the injectable Aravis enumeration module + - Frozen `DiscoveredAravisCamera` dataclass (stable_id, camera_id, model, address, physical_id, protocol, serial, vendor) and `AravisDiscoveryResult` (cameras, failures); `enumerate_aravis(enumerator=None)` defaulting to a lazy import of `aravis_functions.getCameras()`, mapping each returned Camera object; import or enumeration failure yields an empty result with a failure record, never an exception + - Pure `aravis_stable_id(vendor, model, serial, physical_id)` deriving `arv-{sha1(vendor|model|serial)[:12]}` with the empty-serial fallback including `physical_id` + - _Requirements: 2.1, 2.3, 2.6, 2.7_ + + - [x]* 3.2 Write property test for stable id determinism + - **Feature: aravis-camera-input, Property 3: Aravis stable id determinism** + - **Validates: Requirements 2.2** + - hypothesis over identity tuples in `test/backend-test/camera_discovery`: pure-function invariance under bus order / runtime-id / address changes; distinctness within generated enumerations + + - [x] 3.3 Wire Aravis enumeration into the CameraDiscovery loop and snapshot + - `CameraDiscovery` accepts an injectable Aravis enumerator alongside the V4L2 layer; each periodic pass runs both; the tracked-snapshot diff treats Aravis stable ids identically to V4L2 ids (present/absent, `absent_since`, `on_change` only on change); no second timer; V4L2 behavior unchanged + - _Requirements: 2.1, 2.5, 2.7_ + + - [x]* 3.4 Write property test for Aravis absence marking + - **Feature: aravis-camera-input, Property 5: Aravis absence marking on re-enumeration** + - **Validates: Requirements 2.5** + - hypothesis over sequences of fake Aravis enumeration results driven through the discovery diff + + - [x]* 3.5 Write property test for enumeration completeness with identity capture + - **Feature: aravis-camera-input, Property 2: Aravis discovery enumeration completeness with identity capture** + - **Validates: Requirements 2.1, 2.3** + - hypothesis over generated fake Aravis buses (unicode names, duplicate models, empty serials); every camera contributes exactly one entry with type `AravisDiscovered`, origin `edge-discovered`, and all identity fields captured + +- [x] 4. Extend the inventory merge (`src/backend/camera_sync/inventory.py`) + - [x] 4.1 Implement the Aravis branch of build_inventory + - Configured `Camera`-type Image_Source with `cameraId` equal to a tracked Aravis camera's id merges into ONE entry under `cfg-{imageSourceId}` (configured params + `capabilities.aravis` identity metadata, `discovered: True`, tracked absent state); unmerged Aravis cameras yield `AravisDiscovered` / `edge-discovered` entries with params `{cameraId, serial, protocol, address}` and `capabilities.aravis`; each tracked camera contributes to exactly one entry; output stays sorted and deterministic; inputs with no Aravis cameras produce output identical to today + - _Requirements: 2.1, 2.3, 2.4, 7.2_ + + - [x]* 4.2 Write property test for the configured/discovered Aravis merge + - **Feature: aravis-camera-input, Property 4: Configured/discovered Aravis merge by camera id** + - **Validates: Requirements 2.4** + - hypothesis in `test/backend-test/camera_sync` over configured sources and discovered cameras with random cameraId overlap + + - [x]* 4.3 Write property test for Aravis failure isolation and no-Aravis identity + - **Feature: aravis-camera-input, Property 6: Aravis failure isolation and no-Aravis identity** + - **Validates: Requirements 2.6, 7.2** + - hypothesis over configured + V4L2 inventories with a raising/unavailable Aravis enumerator; output equals the pre-feature output for the same inputs, a failure record is present, nothing raises + + - [x]* 4.4 Write unit test for registry ingestion of AravisDiscovered reports + - Ingest a report containing `AravisDiscovered` entries through the portal `reduce_report`/handler path (moto conftest stack); entries stored verbatim with existing types unaffected + - _Requirements: 7.3_ + +- [x] 5. Checkpoint - edge inventory complete + - Ensure the edge-scoped suites pass (`PYTHONPATH=src/backend:test/backend-test`, camera_discovery + camera_sync + workflow_engine), ask the user if questions arise. + +- [x] 6. Implement the Workflow_Builder Aravis picker (frontend) + - [x] 6.1 Extend cameraReference.ts with the pure Aravis helpers + - `isCameraReferenceParameter` returns true for (`aravis_camera_source`, `camera_id`); new `isAravisCompatibleCamera` (type `AravisDiscovered`, or type `Camera` with non-empty string `params.cameraId`), `cameraIdValue`, and `applyAravisCameraSelection` (populates `camera_id`, copies numeric gain/exposure, returns the standard `CameraBindingHint`, leaves other parameters untouched) + - _Requirements: 3.1, 3.2, 3.3_ + + - [x] 6.2 Wire the Aravis control into NodeConfigPanel + - The `aravis_camera_source` node's `camera_id` renders the camera reference control; option list filtered through `isAravisCompatibleCamera`; options display name, type, camera id, sync status, and staleness badge; selection applied through `applyAravisCameraSelection` storing `data.cameraBindingHint`; manual-entry toggle retained; `camera_source`'s control path untouched + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 7.4_ + + - [x]* 6.3 Write property test for the Aravis compatibility filter + - **Feature: aravis-camera-input, Property 7: Aravis picker compatibility filter** + - **Validates: Requirements 3.2** + - fast-check (`numRuns: 100`) over generated `CameraSourceEntry` lists: filter soundness and completeness + + - [x]* 6.4 Write property test for Aravis selection application + - **Feature: aravis-camera-input, Property 8: Aravis selection populates the node and records the hint** + - **Validates: Requirements 3.3** + - fast-check over Aravis-compatible entries and prior parameter records through `applyAravisCameraSelection` + + - [x]* 6.5 Write component tests for the Aravis picker + - Control renders for the Aravis node's `camera_id` (and not for unrelated parameters); manual entry toggle accepts a typed camera id; option display fields including stale/pending badges + - _Requirements: 3.1, 3.4, 3.5_ + +- [x] 7. Extend the Component_Packager (`edge-cv-portal/backend/functions/workflow_packaging.py`) + - [x] 7.1 Emit Aravis binding points and version-item records + - `ARAVIS_CAMERA_SOURCE_TYPE_ID = 'aravis_camera_source'` joins `gather_camera_input_nodes`; `build_binding_points` marks Aravis nodes' entries `aravisBinding: true` with empty slots on every device architecture, parameters carrying the rendered `camera_id`/`gain`/`exposure`; version item records the nodes in `camera_input_nodes` with `has_binding_points: true`; all other node types' output unchanged + - _Requirements: 4.1, 4.2, 4.3_ + + - [x]* 7.2 Write property test for Aravis binding point emission + - **Feature: aravis-camera-input, Property 9: Packaging emits Aravis binding points** + - **Validates: Requirements 4.1, 4.2** + - hypothesis in `edge-cv-portal/backend/tests` over definitions with 1..n Aravis (and mixed camera) nodes through the binding-point builder + + - [x]* 7.3 Write property test for Aravis-free packaging identity + - **Feature: aravis-camera-input, Property 10: Aravis-free packaging identity** + - **Validates: Requirements 4.3** + - hypothesis over Aravis-free definitions; `compiled_document_json` byte-equal to the pre-feature serialization + +- [x] 8. Extend the Deployment_Service and binding matrix + - [x] 8.1 Add the Aravis type-compatibility rule and matrix filtering + - `_CAMERA_COMPATIBLE_SOURCE_TYPES` gains `aravis_camera_source: {Camera, AravisDiscovered}` and adds `AravisDiscovered` to `camera_source`'s set; override constraint checking resolves the `aravis_camera_source` descriptor through the existing catalog lookup; frontend `CameraBindingMatrix` filters an `aravis_camera_source` row's options through the shared Aravis compatibility predicate with hint pre-selection unchanged + - _Requirements: 5.1, 5.2, 5.3, 5.4_ + + - [x]* 8.2 Write property test for Aravis type-compatibility validation + - **Feature: aravis-camera-input, Property 11: Aravis type-compatibility validation** + - **Validates: Requirements 5.2, 5.3** + - hypothesis over version items with Aravis and camera_source nodes, registries with arbitrary source types, and binding sets; `CAMERA_TYPE_INCOMPATIBLE` exactly per the compatibility-map oracle + + - [x]* 8.3 Write property test for Aravis override constraint validation + - **Feature: aravis-camera-input, Property 12: Aravis override constraint validation** + - **Validates: Requirements 5.4** + - hypothesis over valid and violating overrides (`camera_id` emptiness, gain/exposure bounds, undeclared keys) + + - [x]* 8.4 Write unit and component tests for matrix presentation and delivery + - Component test: Aravis node row offers only compatible sources with hint pre-selection; unit test: a submission with an Aravis binding writes the expected `dda-camera-bindings` desired document and leaves the artifact bytes untouched + - _Requirements: 5.1, 5.5_ + +- [x] 9. Checkpoint - portal surfaces complete + - Ensure the portal backend suite, frontend vitest, and `npm run build` pass, ask the user if questions arise. + +- [x] 10. Implement edge resolution and the executor Aravis frame feed + - [x] 10.1 Extend resolve_bindings with Aravis assignments + - `camera_binding.py`: binding points with `aravisBinding: true` never substitute slots; resolved `cameraSourceId` and constraint-valid override bindings contribute to a new `aravis_assignments` field on `ResolutionResult` (default empty — existing consumers unaffected); `_PARAM_ALIASES` gains `cameraId → camera_id`; missing ids and override violations follow the existing invalid path with reasons + - _Requirements: 6.1, 6.2, 6.3_ + + - [x]* 10.2 Write property test for device-side Aravis binding resolution + - **Feature: aravis-camera-input, Property 13: Device-side Aravis binding resolution** + - **Validates: Requirements 6.1, 6.2, 6.3** + - hypothesis in `test/backend-test/workflow_engine` over documents with Aravis binding points, binding maps (cameraSourceId / override / missing / violating), and inventories + + - [x] 10.3 Implement the aravis_feed planning module + - New pure `src/backend/workflow_engine/aravis_feed.py`: `AravisFeed` dataclass and `plan_aravis_feeds(document, resolution)` — assignment values when present, else the binding point's rendered parameters; empty-camera-id plans surface as node-attributed errors; more than one Aravis feed per document is a registration-side validation error (single Frame_Feed contract) + - _Requirements: 6.4_ + + - [x]* 10.4 Write property test for feed plan precedence + - **Feature: aravis-camera-input, Property 14: Aravis feed plan precedence** + - **Validates: Requirements 6.4** + - hypothesis over documents with Aravis points and optional resolution results + + - [x] 10.5 Wire the resolution provider and frame feed into the WorkflowExecutor + - `WorkflowExecutor` gains an injectable `binding_resolution_provider`; engine startup (`runtime.py`) wires it to the watcher's `binding_resolution()` accessor; when a resolution exists the executor runs its substituted document; before pipeline start, each planned `AravisFeed` grabs one frame via `camera_manager.get_camera_frame(camera_id, config)` (lazy import) and the run goes through `run_pipeline(launch_string, frame_data)` with appsrc caps derived from the frame; grab failure fails the execution with `failing_node_id` set to the Aravis node; provider failure falls back to the disk document; documents with no Aravis points take the exact pre-feature call path + - _Requirements: 6.4, 6.5, 6.6_ + + - [x]* 10.6 Write property test for Aravis-free execution identity + - **Feature: aravis-camera-input, Property 15: Aravis-free execution identity** + - **Validates: Requirements 6.6** + - hypothesis over Aravis-free documents (including legacy documents without `bindingPoints`): zero feeds planned and the executor invokes the fake pipeline manager without frame_data + + - [x]* 10.7 Write executor unit tests for the frame feed + - Fake camera manager + fake pipeline manager: successful grab/push call shape with resolved and rendered-default camera ids; raising camera manager → execution failed with the Aravis node id and camera error; multiple Aravis points → registration-side invalid reason; provider raising → disk-document fallback + - _Requirements: 6.4, 6.5_ + +- [x] 11. Final checkpoint + - Ensure all baselines pass: portal backend pytest, frontend vitest + `npm run build`, and the edge-scoped suites (`PYTHONPATH=src/backend:test/backend-test` over workflow_engine, camera_discovery, camera_sync); verify the two workflow_core catalog trees are byte-identical; ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional test tasks and can be skipped for a faster MVP +- Each task references specific requirements for traceability; all 15 design properties are covered by tasks 1.4, 3.2, 3.4, 3.5, 4.2, 4.3, 6.3, 6.4, 7.2, 7.3, 8.2, 8.3, 10.2, 10.4, 10.6 +- Python property tests use hypothesis with no hardcoded `max_examples` (project default ≥100 iterations); TypeScript property tests use fast-check with `numRuns: 100`; each tagged `**Feature: aravis-camera-input, Property {number}: {property_text}**` +- The catalog change lands first and is mirrored byte-identically (portal layer + edge vendor copy) before any consumer work begins; the mirror diff check in task 1.3 guards it for the rest of the implementation +- Aravis hardware, the `gi`/Aravis runtime, GStreamer, and IoT shadow transport are exercised through injectable fakes everywhere; no test requires a physical GenICam camera +- No shadow document, sync reducer, registry table, or `dda-camera-bindings` schema changes: `AravisDiscovered` is one more opaque type string to the existing transport and storage diff --git a/.kiro/specs/camera-registry-sync/.config.kiro b/.kiro/specs/camera-registry-sync/.config.kiro new file mode 100644 index 00000000..1d6f7515 --- /dev/null +++ b/.kiro/specs/camera-registry-sync/.config.kiro @@ -0,0 +1 @@ +{"specId": "519d069a-9e3c-4efd-9dc1-4cff9c35a90c", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/camera-registry-sync/design.md b/.kiro/specs/camera-registry-sync/design.md new file mode 100644 index 00000000..0ed0236d --- /dev/null +++ b/.kiro/specs/camera-registry-sync/design.md @@ -0,0 +1,571 @@ +# Design Document: Camera Registry Sync + +## Overview + +This feature closes the visibility gap between the Portal and the edge devices' input sources. Today the DDA LocalServer owns all camera knowledge: Image_Source records in its SQLite configuration database (image_source / input_configuration DAOs), and physical capture hardware visible only to the device kernel. Portal-built workflows inline device paths blind — `workflow_packaging.py` renders `v4l2src device={device}` into `compiled_pipeline.json` at packaging time with no check that `/dev/video0` exists anywhere. + +The design adds five cooperating pieces: + +1. **Camera_Registry** — a Portal DynamoDB table holding, per Edge_Device, the set of Camera_Sources known for the device with sync metadata (Requirement 1). +2. **Camera_Discovery + Edge_Sync_Agent** — a LocalServer subsystem that enumerates physical capture hardware (V4L2 nodes, Jetson CSI sensors), merges the results with configured Image_Sources, and exchanges Camera_Source state with the Portal over a **named IoT device shadow** using the existing `IoTShadowAccessor` IPC pattern (Requirements 2, 3, 5, 6). +3. **Portal_Sync_Service** — a Lambda that ingests device shadow reports into the Camera_Registry, writes portal-originated changes into the shadow's desired state, and detects/records conflicts with edge-wins resolution (Requirements 3, 5, 6). +4. **Portal camera pickers** — the Workflow_Builder's `camera_source` device parameter becomes a camera reference picker (free-text fallback retained), and the deployment flow gains a per-device Camera_Binding step with validation (Requirements 7, 8, 9). +5. **Device-side binding resolution** — the Component_Packager emits *binding points* into `compiled_pipeline.json`; Camera_Bindings travel per-device in a second named shadow, and the Workflow_Engine resolves them against local inventory at registration time. Documents without binding points (everything packaged before this feature) register and run exactly as today (Requirements 10, 11). + +### Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Sync_Channel transport | Named IoT device shadow `dda-camera-registry` per thing: `reported` written by the Edge_Sync_Agent, `desired` written by the Portal_Sync_Service, delta drives edge apply | Matches the requirements' Sync_Channel definition and the existing `IoTShadowAccessor` IPC pattern (`dao/iotshadow/IoTShadowAccessor.py`, used by `WorkflowAccessor` and the pipeline shadow today). Shadow persistence gives offline queueing in both directions for free: a disconnected device's pending `desired` waits in the shadow (5.5), and the device republishing its full `reported` state on reconnect restores edge→portal sync (3.3) | +| Portal ingest of device reports | IoT topic rule on `$aws/things/+/shadow/name/dda-camera-registry/update/documents` routed to the Portal_Sync_Service Lambda (cross-account SQS destination in the portal account when the Use_Case runs in its own account, provisioned with the existing ComputeStack/use-case onboarding handlers); plus an on-demand pull (`GetThingShadow` via the existing `get_usecase_client` assumed-role pattern) behind a refresh endpoint | Event-driven keeps the registry near-real-time (3.2) without polling every device; the pull path covers rule misconfiguration and gives users an explicit "refresh now" | +| Camera_Binding delivery to devices | A second named shadow `dda-camera-bindings` per thing, desired state keyed `{workflowId}/{version}` → per-node bindings, written by the Portal at deployment submission | Requirement 8.6 forbids touching the packaged artifact, and Greengrass per-deployment configuration merge cannot express *per-device* values inside a thing-group deployment. A per-thing shadow is per-device by construction, persists for offline devices, and reuses the exact same accessor the LocalServer already has | +| Binding application on device | The Component_Packager adds an optional `bindingPoints` section to `compiled_pipeline.json` mapping each Camera_Input_Node's logical parameters to the rendered element arguments (or, on JP4/5, to the camera-adapter connection). The Workflow_Engine substitutes resolved values at registration time | The compiled document stays fully rendered and self-contained for the default (unbound) case, so old LocalServers ignore the new field and old documents lack it — backward compatibility (11.1, 11.5) falls out of "no `bindingPoints` ⇒ no resolution step" | +| Binding-required rule | Workflow versions packaged **with** `bindingPoints` require a binding or override per Camera_Input_Node per target device (8.7); versions packaged **before** this feature (no `bindingPoints` in the version item) skip the binding step and instead get the compiled-in-path comparison warning (9.5) | One crisp discriminator (presence of packager-recorded binding metadata on the version item) separates the new strict behavior from legacy leniency, so no existing deployment flow breaks (11.5) | +| Camera_Source identity | Edge-configured sources: `cfg-{imageSourceId}` (stable — the SQLite primary key). Discovered hardware: `disc-{sha1(bus_info + card_name)[:12]}` (stable across reboots and `/dev/videoN` renumbering, which V4L2 does not guarantee). When an Image_Source references a discovered device's path the two report as one Camera_Source under the configured id with merged capability metadata (2.5) | Stable ids are what Camera_Bindings reference; deriving the discovered id from V4L2 bus info instead of the device path survives USB re-enumeration | +| Conflict rule | Edge wins (6.2). The Portal_Sync_Service detects a conflict when an incoming edge report for a Camera_Source arrives while the registry entry has an unacknowledged portal-originated change (sync status `pending`) and the edge payload does not carry that change's `portal_change_id` acknowledgment | Deterministic, testable as a pure classification function; both versions are preserved in a conflict event so the operator can re-apply the portal edit (6.3, 6.4) | +| Camera_Discovery implementation | Direct V4L2 ioctls (`VIDIOC_QUERYCAP`, `VIDIOC_ENUM_FMT`, `VIDIOC_ENUM_FRAMESIZES`) over `/dev/video*` via `fcntl.ioctl` — no new binary dependency; CSI sensors identified by the V4L2 driver name (`tegra-video` family) on Jetson, which exposes CSI sensors as video nodes | LocalServer already ships without `v4l2-ctl` guarantees across JetPack versions; raw ioctls work identically on x86 and all Jetson images and return exactly the metadata Requirement 2.2 asks for | +| Registry storage | New DynamoDB table `dda-portal-camera-registry`, PK `device_id`, SK item-type-prefixed (`CAMERA#{id}`, `META`, `CONFLICT#{ts}#{id}`), GSI on `usecase_id` | Single-partition read serves the device detail view and the deployment binding picker; conflict events co-located with the device rows they annotate; follows the existing `dda-portal-*` table conventions | +| Staleness_Threshold | Portal settings table entry `camera_registry.staleness_threshold_hours` (default 24), editable by PortalAdmin through the existing settings API | The settings table already backs PortalAdmin-configurable values (e.g. Bedrock configuration) — no new mechanism (4.3) | + +## Architecture + +### System Context + +```mermaid +graph TB + subgraph Portal Account + FE[React Frontend
Device cameras view,
Workflow Builder picker,
CreateDeployment bindings] + APIGW[API Gateway] + CAM[camera_registry.py Lambda
CRUD + conflicts + refresh] + SYNC[camera_sync.py Lambda
Portal_Sync_Service ingest] + DEP[deployments.py - extended
binding validation + delivery] + PKG[workflow_packaging.py - extended
emits bindingPoints] + Q[SQS shadow-report queue] + DDB[(DynamoDB
dda-portal-camera-registry,
settings, workflow tables)] + end + subgraph Use_Case Account + RULE[IoT topic rule
camera-registry shadow documents] + SHADOW1[(Named shadow
dda-camera-registry)] + SHADOW2[(Named shadow
dda-camera-bindings)] + end + subgraph Edge Device + ESA[Edge_Sync_Agent
report + apply] + DISC[Camera_Discovery
V4L2 / CSI enumeration] + ACC[Image_Source accessors
+ SQLite DAOs] + WE[Workflow_Engine
watcher + binding resolution] + end + + FE --> APIGW + APIGW --> CAM & DEP + CAM --> DDB + CAM -.desired writes +
on-demand GetThingShadow
assumed role.-> SHADOW1 + DEP --> DDB + DEP -.binding desired writes.-> SHADOW2 + PKG --> DDB + RULE --> Q --> SYNC --> DDB + SHADOW1 <-->|IPC UpdateThingShadow /
delta subscription| ESA + SHADOW2 -->|IPC GetThingShadow /
delta subscription| WE + DISC --> ESA + ESA --> ACC + ACC --> WE + SHADOW1 --> RULE +``` + +### Edge→Portal report flow (Requirements 2, 3) + +```mermaid +sequenceDiagram + participant DISC as Camera_Discovery + participant ACC as Image_Source accessors + participant ESA as Edge_Sync_Agent + participant SH as Shadow dda-camera-registry + participant SYNC as Portal_Sync_Service + participant REG as Camera_Registry (DDB) + + Note over ESA: LocalServer start + DISC->>ESA: initial enumeration (V4L2 + CSI) + ACC->>ESA: configured Image_Sources + ESA->>ESA: merge by device path (2.5),
assign stable ids + versions + ESA->>SH: UpdateThingShadow reported = full inventory (3.4) + SH->>SYNC: shadow documents event (IoT rule -> SQS) + SYNC->>REG: upsert entries, drop stale versions (3.5),
stamp last_reported_at (3.2) + + Note over DISC: every 5 min / on Image_Source change + DISC->>ESA: re-enumeration diff (absent marking, 2.4) + ESA->>SH: debounced reported update within 30 s (3.1) +``` + +### Portal→Edge change flow with conflict handling (Requirements 5, 6) + +```mermaid +sequenceDiagram + participant UI as Portal UI (Operator) + participant CAM as camera_registry.py + participant REG as Camera_Registry (DDB) + participant SH as Shadow dda-camera-registry + participant ESA as Edge_Sync_Agent + participant ACC as Image_Source accessors + + UI->>CAM: PUT /devices/{id}/cameras/{csid} + CAM->>REG: entry -> sync_status=pending,
portal_change_id=uuid + CAM->>SH: update desired.changes[csid] (5.1) + SH-->>ESA: delta (or on reconnect, 5.5) + ESA->>ACC: apply via input-configuration accessors (5.2) + alt apply succeeds + ESA->>SH: reported entry + ack portal_change_id (5.3) + Note over SH: Portal_Sync_Service marks synced + else accessor validation rejects + ESA->>SH: reported failure + reason (5.4) + Note over SH: Portal_Sync_Service marks failed,
reason shown in Portal + else edge modified same source meanwhile + ESA->>SH: reported edge version, no ack + Note over SH: Portal_Sync_Service classifies Conflict (6.1),
edge wins (6.2), conflict event recorded (6.3) + end +``` + +### Deploy-time binding flow (Requirements 8, 9, 10) + +```mermaid +sequenceDiagram + participant UI as CreateDeployment page + participant DEP as deployments.py + participant REG as Camera_Registry (DDB) + participant SH2 as Shadow dda-camera-bindings + participant WE as Workflow_Engine (device) + + UI->>DEP: GET binding context (nodes x devices) + DEP->>REG: cameras per target device + DEP-->>UI: binding options + hint pre-selection (8.1, 8.5) + UI->>DEP: POST workflow deployment + camera_bindings + DEP->>DEP: validate: unbound (8.7), missing (9.2),
type mismatch (9.4), override constraints (8.4) + DEP->>DEP: warnings: stale/absent/pending/failed (9.3),
legacy path check (9.5) - need confirmed flag + DEP->>SH2: desired.bindings[wf/ver] per device (8.6) + DEP->>DEP: create Greengrass deployment (unchanged artifact) + Note over WE: component lands via Greengrass + WE->>WE: registration: resolve bindings against
local inventory, substitute bindingPoints (10.1) + alt camera missing locally + WE->>WE: registration invalid + reason,
triggers rejected (10.2),
re-evaluated when camera appears (10.4) + end +``` + +## Components and Interfaces + +### 1. Camera_Discovery (LocalServer, new module `src/backend/camera_discovery/`) + +```python +class DiscoveredCamera: # frozen dataclass + stable_id: str # disc-{sha1(bus_info+card)[:12]} + device_path: str # /dev/video0 + card_name: str # from VIDIOC_QUERYCAP + bus_info: str + driver: str + kind: str # "v4l2" | "csi" + formats: list[dict] # [{pixel_format, resolutions: [[w,h],...]}] + +class CameraDiscovery: + def enumerate(self) -> DiscoveryResult + # DiscoveryResult: cameras: list[DiscoveredCamera], + # failures: list[{device_path, error}] + def start(self, interval_seconds=300, on_change=callback) -> None + def stop(self) -> None +``` + +- `enumerate()` globs `/dev/video*`, opens each node read-only, and issues `VIDIOC_QUERYCAP` / `VIDIOC_ENUM_FMT` / `VIDIOC_ENUM_FRAMESIZES` ioctls. Nodes without the `VIDEO_CAPTURE` capability flag (metadata nodes, encoders) are skipped. A node whose driver is in the Tegra CSI driver set is reported with `kind="csi"` (2.1, 2.2). +- Per-node failures are captured into `failures` and enumeration continues (2.6). A total failure yields an empty result plus a logged error — never an exception into LocalServer startup (11.2). +- The periodic loop (default 300 s, configurable via the existing feature-configs mechanism, 2.3) diffs against the previous result and invokes `on_change` only when the inventory changed. Previously seen `stable_id`s missing from the new result are handed to the Edge_Sync_Agent as **absent**, never dropped (2.4). + +### 2. Edge_Sync_Agent (LocalServer, new module `src/backend/camera_sync/`) + +```python +class EdgeSyncAgent: + def __init__(self, iot_shadow_accessor, image_source_accessor, + input_configuration_accessor, camera_discovery, db_session_factory) + def start(self) -> None # full report (3.4), subscribe to delta + def stop(self) -> None + def report_inventory(self) -> None # debounced, <= 30 s after change (3.1) + def on_delta(self, delta: dict) -> None # apply portal changes (5.2) + def build_inventory(self, image_sources, discovery_result) -> list[CameraSourceState] +``` + +- **Shadow name**: `dda-camera-registry`; thing name from `AWS_IOT_THING_NAME`, exactly like the pipeline shadow. All shadow I/O goes through the existing `IoTShadowAccessor` (IPC — device IoT identity and policies, 12.4); delta notifications arrive through the existing MQTT `SubscriptionHandler` pattern on `$aws/things/{thing}/shadow/name/dda-camera-registry/update/delta`. +- **`build_inventory` is a pure function**: it merges configured Image_Sources (via `ImageSourceAccessor`, read-only — 11.3) with the latest `DiscoveryResult`. An Image_Source whose resolved device path equals a discovered camera's path yields **one** entry: configured id, configured params, discovered capability metadata, origin `edge-configured` with `discovered: true` (2.5). Discovered-only hardware yields origin `edge-discovered`. Each entry carries the per-source `version` counter persisted in a small local state file (`/aws_dda/camera_sync_state.json`) and bumped whenever that source's content hash changes. +- **Report triggers**: LocalServer start (3.4), Image_Source CRUD (a post-commit hook in the accessors' code path — the accessors themselves are unchanged; the agent observes via the existing FastAPI route layer calling `agent.report_inventory()`), discovery `on_change`, and portal-change application. Reports are debounced to one shadow write per 5 s window, well inside the 30 s bound (3.1). Failed shadow writes (offline) are retried with backoff; because each report is the *complete current state*, the reconnect retry is automatically the catch-up publish (3.3). +- **Apply path** (`on_delta`): each `desired.changes[csid]` entry is applied through `InputConfigurationAccessor` / `ImageSourceAccessor` — schema validation, camera-manager side effects, and DIO handling all preserved (5.2, 11.3). Success reports the applied state with the change's `portal_change_id` echoed in an `ack` field (5.3); a `ValidationError`/`HTTPException` reports `{csid, status: "failed", reason}` (5.4). Changes targeting origin `edge-discovered` entries are refused with reason `discovery-managed` (defense in depth behind the portal-side rejection, 5.6). Applied or failed desired entries are cleared from `desired` by writing `null`, per standard shadow discipline. +- The agent runs on a daemon thread started from `server_setup.py`; any crash is logged and leaves the rest of LocalServer untouched (11.2). No migration: the agent only reads existing tables through accessors (11.4). + +### 3. Sync_Channel message shapes (shadow `dda-camera-registry`) + +```jsonc +// reported (Edge_Sync_Agent -> Portal), complete inventory every write +{ + "schemaVersion": 1, + "reportedAt": 1730000000000, + "cameras": { + "cfg-a1b2": { + "version": 7, + "name": "Line 1 inspection cam", + "type": "Camera", // ImageSourceType values + "V4L2Discovered" + "origin": "edge-configured", // | "edge-discovered" | "portal-created" + "params": { "devicePath": "/dev/video0", "cameraId": "cam-1", + "gain": 4, "exposure": 5000000 }, + "capabilities": { "formats": [{ "pixelFormat": "YUYV", + "resolutions": [[1920,1080],[1280,720]] }] }, + "absent": false, + "ack": "pc-9f31..." // portal_change_id just applied, else omitted + }, + "disc-3fe9c0d21ab4": { "version": 2, "origin": "edge-discovered", + "absent": true, "absentSince": 1729990000000, ... } + }, + "failures": { "cfg-c3d4": { "reason": "location is required when ...", + "portalChangeId": "pc-77aa..." } }, + "discoveryErrors": [ { "devicePath": "/dev/video3", "error": "ioctl EINVAL" } ] +} + +// desired (Portal_Sync_Service -> Edge), sparse, cleared on ack +{ + "changes": { + "cfg-a1b2": { "op": "update", "portalChangeId": "pc-9f31...", + "baseVersion": 7, "name": "...", "type": "Camera", "params": {...} }, + "portal-new": { "op": "create", "portalChangeId": "pc-1c2d...", ... }, + "cfg-old": { "op": "delete", "portalChangeId": "pc-8e0f...", "baseVersion": 3 } + } +} +``` + +The document is capped well under the 8 KB shadow limit for realistic inventories (tens of sources); capability metadata is truncated to the top resolutions per format if a report would exceed 7 KB, with a `capabilitiesTruncated` flag. + +### 4. Portal_Sync_Service (`camera_sync.py` Lambda) + +Consumes the SQS queue fed by the per-use-case IoT rule (rule + cross-account queue policy provisioned by the existing use-case onboarding ComputeStack handlers). Core logic is a **pure reducer** applied per camera source, then persisted: + +```python +def reduce_report(registry_entry: dict | None, incoming: dict, now_ms: int) -> SyncOutcome +# SyncOutcome: action = upsert | discard_stale | conflict +# - discard_stale: incoming.version < registry_entry.version (3.5) +# - conflict: registry_entry.sync_status == "pending" and +# incoming.ack != registry_entry.portal_change_id and +# incoming content differs from the pending portal content (6.1) +# -> upsert edge state (edge wins, 6.2) + ConflictEvent (6.3) +# - a reported deletion (source missing from a full report) while a portal +# update is pending -> deletion wins + ConflictEvent (6.5) +# - incoming.ack == portal_change_id -> upsert, sync_status = synced (5.3) +# - incoming failure entry -> sync_status = failed + reason (5.4) +``` + +Every processed report also updates the device `META` item (`last_report_at`, clearing `never_synced`). Duplicate/out-of-order SQS delivery is safe because reduction is version-guarded and idempotent. + +### 5. Camera_Registry API (`camera_registry.py` Lambda) + +| Route | Permission | Behavior | +|---|---|---| +| `GET /devices/{device_id}/cameras` | Viewer (12.1, 1.3) | Registry entries + `META`; computes per-entry `stale` against the Staleness_Threshold (4.1) and attaches the IoT connectivity status from the existing device-status lookup (4.2). `never_synced` devices return `{state: "never-synced"}`, not an empty list (1.6) | +| `POST /devices/{device_id}/cameras` | Operator (12.2, 5.7) | Creates origin `portal-created` entry, `sync_status=pending`, writes shadow `desired` (5.1); audit event (12.3) | +| `PUT /devices/{device_id}/cameras/{csid}` | Operator | As above; rejects origin `edge-discovered` with `DISCOVERY_MANAGED` (5.6) | +| `DELETE /devices/{device_id}/cameras/{csid}` | Operator | Pending delete via shadow desired; rejects discovery-managed | +| `GET /devices/{device_id}/cameras/conflicts` | Viewer | Conflict events, newest first (6.3) | +| `POST /devices/{device_id}/cameras/conflicts/{cid}/reapply` | Operator | Re-issues the overridden portal version as a new pending change (6.4) | +| `POST /devices/{device_id}/cameras/refresh` | Viewer | On-demand `GetThingShadow` pull through `get_usecase_client`, runs the same reducer | + +Authorization follows the existing pattern (`has_workflow_permission`-style checks against the device's `usecase_id`; out-of-scope requests get the standard 403, 1.5). All mutating routes call `log_audit_event` (12.3). + +### 6. Workflow_Builder camera picker (frontend) + +- `NodeConfigPanel` renders a **camera reference control** for the `camera_source` node's `device` parameter (keyed off node type + parameter name; no `workflow_core` schema change): a reference-device selector (devices of the current Use_Case), then a camera dropdown fed by `GET /devices/{id}/cameras`, each option showing name, type, path/URL, sync status, and staleness badge (7.1, 7.4). +- Selecting a camera populates the node's `device` (and gain/exposure when present in the source params) and stores a **binding hint** in the node data: `data.cameraBindingHint = { cameraSourceId, cameraName, sourceDeviceId }` (7.2). The hint is advisory metadata inside the workflow definition JSON — validator and compiler ignore unknown node data keys, so the workflow stays device-portable (7.5) and existing definitions are untouched (11.5). +- A "manual entry" toggle keeps the plain text input (7.3). + +### 7. Component_Packager extension (`workflow_packaging.py`) + +For each Camera_Input_Node (node whose type descriptor is `camera_source`, or a Custom_Node_Type declared camera-backed via a new optional `camera_backed: true` descriptor flag), the packager appends to `compiled_pipeline.json`: + +```jsonc +"bindingPoints": [ + { + "nodeId": "n1", + "nodeType": "camera_source", + "bindingHint": { "cameraSourceId": "cfg-a1b2" }, // from the definition, may be absent + "parameters": { "device": "/dev/video0", "gain": 4, "exposure": 5000000 }, + "slots": [ // arch-specific: where resolved values land in THIS document + { "param": "device", "segment": 0, "element": 0, "arg": "device" } + ] + } +] +``` + +- On x86_64 / x86_64_nvidia the slot points at the `v4l2src` `device` arg. On JP4/JP5 (appsrc fed by the camera adapter) `slots` is empty and the binding point instead carries `"adapterBinding": true` — resolution selects which local Image_Source/cameraId the executor's camera adapter connects to. On JP6 the binding selects the CSI sensor the capture host service stages from. The compiled elements keep their fully rendered default values, so an unbound document is byte-identical in behavior to today's output. +- The packager also records `has_binding_points: true` and the Camera_Input_Node list on the workflow **version item** in DynamoDB — the discriminator the Deployment_Service uses for the strict-vs-legacy rule. + +### 8. Deployment_Service extension (`deployments.py` + `CreateDeployment.tsx`) + +Request body addition to `create_workflow_deployment`: + +```jsonc +"camera_bindings": { + "": { + "": { "cameraSourceId": "cfg-a1b2" } + // or { "override": { "device": "/dev/video2", "gain": 8 } } + } +}, +"confirmed_warnings": ["", ...] +``` + +Validation pipeline (pure function `validate_camera_bindings(version_item, targets, registry_snapshot, bindings, confirmed) -> (errors, warnings)`), run pre-submit alongside the existing LocalServer/plugin gates: + +- **Errors (reject)**: unbound Camera_Input_Node on any target when `has_binding_points` (8.7); referenced `cameraSourceId` not present in the target's registry (9.1, 9.2); Camera_Source type incompatible with the node type — e.g. `Folder` bound to `camera_source` (9.4); override values violating the node type's declared parameter constraints, checked with the existing `workflow_core` parameter validator (8.4). +- **Warnings (require matching `confirmed_warnings` ids)**: bound source is stale, absent, or sync status pending/failed (9.3); target device `never_synced` — bindings restricted to manual override (8.8); **legacy path check**: when `has_binding_points` is false, each compiled-in device path is compared against the target's registry and unmatched paths produce warnings only (9.5, 11.1). +- Distinct bindings per device for the same node are the natural shape of the map (8.3); workflows with no Camera_Input_Nodes skip the step entirely (8.9). +- On successful validation the Lambda writes `desired.bindings["{workflowId}/{version}"] = {nodeId: binding}` into each target thing's `dda-camera-bindings` shadow (assumed-role `iot-data` client), then creates the Greengrass deployment exactly as today — artifact untouched (8.6). Bindings are also stored on the workflow-deployment record for display and audit (12.3). +- `CreateDeployment.tsx` gains a binding matrix step (nodes × target devices) shown only when the selected workflow version has Camera_Input_Nodes, pre-selecting registry entries matching each node's `cameraBindingHint` (8.5), with per-cell manual-override entry and warning confirmation checkboxes. + +### 9. Workflow_Engine binding resolution (LocalServer) + +New module `src/backend/workflow_engine/camera_binding.py`: + +```python +def resolve_bindings(document: dict, bindings: dict | None, + local_inventory: dict) -> ResolutionResult +# ResolutionResult: document (substituted copy), status: resolved | invalid, +# missing: [{nodeId, cameraSourceId}], adapter_assignments: {...} +``` + +- Pure over its inputs: for each `bindingPoints` entry with a binding, a `cameraSourceId` binding looks up the local inventory (Image_Source records + latest discovery result, same `build_inventory` ids) and substitutes the resolved parameter values into the declared `slots` (10.1); an `override` binding substitutes its values directly, constraint-checked against the vendored catalog descriptor (10.3). Documents without `bindingPoints`, or with no bindings supplied, are returned unchanged — the compiled-in values run as-is (10.5, 11.1). +- `WorkflowWatcher._register` calls a `CameraBindingStore` (reads the `dda-camera-bindings` shadow via `IoTShadowAccessor`, cached, refreshed on shadow delta) for `{workflow_id}/{version}`, then `resolve_bindings`. An unresolved `cameraSourceId` marks the registration `invalid` with reason `missing camera source {csid}` — the existing invalid-registration path already rejects triggers (10.2). The watcher re-runs resolution for invalid registrations on discovery `on_change` and bindings-shadow delta, flipping them to `registered` when everything resolves (10.4). +- JP4/5 adapter binding points produce `adapter_assignments` consumed by the executor when it connects the camera adapter to the appsrc. + +## Data Models + +### DynamoDB: `dda-portal-camera-registry` + +| Item | PK `device_id` | SK | Attributes | +|---|---|---|---| +| Camera source | thing name | `CAMERA#{camera_source_id}` | `usecase_id`, `name`, `type` (`Camera`\|`Folder`\|`ICam`\|`NvidiaCSI`\|`RTSP`\|`V4L2Discovered`), `params` (map), `capabilities` (map), `origin` (`edge-configured`\|`edge-discovered`\|`portal-created`), `version` (N, monotonic), `last_reported_at` (N), `sync_status` (`synced`\|`pending`\|`failed`), `failure_reason?`, `portal_change_id?`, `pending_content?` (map), `absent` (BOOL), `absent_since?` (N) | +| Device meta | thing name | `META` | `usecase_id`, `last_report_at?` (N), `never_synced` (BOOL, default true) | +| Conflict event | thing name | `CONFLICT#{ts}#{uuid}` | `usecase_id`, `camera_source_id`, `edge_version` (map), `portal_version` (map), `resolution` (`edge-retained`\|`deletion-retained`), `created_at` (N), `reapplied_as?` | + +GSI `usecase-index` on `usecase_id` for Use_Case-scoped listings and authorization checks (1.4, 1.5). Requirements 1.1/1.2 map directly onto the camera-source item shape. + +### Workflow version item additions (existing workflow tables) + +- `has_binding_points: bool` — set by the Component_Packager. +- `camera_input_nodes: [{node_id, node_type, binding_hint?, compiled_device_paths: {arch: path}}]` — the deployment flow's source of truth for the binding matrix and the legacy path check (9.5) without re-reading compiled documents from S3. + +### Settings table addition + +- `camera_registry.staleness_threshold_hours` (default `24`), PortalAdmin-editable through the existing settings route (4.3). + +### Device-local state (LocalServer) + +- `/aws_dda/camera_sync_state.json` — `{camera_source_id: {version, content_hash}}` plus the last discovery snapshot. Written atomically (temp file + rename). Loss of this file only resets version counters upward on next report (versions restart from `max(known)+1` using the shadow's current reported state as floor), never corrupts sync. +- **No SQLite schema change**: Image_Source and input-configuration tables are untouched; all reads/writes go through the existing accessors (11.3, 11.4). + +### Camera_Binding shadow document (`dda-camera-bindings`) + +```jsonc +{ + "desired": { + "bindings": { + "wf-123/3": { + "n1": { "cameraSourceId": "cfg-a1b2" }, + "n2": { "override": { "device": "/dev/video2" } } + } + } + } +} +``` + +Entries are keyed by `{workflowId}/{version}` so a revised deployment of a newer version simply adds its key; the Portal prunes keys for versions no longer deployed to the device when it writes. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The sync reducer, inventory builder, discovery diff, deployment validation, and binding resolution are all pure functions by design, which makes them directly property-testable with generated inputs. + +### Property 1: Sync reducer round trip with version guard + +*For any* registry state and any incoming device report, processing the report through `reduce_report` yields registry entries that preserve every declared Camera_Source field (id, name, type, params, capabilities, origin, version, last-reported timestamp) and the device's `usecase_id`, entries not referenced by the report are unchanged, entries whose incoming version is lower than the recorded version are discarded leaving the recorded entry intact, and the device meta's last-report timestamp is stamped. + +**Validates: Requirements 1.1, 1.2, 1.4, 3.2, 3.5** + +### Property 2: Discovery enumeration completeness + +*For any* set of fake capture devices presented to the enumeration layer, every device carrying the video-capture capability appears in the discovery result exactly once with its device path, card name, and format/resolution metadata propagated, and every resulting inventory entry not matching a configured Image_Source has origin `edge-discovered`. + +**Validates: Requirements 2.1, 2.2** + +### Property 3: Discovery failure isolation + +*For any* set of fake capture devices and any subset chosen to fail enumeration, the discovery result contains every non-failing device, one failure record per failing device, and no exception escapes the enumeration call. + +**Validates: Requirements 2.6** + +### Property 4: Absence marking on re-enumeration + +*For any* pair of consecutive discovery snapshots, every stable id present in the first and missing from the second appears in the diff output marked absent with an absence timestamp, and no id is ever removed from the tracked set by a diff. + +**Validates: Requirements 2.4** + +### Property 5: Configured/discovered merge + +*For any* set of configured Image_Sources and any set of discovered cameras, `build_inventory` emits exactly one entry per path-matching configured/discovered pair — carrying the configured id and parameters combined with the discovered capability metadata — plus one entry per unmatched member of either set, and no device path appears in two entries. + +**Validates: Requirements 2.5** + +### Property 6: Reconnect publishes complete current state + +*For any* sequence of local inventory changes interleaved with failing shadow writes, the first successful shadow write after the failures carries the complete current inventory (equal to `build_inventory` over the state at publish time), so no retained change is lost. + +**Validates: Requirements 3.3** + +### Property 7: Portal change apply/report round trip + +*For any* portal-originated Camera_Source change, applying it through the Edge_Sync_Agent against a device database and reducing the resulting report in the Portal ends with: for schema-valid changes, the device-local state matching the change, the report acknowledging the `portal_change_id`, and the registry entry marked `synced` with the applied content; for schema-invalid changes, the device-local state unchanged and the registry entry marked `failed` with a non-empty reason. + +**Validates: Requirements 5.2, 5.3, 5.4** + +### Property 8: Conflict classification with edge-wins resolution + +*For any* registry entry holding a pending portal change and any incoming edge report for the same Camera_Source that does not acknowledge that change, the reducer classifies a Conflict exactly when the edge content differs from the pending portal content; on every conflict the retained effective state equals the edge state (including edge deletion winning over portal modification), and the emitted conflict event contains both conflicting versions, the resolution applied, and a timestamp. + +**Validates: Requirements 6.1, 6.2, 6.3, 6.5** + +### Property 9: Portal mutation produces pending state and matching desired document + +*For any* valid create, update, or delete of a Camera_Source through the portal API, the registry entry transitions to `pending` with a fresh `portal_change_id`, and the shadow desired document written for the device contains a change entry whose operation and content round-trip to the submitted mutation. + +**Validates: Requirements 5.1** + +### Property 10: Discovery-managed sources are immutable from the Portal + +*For any* Camera_Source entry and any mutation operation (create-over, update, delete), the portal API rejects the mutation identifying the source as discovery-managed exactly when the entry's origin is `edge-discovered`, and accepts it (subject to other validation) for every other origin. + +**Validates: Requirements 5.6** + +### Property 11: Camera selection populates the node and records the hint + +*For any* Camera_Source, applying it as the selection for a Camera_Input_Node yields node parameter values matching the source's parameters for every parameter the source provides, and a binding hint recording exactly that source's identifier. + +**Validates: Requirements 7.2** + +### Property 12: Binding hints are transparent to validation and compilation + +*For any* valid workflow definition and any binding hints attached to its Camera_Input_Nodes, validating and compiling the hinted definition produces results equivalent to validating and compiling the same definition with the hints stripped. + +**Validates: Requirements 7.5, 11.5** + +### Property 13: Binding completeness validation + +*For any* workflow version with binding points, any set of target devices, and any bindings map, deployment validation reports an unbound error identifying exactly the (Camera_Input_Node, Edge_Device) pairs missing from the map — including maps that bind the same node to different sources on different devices without error — and reports nothing for workflow versions containing no Camera_Input_Nodes. + +**Validates: Requirements 8.3, 8.7, 8.9** + +### Property 14: Binding existence validation + +*For any* bindings map and any per-device registry snapshots, validation reports a missing-source error identifying the Camera_Source and Edge_Device exactly for those bindings whose referenced `cameraSourceId` is not present in that device's registry snapshot. + +**Validates: Requirements 9.1, 9.2** + +### Property 15: Degraded-source warnings gate submission on confirmation + +*For any* binding whose referenced Camera_Source is stale, marked absent, or has sync status pending or failed, and for any target device that has never synced, validation emits a warning identifying the condition, and the deployment is accepted if and only if every emitted warning id appears in the submitted confirmations (with never-synced devices additionally restricted to manual overrides). + +**Validates: Requirements 8.8, 9.3** + +### Property 16: Type compatibility and override constraint validation + +*For any* Camera_Binding, validation rejects it identifying a type mismatch exactly when the bound Camera_Source's type is outside the node type's compatible set, and accepts a manual override exactly when every override value satisfies the node type's declared parameter constraints (as judged by the existing `workflow_core` parameter validator). + +**Validates: Requirements 8.4, 9.4** + +### Property 17: Legacy compiled-path warning + +*For any* workflow version without binding points carrying compiled-in device paths, and any target device registry, validation emits a warning for exactly those compiled-in paths that match no registered Camera_Source's device path on that device, and never emits an error for them. + +**Validates: Requirements 9.5, 11.1** + +### Property 18: Binding hint pre-selection + +*For any* Camera_Input_Node hint and any device registry snapshot, the proposed pre-selection equals the registry entry whose id matches the hint when such an entry exists, and is empty otherwise. + +**Validates: Requirements 8.5** + +### Property 19: Binding delivery round trip + +*For any* validated bindings map, the desired document written to each target device's bindings shadow, decoded back, equals the submitted bindings for that device and workflow version, and the packaged Workflow_Component artifact is not modified by the submission. + +**Validates: Requirements 8.2, 8.6** + +### Property 20: Device-side binding resolution + +*For any* compiled document with binding points, any bindings, and any local inventory: bindings referencing a `cameraSourceId` present in the inventory substitute that source's parameter values into exactly the declared slots; override bindings substitute the override values regardless of inventory; and any binding referencing an id absent from the inventory yields status invalid with a reason identifying the missing Camera_Source, leaving the registration non-runnable. + +**Validates: Requirements 10.1, 10.2, 10.3** + +### Property 21: Registration re-evaluation on camera appearance + +*For any* document and binding whose resolution is invalid against an inventory missing the referenced Camera_Source, re-resolving after adding that source to the inventory yields a resolved document, and re-evaluation flips the registration from invalid to registered. + +**Validates: Requirements 10.4** + +### Property 22: No-binding identity + +*For any* compiled document — with or without binding points, including documents packaged before this feature — resolving with no bindings supplied returns the document unchanged, so execution uses exactly the compiled-in parameter values. + +**Validates: Requirements 10.5, 11.1, 11.5** + +## Error Handling + +### Edge device + +| Failure | Handling | +|---|---| +| Individual V4L2 node enumeration error (ioctl failure, permission) | Recorded in `discoveryErrors`, remaining nodes enumerated, loop continues (2.6) | +| Total discovery failure / missing `/dev/video*` | Empty discovery result, logged; configured Image_Sources still reported | +| Edge_Sync_Agent thread crash or IPC/shadow unavailability | Logged, retried with exponential backoff (cap 5 min); LocalServer pipelines and Workflow_Engine unaffected — the agent is a daemon thread with a top-level exception guard (11.2) | +| Shadow write rejected (size, throttle) | Capability metadata truncated (`capabilitiesTruncated`) and retried; oversize after truncation logs and drops capabilities only, never identity fields | +| Portal change fails accessor validation | `ValidationError`/`HTTPException` message captured verbatim into the failure report's `reason` (5.4); desired entry cleared so the delta does not re-fire | +| Portal change targets discovery-managed source | Refused with reason `discovery-managed` (defense in depth, 5.6) | +| Bindings shadow unreadable at registration | Registration proceeds only for documents without binding points; documents requiring bindings are marked invalid with reason `bindings unavailable` and re-evaluated on the next shadow delta or watcher sync (10.2, 10.4) | +| Local state file corrupt/missing | Version counters re-floored from the shadow's current reported state; full re-report published | + +### Portal + +| Failure | Handling | +|---|---| +| Malformed or unparseable shadow report in SQS | Message logged and dead-lettered (DLQ on the queue); one bad report never blocks others | +| Out-of-order / duplicate SQS delivery | Version-guarded idempotent reducer (Property 1) makes reprocessing safe | +| `GetThingShadow`/`UpdateThingShadow` assumed-role failure | 502 with the standard error envelope; registry state untouched; mutation not recorded as pending unless the desired write succeeded (write desired first, then mark pending — a desired write without a pending mark self-heals on the ack report) | +| Binding shadow write fails for one target mid-submission | Deployment not created; already-written targets' desired entries are pruned (best-effort) and the error identifies the failing device — no partial deployment | +| Registry read fails during deployment validation | Deployment rejected with `REGISTRY_UNAVAILABLE` rather than silently skipping validation | +| Unauthorized access | Standard 403 envelope + `unauthorized_access` audit event, matching existing handlers (1.5, 5.7, 12.1, 12.2) | + +## Testing Strategy + +The property-based tests target the pure cores called out above; example-based unit tests cover RBAC, API envelopes, UI interactions, and timing behavior; a small number of integration/smoke tests cover the transports that AWS owns. + +### Property-based tests + +- **Library**: Hypothesis (Python — reducer, inventory builder, discovery diff/merge, apply path against in-memory SQLite, deployment validation, binding resolution); fast-check (TypeScript — selection application, hint pre-selection where implemented in the frontend). +- **Configuration**: minimum 100 iterations per property (`max_examples=100` / `numRuns: 100`). +- **Traceability**: each property is implemented as a single property-based test tagged with a comment in the form + `**Feature: camera-registry-sync, Property {number}: {property_text}**`. +- Generators produce: multi-source inventories with overlapping device paths, version sequences (including regressions), all origins and sync statuses, edge deletions, whitespace/unicode names, degenerate registries (empty, never-synced), documents with zero to many binding points across all architectures, and legacy documents lacking `bindingPoints`. + +### Example-based unit tests + +- RBAC matrix per route (Viewer read, Operator mutate, cross-use-case 403) — 1.3, 1.5, 5.7, 12.1, 12.2. +- Never-synced response shape (1.6); staleness boundary cases at exactly/just-past the threshold (4.1); disconnected-status pass-through (4.2); settings default (4.3); absent display fields (4.4). +- Debounce/report timing with a fake clock (3.1); startup full report (3.4); discovery interval configuration (2.3). +- Conflict re-apply endpoint (6.4); audit event payload per mutating route (12.3). +- Frontend component tests: picker population and display fields (7.1, 7.4), manual entry toggle (7.3), binding matrix rendering and warning confirmation flow (8.1). +- Agent failure isolation: a raising agent constructor/start does not propagate into server setup (11.2). + +### Integration and smoke tests + +- One integration test per direction over a real or emulated named shadow: reported ingest via the IoT rule path, desired delivery and delta receipt (5.5, 12.4). +- Smoke test: Edge_Sync_Agent started against a populated fixture SQLite database leaves every existing row byte-identical (11.4). +- Existing packaging tests (`test_workflow_generation.py` and packaging suite) extended with one snapshot assertion: packaging a camera workflow with the feature enabled produces the same rendered segments as before plus the `bindingPoints` section, and packaging a workflow without camera nodes is byte-identical to the pre-feature output. + +### What is explicitly not property-tested + +- Real V4L2/CSI hardware enumeration (integration on device), AWS shadow persistence semantics (5.5), IoT rule routing, and Greengrass delivery — these are external-service behaviors verified with single integration examples, per the testing decision guide. diff --git a/.kiro/specs/camera-registry-sync/requirements.md b/.kiro/specs/camera-registry-sync/requirements.md new file mode 100644 index 00000000..4bf0d27e --- /dev/null +++ b/.kiro/specs/camera-registry-sync/requirements.md @@ -0,0 +1,181 @@ +# Requirements Document + +## Introduction + +Today, camera and input-source knowledge exists only on the edge device. The DDA LocalServer stores Image_Sources (cameras, folders, ICam, NVIDIA CSI) in its local SQLite database, created and edited through the device's local UI and REST API, and physical capture devices (V4L2 nodes, CSI sensors) are visible only to the device itself. The Edge CV Portal has no visibility into any of this: portal-built workflows reference camera device paths blindly (for example `device=/dev/video0` inlined into the compiled pipeline at packaging time), with no validation that the referenced camera exists on the target device. On JetPack 4/5, the camera_source node renders as an appsrc fed by LocalServer's camera adapter, which requires a matching device-side Image_Source to exist — a dependency that is invisible at deploy time. + +This feature introduces a per-device Camera_Registry in the Portal, populated by camera registration and discovery on the edge device and synchronized between edge and Portal over the existing AWS IoT channel. It adds Portal-side camera selection: users building workflows and creating deployments pick cameras from the synced per-device inventory instead of typing device paths blind, and can bind a workflow's camera input nodes to different cameras per target device at deploy time (or supply explicit overrides). Deploy-time validation warns or errors when a workflow's input source does not match anything registered on the target device. Existing workflows with hardcoded device paths continue to work unchanged. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend, DynamoDB storage) used to manage DDA use cases, models, workflows, deployments, and devices. +- **LocalServer**: The Greengrass component (aws.edgeml.dda.LocalServer.<arch>) running on an Edge_Device. It owns the device-local Image_Source configuration tables (SQLite via image_source and input_configuration DAOs) and executes GStreamer pipelines. +- **Edge_Device**: A Greengrass core device registered in the Portal's devices table, scoped to a Use_Case, that runs LocalServer and deployed Workflow_Components. +- **Camera_Source**: A named input-source entry describing a physical or logical frame source available on an Edge_Device, including its type (V4L2 camera, NVIDIA CSI, RTSP, Folder, ICam, or adapter-fed), device path or URL, and available metadata (for example supported resolutions, formats, and acquisition settings). A Camera_Source originates either from a configured device-local Image_Source record or from device discovery of physical capture hardware. +- **Image_Source**: The pre-existing device-local record managed by LocalServer's image_source DAO and accessors, which stores input-source configuration on the Edge_Device today. +- **Camera_Discovery**: The device-side capability that enumerates physical capture devices present on an Edge_Device (for example V4L2 device nodes and CSI sensors) together with their metadata. +- **Camera_Registry**: The Portal-side store and API that holds, per Edge_Device, the set of Camera_Sources known for that device, together with per-source sync metadata (origin, version, last-reported timestamp, sync status). +- **Edge_Sync_Agent**: The LocalServer-side component that reports the Edge_Device's Camera_Source inventory (configured Image_Sources and Camera_Discovery results) to the Portal, and applies Portal-originated Camera_Source changes to the device-local Image_Source tables. +- **Portal_Sync_Service**: The Portal-side component that receives Camera_Source state from Edge_Devices, updates the Camera_Registry, and delivers Portal-originated Camera_Source changes to Edge_Devices. +- **Sync_Channel**: The AWS IoT communication path (device shadow or equivalent, following the existing iot_shadow_accessor pattern) used to exchange Camera_Source state between an Edge_Device and the Portal. +- **Conflict**: The condition in which the same Camera_Source was modified on both the Edge_Device and in the Portal after the last completed synchronization of that Camera_Source. +- **Workflow_Builder**: The graphical canvas UI within the Portal where users compose workflow definitions from nodes, including camera input nodes. +- **Workflow_Component**: The versioned Greengrass component produced by the Portal's Component_Packager for a workflow, containing the compiled pipeline (compiled_pipeline.json) deployed to Edge_Devices under /aws_dda/workflows/{workflowId}/{version}/. +- **Camera_Input_Node**: An input node in a workflow definition whose frames come from a device camera (the camera_source node type, or a custom input node type declared as camera-backed such as an RTSP source), as opposed to folder or digital-input nodes. +- **Camera_Binding**: A per-deployment, per-device mapping from a Camera_Input_Node in a workflow to either a Camera_Source registered for the target Edge_Device or an explicit set of override parameter values supplied at deployment time. +- **Deployment_Service**: The existing Portal capability (deployments.py Lambda and CreateDeployment page), extended by this feature, that creates Greengrass deployments targeting Edge_Devices. +- **Workflow_Engine**: The LocalServer subsystem (watcher, discovery, api) that discovers deployed Workflow_Components on the device and registers them as runnable workflow registrations. +- **Staleness_Threshold**: The configurable duration (default 24 hours) after which a Camera_Registry entry whose last-reported timestamp has not been refreshed is presented as stale. +- **Use_Case**: The existing Portal tenancy unit (usecase_id) to which devices, workflows, and deployments are scoped, with RBAC roles Viewer, Operator, DataScientist, UseCaseAdmin, and PortalAdmin. + +## Requirements + +### Requirement 1: Per-Device Camera Registry in the Portal + +**User Story:** As an operator, I want to see which cameras and input sources exist on each edge device from the Portal, so that I can understand device capabilities without physical or local-UI access to the device. + +#### Acceptance Criteria + +1. THE Camera_Registry SHALL store, for each Edge_Device, a set of Camera_Sources each carrying a stable identifier, name, type (V4L2 camera, NVIDIA CSI, RTSP, Folder, ICam, or adapter-fed), type-specific parameters (device path or URL, and acquisition settings), available capability metadata (for example supported resolutions and formats), origin (edge-configured, edge-discovered, or portal-created), a monotonically increasing version, and a last-reported timestamp. +2. THE Camera_Registry SHALL support multiple Camera_Sources per Edge_Device, each independently identified and versioned. +3. WHEN a user with the Viewer permission for a Use_Case opens an Edge_Device's detail view in the Portal, THE Portal SHALL display that Edge_Device's Camera_Sources from the Camera_Registry with their name, type, parameters, capability metadata, origin, sync status, and last-reported timestamp. +4. THE Camera_Registry SHALL scope every Camera_Source to the Use_Case of its Edge_Device. +5. WHEN a user requests Camera_Registry data for an Edge_Device outside the Use_Cases the user is authorized for, THE Portal SHALL deny the request with an authorization error. +6. WHEN an Edge_Device has never completed a synchronization, THE Portal SHALL display that Edge_Device's Camera_Registry state as "never synced" rather than as an empty camera list. + +### Requirement 2: Device-Side Camera Discovery + +**User Story:** As an operator, I want the edge device to discover its physical capture devices automatically, so that the Portal inventory reflects what hardware is actually attached, not just what someone configured by hand. + +#### Acceptance Criteria + +1. WHEN LocalServer starts, THE Camera_Discovery SHALL enumerate the physical capture devices present on the Edge_Device (V4L2 device nodes and, on Jetson platforms, CSI sensors) and record each as a Camera_Source with origin edge-discovered. +2. WHEN Camera_Discovery enumerates a capture device, THE Camera_Discovery SHALL capture the device path, device name, and available capability metadata (supported resolutions and pixel formats) reported by the device. +3. THE Camera_Discovery SHALL re-enumerate physical capture devices at a configurable interval with a default of 5 minutes. +4. WHEN a re-enumeration detects that a previously discovered capture device is no longer present, THE Camera_Discovery SHALL mark the corresponding Camera_Source as absent rather than deleting the record. +5. WHEN a device-local Image_Source record references the same device path as a discovered capture device, THE Edge_Sync_Agent SHALL report the configured Image_Source and the discovered capture device as one Camera_Source combining the configured parameters with the discovered capability metadata. +6. IF enumeration of an individual capture device fails, THEN THE Camera_Discovery SHALL record the failure for that device, include the remaining devices in the enumeration result, and continue operation. + +### Requirement 3: Edge-to-Portal Synchronization + +**User Story:** As an operator, I want camera sources configured, discovered, or removed on the edge device to appear in the Portal automatically, so that the Portal's view of device cameras stays accurate. + +#### Acceptance Criteria + +1. WHEN a Camera_Source is created, updated, or deleted on an Edge_Device through LocalServer, or a Camera_Discovery enumeration changes the discovered inventory, THE Edge_Sync_Agent SHALL publish the resulting Camera_Source state to the Sync_Channel within 30 seconds. +2. WHEN the Portal_Sync_Service receives Camera_Source state from an Edge_Device over the Sync_Channel, THE Portal_Sync_Service SHALL update the Camera_Registry entries for that Edge_Device to reflect the received state and record the last-reported timestamp. +3. WHILE an Edge_Device has no connectivity to AWS IoT, THE Edge_Sync_Agent SHALL retain unpublished local Camera_Source changes and publish the current complete Camera_Source state when connectivity is restored. +4. WHEN LocalServer starts, THE Edge_Sync_Agent SHALL publish the device's complete current Camera_Source state to the Sync_Channel. +5. IF the Portal_Sync_Service receives Camera_Source state carrying a version older than the version already recorded in the Camera_Registry for that Camera_Source, THEN THE Portal_Sync_Service SHALL discard the stale state and retain the newer Camera_Registry entry. + +### Requirement 4: Inventory Staleness and Offline Devices + +**User Story:** As an operator, I want the Portal to tell me how fresh a device's camera inventory is, so that I do not trust stale data from a device that has been offline. + +#### Acceptance Criteria + +1. WHEN the Portal displays a Camera_Source whose last-reported timestamp is older than the Staleness_Threshold, THE Portal SHALL present the Camera_Source as stale together with its last-reported timestamp. +2. WHEN the Portal displays Camera_Registry data for an Edge_Device that AWS IoT reports as disconnected, THE Portal SHALL indicate the Edge_Device's disconnected status alongside the camera inventory. +3. THE Portal SHALL allow a user with the PortalAdmin role to configure the Staleness_Threshold. +4. WHEN a Camera_Source is marked absent by Camera_Discovery, THE Portal SHALL display the Camera_Source as absent with the timestamp at which the absence was reported. + +### Requirement 5: Portal-to-Edge Synchronization + +**User Story:** As an operator, I want to define or edit camera sources for a device from the Portal, so that I can prepare and correct device input configuration remotely. + +#### Acceptance Criteria + +1. WHEN a user with the Operator permission creates, updates, or deletes a Camera_Source for an Edge_Device in the Portal, THE Portal_Sync_Service SHALL record the change in the Camera_Registry as pending and deliver the change to the Edge_Device over the Sync_Channel. +2. WHEN the Edge_Sync_Agent receives a Portal-originated Camera_Source change, THE Edge_Sync_Agent SHALL apply the change to the device-local Image_Source tables through the existing LocalServer input-configuration accessors, preserving the validation those accessors enforce. +3. WHEN the Edge_Sync_Agent successfully applies a Portal-originated change, THE Edge_Sync_Agent SHALL report the applied state to the Sync_Channel, and THE Portal_Sync_Service SHALL mark the corresponding Camera_Registry entry as synced. +4. IF the Edge_Sync_Agent fails to apply a Portal-originated change (for example the LocalServer schema validation rejects the configuration), THEN THE Edge_Sync_Agent SHALL report the failure with a descriptive reason to the Sync_Channel, and THE Portal_Sync_Service SHALL mark the Camera_Registry entry as failed and display the reason in the Portal. +5. WHILE a target Edge_Device is disconnected from AWS IoT, THE Portal_Sync_Service SHALL retain Portal-originated changes as pending and deliver them when the Edge_Device reconnects. +6. WHEN a user attempts to create, update, or delete a Camera_Source with origin edge-discovered, THE Portal SHALL reject the modification and identify the Camera_Source as discovery-managed. +7. WHEN a user without the Operator permission attempts to create, update, or delete a Camera_Source in the Portal, THE Portal SHALL deny the request with an authorization error. + +### Requirement 6: Conflict Detection and Resolution + +**User Story:** As an operator, I want the system to handle the same camera being edited on the device and in the Portal at the same time in a predictable way, so that no edit is silently lost without a trace. + +#### Acceptance Criteria + +1. WHEN synchronization discovers that a Camera_Source was modified on both the Edge_Device and in the Portal after that Camera_Source's last completed synchronization, THE Portal_Sync_Service SHALL classify the condition as a Conflict. +2. WHEN a Conflict is detected, THE Portal_Sync_Service SHALL retain the Edge_Device's version as the effective Camera_Source configuration in the Camera_Registry. +3. WHEN a Conflict is detected, THE Portal_Sync_Service SHALL record a conflict event containing both conflicting versions, the resolution applied, and the timestamp, and THE Portal SHALL display the conflict event on the affected Edge_Device's Camera_Registry view. +4. WHEN a user with the Operator permission reviews a recorded conflict event in the Portal, THE Portal SHALL offer to re-apply the overridden Portal version as a new Portal-originated change. +5. WHEN a Camera_Source is deleted on the Edge_Device and modified in the Portal after the last completed synchronization, THE Portal_Sync_Service SHALL treat the deletion as the effective state and record a conflict event. + +### Requirement 7: Camera Selection in the Workflow Builder + +**User Story:** As a computer vision engineer building a workflow, I want to pick a camera from a device's registered inventory when configuring a camera input node, so that I do not have to type device paths blind. + +#### Acceptance Criteria + +1. WHEN a user configures a Camera_Input_Node in the Workflow_Builder and selects a reference Edge_Device, THE Workflow_Builder SHALL present the Camera_Sources registered for that Edge_Device in the Camera_Registry as selectable values for the node's device parameters. +2. WHEN a user selects a Camera_Source for a Camera_Input_Node, THE Workflow_Builder SHALL populate the node's parameters from the selected Camera_Source and record the selection as a default binding hint on the node. +3. WHERE a user prefers manual entry, THE Workflow_Builder SHALL continue to accept directly typed device parameter values for a Camera_Input_Node. +4. WHEN the Workflow_Builder presents Camera_Sources for selection, THE Workflow_Builder SHALL display each Camera_Source's name, type, device path or URL, sync status, and staleness indication. +5. WHEN a Camera_Input_Node carries a default binding hint, THE Workflow_Builder SHALL retain the hint in the workflow definition without making the workflow specific to the referenced Edge_Device. + +### Requirement 8: Deploy-Time Camera Binding + +**User Story:** As an operator deploying a workflow, I want to bind the workflow's camera input nodes to actual cameras registered on each target device, so that one workflow can deploy to multiple devices with different camera assignments instead of relying on paths baked in at packaging time. + +#### Acceptance Criteria + +1. WHEN a user creates a deployment of a Workflow_Component that contains at least one Camera_Input_Node, THE Deployment_Service SHALL present, for each Camera_Input_Node and each target Edge_Device, the Camera_Sources registered for that Edge_Device in the Camera_Registry as selectable binding options. +2. WHEN a user selects a Camera_Source as the binding for a Camera_Input_Node on a target Edge_Device, THE Deployment_Service SHALL record a Camera_Binding associating that Camera_Input_Node, that Edge_Device, and the selected Camera_Source identifier in the deployment. +3. THE Deployment_Service SHALL accept distinct Camera_Bindings for the same Camera_Input_Node across different target Edge_Devices in a single deployment. +4. WHERE a user chooses manual override instead of selecting a registered Camera_Source, THE Deployment_Service SHALL accept explicit parameter values for the Camera_Input_Node, validate the values against the node type's declared parameter constraints, and record the override as the Camera_Binding. +5. WHEN a Camera_Input_Node carries a default binding hint that matches a Camera_Source present in a target Edge_Device's Camera_Registry, THE Deployment_Service SHALL pre-select that Camera_Source as the proposed binding for that Edge_Device, subject to user confirmation or change. +6. THE Deployment_Service SHALL deliver Camera_Bindings to target Edge_Devices as deployment configuration alongside the Workflow_Component, leaving the packaged Workflow_Component artifact unchanged. +7. IF a deployment of a Workflow_Component containing a Camera_Input_Node is submitted with a Camera_Input_Node that has neither a selected Camera_Source nor a manual override for a target Edge_Device, THEN THE Deployment_Service SHALL reject the deployment with a message identifying the unbound Camera_Input_Node and Edge_Device. +8. IF a target Edge_Device has no Camera_Sources in the Camera_Registry and has never completed a synchronization, THEN THE Deployment_Service SHALL display a warning and permit binding only through manual override. +9. WHERE a Workflow_Component contains no Camera_Input_Node, THE Deployment_Service SHALL create the deployment without requesting Camera_Bindings. + +### Requirement 9: Deploy-Time Binding Validation + +**User Story:** As an operator, I want camera bindings checked against the target device's registered cameras before the deployment goes out, so that I catch missing or mismatched cameras in the Portal instead of on the factory floor. + +#### Acceptance Criteria + +1. WHEN a deployment with Camera_Bindings is submitted, THE Deployment_Service SHALL verify that every Camera_Binding referencing a registered Camera_Source resolves to a Camera_Source currently present in the Camera_Registry for the target Edge_Device. +2. IF a Camera_Binding references a Camera_Source that is absent from the target Edge_Device's Camera_Registry at submission time, THEN THE Deployment_Service SHALL reject the deployment with a message identifying the missing Camera_Source and Edge_Device. +3. IF a Camera_Binding references a Camera_Source that is marked absent, stale, or whose sync status is failed or pending, THEN THE Deployment_Service SHALL display a warning identifying the Camera_Source's condition and require explicit user confirmation before creating the deployment. +4. WHEN a Camera_Binding's Camera_Source type is incompatible with the Camera_Input_Node's node type (for example a Folder source bound to a camera_source node), THE Deployment_Service SHALL reject the binding with a message identifying the type mismatch. +5. WHEN a deployment targets an Edge_Device and the Workflow_Component's Camera_Input_Nodes carry only compiled-in device paths without Camera_Bindings, THE Deployment_Service SHALL compare each compiled-in device path against the target Edge_Device's Camera_Registry and display a warning for each path that matches no registered Camera_Source. + +### Requirement 10: Device-Side Binding Resolution + +**User Story:** As an operator, I want the edge device to resolve camera bindings when a deployed workflow is registered, so that the workflow runs against the bound camera and fails visibly when the camera is missing. + +#### Acceptance Criteria + +1. WHEN the Workflow_Engine registers a deployed Workflow_Component that carries Camera_Bindings, THE Workflow_Engine SHALL resolve each Camera_Binding against the device-local Camera_Source inventory (Image_Source records and Camera_Discovery results) and apply the resolved camera parameters to the corresponding Camera_Input_Node before the workflow becomes runnable. +2. IF a Camera_Binding references a Camera_Source that has no matching entry in the device-local inventory at registration time, THEN THE Workflow_Engine SHALL mark the workflow registration as invalid with a reason identifying the missing Camera_Source, and THE Workflow_Engine SHALL reject trigger requests for that registration. +3. WHEN a Camera_Binding carries manual override parameter values, THE Workflow_Engine SHALL apply the override values to the Camera_Input_Node in place of a registered Camera_Source lookup. +4. WHEN a previously missing Camera_Source becomes present on the Edge_Device after a registration was marked invalid, THE Workflow_Engine SHALL re-evaluate the affected registration and mark it registered when all Camera_Bindings resolve. +5. WHEN a Workflow_Component without Camera_Bindings is registered, THE Workflow_Engine SHALL register the Workflow_Component using the parameter values compiled into the Workflow_Component. + +### Requirement 11: Backward Compatibility + +**User Story:** As an operator, I want existing workflows and device configurations to keep working exactly as they do today, so that introducing camera sync does not disrupt production lines. + +#### Acceptance Criteria + +1. WHEN a Workflow_Component packaged before this feature (carrying only compiled-in device paths and no Camera_Bindings) is deployed or already resides on an Edge_Device, THE Workflow_Engine SHALL register and execute the Workflow_Component using the compiled-in parameter values with the same behavior as before this feature. +2. WHEN the Edge_Sync_Agent or Camera_Discovery is unavailable or fails on an Edge_Device, THE LocalServer SHALL continue executing its existing pipelines and deployed Workflow_Components without interruption. +3. THE Edge_Sync_Agent SHALL read from and write to the device-local Image_Source tables only through the existing LocalServer accessors, leaving the schema and semantics of those tables unchanged for existing LocalServer functionality. +4. WHEN a LocalServer version containing this feature is deployed to an Edge_Device, THE LocalServer SHALL preserve all existing Image_Source records and classic pipeline configurations without migration or modification. +5. THE Portal SHALL introduce the Camera_Registry without requiring changes to existing saved workflow definitions or previously created deployments. + +### Requirement 12: Access Control and Auditing + +**User Story:** As a use case administrator, I want camera registry changes and binding decisions restricted by role and recorded, so that I can trace who changed device input configuration and when. + +#### Acceptance Criteria + +1. THE Portal SHALL permit viewing the Camera_Registry to users holding the Viewer permission or higher for the Edge_Device's Use_Case. +2. THE Portal SHALL restrict creating, updating, and deleting Camera_Sources and confirming binding warnings to users holding the Operator permission or higher for the Edge_Device's Use_Case. +3. WHEN a Camera_Source is created, updated, or deleted through the Portal, or a Conflict is resolved, or a deployment with Camera_Bindings is created, THE Portal SHALL record an audit event in the existing Portal audit log containing the acting user, the affected Edge_Device, the affected Camera_Source or deployment identifier, and the timestamp. +4. WHEN Camera_Source state is exchanged over the Sync_Channel, THE Portal_Sync_Service and Edge_Sync_Agent SHALL use the Edge_Device's existing AWS IoT identity and policies for authentication and authorization of the exchange. diff --git a/.kiro/specs/camera-registry-sync/tasks.md b/.kiro/specs/camera-registry-sync/tasks.md new file mode 100644 index 00000000..5b4f3791 --- /dev/null +++ b/.kiro/specs/camera-registry-sync/tasks.md @@ -0,0 +1,320 @@ +# Implementation Plan: Camera Registry Sync + +## Overview + +Implementation spans three codebases. The pure cores are built first because everything else hangs off them: the Camera_Discovery enumeration and diff, the `build_inventory` merge, the `reduce_report` sync reducer, the `validate_camera_bindings` deployment validator, and the `resolve_bindings` device-side resolver. Portal infrastructure (DynamoDB table, IoT rule + SQS + Lambda wiring, API routes) proceeds in parallel; the Lambdas, frontend views, and edge wiring follow, ending with integration tests and backward-compatibility verification. + +Test baselines that must stay green throughout: portal backend `edge-cv-portal/backend/tests` (moto-backed conftest stack, 883 passing), infrastructure jest (30 passing) plus `npx tsc --noEmit` clean, frontend vitest (423 passing) plus `npm run build`, and LocalServer `test/backend-test` run with `PYTHONPATH=src/backend:test/backend-test` (204 passing + 3 skipped). Python property tests use `hypothesis` and TypeScript property tests use `fast-check`, each configured for a minimum of 100 iterations and tagged `**Feature: camera-registry-sync, Property {number}: {property_text}**`. + +## Tasks + +- [x] 1. Implement edge camera discovery (`src/backend/camera_discovery/`) + - [x] 1.1 Implement the V4L2/CSI enumeration core + - New module `src/backend/camera_discovery/` with frozen `DiscoveredCamera` dataclass (`stable_id`, `device_path`, `card_name`, `bus_info`, `driver`, `kind`, `formats`) and `CameraDiscovery.enumerate() -> DiscoveryResult` + - Glob `/dev/video*`, issue `VIDIOC_QUERYCAP` / `VIDIOC_ENUM_FMT` / `VIDIOC_ENUM_FRAMESIZES` via `fcntl.ioctl` behind an injectable ioctl layer so tests supply fake devices; skip nodes without the `VIDEO_CAPTURE` capability; classify Tegra CSI drivers as `kind="csi"`; stable id `disc-{sha1(bus_info+card)[:12]}` + - Per-node failures recorded in `DiscoveryResult.failures` with enumeration continuing; total failure yields an empty result plus a logged error, never an exception + - _Requirements: 2.1, 2.2, 2.6, 11.2_ + + - [x] 1.2 Write property test for discovery enumeration completeness + - **Feature: camera-registry-sync, Property 2: Discovery enumeration completeness** + - **Validates: Requirements 2.1, 2.2** + - Hypothesis over generated fake capture devices; run under `PYTHONPATH=src/backend:test/backend-test` in `test/backend-test` + + - [x] 1.3 Write property test for discovery failure isolation + - **Feature: camera-registry-sync, Property 3: Discovery failure isolation** + - **Validates: Requirements 2.6** + + - [x] 1.4 Implement the periodic re-enumeration loop and snapshot diff + - `CameraDiscovery.start(interval_seconds=300, on_change=...)` / `stop()`; interval configurable through the existing feature-configs mechanism + - Pure diff over consecutive `DiscoveryResult` snapshots: stable ids present before and missing now are emitted as absent with an absence timestamp; ids are never dropped from the tracked set; `on_change` fires only when the inventory changed + - _Requirements: 2.3, 2.4_ + + - [x] 1.5 Write property test for absence marking on re-enumeration + - **Feature: camera-registry-sync, Property 4: Absence marking on re-enumeration** + - **Validates: Requirements 2.4** + + - [x] 1.6 Write unit tests for discovery interval configuration + - Default 300 s interval, feature-config override honored, `on_change` suppressed when consecutive enumerations are identical (fake clock) + - _Requirements: 2.3_ + +- [x] 2. Implement the Edge_Sync_Agent (`src/backend/camera_sync/`) + - [x] 2.1 Implement the build_inventory pure merge and local version state + - Pure `build_inventory(image_sources, discovery_result) -> list[CameraSourceState]`: an Image_Source whose resolved device path equals a discovered camera's path yields one entry (configured id `cfg-{imageSourceId}`, configured params, discovered capability metadata, origin `edge-configured`, `discovered: true`); discovered-only hardware yields origin `edge-discovered`; no device path appears in two entries + - Per-source version counters persisted in `/aws_dda/camera_sync_state.json` (atomic temp-file + rename), bumped on content-hash change; corrupt/missing state file re-floors versions from the shadow's current reported state + - Image_Sources read only through the existing `ImageSourceAccessor` — no SQLite schema or accessor changes + - _Requirements: 1.1, 2.5, 11.3_ + + - [x] 2.2 Write property test for the configured/discovered merge + - **Feature: camera-registry-sync, Property 5: Configured/discovered merge** + - **Validates: Requirements 2.5** + + - [x] 2.3 Implement the report path over the dda-camera-registry shadow + - `EdgeSyncAgent` using the existing `IoTShadowAccessor` (shadow name `dda-camera-registry`, thing name from `AWS_IOT_THING_NAME`) and the MQTT `SubscriptionHandler` pattern for delta notifications + - Every report is the complete current inventory in the reported-document shape from the design (schemaVersion, cameras keyed by stable id, failures, discoveryErrors); capability metadata truncated with `capabilitiesTruncated` when a report would exceed 7 KB + - Report triggers: LocalServer start (full report), Image_Source CRUD via `agent.report_inventory()` called from the existing FastAPI route layer, discovery `on_change`, and portal-change application; debounced to one shadow write per 5 s window (within the 30 s bound); failed writes retried with exponential backoff so the first post-reconnect write is automatically the complete catch-up state + - _Requirements: 3.1, 3.3, 3.4, 12.4_ + + - [x] 2.4 Write property test for reconnect catch-up publication + - **Feature: camera-registry-sync, Property 6: Reconnect publishes complete current state** + - **Validates: Requirements 3.3** + - Hypothesis over interleaved inventory changes and failing shadow writes against a fake shadow accessor + + - [x] 2.5 Write unit tests for report timing + - Debounce window and 30 s bound with a fake clock; startup full report; report trigger on Image_Source CRUD hook + - _Requirements: 3.1, 3.4_ + + - [x] 2.6 Implement the portal-change apply path (on_delta) + - Apply each `desired.changes[csid]` through `InputConfigurationAccessor` / `ImageSourceAccessor`, preserving schema validation, camera-manager side effects, and DIO handling; success reports the applied state with `portal_change_id` echoed in `ack`; `ValidationError`/`HTTPException` reports `{csid, status: "failed", reason}` with the message verbatim; changes targeting origin `edge-discovered` refused with reason `discovery-managed`; applied or failed desired entries cleared by writing `null` + - _Requirements: 5.2, 5.3, 5.4, 5.6, 11.3_ + + - [x] 2.7 Write property test for the portal change apply/report round trip + - **Feature: camera-registry-sync, Property 7: Portal change apply/report round trip** + - **Validates: Requirements 5.2, 5.3, 5.4** + - Hypothesis over portal changes applied against an in-memory SQLite device database, reduced through the portal reducer (import from the backend module or mirror the reducer contract) + + - [x] 2.8 Wire the agent into server_setup.py with failure isolation + - Start `CameraDiscovery` and `EdgeSyncAgent` on a daemon thread from `server_setup.py` behind a top-level exception guard; construction or start failure is logged and leaves the rest of LocalServer startup untouched; no migration — the agent only reads existing tables through accessors + - _Requirements: 11.2, 11.4_ + + - [x] 2.9 Write agent isolation and no-migration tests + - A raising agent constructor/start does not propagate into server setup; smoke test: agent started against a populated fixture SQLite database leaves every existing row byte-identical + - _Requirements: 11.2, 11.4_ + +- [x] 3. Checkpoint - edge discovery and sync agent complete + - Ensure all tests pass (`PYTHONPATH=src/backend:test/backend-test`, baseline 204+3skip preserved), ask the user if questions arise. + +- [x] 4. Provision portal infrastructure (CDK, `edge-cv-portal/infrastructure`) + - [x] 4.1 Add camera registry infrastructure to the CDK stacks + - DynamoDB table `dda-portal-camera-registry` (PK `device_id`, SK item-type-prefixed `CAMERA#`/`META`/`CONFLICT#`, GSI `usecase-index` on `usecase_id`) following the existing `dda-portal-*` conventions + - SQS shadow-report queue with DLQ; `camera_sync.py` Lambda with SQS event source; `camera_registry.py` Lambda with API Gateway routes (`GET/POST /devices/{id}/cameras`, `PUT/DELETE /devices/{id}/cameras/{csid}`, `GET /devices/{id}/cameras/conflicts`, `POST .../conflicts/{cid}/reapply`, `POST .../cameras/refresh`); deployments Lambda granted `iot-data` shadow write for `dda-camera-bindings` + - IoT topic rule on `$aws/things/+/shadow/name/dda-camera-registry/update/documents` routed to the queue (cross-account queue policy), provisioned through the existing ComputeStack/use-case onboarding handlers + - _Requirements: 1.1, 1.4, 3.2, 12.4_ + + - [x] 4.2 Write infrastructure tests + - Jest assertions for the table schema/GSI, queue + DLQ wiring, Lambda event sources, routes, and the IoT rule/queue policy in the onboarding template; `npx tsc --noEmit` clean, jest baseline 30 preserved + - _Requirements: 1.1, 3.2_ + +- [x] 5. Implement the Portal_Sync_Service (`edge-cv-portal/backend/functions/camera_sync.py`) + - [x] 5.1 Implement the reduce_report pure reducer + - `reduce_report(registry_entry, incoming, now_ms) -> SyncOutcome` with actions `upsert | discard_stale | conflict`: version-guarded staleness discard; ack matching `portal_change_id` marks synced; failure entries mark failed with reason; conflict exactly when a pending portal change is unacknowledged and the edge content differs from the pending content, edge wins with a ConflictEvent carrying both versions, resolution, and timestamp; a reported deletion while a portal update is pending resolves as deletion-retained with a ConflictEvent + - Reducer is idempotent under duplicate/out-of-order delivery; every processed report stamps the device `META` item (`last_report_at`, clears `never_synced`) + - _Requirements: 3.2, 3.5, 5.3, 5.4, 6.1, 6.2, 6.3, 6.5_ + + - [x] 5.2 Write property test for the sync reducer round trip + - **Feature: camera-registry-sync, Property 1: Sync reducer round trip with version guard** + - **Validates: Requirements 1.1, 1.2, 1.4, 3.2, 3.5** + - Hypothesis generators per the design: multi-source inventories, version regressions, all origins and sync statuses, whitespace/unicode names, degenerate registries + + - [x] 5.3 Write property test for conflict classification + - **Feature: camera-registry-sync, Property 8: Conflict classification with edge-wins resolution** + - **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + + - [x] 5.4 Implement the SQS ingest handler + - Lambda handler consuming shadow-documents events from the queue, applying `reduce_report` per camera source and persisting outcomes (camera items, META, CONFLICT items) to `dda-portal-camera-registry` scoped to the device's `usecase_id`; malformed or unparseable reports logged and dead-lettered without blocking the batch + - _Requirements: 1.4, 3.2_ + + - [x] 5.5 Write unit tests for ingest behavior + - Duplicate and out-of-order SQS delivery idempotency; malformed report dead-lettering; META stamping and `never_synced` clearing; moto-backed conftest stack + - _Requirements: 3.2, 3.5_ + +- [x] 6. Implement the Camera_Registry API (`edge-cv-portal/backend/functions/camera_registry.py`) + - [x] 6.1 Implement the read routes + - `GET /devices/{device_id}/cameras` (Viewer): registry entries plus META, per-entry `stale` computed against the Staleness_Threshold, absent flag with `absent_since`, IoT connectivity status from the existing device-status lookup; `never_synced` devices return `{state: "never-synced"}`, not an empty list + - `GET /devices/{device_id}/cameras/conflicts` (Viewer): conflict events newest first + - Authorization via the existing use-case permission checks against the device's `usecase_id`; out-of-scope requests get the standard 403 + - _Requirements: 1.3, 1.5, 1.6, 4.1, 4.2, 4.4, 6.3, 12.1_ + + - [x] 6.2 Implement the mutation, conflict-reapply, and refresh routes + - `POST/PUT/DELETE` camera routes (Operator): create origin `portal-created` / update / pending-delete; write the shadow `desired.changes` entry first, then mark the registry entry `pending` with a fresh `portal_change_id`; reject mutations of origin `edge-discovered` with `DISCOVERY_MANAGED`; assumed-role shadow client failures return 502 with registry state untouched + - `POST .../conflicts/{cid}/reapply` (Operator): re-issue the overridden portal version as a new pending change; `POST .../cameras/refresh` (Viewer): on-demand `GetThingShadow` via `get_usecase_client` running the same reducer + - All mutating routes call `log_audit_event` with acting user, device, camera source, and timestamp + - _Requirements: 5.1, 5.6, 5.7, 6.4, 12.2, 12.3_ + + - [x] 6.3 Write property test for portal mutations + - **Feature: camera-registry-sync, Property 9: Portal mutation produces pending state and matching desired document** + - **Validates: Requirements 5.1** + + - [x] 6.4 Write property test for discovery-managed immutability + - **Feature: camera-registry-sync, Property 10: Discovery-managed sources are immutable from the Portal** + - **Validates: Requirements 5.6** + + - [x] 6.5 Add the staleness threshold setting + - Settings table entry `camera_registry.staleness_threshold_hours` (default 24) readable by the cameras route and editable by PortalAdmin through the existing settings API + - _Requirements: 4.3_ + + - [x] 6.6 Write unit tests for the registry API + - RBAC matrix per route (Viewer read, Operator mutate, cross-use-case 403 with `unauthorized_access` audit event); never-synced response shape; staleness boundaries at exactly/just past the threshold; disconnected-status pass-through; settings default; absent display fields; conflict re-apply; audit event payload per mutating route + - _Requirements: 1.5, 1.6, 4.1, 4.2, 4.3, 4.4, 5.7, 6.4, 12.1, 12.2, 12.3_ + +- [x] 7. Checkpoint - portal sync and registry API complete + - Ensure all tests pass (backend baseline 883 preserved, jest 30, tsc clean), ask the user if questions arise. + +- [x] 8. Implement the device cameras view (frontend) + - [x] 8.1 Implement the device detail cameras view + - Cameras section on the device detail view listing name, type, parameters, capability metadata, origin, sync status (with failure reason), and last-reported timestamp; stale and absent badges with timestamps; device disconnected indicator; explicit "never synced" state; conflict event list with re-apply action; create/edit/delete forms for portal-managed sources (discovery-managed sources read-only); refresh-now button hitting the refresh route + - _Requirements: 1.3, 1.6, 4.1, 4.2, 4.4, 5.4, 6.3, 6.4_ + + - [x] 8.2 Write frontend tests for the cameras view + - Vitest component tests: field display, stale/absent/disconnected/never-synced rendering, discovery-managed edit blocking, conflict re-apply flow + - _Requirements: 1.3, 1.6, 4.1, 4.2, 4.4, 5.6, 6.4_ + +- [x] 9. Implement the Workflow_Builder camera picker (frontend) + - [x] 9.1 Implement the camera reference control in NodeConfigPanel + - For the `camera_source` node's `device` parameter (keyed off node type + parameter name, no `workflow_core` schema change): reference-device selector over the current Use_Case's devices, camera dropdown fed by `GET /devices/{id}/cameras` showing name, type, path/URL, sync status, and staleness badge; selection populates the node's `device` (plus gain/exposure when present) and stores `data.cameraBindingHint = { cameraSourceId, cameraName, sourceDeviceId }`; "manual entry" toggle retains the plain text input; the hint stays advisory node data so the definition remains device-portable + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5_ + + - [x] 9.2 Write property test for camera selection application + - **Feature: camera-registry-sync, Property 11: Camera selection populates the node and records the hint** + - **Validates: Requirements 7.2** + - fast-check, `numRuns: 100`, over generated Camera_Sources applied to a Camera_Input_Node + + - [x] 9.3 Write frontend tests for the picker + - Picker population and display fields, staleness badges, manual entry toggle, hint persistence in node data + - _Requirements: 7.1, 7.3, 7.4, 7.5_ + +- [x] 10. Extend the Component_Packager (`edge-cv-portal/backend/functions/workflow_packaging.py`) + - [x] 10.1 Emit bindingPoints and record the version-item discriminator + - For each Camera_Input_Node (type `camera_source` or a Custom_Node_Type with the new optional `camera_backed: true` descriptor flag), append a `bindingPoints` entry to `compiled_pipeline.json`: nodeId, nodeType, bindingHint from the definition, rendered default parameters, and arch-specific `slots` (v4l2src `device` arg on x86_64/x86_64_nvidia; `adapterBinding: true` with empty slots on JP4/JP5; CSI sensor selection on JP6); compiled elements keep their fully rendered defaults + - Record `has_binding_points: true` and `camera_input_nodes` (node id, node type, binding hint, per-arch compiled device paths) on the workflow version item in DynamoDB + - _Requirements: 8.6, 11.5_ + + - [x] 10.2 Write property test for binding-hint transparency + - **Feature: camera-registry-sync, Property 12: Binding hints are transparent to validation and compilation** + - **Validates: Requirements 7.5, 11.5** + - Hypothesis: validate and compile hinted definitions versus hint-stripped equivalents + + - [x] 10.3 Write packaging snapshot tests + - Extend `test_workflow_generation.py`/packaging suite: packaging a camera workflow produces the same rendered segments as before plus the `bindingPoints` section; packaging a workflow without camera nodes is byte-identical to pre-feature output + - _Requirements: 11.1, 11.5_ + +- [x] 11. Extend the Deployment_Service (`edge-cv-portal/backend/functions/deployments.py`) + - [x] 11.1 Implement the validate_camera_bindings pure function + - `validate_camera_bindings(version_item, targets, registry_snapshot, bindings, confirmed) -> (errors, warnings)` + - Errors: unbound Camera_Input_Node on any target when `has_binding_points`; referenced `cameraSourceId` absent from the target's registry; Camera_Source type incompatible with the node type; override values violating the node type's declared parameter constraints via the existing `workflow_core` parameter validator + - Warnings requiring matching `confirmed_warnings` ids: bound source stale/absent/pending/failed; `never_synced` target restricted to manual override; legacy path check when `has_binding_points` is false comparing compiled-in device paths against the target registry (warnings only, never errors) + - Distinct bindings per device for the same node accepted; versions with no Camera_Input_Nodes produce no errors or warnings + - _Requirements: 8.3, 8.4, 8.7, 8.8, 8.9, 9.1, 9.2, 9.3, 9.4, 9.5, 11.1_ + + - [x] 11.2 Write property test for binding completeness validation + - **Feature: camera-registry-sync, Property 13: Binding completeness validation** + - **Validates: Requirements 8.3, 8.7, 8.9** + + - [x] 11.3 Write property test for binding existence validation + - **Feature: camera-registry-sync, Property 14: Binding existence validation** + - **Validates: Requirements 9.1, 9.2** + + - [x] 11.4 Write property test for degraded-source warning gating + - **Feature: camera-registry-sync, Property 15: Degraded-source warnings gate submission on confirmation** + - **Validates: Requirements 8.8, 9.3** + + - [x] 11.5 Write property test for type and override constraint validation + - **Feature: camera-registry-sync, Property 16: Type compatibility and override constraint validation** + - **Validates: Requirements 8.4, 9.4** + + - [x] 11.6 Write property test for the legacy compiled-path warning + - **Feature: camera-registry-sync, Property 17: Legacy compiled-path warning** + - **Validates: Requirements 9.5, 11.1** + + - [x] 11.7 Implement the binding context endpoint and deployment submission + - Binding context endpoint returning per-target Camera_Sources for each Camera_Input_Node with hint-matching pre-selection; `create_workflow_deployment` accepts `camera_bindings` and `confirmed_warnings`, runs `validate_camera_bindings` alongside the existing pre-submit gates, and rejects with `REGISTRY_UNAVAILABLE` when the registry read fails rather than skipping validation + - On success, write `desired.bindings["{workflowId}/{version}"]` into each target thing's `dda-camera-bindings` shadow (assumed-role iot-data client), prune keys for versions no longer deployed, then create the Greengrass deployment with the artifact untouched; a mid-submission shadow write failure aborts deployment creation and best-effort prunes already-written targets; bindings stored on the workflow-deployment record; audit event on creation + - _Requirements: 8.1, 8.2, 8.5, 8.6, 12.3_ + + - [x] 11.8 Write property test for hint pre-selection + - **Feature: camera-registry-sync, Property 18: Binding hint pre-selection** + - **Validates: Requirements 8.5** + + - [x] 11.9 Write property test for binding delivery + - **Feature: camera-registry-sync, Property 19: Binding delivery round trip** + - **Validates: Requirements 8.2, 8.6** + - Hypothesis: desired documents written to a fake shadow client decode back to the submitted bindings per device; the packaged artifact bytes are unchanged by submission + + - [x] 11.10 Write unit tests for deployment submission behavior + - Partial shadow-write failure aborting with target pruning; `REGISTRY_UNAVAILABLE` rejection; audit event payload; binding storage on the deployment record + - _Requirements: 8.6, 12.3_ + +- [x] 12. Implement the CreateDeployment binding matrix (frontend) + - [x] 12.1 Add the binding matrix step to CreateDeployment.tsx + - Nodes × target devices matrix shown only when the selected workflow version has Camera_Input_Nodes (skipped entirely otherwise); per-cell camera dropdown from the binding context endpoint with hint pre-selection subject to user confirmation; per-cell manual-override entry; never-synced targets warn and restrict to manual override; warning confirmation checkboxes feeding `confirmed_warnings`; validation errors surfaced identifying the node and device + - _Requirements: 8.1, 8.4, 8.5, 8.7, 8.8, 8.9, 9.2, 9.3_ + + - [x] 12.2 Write frontend tests for the binding matrix + - Matrix rendering per node/device, hint pre-selection, manual override entry, warning confirmation flow, skip when no Camera_Input_Nodes + - _Requirements: 8.1, 8.5, 8.8, 8.9, 9.3_ + +- [x] 13. Checkpoint - packaging, deployment binding, and frontend complete + - Ensure all tests pass (backend 883 baseline, frontend vitest 423 baseline, `npm run build`), ask the user if questions arise. + +- [x] 14. Implement Workflow_Engine binding resolution (`src/backend/workflow_engine/`) + - [x] 14.1 Implement the resolve_bindings pure function + - New `src/backend/workflow_engine/camera_binding.py`: `resolve_bindings(document, bindings, local_inventory) -> ResolutionResult` (substituted document copy, status resolved|invalid, missing list, adapter_assignments) + - `cameraSourceId` bindings look up the local inventory (same `build_inventory` stable ids) and substitute resolved parameter values into declared `slots`; `override` bindings substitute directly, constraint-checked against the vendored catalog descriptor; JP4/5 adapter binding points produce `adapter_assignments`; documents without `bindingPoints` or with no bindings supplied are returned unchanged + - _Requirements: 10.1, 10.3, 10.5, 11.1_ + + - [x] 14.2 Write property test for device-side binding resolution + - **Feature: camera-registry-sync, Property 20: Device-side binding resolution** + - **Validates: Requirements 10.1, 10.2, 10.3** + + - [x] 14.3 Write property test for no-binding identity + - **Feature: camera-registry-sync, Property 22: No-binding identity** + - **Validates: Requirements 10.5, 11.1, 11.5** + - Generators include legacy documents lacking `bindingPoints` entirely + + - [x] 14.4 Implement the CameraBindingStore and watcher integration + - `CameraBindingStore` reading the `dda-camera-bindings` shadow via `IoTShadowAccessor`, cached, refreshed on shadow delta; `WorkflowWatcher._register` fetches bindings for `{workflow_id}/{version}` and calls `resolve_bindings`; unresolved `cameraSourceId` marks the registration invalid with reason `missing camera source {csid}` so the existing invalid-registration path rejects triggers; unreadable bindings shadow marks binding-point documents invalid with reason `bindings unavailable` while documents without binding points register as today; invalid registrations re-resolved on discovery `on_change` and bindings-shadow delta, flipping to registered when everything resolves + - _Requirements: 10.2, 10.4, 11.1_ + + - [x] 14.5 Write property test for registration re-evaluation + - **Feature: camera-registry-sync, Property 21: Registration re-evaluation on camera appearance** + - **Validates: Requirements 10.4** + + - [x] 14.6 Write unit tests for watcher binding behavior + - Bindings shadow unreadable: binding-point documents invalid, legacy documents register unchanged; trigger rejection for invalid registrations; pre-feature compiled documents register and execute with compiled-in values exactly as before + - _Requirements: 10.2, 11.1_ + +- [x] 15. Integration, backward compatibility, and wiring + - [x] 15.1 Write shadow sync integration tests + - One integration test per direction over an emulated named shadow: edge report ingested through the IoT-rule/SQS path into the registry using the device's IoT identity; portal desired change delivered as a delta, applied, acknowledged, and marked synced, including the disconnected-then-reconnect pending-delivery case + - _Requirements: 3.3, 5.5, 12.4_ + + - [x] 15.2 Verify backward compatibility across all suites + - Run every existing suite unchanged against the new build and assert baselines: LocalServer `test/backend-test` 204+3skip, portal backend 883, frontend vitest 423 + `npm run build`, infrastructure jest 30 + `npx tsc --noEmit`; assert existing Image_Source records, classic pipeline configurations, saved workflow definitions, and prior deployments behave identically + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ + + - [x] 15.3 Final wiring and deployment readiness + - Verify end-to-end wiring in code: `server_setup.py` starts discovery and the sync agent, CDK stack synthesizes with the new table/queue/Lambdas/routes/IoT rule, API routes registered and reachable in the moto-backed route tests, frontend views wired to the new endpoints; fix any gaps surfaced by the full test runs + - _Requirements: 1.3, 3.2, 5.1, 8.6, 11.2_ + +- [x] 16. Final checkpoint + - Ensure all tests pass across all four baselines, ask the user if questions arise. + +## Notes + +- Each task references specific requirements for traceability +- Property tests use hypothesis (Python) and fast-check (TypeScript) with a minimum of 100 iterations, tagged `**Feature: camera-registry-sync, Property {number}: {property_text}**`; all 22 design properties are covered by tasks 1.2, 1.3, 1.5, 2.2, 2.4, 2.7, 5.2, 5.3, 6.3, 6.4, 9.2, 10.2, 11.2–11.6, 11.8, 11.9, 14.2, 14.3, 14.5 +- Pure cores are built first (discovery enumeration/diff, `build_inventory`, `reduce_report`, `validate_camera_bindings`, `resolve_bindings`) — everything else consumes them +- Portal backend tests run against the moto-backed conftest stack in `edge-cv-portal/backend/tests`; LocalServer tests run with `PYTHONPATH=src/backend:test/backend-test` +- V4L2 ioctls, IoT shadow transport, and Greengrass delivery are exercised through injectable fakes in unit/property tests; single integration examples cover the AWS-owned transports (task 15.1) +- No SQLite schema changes, no accessor changes, no packaged-artifact changes: all edge access goes through existing accessors, and bindings travel in the `dda-camera-bindings` shadow + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "4.1", "5.1", "10.1"] }, + { "id": 1, "tasks": ["1.2", "1.4", "4.2", "5.2", "10.2", "11.1"] }, + { "id": 2, "tasks": ["1.3", "1.5", "2.1", "5.3", "10.3", "11.2"] }, + { "id": 3, "tasks": ["1.6", "2.2", "5.4", "6.1", "11.3"] }, + { "id": 4, "tasks": ["2.3", "5.5", "6.2", "9.1", "11.4"] }, + { "id": 5, "tasks": ["2.4", "6.3", "8.1", "9.2", "11.5", "11.7"] }, + { "id": 6, "tasks": ["2.5", "2.6", "6.4", "9.3", "11.6", "12.1"] }, + { "id": 7, "tasks": ["2.7", "6.5", "8.2", "11.8", "14.1"] }, + { "id": 8, "tasks": ["2.8", "6.6", "11.9", "12.2", "14.2"] }, + { "id": 9, "tasks": ["2.9", "11.10", "14.3", "14.4"] }, + { "id": 10, "tasks": ["14.5", "15.1"] }, + { "id": 11, "tasks": ["14.6", "15.2"] }, + { "id": 12, "tasks": ["15.3"] } + ] +} +``` diff --git a/.kiro/specs/custom-node-code-assist/.config.kiro b/.kiro/specs/custom-node-code-assist/.config.kiro new file mode 100644 index 00000000..e9c6a636 --- /dev/null +++ b/.kiro/specs/custom-node-code-assist/.config.kiro @@ -0,0 +1 @@ +{"specId": "a48fdaa6-e92d-4d8f-9f50-df37f7c9774a", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/custom-node-code-assist/design.md b/.kiro/specs/custom-node-code-assist/design.md new file mode 100644 index 00000000..0572d7d2 --- /dev/null +++ b/.kiro/specs/custom-node-code-assist/design.md @@ -0,0 +1,538 @@ +# Design Document: Custom Node Code Assist + +## Overview + +This feature attaches a Bedrock-backed Code_Assistant to every Portal surface where custom Python node module code is edited, and introduces an Import_Analyzer that derives the node's pip `requirements` from the code's import statements: + +- **Code_Assistant** — a prompt panel rendered beside each code editor. The user describes the desired code or filter in natural language; the backend Code_Assist_Generator invokes the account's configured Bedrock model with the prompt, the target Node_Contract, and the runtime environment description, validates that the returned code parses and defines the required entry point, and returns it for review. Accepting places the code into the editor; nothing is auto-saved. +- **Import_Analyzer** — a pure frontend function that, on every (debounced) code change, extracts the code's imports (including imports nested in functions and conditional blocks), maps import names to pip distribution names (`cv2` → `opencv-python-headless`, `PIL` → `Pillow`, …), excludes the standard library and the runtime-provided `dda_frames` module, and reconciles the derived list into the node's `requirements` parameter while preserving every manually added or version-pinned entry. + +The feature introduces **no new configuration**: the Code_Assist_Generator reads the existing `bedrock_configuration` settings item with exactly the semantics of the workflow generation feature (defaults, explicit-null sampling parameters, timeout clamped to 1–60 s), extracted into a shared module so the two features cannot drift. + +### Code_Editing_Surfaces covered + +| Surface | Editor | Node_Contract | `requirements` param | +|---|---|---|---| +| Workflow_Builder NodeConfigPanel, `custom_python` node `code` parameter | `paramType === 'code'` Textarea | `process_frame(frame, metadata)` **or** `handle(frame_bytes, metadata)` (exactly one) | yes — Import_Analyzer active | +| Workflow_Builder NodeConfigPanel, `custom_python_preprocess` node `code` parameter | same | `process_frame(frame, metadata)` | yes — Import_Analyzer active | +| Node_Designer CreateWizard scaffold review, `plugin/frame_processing_hook.py` tab | scaffold file Tabs Textarea | `process_frame(frame, params)` (Frame_Processing_Hook) | no — plugin dependencies are meson-managed, no pip `requirements` parameter exists | +| Node_Designer GeneratePanel scaffold review, `plugin/frame_processing_hook.py` tab | same | same | no | + +The Node_Designer surfaces present the assistant only on tabs whose path ends in `.py` (today exactly the Frame_Processing_Hook); C sources, meson files, and READMEs get no assistant (Requirement 1.3 covers *Python* node module code only). + +### Key findings from investigation + +- **Bedrock plumbing exists twice already.** `workflow_generator.py` and `node_generator.py` each carry `get_bedrock_configuration()` (settings key `bedrock_configuration`, `DEFAULT_BEDROCK_CONFIG`, explicit-null handling for `temperature`/`top_p`, timeout clamped 1..60 s) and `get_bedrock_client(region, timeout)` (per-`(region, timeout)` cached `bedrock-runtime` client, client-side read timeout, retries disabled). Requirement 4 demands byte-equal semantics with workflow generation, so this design extracts the logic into a shared module rather than adding a third copy. +- **The runtime contract to describe in the prompt** lives in `src/backend/workflow_engine/python_bridge.py`: `process_frame(frame, metadata)` receives a NumPy uint8 array (H×W×C, H×W for GRAY8) and must return `None` (pass-through) or an array of identical shape/dtype; `handle(frame_bytes, metadata)` receives raw bytes and returns `(frame_bytes, metadata)`; `cv2`, `np`, and `numpy` are pre-bound on the handler module; `dda_frames` provides `to_array`, `to_bytes`, `frame_info()`, `load_image(path or s3://…)`; every handler sees `metadata["frame"] = {width, height, format}`; stdout belongs to the framed protocol so handlers must never print to it. +- **The Node_Designer hook contract differs**: `render_scaffold` (`workflow_core/scaffold.py`) emits `plugin/frame_processing_hook.py` exposing `process_frame(frame, params)` where `params` carries the declared GObject parameters — no `metadata`, no `dda_frames`, no pip requirements. The generator therefore needs a per-contract environment description, not one hardcoded prompt. +- **`requirements` is a plain string parameter** (requirements.txt form) packaged verbatim by `workflow_packaging.py` as `python/{nodeId}/requirements.txt`. There is no side-channel to store "which entries were derived", so derived-vs-manual must be encoded in the string itself (see the marker-comment decision below). +- **The packager ships only `handler.py`** per custom Python node — no sibling source files exist on the Workflow_Builder surfaces, so Requirement 3.3's "modules packaged alongside the node module's own files" reduces to excluding relative imports (`from . import x`). +- **Routing precedent**: `POST /workflows/generate` is a synchronous LambdaIntegration on the `WorkflowGeneratorHandler` Lambda (60 s Lambda timeout). The api-gateway nested stack is close to the CloudFormation 500-resource limit (its integrations disable test-invoke for that reason), so a new endpoint should reuse an existing Lambda rather than adding a function + role + permissions. +- **Frontend gating predicates exist**: `canEditWorkflows(role)` (`WorkflowToolbar.tsx`) gates workflow editing (DataScientist/UseCaseAdmin/PortalAdmin — exactly the roles holding `workflow:create`/`workflow:edit`); the Node_Designer backend's `can_generate` (`node_generator.py`) allows UseCaseAdmin-in-Use_Case or PortalAdmin. +- **Error-view precedent**: `node-designer/generate.ts` shows the pattern for pure, unit-testable error-presentation helpers (`describeGenerationError`) that keep the prompt in the input box on every failure. +- **The catalog's own `requirements` example** already names `opencv-python-headless`, and the edge LocalServer environment reports its OpenCV as `opencv_python_headless` (`src/backend/endpoints/system.py`), so the Import_Mapping designates `cv2 → opencv-python-headless`. + +## Architecture + +```mermaid +graph TB + subgraph Frontend[React frontend] + NCP[NodeConfigPanel
custom_python / custom_python_preprocess
code + requirements params] + NDW[Node_Designer CreateWizard / GeneratePanel
frame_processing_hook.py tab] + CAP[CodeAssistPanel
prompt, review, accept/reject
pure reducer codeAssistState.ts] + IA[importAnalyzer.ts
extractImports -> deriveRequirements
-> reconcileRequirements
IMPORT_MAPPING + STDLIB tables] + API[services/api.ts
codeAssist request] + NCP --> CAP + NDW --> CAP + NCP -->|debounced code change| IA + IA -->|reconciled requirements string| NCP + CAP --> API + end + subgraph Backend[WorkflowGeneratorHandler Lambda] + RT[workflow_generator.handler
route dispatch] + CA[code_assist.py
RBAC per surface, prompt assembly,
invocation, entry-point validation] + BC[bedrock_common.py shared module
get_bedrock_configuration
get_bedrock_client, defaults] + WG[workflow_generator.py
existing /workflows/generate] + RT --> CA + RT --> WG + CA --> BC + WG --> BC + end + API -->|POST /code-assist| APIGW[API Gateway] + APIGW --> RT + CA -->|Converse, forced tool use| BR[Amazon Bedrock] + BC -->|bedrock_configuration| DDB[(Settings table)] + CA -->|denied attempts| AUD[(Audit log)] +``` + +Request flow for one assist: + +1. The user types a prompt (1–4,000 chars) in the CodeAssistPanel and submits. The panel reducer enters `submitting` (spinner shown, submit disabled, editor untouched and still editable). +2. The frontend POSTs `/code-assist` with `{usecase_id, surface, contract, prompt, current_code, context}` — `current_code` is sent only when the editor holds a non-whitespace character (Requirements 2.6, 2.10). +3. `code_assist.py` authorizes per surface (workflow permission or Node_Designer role), returning 403 + audit entry before any generation on denial (Requirement 6.3). +4. It loads the Bedrock_Configuration through the shared module, assembles the contract-specific system prompt and user message, and invokes Converse with a forced `provide_code` tool call. +5. The tool output's `code` is validated server-side with `ast.parse` + entry-point inspection; failures return categorized error envelopes; success returns `{code, notes, model_id}`. +6. The panel shows the returned code for review; Accept writes it into the editor (which triggers the Import_Analyzer on Workflow_Builder surfaces), Reject discards it and keeps the prompt. +7. Independently of the assistant, every editor code change (typed or accepted) runs the Import_Analyzer after a 750 ms debounce — well inside the 2-second bound of Requirement 3.1 — and reconciles the node's `requirements` parameter. + +### Key Design Decisions + +| Decision | Choice | Rationale | +|---|---|---| +| Where the Import_Analyzer runs | Frontend TypeScript, pure synchronous module | Requirement 3.1's 2-second reactive bound applies to *every* keystroke-driven code change; an in-browser pure function is instant, works offline from the backend, adds no per-edit Lambda traffic, and — being pure — is the feature's best property-test target. The mapping table is **not shared with the backend** because the backend never derives requirements (generation returns code only; derivation always happens editor-side, uniformly for typed and generated code, Requirement 3.1). One table, one owner, no cross-language sync problem. | +| Import extraction technique | Hand-written line/continuation-aware scanner over import statements (comments and string literals stripped first), not a full Python parser | A real Python grammar in the browser (pyodide, tree-sitter WASM) costs megabytes for one function. The scanner recognizes the complete `import` / `from … import` statement grammar including parenthesized and backslash continuations and nested (indented) imports. "Cannot be parsed" (Requirement 3.10) is defined as: a malformed import statement or an unterminated string/paren encountered while scanning — precisely the failures that could corrupt derivation. Syntax errors *outside* import statements cannot change the import set, so deriving from a module that is otherwise broken is safe and useful (the user is mid-edit most of the time). | +| Derived-vs-manual encoding | Trailing marker comment on derived lines: `name # via code imports` (unmapped: `name # via code imports (verify package name)`) | The `requirements` parameter string is the only persisted artifact (packaged verbatim as requirements.txt, where comments are legal). A sidecar data structure would desync on reload/duplicate/generate paths. Any line **without** the marker is manual and preserved verbatim — which automatically protects user pins like `numpy==1.24.0` (Requirements 3.5, 3.9). | +| `cv2` mapping | `opencv-python-headless` | Matches the catalog's own `requirements` example and the edge LocalServer's installed OpenCV distribution; `opencv-python` would drag GUI/X11 dependencies onto headless devices. | +| Endpoint shape | Synchronous `POST /code-assist` served by the existing WorkflowGeneratorHandler Lambda (new `code_assist.py` module, dispatched from `workflow_generator.handler`) | Single-file code generation is a small completion (one module, ≤ `max_tokens`), far quicker than the 45–50 s multi-file scaffold generation that forced `node_generator.py` into start/poll sessions. Reusing the Lambda avoids a new function/role/permission set in a nested stack near the CloudFormation resource limit, and the Lambda already holds Bedrock invoke permissions and the settings-table read. The API Gateway 29 s integration ceiling is a pre-existing, shared caveat with `POST /workflows/generate`; a gateway-cut request surfaces through the same timeout error path (Requirement 5.2). | +| Statelessness | No chat sessions; each request carries the current editor code | Requirement 2.6 defines follow-up semantics purely in terms of the current editor content. Dropping sessions removes the DynamoDB table, TTL, and S3 snapshots the workflow generator needs, and guarantees Requirement 6.4 (authorization evaluated fresh per request) structurally. | +| Structured output | Converse forced tool use (`provide_code` tool with `{code, notes}` input schema) | Same mechanism as both existing generators: extraction is a field read, never regex-scraping markdown fences. "No tool call or empty `code`" is the well-defined trigger for Requirement 5.3. | +| Entry-point validation location | Backend, `ast.parse` + top-level `FunctionDef` inspection | The Lambda has a real Python parser; the response is then either code that provably parses and carries the contract's entry point, or a categorized error (Requirements 2.2, 2.3, 5.6). For `custom_python` the validator requires **exactly one** of `process_frame`/`handle` (Requirement 2.3) — two entry points would silently shadow one another at runtime (the bridge prefers `process_frame`). | +| Shared config module | New `backend/functions/bedrock_common.py`; `workflow_generator.py` refactored to import it; `node_generator.py` migration optional follow-up | Requirement 4 pins the assist feature to workflow generation's exact semantics; sharing code makes divergence impossible. The `node_generator.py` copy is left as-is to keep this feature's diff contained (its docstrings/session logic have diverged); migrating it is a mechanical cleanup, not a requirement. | + +## Components and Interfaces + +### 1. `bedrock_common.py` — shared Bedrock configuration (backend/functions) + +Extracted verbatim from `workflow_generator.py` (Requirements 4.1–4.7): + +```python +BEDROCK_CONFIG_SETTING_KEY = 'bedrock_configuration' +MAX_TIMEOUT_SECONDS = 60 +DEFAULT_BEDROCK_CONFIG = { ... } # unchanged values + +def get_bedrock_configuration() -> Dict: # unchanged semantics: + # defaults -> stored overrides; temperature/top_p honor explicit null; + # timeout coerced to int, junk -> 60, clamped to [1, 60] +def get_bedrock_client(region: str, timeout_seconds: int): # unchanged: + # per-(region, timeout) cache, connect_timeout min(t, 10), + # read_timeout = t, retries disabled +def build_inference_config(config: Dict) -> Dict: + # {'maxTokens': int(config['max_tokens'])} plus at most ONE sampling + # parameter: temperature when set, else topP when set (4.2, 4.3) +``` + +`workflow_generator.py` replaces its local copies with imports (same Lambda bundle — `backend/functions` is one code asset — so this is a same-directory import exactly like its existing `from workflow_validation import …`). `build_inference_config` is newly factored out of `invoke_generation` so both callers share the sampling-exclusivity rule. + +### 2. `code_assist.py` — Code_Assist_Generator (backend/functions) + +Handles `POST /code-assist`, dispatched from `workflow_generator.handler` (the module lives in the same bundle; the handler gains one `resource == '/code-assist'` branch). Same error envelope, `parse_body`, `forbidden_response`-style helpers as the existing generators. + +**Request validation** (400 on failure): +- `usecase_id`, `surface`, `contract`, `prompt` required (`MISSING_FIELDS`) +- `surface` ∈ {`workflow-builder`, `node-designer`} (`INVALID_SURFACE`) +- `contract` ∈ {`process_frame`, `process_frame_or_handle`, `frame_hook`} (`INVALID_CONTRACT`) +- `prompt` a string with ≥ 1 non-whitespace character and `len(prompt) <= 4000` (`INVALID_PROMPT`) — the server-side twin of the frontend check (Requirements 1.4, 2.8) +- `current_code` optional string; `context` optional object (`{node_type?, parameters?: [{name, param_type, description?}]}`, used by `frame_hook` prompts) + +**Authorization** (Requirements 6.1–6.4), evaluated on every request before any Bedrock call: + +```python +def is_authorized(user, usecase_id, surface) -> bool: + if surface == 'workflow-builder': + return has_permission(WORKFLOW_CREATE) or has_permission(WORKFLOW_EDIT) + return rbac_manager.get_user_role(user, usecase_id) == 'UseCaseAdmin' \ + or user_is_portal_admin(user) # same rule as node_generator.can_generate +``` + +Denial returns the uniform 403 `FORBIDDEN` envelope and writes an `unauthorized_access` audit entry carrying the acting user, `surface`, `usecase_id`, and timestamp (Requirement 6.3) — the same `log_audit_event` call shape as `forbidden_response` in `workflow_generator.py`. `get_usecase(usecase_id)` failure returns 404 `USECASE_NOT_FOUND`. + +**Prompt assembly** — pure functions (property-tested): + +```python +CONTRACTS = { + 'process_frame': { + 'entry_points': frozenset({'process_frame'}), 'require_exactly_one': False, + 'signature': 'process_frame(frame, metadata)', + 'environment': PYTHON_BRIDGE_ENVIRONMENT, # see below + }, + 'process_frame_or_handle': { + 'entry_points': frozenset({'process_frame', 'handle'}), 'require_exactly_one': True, + 'signature': 'process_frame(frame, metadata) or handle(frame_bytes, metadata)', + 'environment': PYTHON_BRIDGE_ENVIRONMENT, + }, + 'frame_hook': { + 'entry_points': frozenset({'process_frame'}), 'require_exactly_one': False, + 'signature': 'process_frame(frame, params)', + 'environment': FRAME_HOOK_ENVIRONMENT, + }, +} + +def build_system_prompt(contract: str, context: Optional[Dict]) -> str +def build_user_message(prompt: str, current_code: Optional[str]) -> str +``` + +`PYTHON_BRIDGE_ENVIRONMENT` describes the Python_Bridge runtime faithfully (Requirement 2.1), sourced from `python_bridge.py`: +- `process_frame(frame, metadata)`: `frame` is a NumPy uint8 array (H×W×C; H×W for GRAY8, formats RGB/BGR/RGBA/GRAY8); return `None` to pass the frame through, or an array of **identical shape and dtype** (the bridge rejects anything else); mutate `metadata` in place to attach results. +- `handle(frame_bytes, metadata)`: raw bytes in, `(frame_bytes, metadata)` out. +- `cv2`, `np`, and `numpy` are pre-bound on the module — no import needed, but an explicit import is harmless. +- `import dda_frames` provides `to_array(frame_bytes, width, height, format)`, `to_bytes(array)`, `frame_info()` → `{'width', 'height', 'format'}`, `load_image(path_or_s3_uri)` (BGR uint8). +- `metadata["frame"]` carries `{width, height, format}` on every invocation. +- Never write to stdout (it belongs to the frame protocol); use `sys.stderr` for diagnostics. +- Extra pip packages may be imported freely; the Portal derives the node's requirements from the imports (so the model should emit a normal `import` for any library the user asks for — Requirement 3.4). +- Keep the module complete and self-contained: when current code is provided, return the **entire modified module**, never a fragment or diff (Requirement 2.6). + +`FRAME_HOOK_ENVIRONMENT` describes the Frame_Processing_Hook: `process_frame(frame, params)` with `params` holding the declared element parameters (names/types injected from `context.parameters`), embedded-interpreter execution, return the processed frame. + +`build_user_message(prompt, current_code)` embeds `current_code` in a `CURRENT MODULE CODE` block with modify-not-regenerate instructions **iff** it contains a non-whitespace character; otherwise it sends the prompt alone (Requirements 2.6, 2.10) — mirroring `workflow_generator.build_user_message`. + +**Invocation** — `get_bedrock_client(...).converse(...)` with `build_inference_config(config)` and forced tool use: + +```python +TOOL_NAME = 'provide_code' +# inputSchema: {type: object, required: [code], properties: { +# code: {type: string, description: 'the complete Python module'}, +# notes: {type: string, description: 'one short paragraph for the user'}}} +``` + +Exception mapping mirrors `invoke_generation` and adds Requirement 5.1's categories in `details.category` (see Error Handling). + +**Output validation** — pure function (property-tested): + +```python +def validate_entry_point(code: str, contract: str) -> Optional[str]: + """None when valid; a defect description otherwise. + - ast.parse failure -> 'generated code is not valid Python: ...' + - top-level FunctionDef names are intersected with the contract's + entry_points; zero matches -> 'missing entry point ...' + - require_exactly_one and both + process_frame and handle defined -> 'defines both entry points ...' + """ +``` + +Parse failure returns 422 `GENERATED_CODE_INVALID`; a missing/duplicated entry point returns 422 `MISSING_ENTRY_POINT` (Requirements 2.2, 2.3, 5.6). Empty/absent tool output returns 422 `NO_CODE_RETURNED` (Requirement 5.3). + +**Success response** (200): + +```json +{ "code": "...", "notes": "...", "model_id": "us.anthropic....", "contract": "process_frame" } +``` + +Nothing is persisted anywhere — no DynamoDB, no S3 (Requirements 2.7, 6.4). + +### 3. Infrastructure (api-gateway-stack.ts, compute-stack.ts) + +- `api-gateway-stack.ts`: one new top-level resource `code-assist` with `POST` on the existing `workflowGeneratorIntegration` (Cognito authorizer, `allowTestInvoke: false`, CORS OPTIONS like its siblings). Two CloudFormation resources total; no new Lambda. +- `compute-stack.ts`: no change needed — `WorkflowGeneratorHandler` already bundles `backend/functions`, has the 60 s timeout, the settings-table read, and `bedrock:InvokeModel*` permissions used by `/workflows/generate`. + +### 4. `importAnalyzer.ts` — Import_Analyzer (frontend/src/pages/workflows/) + +A dependency-free pure module (the feature's main property-test target): + +```typescript +/** Result of scanning module code for import statements. */ +export type ImportScan = + | { ok: true; imports: string[] } // absolute top-level module names, deduped + | { ok: false }; // unparseable (Requirement 3.10) + +export function extractImports(code: string): ImportScan; +``` + +The scanner strips comments and string literals (tracking single/double/triple quotes; an unterminated string ⇒ `{ok:false}`), joins backslash and open-paren continuations, and matches every logical line — at any indentation, so nested imports count (Requirement 3.1) — against the import-statement grammar: + +- `import a.b.c as x, d` → top-level names `a`, `d` +- `from a.b import x, y` → top-level name `a` +- `from . import x` / `from .sib import x` → **relative**: recorded as excluded (Requirement 3.3) +- A line starting with `import`/`from` that does not match the grammar ⇒ `{ok:false}` + +```typescript +/** One derived requirements entry. */ +export interface DerivedRequirement { + distribution: string; // pip distribution name + needsReview: boolean; // true when the import had no mapping (3.7) +} + +export function deriveRequirements(imports: string[]): DerivedRequirement[]; +``` + +`deriveRequirements` drops names in `STDLIB_MODULES` (the union of CPython 3.9 and 3.11 `sys.stdlib_module_names`, plus `__future__`) and `dda_frames`, then maps the rest: `IMPORT_MAPPING[name]` when present (`needsReview: false`), else the import name itself with `needsReview: true` (Requirements 3.2, 3.3, 3.7). Output is sorted and deduped. + +```typescript +export const IMPORT_MAPPING: Record = { + cv2: 'opencv-python-headless', PIL: 'Pillow', + sklearn: 'scikit-learn', skimage: 'scikit-image', + yaml: 'PyYAML', bs4: 'beautifulsoup4', + dateutil: 'python-dateutil', dotenv: 'python-dotenv', + serial: 'pyserial', usb: 'pyusb', + zmq: 'pyzmq', paho: 'paho-mqtt', + tflite_runtime: 'tflite-runtime', numpy: 'numpy', + scipy: 'scipy', pandas: 'pandas', requests: 'requests', + matplotlib: 'matplotlib', torch: 'torch', torchvision: 'torchvision', + onnxruntime: 'onnxruntime', boto3: 'boto3', +}; +``` + +(Identity entries are listed explicitly where the requirements name them — `numpy` per 3.2 — or where they are common in this domain; anything absent falls through to the identity-plus-review rule, so the table only ever *improves* accuracy.) + +**Reconciliation** — the derived-vs-manual merge (Requirements 3.5, 3.9): + +```typescript +export const DERIVED_MARKER = '# via code imports'; + +export interface RequirementsEntry { + raw: string; // the verbatim line (manual lines round-trip exactly) + distribution: string | null; // normalized (PEP 503) name, null for blank/comment lines + derived: boolean; // line carries DERIVED_MARKER + needsReview: boolean; // derived line carries the verify suffix +} + +export function parseRequirements(text: string): RequirementsEntry[]; +export function renderRequirements(entries: RequirementsEntry[]): string; + +export function reconcileRequirements( + currentText: string, + derived: DerivedRequirement[] +): string; +``` + +`reconcileRequirements`: +1. `parseRequirements(currentText)`; keep every non-derived line **verbatim and in order** (manual entries, pins, user comments, blank lines). +2. Drop every previously derived line (marker present). +3. Append one line per derived entry whose PEP 503-normalized distribution does not match any surviving manual entry's distribution (Requirement 3.9): `${distribution} ${DERIVED_MARKER}` or `${distribution} ${DERIVED_MARKER} (verify package name)`. + +The function is idempotent for a fixed derived list and never touches manual text. Callers apply it only when `extractImports` returned `ok: true` (Requirement 3.10) and only when the result differs from the current value (no spurious dirty state). + +### 5. `CodeAssistPanel` — shared assistant UI (frontend/src/components/code-assist/) + +CloudScape panel rendered inside the owning surface (Requirements 1.1–1.3), props: + +```typescript +interface CodeAssistPanelProps { + usecaseId: string | null; + surface: 'workflow-builder' | 'node-designer'; + contract: CodeAssistContract; + context?: { nodeType?: string; parameters?: HookParameter[] }; + editorCode: string; // live editor value (2.6 / 2.10) + onAccept: (code: string) => void; // the ONLY path that touches the editor +} +``` + +State is a pure reducer (`codeAssistState.ts`, property-tested): + +```typescript +type CodeAssistState = + | { phase: 'idle'; prompt: string; error: CodeAssistErrorView | null } + | { phase: 'submitting'; prompt: string } + | { phase: 'reviewing'; prompt: string; code: string; notes: string }; + +type CodeAssistEvent = + | { type: 'edit-prompt'; value: string } + | { type: 'submit' } // ignored unless idle + valid prompt (1.4, 1.6, 2.8) + | { type: 'succeeded'; code: string; notes: string } + | { type: 'failed'; error: CodeAssistErrorView } // -> idle, SAME prompt (5.1-5.3, 5.5) + | { type: 'accept' } // reviewing -> idle, prompt cleared, onAccept fired + | { type: 'reject' }; // reviewing -> idle, SAME prompt (2.9) + +export function isSubmittablePrompt(prompt: string): boolean; +// trimmed.length >= 1 && prompt.length <= 4000 (1.4, 2.8) +``` + +Rendering: prompt Textarea with a character counter and constraint text; Generate button disabled while `submitting` or the prompt is unsubmittable, with a `disabledReason` (matching GenerateChatPanel's pattern); `submitting` shows a `StatusIndicator` spinner (1.6) — the *editor* is a sibling component and stays enabled (1.5); `reviewing` shows the returned code read-only (monospace) with the model's `notes` and Accept / Reject buttons (2.4, 2.5, 2.9); failures render an inline Alert from `describeCodeAssistError` (pure helper modeled on `node-designer/generate.ts`) — header per category, message, and for timeouts the applied seconds — while the prompt stays in the input (5.1–5.3). + +Errors never invoke `onAccept` and the panel has no access to the requirements or save paths, so Requirement 5.4 holds structurally. + +### 6. Workflow_Builder integration (NodeConfigPanel.tsx) + +For nodes whose `typeId` is `custom_python` or `custom_python_preprocess`: + +- Below the `code` parameter's editor, render `CodeAssistPanel` with `surface='workflow-builder'`, `contract` = `process_frame_or_handle` / `process_frame` respectively, `editorCode` = the effective `code` value, and `onAccept` writing the code into `node.data.parameters` through the existing `onParametersChange` path — the exact channel manual edits use, so canvas markers, validation, and save behavior are untouched (2.5, 2.7). The panel renders only when `canEditWorkflows(role)` (the role is already threaded to the builder page) — Viewer/Operator see no assistant entry point (6.1, 6.5). +- A `useEffect` with a 750 ms debounce watches the effective `code` value; on change it runs `extractImports` → `deriveRequirements` → `reconcileRequirements(currentRequirements, derived)` and, when the text changed, writes the `requirements` parameter through `onParametersChange` (3.1, 3.5). An `{ok:false}` scan applies nothing (3.10). +- The `requirements` parameter control gains a read-only annotation list under the existing editable Textarea (3.6 — the raw text stays the editing surface): each parsed entry renders with a "derived" badge, and `needsReview` entries a warning badge "verify package name" (3.7), reusing the node-designer `badges.tsx` styling. + +### 7. Node_Designer integration (CreateWizard.tsx, GeneratePanel.tsx) + +Both scaffold-review Tabs editors render `CodeAssistPanel` under the Textarea of any tab whose path ends with `.py` (today `plugin/frame_processing_hook.py`), with `surface='node-designer'`, `contract='frame_hook'`, `context.parameters` = the declaration's parameters, `editorCode` = that file's content, and `onAccept` replacing that file in the `files` map (1.3, 2.5). Gating: the panel renders only for UseCaseAdmin/PortalAdmin — the same rule that already gates these pages' mutating actions (6.2, 6.5). No Import_Analyzer here (no pip `requirements` parameter exists on this surface). + +In GeneratePanel the per-file assistant coexists with the existing whole-scaffold chat: the chat regenerates the full file set through `node_generator.py` sessions; the Code_Assistant edits the hook file in place. They share no state. + +### 8. Frontend API client (services/api.ts) + +```typescript +async codeAssist(request: CodeAssistRequest): Promise +// POST /code-assist following the existing request conventions +// (bearer token, loading bus, ApiError envelope with code/details) +``` + +## Data Models + +### Code-assist API + +```typescript +type CodeAssistContract = 'process_frame' | 'process_frame_or_handle' | 'frame_hook'; + +interface CodeAssistRequest { + usecase_id: string; + surface: 'workflow-builder' | 'node-designer'; + contract: CodeAssistContract; + prompt: string; // 1..4000 chars, non-whitespace + current_code?: string; // present iff editor has non-whitespace content + context?: { + nodeType?: string; // e.g. 'custom_python_preprocess' + parameters?: { name: string; param_type: string; description?: string }[]; + }; +} + +interface CodeAssistResponse { + code: string; + notes: string; + model_id: string; + contract: CodeAssistContract; +} +``` + +### Error envelope + +`{"error": {"code", "message", "details"}}` — identical shape to every Workflow Manager endpoint. + +| HTTP | code | details | Requirement | +|---|---|---|---| +| 400 | `MISSING_FIELDS` / `INVALID_PROMPT` / `INVALID_SURFACE` / `INVALID_CONTRACT` / `INVALID_JSON` | — | 1.4, 2.8 | +| 403 | `FORBIDDEN` | `{surface, usecase_id}` | 6.1–6.3 | +| 404 | `USECASE_NOT_FOUND` | — | — | +| 502 | `BEDROCK_INVOCATION_FAILED` | `{category, bedrock_error_code, model_id}` | 5.1 | +| 502 | `BEDROCK_UNREACHABLE` | `{region, category: 'model-access'}` | 5.1 | +| 504 | `GENERATION_TIMEOUT` | `{timeout_seconds, model_id}` | 5.2 | +| 422 | `NO_CODE_RETURNED` | `{stop_reason}` | 5.3 | +| 422 | `GENERATED_CODE_INVALID` | `{defect}` | 2.2, 2.3 | +| 422 | `MISSING_ENTRY_POINT` | `{defect, contract}` | 5.6 | + +`details.category` ∈ `'throttling' | 'authorization' | 'model-access' | 'model-error'` (Requirement 5.1), from: + +| botocore error code | category | +|---|---| +| `ThrottlingException`, `TooManyRequestsException`, `ServiceQuotaExceededException` | `throttling` | +| `AccessDeniedException`, `UnrecognizedClientException`, `ExpiredTokenException` | `authorization` | +| `ResourceNotFoundException`, `ModelNotReadyException`, `ValidationException` | `model-access` | +| `ModelErrorException`, `ModelTimeoutException`, `ServiceUnavailableException`, `InternalServerException`, anything else | `model-error` | + +### Requirements text model (frontend) + +- `DerivedRequirement { distribution: string; needsReview: boolean }` +- `RequirementsEntry { raw: string; distribution: string | null; derived: boolean; needsReview: boolean }` +- Derived line format: ` # via code imports` / ` # via code imports (verify package name)` +- Distribution-name normalization for duplicate detection (PEP 503): lowercase, `[-_.]+` → `-`. + +### Bedrock_Configuration (unchanged, read-only reuse) + +Settings-table item under key `bedrock_configuration`: `{model_id, region, max_tokens, temperature, top_p, timeout_seconds}` with workflow generation's defaults and null semantics (Requirement 4). + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The pure functions of this design — prompt validity, invocation assembly, entry-point validation, import extraction/derivation/reconciliation, Bedrock config resolution, error categorization, and the panel reducer — are all deterministic input→output transformations, making them direct property-test targets. + +### Property 1: Prompt validity predicate + +*For any* string, `isSubmittablePrompt` (and the backend's `INVALID_PROMPT` check) accepts it if and only if it contains at least one non-whitespace character and its length is at most 4,000; a rejected prompt never triggers a Code_Assist_Generator invocation. + +**Validates: Requirements 1.4, 2.8** + +### Property 2: Invocation assembly + +*For any* prompt, contract, and editor content: the assembled Converse messages contain the prompt verbatim; the system prompt contains the contract's entry-point signature and its environment description markers (`dda_frames`, pre-bound `cv2`/`np` for the Python_Bridge contracts; `params` for `frame_hook`); and the user message embeds the editor content in a modify-this-module block if and only if the editor content contains a non-whitespace character. + +**Validates: Requirements 2.1, 2.6, 2.10** + +### Property 3: Entry-point validation + +*For any* generated Python source and contract, `validate_entry_point` returns no defect if and only if the source parses as Python **and** the set of top-level function definitions intersected with the contract's entry points satisfies the contract's rule — at least one match for `process_frame` and `frame_hook`, exactly one of {`process_frame`, `handle`} for `process_frame_or_handle`. + +**Validates: Requirements 2.2, 2.3, 5.6** + +### Property 4: Import extraction completeness + +*For any* Python module assembled from a random set of import statements (plain, aliased, multi-name, `from … import`, dotted, placed at top level or nested inside function bodies and conditional blocks, interleaved with non-import code, comments, and string literals that mention import-like text), `extractImports` returns `ok: true` with exactly the set of absolute top-level module names of the planted imports. + +**Validates: Requirements 3.1** + +### Property 5: Requirements derivation + +*For any* set of imported top-level module names, every element of `deriveRequirements`: standard-library names, `dda_frames`, and relative imports produce no entry; every name present in the Import_Mapping produces exactly its mapped distribution with `needsReview: false` (in particular `cv2` → `opencv-python-headless` and `numpy` → `numpy`); every other name produces the name itself with `needsReview: true`; and no other entries exist. + +**Validates: Requirements 3.2, 3.3, 3.7, 3.8** + +### Property 6: Reconciliation preserves manual entries and replaces derived ones + +*For any* requirements text composed of random manual lines (including version-pinned entries and comments) and previously derived marker lines, and any derived list: `reconcileRequirements` keeps every manual line verbatim and in order, removes every previously derived line not re-derived, and adds no derived entry whose PEP 503-normalized distribution equals that of a surviving manual entry. + +**Validates: Requirements 3.5, 3.9** + +### Property 7: Reconciliation idempotence + +*For any* requirements text and derived list, applying `reconcileRequirements` twice with the same derived list yields the same text as applying it once. + +**Validates: Requirements 3.5** + +### Property 8: Requirements text round trip + +*For any* list of requirements entries, `parseRequirements(renderRequirements(entries))` yields entries with identical raw lines, derived flags, and needs-review flags — so the derived/manual distinction encoded in the parameter string survives persistence and reload. + +**Validates: Requirements 3.5, 3.6, 3.7** + +### Property 9: Unparseable code changes nothing + +*For any* module code into which a malformed import statement or unterminated string literal has been injected, `extractImports` returns `ok: false`, and the surface's derivation step consequently leaves the current requirements text byte-identical. + +**Validates: Requirements 3.10** + +### Property 10: Bedrock configuration resolution + +*For any* stored configuration item (any subset of the known keys, with arbitrary extra keys, Decimal-typed numbers, explicit nulls for sampling parameters, and arbitrary junk in `timeout_seconds`), the resolved configuration equals the workflow-generation defaults overridden by the present non-null values, except that explicitly stored null `temperature`/`top_p` remain unset; and the resolved timeout is an integer in [1, 60], equal to 60 whenever the stored value is missing or not interpretable as a number. + +**Validates: Requirements 4.1, 4.4, 4.6, 4.7** + +### Property 11: Sampling parameter exclusivity + +*For any* combination of `temperature` and `top_p` values (each set or unset), `build_inference_config` emits at most one sampling parameter: `temperature` when it is set, else `topP` when it is set, and omits any parameter that is unset. + +**Validates: Requirements 4.2, 4.3** + +### Property 12: Failure category totality + +*For any* Bedrock error-code string, the categorization function returns exactly one of `throttling`, `authorization`, `model-access`, `model-error`, with each code in the designated mapping table landing in its designated category. + +**Validates: Requirements 5.1** + +### Property 13: Panel failure recovery preserves the prompt + +*For any* sequence of CodeAssistPanel reducer events, whenever a `failed` event is processed the resulting state is `idle` with the prompt string unchanged from the moment of submission and an error view present; a `reject` event likewise returns to `idle` with the prompt unchanged; `submit` is a no-op except from `idle` with a submittable prompt; and `onAccept` is invoked only by an `accept` event from `reviewing`. + +**Validates: Requirements 1.6, 2.9, 5.1, 5.2, 5.3, 5.5** + +## Error Handling + +### Backend (`code_assist.py`) + +- **Request errors** (400/403/404): validated before any Bedrock traffic; the 403 path writes the `unauthorized_access` audit entry with user, surface, Use_Case, and timestamp and never constructs a Bedrock client (Requirement 6.3). +- **Timeout** (`ReadTimeoutError`/`ConnectTimeoutError`): 504 `GENERATION_TIMEOUT` whose message states the applied (clamped) timeout in seconds and whose details carry `timeout_seconds` (Requirement 5.2). The client-side read timeout equals the clamped value and retries are disabled, so wall time cannot exceed it. +- **Endpoint unreachable** (`EndpointConnectionError`): 502 `BEDROCK_UNREACHABLE`, category `model-access`. +- **`ClientError`**: 502 `BEDROCK_INVOCATION_FAILED` with `details.category` from the mapping table and the original Bedrock message (Requirement 5.1). +- **No/empty tool output**: 422 `NO_CODE_RETURNED` (Requirement 5.3). +- **Invalid/entry-point-less code**: 422 `GENERATED_CODE_INVALID` / `MISSING_ENTRY_POINT` with the defect description (Requirements 2.2, 2.3, 5.6). +- Unexpected exceptions fall through the handler's existing 500 `INTERNAL_ERROR` guard. +- A settings-table read failure logs a warning and proceeds with defaults inside `get_bedrock_configuration` — the request never fails for that reason (Requirement 4.5). + +### Frontend + +- Every failure path funnels through the reducer's `failed` event: the spinner clears, the prompt stays in the input for resubmission, manual editing was never blocked, and the editor/`requirements`/workflow state are untouched because failures carry no code and only `accept` can emit code (Requirements 5.4, 5.5). +- `describeCodeAssistError` maps `ApiError` code + `details.category` to a headed alert (Throttled / Not authorized to invoke the model / Model not available / Model error / Timed out after N seconds / No code produced), falling back to a generic header for unknown codes — the same defensive pattern as `describeGenerationError`. +- The Import_Analyzer is failure-free by construction: an `{ok:false}` scan is a normal outcome that applies no change (Requirement 3.10); reconciliation is applied only when its output differs from the current text. + +## Testing Strategy + +Property-based tests use **hypothesis** for backend Python (`edge-cv-portal/backend/tests/`, matching `test_workflow_generation.py` conventions) and **fast-check** for frontend TypeScript (colocated `*.property.test.ts`, matching the node-designer suites). Every property test runs a minimum of **100 iterations** and carries a comment tag referencing its design property: + +``` +# Feature: custom-node-code-assist, Property 3: Entry-point validation +``` + +**Property tests (backend, hypothesis)** — Properties 2, 3, 10, 11, 12 against `code_assist.py` and `bedrock_common.py`. Generators: prompt/code strings including unicode and whitespace-only cases; synthesized Python modules with controlled top-level/nested function definitions; partial config dicts with Decimals, nulls, and junk timeouts; arbitrary error-code strings seeded with the known Bedrock codes. + +**Property tests (frontend, fast-check)** — Property 1 (`isSubmittablePrompt`), Properties 4–9 (`importAnalyzer.ts`: a module-builder arbitrary that plants known imports at random nesting/positions among filler statements, comments, and strings; a requirements-text arbitrary mixing manual lines, pins, comments, and marker lines; a corruption arbitrary injecting malformed imports/unterminated strings), and Property 13 (`codeAssistState.ts` reducer over random event sequences). + +**Unit/example tests** +- Backend: RBAC matrix per surface incl. audit-entry assertions and no-Bedrock-call-on-denial (6.1–6.3); settings read failure → defaults (4.5); mocked Converse responses for timeout with `timeout_seconds` echo (5.2), missing tool call (5.3), and the happy path; handler route dispatch for `/code-assist`. +- Frontend (Vitest + Testing Library): assistant presence on both Workflow_Builder node types and on the `.py` scaffold tab but not the C tab (1.1–1.3); role gating hides the panel for Viewer/Operator and non-admin Node_Designer users (6.5); review-before-apply, accept-into-editor, reject-preserves-everything flows (2.4, 2.5, 2.9); editor remains editable and unchanged while a request is pending (1.5, 1.6); requirements badges render for derived and needs-review entries (3.6, 3.7); no save/persist API call in any assistant flow (2.7). +- Debounce timing (3.1's 2-second bound) is verified with fake timers: a code change leads to exactly one analysis after 750 ms. + +**Not automated**: Requirement 3.4 (the model actually importing a prompt-requested library) is nondeterministic LLM behavior; it is addressed by the system-prompt instruction (asserted in Property 2's markers) and by the derivation pipeline handling whatever imports are returned. Requirement 6.4 (per-request authorization) holds structurally in the stateless handler and is covered by code review. diff --git a/.kiro/specs/custom-node-code-assist/requirements.md b/.kiro/specs/custom-node-code-assist/requirements.md new file mode 100644 index 00000000..00552f7a --- /dev/null +++ b/.kiro/specs/custom-node-code-assist/requirements.md @@ -0,0 +1,116 @@ +# Requirements Document + +## Introduction + +The Portal's workflow designer and custom node designer let users write Python code for custom node modules: the `custom_python` post-processing node and the `custom_python_preprocess` node (spec: custom-python-frames). Today the user writes that code unaided in a plain code editor and hand-maintains the node's pip `requirements` list. + +This feature adds Bedrock-backed code assistance to every surface where custom Python node module code is edited. The user describes the desired custom code or filter in natural language; the Code_Assistant generates or modifies Python code that conforms to the correct runtime contract for the node type being edited (`process_frame(frame, metadata)` for frame processing, `handle(frame_bytes, metadata)` for raw bytes), using the runtime's pre-bound OpenCV/NumPy bindings and the `dda_frames` helper module. Alongside generation, an Import_Analyzer derives the node's pip requirements from the code's import statements (whether user-written or generated), mapping import names to the correct pip distribution names (for example `import cv2` → the OpenCV package), so the user no longer hand-maintains the pip list but can still review and edit it. + +The feature reuses the existing per-account Bedrock_Configuration (spec: workflow-manager) — model id, region, max tokens, optional sampling parameters, and a timeout of at most 60 seconds — rather than introducing new configuration, and surfaces Bedrock failures (throttling, authorization, model errors, timeouts) as descriptive errors that leave the editing surface and the user's code untouched. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend) used to design, package, and deploy workflows. +- **Workflow_Builder**: The graphical canvas UI (Node_Palette, canvas, NodeConfigPanel) where users compose workflows and configure node parameters, including the code editor for custom Python node modules. +- **Node_Designer**: The Portal capability (spec: custom-node-designer) that lets authorized users create Custom_Node_Types, including wizard surfaces where node module code is written or generated. +- **Custom_Python_Node**: The post-processing node type (`custom_python`) whose user code runs on the edge through the Python_Bridge under the `handle(frame_bytes, metadata)` or `process_frame(frame, metadata)` contract. +- **Custom_Python_Preprocess_Node**: The preprocessing node type (`custom_python_preprocess`, spec: custom-python-frames) with VideoFrames input and output ports and the `process_frame(frame, metadata)` NumPy-array contract. +- **Code_Editing_Surface**: Any Portal UI where a user edits the Python code of a custom node module: the `code` parameter editor of a Custom_Python_Node or Custom_Python_Preprocess_Node in the Workflow_Builder, and any Node_Designer wizard step presenting Python node module code. +- **Code_Assistant**: The capability introduced by this feature: a prompt interface attached to each Code_Editing_Surface through which the user describes desired code in natural language and receives generated or modified Python code. +- **Code_Assist_Generator**: The Portal backend component that invokes the Amazon Bedrock model specified in the Bedrock_Configuration with the user's prompt, the target Node_Contract, and the current editor code, and returns the resulting Python code. +- **Node_Contract**: The runtime entry-point contract of the node type being edited: `process_frame(frame, metadata)` receiving a NumPy uint8 array for frame processing, or `handle(frame_bytes, metadata)` receiving raw bytes, as executed by the Python_Bridge runner. +- **Python_Bridge**: The LocalServer component (`src/backend/workflow_engine/python_bridge.py`) that executes custom Python node handlers on the edge, pre-binding `cv2`, `np`/`numpy`, and providing the Frame_Helpers module. +- **Frame_Helpers**: The runtime helper module importable from handler code as `dda_frames` (spec: custom-python-frames), providing `to_array`, `to_bytes`, `frame_info`, and `load_image` (local path or `s3://` URI). +- **Import_Analyzer**: The component introduced by this feature that derives a node module's Pip_Requirements from the import statements in the module's code. +- **Pip_Requirements**: The value of a custom Python node's `requirements` parameter: pip packages in requirements.txt form, installed on the edge device for the node module (packaged by the Component_Packager as `python/{nodeId}/requirements.txt`). +- **Import_Mapping**: The mapping from a Python import name to the pip distribution name that provides it (for example `cv2` → the OpenCV package, `PIL` → `Pillow`, `sklearn` → `scikit-learn`). +- **Bedrock_Configuration**: The existing per-account Portal settings (spec: workflow-manager, stored under setting key `bedrock_configuration`) identifying the Amazon Bedrock model id, region, max tokens, optional temperature and top_p sampling parameters, and invocation timeout used by generation features. +- **Use_Case**: The existing Portal tenancy unit to which workflows and Custom_Node_Types are scoped. + +## Requirements + +### Requirement 1: Code Assistance on All Custom Node Code Editing Surfaces + +**User Story:** As a computer vision engineer, I want AI code assistance available wherever I edit custom Python node module code, so that I can get help writing custom code and filters without leaving the editor I am in. + +#### Acceptance Criteria + +1. WHEN the code editor for the `code` parameter of a Custom_Python_Node is displayed in the Workflow_Builder, THE Portal SHALL present the Code_Assistant in the same view as the code editor, without requiring the user to navigate away. +2. WHEN the code editor for the `code` parameter of a Custom_Python_Preprocess_Node is displayed in the Workflow_Builder, THE Portal SHALL present the Code_Assistant in the same view as the code editor, without requiring the user to navigate away. +3. WHEN a Node_Designer wizard step presenting Python node module code is displayed, THE Portal SHALL present the Code_Assistant in the same view as that editor, without requiring the user to navigate away. +4. THE Code_Assistant SHALL accept a natural-language description of the desired custom code or filter of 1 to 4,000 characters as its prompt input. +5. WHILE a Code_Assist_Generator invocation is in progress, THE Code_Editing_Surface SHALL continue to accept manual edits to the editor code and SHALL make no change to the editor code other than the user's own edits. +6. WHILE a Code_Assist_Generator invocation is in progress, THE Code_Assistant SHALL display an in-progress indication and SHALL NOT accept a concurrent prompt submission. + +### Requirement 2: Contract-Conforming Code Generation + +**User Story:** As a process engineer, I want to describe the filter or processing step I need in plain language and receive working Python code for the node I am editing, so that I can build custom processing without knowing the node's runtime contract by heart. + +#### Acceptance Criteria + +1. WHEN a user submits a non-empty prompt from a Code_Editing_Surface, THE Code_Assist_Generator SHALL invoke the Amazon Bedrock model specified in the Bedrock_Configuration with the prompt, the target node type's Node_Contract, and the runtime environment description (pre-bound `cv2`, `np`/`numpy`, and the Frame_Helpers `dda_frames` module). +2. WHEN generating code for a Custom_Python_Preprocess_Node, THE Code_Assist_Generator SHALL return syntactically valid Python code defining a `process_frame(frame, metadata)` entry point that operates on the NumPy array frame. +3. WHEN generating code for a Custom_Python_Node, THE Code_Assist_Generator SHALL return syntactically valid Python code defining exactly one entry point valid under the Custom_Python_Node's runtime contract, either `process_frame(frame, metadata)` or `handle(frame_bytes, metadata)`. +4. WHEN the Code_Assist_Generator returns code, THE Code_Assistant SHALL display the returned code to the user for review before any change to the editor content. +5. WHEN a user accepts reviewed code, THE Code_Assistant SHALL place the accepted code into the code editor as the node module's code value. +6. WHEN a user submits a follow-up prompt while the editor contains code with at least one non-whitespace character, THE Code_Assist_Generator SHALL include the current editor code in the Bedrock invocation and SHALL return the complete modified node module code rather than a fragment, diff, or code unrelated to the current editor code. +7. THE Code_Assistant SHALL leave saving of the workflow or node declaration to the user's existing explicit save action and SHALL NOT itself persist the workflow or node declaration. +8. IF a user submits an empty or whitespace-only prompt, THEN THE Code_Assistant SHALL reject the submission without invoking the Code_Assist_Generator and SHALL indicate that a prompt description is required. +9. WHEN a user rejects or dismisses reviewed code, THE Code_Assistant SHALL leave the editor content unchanged and SHALL preserve the user's prompt. +10. WHEN a user submits a prompt while the editor is empty or contains only whitespace, THE Code_Assist_Generator SHALL generate new code from the prompt and the target Node_Contract without treating the empty editor content as code to modify. + +### Requirement 3: Automatic Pip Requirements Population + +**User Story:** As a computer vision engineer, I want the node's pip requirements derived automatically from the code's imports, including OpenCV and any other libraries I request, so that I do not have to hand-maintain the pip list. + +#### Acceptance Criteria + +1. WHEN the code of a custom Python node module changes on a Code_Editing_Surface (user-written or accepted generated code), THE Import_Analyzer SHALL derive the Pip_Requirements from all import statements in the code, including imports nested inside functions or conditional blocks, within 2 seconds of the change. +2. THE Import_Analyzer SHALL resolve each imported top-level module through the Import_Mapping to the pip distribution name that the Import_Mapping designates for that module, including `cv2` to the OpenCV distribution designated in the Import_Mapping and `numpy` to `numpy`. +3. THE Import_Analyzer SHALL exclude Python standard library modules, the Frame_Helpers `dda_frames` module, and modules whose source files are packaged alongside the node module's own files from the derived Pip_Requirements. +4. WHEN a user's prompt requests use of a specific Python library, THE Code_Assist_Generator SHALL return code containing an import statement for that library. +5. WHEN the Import_Analyzer derives Pip_Requirements, THE Code_Editing_Surface SHALL replace all previously derived entries in the node's `requirements` parameter with the newly derived list and SHALL retain every entry the user added or version-pinned manually unchanged. +6. WHEN the `requirements` parameter is populated, THE Code_Editing_Surface SHALL display the populated list for user review and accept user edits to the list before saving. +7. IF an imported module has no Import_Mapping entry, THEN THE Import_Analyzer SHALL include the import name itself as the pip package entry and THE Code_Editing_Surface SHALL display a visible indication on that entry that it requires user review. +8. WHEN a user accepts generated code containing an import for a prompt-requested library, THE Import_Analyzer SHALL include that library's pip distribution name in the derived Pip_Requirements. +9. IF a derived pip distribution name matches the distribution name of an entry the user added or version-pinned manually, THEN THE Code_Editing_Surface SHALL retain the user's entry and SHALL NOT add a duplicate entry for that distribution. +10. IF the node module code cannot be parsed due to syntax errors, THEN THE Import_Analyzer SHALL leave the node's existing `requirements` parameter unchanged. + +### Requirement 4: Reuse of the Existing Bedrock Configuration + +**User Story:** As a portal administrator, I want code assistance to use the Bedrock model settings I already configured for workflow generation, so that one configuration governs all Bedrock-backed features in the account. + +#### Acceptance Criteria + +1. WHEN preparing a Bedrock invocation, THE Code_Assist_Generator SHALL read the model id, region, max tokens, temperature, top_p, and timeout values in effect at the time of that invocation from the same Bedrock_Configuration settings storage used by the existing workflow generation feature. +2. IF a sampling parameter (temperature or top_p) is unset in the Bedrock_Configuration, where unset means absent from storage or explicitly stored as null, THEN THE Code_Assist_Generator SHALL omit that parameter from the Bedrock invocation. +3. IF both temperature and top_p are set in the Bedrock_Configuration, THEN THE Code_Assist_Generator SHALL send only the temperature parameter to the Bedrock invocation and SHALL omit the top_p parameter. +4. THE Code_Assist_Generator SHALL apply an invocation timeout equal to the configured timeout value clamped to the range 1 to 60 seconds inclusive. +5. IF the Bedrock_Configuration settings storage is unreadable, THEN THE Code_Assist_Generator SHALL invoke Bedrock with the same default configuration values as the existing workflow generation feature and SHALL NOT fail the code assistance request because of the read failure. +6. IF an individual configuration value other than temperature or top_p is missing from the stored Bedrock_Configuration, THEN THE Code_Assist_Generator SHALL substitute the default value used by the existing workflow generation feature for that value while applying the stored values that are present. +7. IF the configured timeout value is missing or not interpretable as a number, THEN THE Code_Assist_Generator SHALL apply a 60-second invocation timeout. + +### Requirement 5: Bedrock Failure Handling + +**User Story:** As a computer vision engineer, I want clear error messages when code generation fails, so that I understand what went wrong and can retry without losing my code or my prompt. + +#### Acceptance Criteria + +1. IF a Bedrock invocation fails with a throttling, authorization, model-access, or model error, THEN THE Code_Assistant SHALL display an error message identifying which of these four failure categories occurred and SHALL retain the submitted prompt text unmodified in the Code_Assistant prompt input for resubmission. +2. IF a Bedrock invocation does not complete within the applied timeout (the Bedrock_Configuration timeout clamped to at most 60 seconds, per Requirement 4), THEN THE Code_Assistant SHALL display a timeout error stating the applied timeout value in seconds and SHALL retain the submitted prompt text unmodified in the Code_Assistant prompt input for resubmission. +3. IF the Code_Assist_Generator returns empty output or output from which no Python code can be extracted, THEN THE Code_Assistant SHALL display an error indicating that no code was produced and SHALL retain the submitted prompt text unmodified in the Code_Assistant prompt input for resubmission. +4. IF a Code_Assist_Generator invocation fails for any reason in criteria 1, 2, 3, or 6, THEN THE Code_Editing_Surface SHALL leave the editor code, the `requirements` parameter, and the enclosing workflow or node declaration unchanged from their values at the moment of prompt submission. +5. IF a Code_Assist_Generator invocation fails, THEN THE Code_Editing_Surface SHALL cease displaying any in-progress indication, SHALL accept manual edits to the editor code, and SHALL accept a new or resubmitted prompt from the Code_Assistant. +6. IF the Code_Assist_Generator returns Python code that does not define an entry point required by the target Node_Contract (`process_frame(frame, metadata)` or `handle(frame_bytes, metadata)`), THEN THE Code_Assistant SHALL display an error indicating that the generated code lacks the required entry point and SHALL retain the submitted prompt text unmodified in the Code_Assistant prompt input for resubmission. + +### Requirement 6: Access Control + +**User Story:** As a portal administrator, I want code assistance gated by the same permissions that gate editing on each surface, so that the assistant grants no capability beyond what the user could already do by hand. + +#### Acceptance Criteria + +1. THE Portal SHALL permit Code_Assistant use on a Workflow_Builder Code_Editing_Surface only for users holding the workflow create or workflow edit permission on the Use_Case to which the edited workflow belongs. +2. THE Portal SHALL permit Code_Assistant use on a Node_Designer Code_Editing_Surface only for users holding the UseCaseAdmin role in the Use_Case to which the edited plugin belongs or the PortalAdmin role, matching the Node_Designer access rules of spec custom-node-designer. +3. IF a user without a permitting role or permission invokes the Code_Assist_Generator, THEN THE Portal SHALL deny the request before any generation is performed, return an authorization error, and record the denied attempt with the acting user, the target Code_Editing_Surface, the Use_Case, and a timestamp in the existing audit log. +4. THE Portal SHALL evaluate Code_Assistant authorization against the user's current role and permission assignments on each Code_Assist_Generator request, independent of authorization results from earlier requests in the same session. +5. WHILE a user lacks the permitting role or permission for a Code_Editing_Surface, THE Portal SHALL omit the Code_Assistant entry point from that Code_Editing_Surface. diff --git a/.kiro/specs/custom-node-code-assist/tasks.md b/.kiro/specs/custom-node-code-assist/tasks.md new file mode 100644 index 00000000..9659d9d0 --- /dev/null +++ b/.kiro/specs/custom-node-code-assist/tasks.md @@ -0,0 +1,204 @@ +# Implementation Plan: Custom Node Code Assist + +## Overview + +Implementation follows the dependency order the design lays out: the shared `bedrock_common.py` module is extracted first (with the workflow-generation regression baseline intact, since Requirement 4 pins the assist feature to those exact semantics), then the `code_assist.py` backend module with its handler dispatch, entry-point validation, and error mapping; the infrastructure route follows once the handler branch exists. On the frontend, the pure `importAnalyzer.ts` module and the `codeAssistState.ts` reducer + `CodeAssistPanel` + API client are built as standalone, property-tested units before being wired into the two surfaces: NodeConfigPanel (assistant + debounced Import_Analyzer + requirements badges) and the Node_Designer scaffold editors (assistant on `.py` tabs). Property tests sit directly beside the code they validate. + +Test baselines that must stay green throughout: portal backend pytest scoped to `tests/` run from `edge-cv-portal/backend` (moto-backed conftest stack), and the frontend suite (`npx vitest run`) plus `npm run build` run from `edge-cv-portal/frontend`. Python property tests use `hypothesis` (no hardcoded `max_examples`; the project default provides ≥100 iterations) as `test_property_*.py`; TypeScript property tests use `fast-check` with `numRuns: 100`. Each property test is tagged `**Feature: custom-node-code-assist, Property {number}: {property_text}**`. + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. bedrock_common.py extraction + workflow_generator refactor] --> T2[2. code_assist.py backend module] + T2 --> T3[3. Checkpoint - backend] + T3 --> T4[4. Infrastructure: POST /code-assist route] + T5[5. importAnalyzer.ts pure module] --> T7[7. NodeConfigPanel integration] + T6[6. codeAssistState + CodeAssistPanel + api client] --> T7 + T6 --> T8[8. Node_Designer integration] + T4 --> T9[9. Final checkpoint] + T7 --> T9 + T8 --> T9 +``` + +```json +{ + "waves": [ + { "wave": 1, "tasks": ["1", "5"], "description": "Independent foundations: the shared Bedrock configuration module extracted from workflow_generator.py (backend), and the pure import-analysis module with its extraction/derivation/reconciliation properties (frontend)" }, + { "wave": 2, "tasks": ["2", "6"], "description": "Consumers of the foundations: the code_assist.py generator with handler dispatch, validation, and error mapping (backend), and the panel reducer, CodeAssistPanel component, error presenter, and API client (frontend)" }, + { "wave": 3, "tasks": ["3"], "description": "Checkpoint: portal backend suite passes with the refactored workflow generator and the new code-assist module" }, + { "wave": 4, "tasks": ["4", "7", "8"], "description": "Wiring: the API Gateway route, the NodeConfigPanel integration (assistant + Import_Analyzer + badges), and the Node_Designer scaffold-tab integration" }, + { "wave": 5, "tasks": ["9"], "description": "Final checkpoint: all baselines pass" } + ] +} +``` + +## Tasks + +- [x] 1. Extract the shared Bedrock configuration module and refactor the workflow generator + - [x] 1.1 Create `bedrock_common.py` with configuration resolution, client cache, and inference config + - New `edge-cv-portal/backend/functions/bedrock_common.py`: `BEDROCK_CONFIG_SETTING_KEY`, `MAX_TIMEOUT_SECONDS`, `DEFAULT_BEDROCK_CONFIG` (values unchanged from `workflow_generator.py`); `get_bedrock_configuration()` with the exact existing semantics (defaults overridden by stored values, explicit-null `temperature`/`top_p` remain unset, timeout coerced to int with junk → 60 and clamped to [1, 60]); `get_bedrock_client(region, timeout_seconds)` with the per-`(region, timeout)` cache, `connect_timeout = min(t, 10)`, `read_timeout = t`, retries disabled; new `build_inference_config(config)` factored out of `invoke_generation` emitting `maxTokens` plus at most one sampling parameter (temperature when set, else topP when set) + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7_ + + - [x] 1.2 Refactor `workflow_generator.py` to import the shared module + - Replace the local `get_bedrock_configuration`/`get_bedrock_client`/defaults copies with same-directory imports from `bedrock_common` (same Lambda bundle, like the existing `from workflow_validation import …`); `invoke_generation` uses `build_inference_config`; behavior byte-equal — the existing `test_workflow_generation.py` suite passes unchanged; `node_generator.py` is left as-is + - _Requirements: 4.1_ + + - [x]* 1.3 Write property test for Bedrock configuration resolution + - **Feature: custom-node-code-assist, Property 10: Bedrock configuration resolution** + - **Validates: Requirements 4.1, 4.4, 4.6, 4.7** + - hypothesis in `edge-cv-portal/backend/tests` over stored configuration items (any subset of known keys, arbitrary extra keys, Decimal-typed numbers, explicit nulls for sampling parameters, junk `timeout_seconds`): resolved config equals defaults overridden by present non-null values with explicit-null sampling parameters unset; resolved timeout is an integer in [1, 60], equal to 60 whenever the stored value is missing or uninterpretable + + - [x]* 1.4 Write property test for sampling parameter exclusivity + - **Feature: custom-node-code-assist, Property 11: Sampling parameter exclusivity** + - **Validates: Requirements 4.2, 4.3** + - hypothesis over set/unset combinations of `temperature` and `top_p`: `build_inference_config` emits at most one sampling parameter — `temperature` when set, else `topP` when set — and omits unset parameters + + - [x]* 1.5 Write unit test for settings read failure falling back to defaults + - A raising settings-table read inside `get_bedrock_configuration` logs a warning and returns the workflow-generation defaults; no exception escapes + - _Requirements: 4.5_ + +- [x] 2. Implement the Code_Assist_Generator backend module + - [x] 2.1 Implement request validation, per-surface authorization, and prompt assembly in `code_assist.py` + - New `edge-cv-portal/backend/functions/code_assist.py`: request validation per the design's 400 matrix (`MISSING_FIELDS`, `INVALID_SURFACE`, `INVALID_CONTRACT`, `INVALID_PROMPT` for prompts without a non-whitespace character or over 4,000 chars, optional `current_code` string and `context` object); `is_authorized(user, usecase_id, surface)` — workflow create/edit permission for `workflow-builder`, UseCaseAdmin-in-Use_Case or PortalAdmin for `node-designer` (same rule as `node_generator.can_generate`); denial returns the uniform 403 `FORBIDDEN` envelope and writes the `unauthorized_access` audit entry (acting user, surface, usecase_id, timestamp) before any Bedrock client is constructed; `get_usecase` failure → 404 `USECASE_NOT_FOUND`; the `CONTRACTS` table (`process_frame`, `process_frame_or_handle` with `require_exactly_one`, `frame_hook`) with `PYTHON_BRIDGE_ENVIRONMENT` and `FRAME_HOOK_ENVIRONMENT` descriptions per the design; pure `build_system_prompt(contract, context)` and `build_user_message(prompt, current_code)` embedding `current_code` in the modify-not-regenerate block iff it contains a non-whitespace character + - _Requirements: 1.4, 2.1, 2.6, 2.8, 2.10, 6.1, 6.2, 6.3, 6.4_ + + - [x] 2.2 Implement the Bedrock invocation, entry-point validation, and error mapping + - Converse call through `bedrock_common.get_bedrock_client` + `build_inference_config` with the forced `provide_code` tool (`{code, notes}` input schema); pure `validate_entry_point(code, contract)` via `ast.parse` + top-level `FunctionDef` intersection with the contract's entry points (at least one match; exactly one of `process_frame`/`handle` for `process_frame_or_handle`); error envelopes per the design table: 504 `GENERATION_TIMEOUT` stating the applied timeout seconds, 502 `BEDROCK_UNREACHABLE` (category `model-access`), 502 `BEDROCK_INVOCATION_FAILED` with `details.category` from the botocore error-code → {throttling, authorization, model-access, model-error} mapping, 422 `NO_CODE_RETURNED` / `GENERATED_CODE_INVALID` / `MISSING_ENTRY_POINT`; success returns `{code, notes, model_id, contract}` with nothing persisted + - _Requirements: 2.2, 2.3, 2.7, 5.1, 5.2, 5.3, 5.6_ + + - [x] 2.3 Dispatch `POST /code-assist` from the workflow generator handler + - `workflow_generator.handler` gains a `resource == '/code-assist'` branch delegating to `code_assist.py` (same bundle); existing routes unchanged; unexpected exceptions fall through the existing 500 `INTERNAL_ERROR` guard + - _Requirements: 2.1_ + + - [x]* 2.4 Write property test for invocation assembly + - **Feature: custom-node-code-assist, Property 2: Invocation assembly** + - **Validates: Requirements 2.1, 2.6, 2.10** + - hypothesis over prompts, contracts, and editor content (including unicode and whitespace-only): assembled messages contain the prompt verbatim; the system prompt carries the contract's entry-point signature and environment markers (`dda_frames`, pre-bound `cv2`/`np` for Python_Bridge contracts; `params` for `frame_hook`); the user message embeds the editor content in the modify-this-module block iff it contains a non-whitespace character + + - [x]* 2.5 Write property test for entry-point validation + - **Feature: custom-node-code-assist, Property 3: Entry-point validation** + - **Validates: Requirements 2.2, 2.3, 5.6** + - hypothesis over synthesized Python modules with controlled top-level/nested function definitions (and invalid sources) × contracts: `validate_entry_point` returns no defect iff the source parses and the top-level definitions satisfy the contract's rule — at least one match for `process_frame`/`frame_hook`, exactly one of {`process_frame`, `handle`} for `process_frame_or_handle` + + - [x]* 2.6 Write property test for failure category totality + - **Feature: custom-node-code-assist, Property 12: Failure category totality** + - **Validates: Requirements 5.1** + - hypothesis over arbitrary error-code strings seeded with the known Bedrock codes: the categorization returns exactly one of `throttling`/`authorization`/`model-access`/`model-error`, with every code in the design's mapping table landing in its designated category + + - [x]* 2.7 Write unit tests for RBAC, error paths, and the happy path + - RBAC matrix per surface (workflow permissions; UseCaseAdmin/PortalAdmin) including the audit-entry assertion and no Bedrock client construction on denial; mocked Converse: read-timeout → 504 with `timeout_seconds` echoing the clamped value, missing/empty tool call → 422 `NO_CODE_RETURNED`, happy path returning `{code, notes, model_id, contract}`; handler route dispatch for `/code-assist`; request-validation 400s + - _Requirements: 5.1, 5.2, 5.3, 6.1, 6.2, 6.3_ + +- [x] 3. Checkpoint - backend complete + - Run the portal backend suite (pytest scoped to `tests/` from `edge-cv-portal/backend`); ensure all tests pass — including the pre-existing workflow generation suite against the refactored module — ask the user if questions arise. + +- [x] 4. Add the infrastructure route + - [x] 4.1 Add `POST /code-assist` to the API Gateway stack + - `edge-cv-portal/infrastructure/lib/api-gateway-stack.ts`: one new top-level `code-assist` resource with `POST` on the existing `workflowGeneratorIntegration` (Cognito authorizer, `allowTestInvoke: false`, CORS OPTIONS like its siblings); no compute-stack change (the Lambda already bundles `backend/functions` with the 60 s timeout, settings-table read, and Bedrock permissions); `npm run build` in `infrastructure` compiles clean + - _Requirements: 2.1_ + +- [x] 5. Implement the Import_Analyzer pure module (frontend) + - [x] 5.1 Implement `extractImports` in `importAnalyzer.ts` + - New `edge-cv-portal/frontend/src/pages/workflows/importAnalyzer.ts`: the line/continuation-aware scanner — strip comments and string literals (single/double/triple quotes; unterminated string → `{ok:false}`), join backslash and open-paren continuations, match every logical line at any indentation against the import grammar (`import a.b.c as x, d` → `a`, `d`; `from a.b import x, y` → `a`; relative `from . import …` recorded as excluded); a line starting with `import`/`from` that does not match the grammar → `{ok:false}`; result deduped absolute top-level names + - _Requirements: 3.1, 3.3, 3.10_ + + - [x] 5.2 Implement `deriveRequirements` with the Import_Mapping and stdlib tables + - `IMPORT_MAPPING` per the design (`cv2` → `opencv-python-headless`, `PIL` → `Pillow`, `sklearn` → `scikit-learn`, `yaml` → `PyYAML`, identity entries incl. `numpy`, …); `STDLIB_MODULES` as the union of CPython 3.9 and 3.11 `sys.stdlib_module_names` plus `__future__`; `deriveRequirements(imports)` drops stdlib names and `dda_frames`, maps mapped names with `needsReview: false`, falls through to identity-plus-`needsReview: true` for unmapped names; output sorted and deduped + - _Requirements: 3.2, 3.3, 3.7_ + + - [x] 5.3 Implement requirements parsing, rendering, and reconciliation + - `DERIVED_MARKER` (`# via code imports`, unmapped suffix `(verify package name)`); `parseRequirements`/`renderRequirements` over `RequirementsEntry` (verbatim raw lines, PEP 503-normalized distribution — lowercase, `[-_.]+` → `-`, derived and needsReview flags); `reconcileRequirements(currentText, derived)` keeping every non-derived line verbatim and in order, dropping previously derived lines, appending one marker line per derived entry whose normalized distribution matches no surviving manual entry; idempotent for a fixed derived list + - _Requirements: 3.5, 3.9_ + + - [x]* 5.4 Write property test for import extraction completeness + - **Feature: custom-node-code-assist, Property 4: Import extraction completeness** + - **Validates: Requirements 3.1** + - fast-check (`numRuns: 100`) with a module-builder arbitrary planting known imports (plain, aliased, multi-name, `from … import`, dotted, top-level or nested in function bodies and conditional blocks) among filler statements, comments, and import-mentioning string literals: `extractImports` returns `ok: true` with exactly the planted absolute top-level names + + - [x]* 5.5 Write property test for requirements derivation + - **Feature: custom-node-code-assist, Property 5: Requirements derivation** + - **Validates: Requirements 3.2, 3.3, 3.7, 3.8** + - fast-check over sets of imported top-level names: stdlib names, `dda_frames`, and relative imports produce no entry; mapped names produce exactly their mapped distribution with `needsReview: false` (in particular `cv2` → `opencv-python-headless`, `numpy` → `numpy`); every other name produces itself with `needsReview: true`; no other entries exist + + - [x]* 5.6 Write property test for reconciliation preserving manual entries + - **Feature: custom-node-code-assist, Property 6: Reconciliation preserves manual entries and replaces derived ones** + - **Validates: Requirements 3.5, 3.9** + - fast-check with a requirements-text arbitrary mixing manual lines, version pins, comments, and marker lines × derived lists: every manual line kept verbatim and in order; every previously derived line not re-derived removed; no derived entry added whose PEP 503-normalized distribution equals a surviving manual entry's + + - [x]* 5.7 Write property test for reconciliation idempotence + - **Feature: custom-node-code-assist, Property 7: Reconciliation idempotence** + - **Validates: Requirements 3.5** + - fast-check over requirements texts and derived lists: applying `reconcileRequirements` twice with the same derived list equals applying it once + + - [x]* 5.8 Write property test for the requirements text round trip + - **Feature: custom-node-code-assist, Property 8: Requirements text round trip** + - **Validates: Requirements 3.5, 3.6, 3.7** + - fast-check over lists of requirements entries: `parseRequirements(renderRequirements(entries))` yields identical raw lines, derived flags, and needs-review flags + + - [x]* 5.9 Write property test for unparseable code changing nothing + - **Feature: custom-node-code-assist, Property 9: Unparseable code changes nothing** + - **Validates: Requirements 3.10** + - fast-check with a corruption arbitrary injecting a malformed import statement or unterminated string literal into module code: `extractImports` returns `ok: false`, and the surface's derivation step consequently leaves the current requirements text byte-identical + +- [x] 6. Implement the CodeAssistPanel, state reducer, and API client (frontend) + - [x] 6.1 Implement the `codeAssistState.ts` pure reducer and prompt predicate + - New `edge-cv-portal/frontend/src/components/code-assist/codeAssistState.ts`: the `idle`/`submitting`/`reviewing` state machine over `edit-prompt`/`submit`/`succeeded`/`failed`/`accept`/`reject` events — `submit` ignored unless idle with a submittable prompt; `failed` → idle with the same prompt and an error view; `reject` → idle with the same prompt; `accept` → idle with the prompt cleared; `isSubmittablePrompt(prompt)` = trimmed length ≥ 1 and total length ≤ 4,000 + - _Requirements: 1.4, 1.6, 2.8, 2.9, 5.5_ + + - [x] 6.2 Implement the error presenter and API client + - Pure `describeCodeAssistError` (modeled on `node-designer/generate.ts`'s `describeGenerationError`) mapping `ApiError` code + `details.category` to headed alerts — Throttled / Not authorized to invoke the model / Model not available / Model error / Timed out after N seconds (from `details.timeout_seconds`) / No code produced — with a generic fallback for unknown codes; `codeAssist(request)` in `edge-cv-portal/frontend/src/services/api.ts` POSTing `/code-assist` with the existing bearer-token/loading-bus/ApiError conventions + - _Requirements: 5.1, 5.2, 5.3_ + + - [x] 6.3 Implement the `CodeAssistPanel` component + - New `edge-cv-portal/frontend/src/components/code-assist/CodeAssistPanel.tsx` over the reducer: prompt Textarea with character counter and constraint text; Generate button disabled while submitting or the prompt is unsubmittable (with `disabledReason`); submitting shows a `StatusIndicator` spinner while the sibling editor stays enabled; reviewing shows the returned code read-only (monospace) with the model's `notes` and Accept/Reject buttons; failures render the inline Alert from `describeCodeAssistError` with the prompt retained; `current_code` sent iff `editorCode` has a non-whitespace character; `onAccept(code)` is the only path that touches the editor; no save/persist call anywhere in the panel + - _Requirements: 1.4, 1.5, 1.6, 2.4, 2.5, 2.6, 2.7, 2.9, 2.10, 5.4, 5.5_ + + - [x]* 6.4 Write property test for the prompt validity predicate + - **Feature: custom-node-code-assist, Property 1: Prompt validity predicate** + - **Validates: Requirements 1.4, 2.8** + - fast-check over arbitrary strings (unicode, whitespace-only, boundary lengths around 4,000): `isSubmittablePrompt` accepts iff at least one non-whitespace character and length ≤ 4,000; the reducer never leaves `idle` on `submit` with a rejected prompt (no invocation) + + - [x]* 6.5 Write property test for panel failure recovery + - **Feature: custom-node-code-assist, Property 13: Panel failure recovery preserves the prompt** + - **Validates: Requirements 1.6, 2.9, 5.1, 5.2, 5.3, 5.5** + - fast-check over random reducer event sequences: every `failed` yields `idle` with the prompt unchanged from submission and an error view present; `reject` returns to `idle` with the prompt unchanged; `submit` is a no-op except from `idle` with a submittable prompt; accept-callback effects occur only on `accept` from `reviewing` + + - [x]* 6.6 Write component tests for the panel flows + - Vitest + Testing Library: review-before-apply (returned code shown, editor untouched until Accept); Accept fires `onAccept` with the code and clears the prompt; Reject preserves editor and prompt; spinner shown and resubmission blocked while pending, editor sibling still editable; error alert per category with prompt retained; no persist API call in any flow + - _Requirements: 1.5, 1.6, 2.4, 2.5, 2.7, 2.9, 5.4, 5.5_ + +- [x] 7. Integrate the assistant and Import_Analyzer into NodeConfigPanel + - [x] 7.1 Render the CodeAssistPanel for the custom Python node types + - In `edge-cv-portal/frontend/src/pages/workflows/NodeConfigPanel.tsx` (custom_python → contract `process_frame_or_handle`, custom_python_preprocess → `process_frame`): render `CodeAssistPanel` below the `code` parameter editor with `surface='workflow-builder'`, `editorCode` = the effective `code` value, and `onAccept` writing through the existing `onParametersChange` path; panel rendered only when `canEditWorkflows(role)` — Viewer/Operator see no assistant entry point + - _Requirements: 1.1, 1.2, 2.5, 2.7, 6.1, 6.5_ + + - [x] 7.2 Wire the debounced Import_Analyzer and requirements badges + - A 750 ms-debounced effect on the effective `code` value runs `extractImports` → `deriveRequirements` → `reconcileRequirements(currentRequirements, derived)` and writes the `requirements` parameter through `onParametersChange` only when the text changed; an `{ok:false}` scan applies nothing; the `requirements` parameter control gains a read-only annotation list under the editable Textarea — "derived" badge per marker entry, "verify package name" warning badge for `needsReview` entries (reusing the node-designer `badges.tsx` styling) + - _Requirements: 3.1, 3.5, 3.6, 3.7, 3.10_ + + - [x]* 7.3 Write component tests for the Workflow_Builder integration + - Assistant present beside the `code` editor for both node types and absent for other node types; hidden for Viewer/Operator roles; fake-timer debounce test — one analysis run 750 ms after a code change updates the `requirements` parameter; accepted generated code triggers derivation; derived and needs-review badges render; manual pins survive a derivation pass + - _Requirements: 1.1, 1.2, 3.1, 3.5, 3.6, 3.7, 3.8, 6.5_ + +- [x] 8. Integrate the assistant into the Node_Designer scaffold editors + - [x] 8.1 Render the CodeAssistPanel on `.py` scaffold tabs in CreateWizard and GeneratePanel + - In both scaffold-review Tabs editors: for any tab whose path ends with `.py` (today `plugin/frame_processing_hook.py`), render `CodeAssistPanel` under the Textarea with `surface='node-designer'`, `contract='frame_hook'`, `context.parameters` = the declaration's parameters, `editorCode` = that file's content, and `onAccept` replacing that file in the `files` map; panel gated to UseCaseAdmin/PortalAdmin (the same rule gating these pages' mutating actions); no Import_Analyzer on this surface; the GeneratePanel whole-scaffold chat is untouched + - _Requirements: 1.3, 2.5, 6.2, 6.5_ + + - [x]* 8.2 Write component tests for the Node_Designer integration + - Assistant present on the `.py` tab and absent on C source/meson/README tabs in both CreateWizard and GeneratePanel; hidden for non-admin users; Accept replaces exactly that file's content in the files map + - _Requirements: 1.3, 2.5, 6.2, 6.5_ + +- [x] 9. Final checkpoint + - Ensure all baselines pass: portal backend pytest scoped to `tests/` from `edge-cv-portal/backend`, and `npx vitest run` plus `npm run build` from `edge-cv-portal/frontend`; verify the infrastructure package compiles; ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional test tasks and can be skipped for a faster MVP +- Each task references specific requirements for traceability; all 13 design properties are covered by tasks 1.3, 1.4 (Properties 10, 11), 2.4, 2.5, 2.6 (Properties 2, 3, 12), 5.4–5.9 (Properties 4–9), and 6.4, 6.5 (Properties 1, 13) +- Python property tests use hypothesis with no hardcoded `max_examples` (project default ≥100 iterations); TypeScript property tests use fast-check with `numRuns: 100`; each tagged `**Feature: custom-node-code-assist, Property {number}: {property_text}**` +- The `bedrock_common.py` extraction lands first with the existing `test_workflow_generation.py` suite as its regression guard — Requirement 4 pins the assist feature to workflow generation's exact configuration semantics, and shared code makes divergence impossible; `node_generator.py` migration is an optional follow-up outside this spec +- Bedrock is exercised through mocked Converse responses everywhere; no test invokes a real model +- Requirement 3.4 (the model actually importing a prompt-requested library) is nondeterministic LLM behavior — addressed by the system-prompt instruction (asserted in Property 2's markers) and the derivation pipeline handling whatever imports are returned; Requirement 6.4 holds structurally in the stateless handler +- No new configuration, no new Lambda, no persistence: the route reuses the WorkflowGeneratorHandler integration, and nothing from a code-assist request is written to DynamoDB or S3 diff --git a/.kiro/specs/custom-node-designer/.config.kiro b/.kiro/specs/custom-node-designer/.config.kiro new file mode 100644 index 00000000..d5e3a236 --- /dev/null +++ b/.kiro/specs/custom-node-designer/.config.kiro @@ -0,0 +1 @@ +{"specId": "a10a3030-7ba6-4a2d-9246-924ccfdd6ccd", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/custom-node-designer/design.md b/.kiro/specs/custom-node-designer/design.md new file mode 100644 index 00000000..488e3f9b --- /dev/null +++ b/.kiro/specs/custom-node-designer/design.md @@ -0,0 +1,537 @@ +# Design Document: Custom Node Designer + +## Overview + +The Custom Node Designer extends the Workflow Manager with a portal capability (Node_Designer) for adding new node types to the Workflow_Builder palette without a platform release. It provides four entry paths into a single pipeline: scaffold creation with a Frame_Processing_Hook (Requirement 1), prompt-based scaffold generation via Bedrock (Requirement 2), import from a public repository (Requirement 4), and import from the official GStreamer Module_Listing with upstream good/bad/ugly risk classification (Requirements 6, 15). All paths converge on a Plugin_Record that flows through per-architecture isolated builds (Requirement 3), visual simulation (Requirement 7), Custom_Node_Type registration into the Node_Type_Catalog (Requirement 8), a dev → test → prod lifecycle with security review and signing (Requirements 9, 10), packaging/delivery through the existing Component_Packager path (Requirement 11), automatic packaging of built Plugin_Artifacts into versioned, individually deployable Greengrass Plugin_Components with dependency-based delivery to devices (Requirement 16), and cloud test runs (Requirement 12). + +Plugins target five Target_Architectures per the requirements glossary: `x86_64` (amd64), `x86_64_nvidia` (x86_64 with NVIDIA GPU runtime), and `arm64_jp4/jp5/jp6` (Jetson JetPack 4/5/6). `x86_64_nvidia` is new relative to the workflow-manager implementation and is threaded through the build matrix, the `workflow_core` architecture constants, the Plugin_Library layout, Greengrass platform manifests, and deployment matching (see "Target_Architecture: x86_64_nvidia" below). + +The design builds directly on the workflow-manager implementation that exists in the repo today: + +- **Catalog**: `workflow_core.catalog` currently declares a static `NODE_CATALOG` tuple of frozen `NodeTypeDescriptor` dataclasses (`edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py`), consumed by the validator, compiler, the node-catalog endpoint, the Workflow_Generator system prompt, the test sandbox (vendored copy), and LocalServer (vendored copy under `src/backend/workflow_engine/vendor/`). This feature makes the catalog **composable**: built-in descriptors stay static; Custom_Node_Type descriptors are stored per Use_Case in DynamoDB and merged into a per-request effective catalog at serve, validate, generate, compile, and package time. +- **Plugin library**: `workflow_packaging.py` already resolves plugin binaries from portal S3 at `{WORKFLOW_PLUGIN_LIBRARY_PREFIX}/{arch}/{plugin}.so` and streams them into the per-arch component zip under `plugins/{arch}/{plugin}.so`. That inline path stays for built-in/curated plugins; this feature adds a per-Use_Case custom prefix with checksum + signature verification, and custom plugin artifacts reach devices via Greengrass dependencies on auto-packaged Plugin_Components (`dda.plugin.{pluginId}`) rather than inline bundling (see "Plugin_Component packaging and deployment"). +- **Simulator**: the Plugin_Simulator reuses the existing test-sandbox pattern (`edge-cv-portal/test-sandbox/` harness in a Fargate task orchestrated by Step Functions, `test-runner-stack.ts`) with a new single-plugin harness mode. +- **Generation**: the Node_Generator reuses the `workflow_generator.py` patterns verbatim — `get_bedrock_configuration()` from the settings table (timeout clamped ≤ 60 s), cached `bedrock-runtime` client with client-side read timeout and no retries, Converse API with forced tool-use for structured output, and session snapshots for follow-up prompts. +- **Edge**: LocalServer already scopes delivered plugins per-run (`src/backend/workflow_engine/gst_plugins.py`: `GST_PLUGIN_PATH` prepend + registry scan, restored afterwards). This feature adds manifest checksum verification before the registry scan. + +### Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Custom catalog storage | DynamoDB `CustomNodeTypes` table (declaration JSON per Use_Case per version) merged with the static `NODE_CATALOG` at request time by a new `workflow_core.catalog.resolve_catalog(custom_descriptors)` function | Built-in descriptors stay frozen dataclasses; custom declarations use the identical wire shape so the validator/compiler/generator work unchanged on the merged tuple. No schema fork, no LocalServer change — the edge only ever sees compiled documents and delivered `.so` files, never the catalog | +| Plugin build execution | AWS CodeBuild, one project per Target_Architecture with custom build images (x86_64 Ubuntu 22.04/GStreamer 1.20 matching the sandbox image; x86_64_nvidia the same base plus the CUDA toolkit and NVIDIA GStreamer runtime headers; arm64 JetPack 4/5/6 cross-build images with the matching L4T + DeepStream SDK) | CodeBuild gives per-build ephemeral, network-policy-controlled containers (isolation per Use_Case build, Requirement 3.2), native git source cloning for imports, build log capture for compiler-output error reporting (3.4), and custom-image support for the CUDA/JetPack/DeepStream toolchains (5.2). Fargate would require building our own build orchestration and log plumbing | +| Artifact signing | KMS asymmetric key (ECDSA P-256) per portal installation: `Sign` on the SHA-256 digest after a successful build; `Verify` in the Component_Packager before inclusion | Managed keys, non-exportable private key, auditable sign/verify calls (Requirement 3.3, 10.4). LocalServer verifies the SHA-256 checksum from the component manifest (10.6) — no device-side KMS dependency | +| Simulator execution | New `simulate` harness mode in the existing test-sandbox image, run as a Fargate task via a new Step Functions state machine with a 5-minute timeout | The sandbox image already has GStreamer 1.20 + Python GI on x86_64 and the incremental-results-flush pattern; a single-plugin pipeline (`multifilesrc ! decode ! ! frame+metadata capture`) is a strict subset of what the harness does today (Requirement 7) | +| Module_Listing retrieval | Lambda fetch of `https://gstreamer.freedesktop.org/modules/` parsed server-side, cached in DynamoDB with a 24 h TTL item | Keeps the parser in one place, testable against synthetic listings; cache satisfies Requirement 6.4 and shields the UI from upstream outages (6.3) | +| good/bad/ugly classification | Pure function `classify_plugin_set(module_name, repo_url) -> good|bad|ugly|unclassified` derived from the official plugin-set module names (`gst-plugins-good`, `gst-plugins-bad`, `gst-plugins-ugly`) and their known repository locations; everything else is `unclassified` | The taxonomy is upstream's own; deriving it from the module identity is deterministic and testable. Arbitrary public repositories are never guessed into an official set (Requirement 15.4) | +| Lifecycle + review storage | `PluginRecords` DynamoDB table keyed `plugin_id` + `version`, holding Lifecycle_State, security review decision, provenance, per-arch artifact entries (S3 key, checksum, signature, build status) | Single-item read answers every gate (packaging, deployment, palette inclusion); versions are separate items so Requirement 9.13/10.5 (new version resets state and review) falls out of the data model | +| Deployment gate for test state | Extend the existing deployment pre-submit check in `deployments.py` (which already compares LocalServer versions per device) with a Plugin_Record lifecycle check against a `test_device` flag on the Devices table | Reuses the one place that already inspects targets before submission (Requirement 9.8) | +| Custom plugin delivery to devices | **Dependency-based**: every successful build set is auto-packaged into a versioned Greengrass Plugin_Component `dda.plugin.{pluginId}`; Workflow_Component recipes declare `ComponentDependencies` on the Plugin_Components their Custom_Node_Types need instead of bundling those `.so` files inline. Built-in/curated plugins keep the existing inline `plugins/{arch}/*.so` bundling | See "Reconciling Requirements 11 and 16" below — one immutable signed artifact per plugin version, Greengrass-native version resolution, standalone deployability from the deployment screen (16.2), and no double-shipping when several workflows share a plugin | +| x86_64 vs x86_64_nvidia platform matching | Greengrass platform attribute `runtime: nvidia` on x86_64_nvidia manifests, mirroring the existing `variant` attribute pattern that already disambiguates the arm64 JetPack builds in `build_recipe` | Both x86_64 flavors map to Greengrass architecture `amd64`; a custom platform attribute (declared in the device's Nucleus platform overrides, exactly like `variant`) is the established mechanism in this codebase for splitting one Greengrass architecture into DDA Target_Architectures | + +## Architecture + +### System Context + +```mermaid +graph TB + subgraph Portal Account + FE[React Frontend
Node_Designer pages] + APIGW[API Gateway] + PR[plugin_records.py Lambda
records, lifecycle, review] + IMP[plugin_importer.py Lambda
repo + Module_Listing import] + GEN2[node_generator.py Lambda
Bedrock scaffold generation] + BLD[plugin_builds.py Lambda
build orchestration] + CB[CodeBuild projects
per Target_Architecture
incl. x86_64_nvidia CUDA image] + PPKG[plugin_components.py Lambda
Plugin_Component auto-packaging
dda.plugin.pluginId] + SIM[Step Functions +
Fargate sandbox
Plugin_Simulator] + KMS[KMS signing key] + DDB[(DynamoDB
PluginRecords, CustomNodeTypes,
ModuleIndexCache, SimulationRuns)] + S3P[(Portal S3
plugin sources, scaffolds,
Plugin_Library, sim results)] + PKG[workflow_packaging.py
Component_Packager - extended
declares Plugin_Component deps] + DEP[deployments.py + components.py
Plugin_Component listing +
lifecycle/arch deployment gates] + WV[workflow_validation.py +
workflow_generator.py
merged catalog consumers] + end + GGR[(Greengrass registry
Use_Case account
dda.workflow.* + dda.plugin.*)] + EXT[Public repositories +
gstreamer.freedesktop.org/modules/] + BR[Amazon Bedrock] + subgraph Edge Device + LS[LocalServer
checksum-verified plugin load] + end + + FE --> APIGW + APIGW --> PR & IMP & GEN2 & BLD & DEP + IMP --> EXT + IMP --> DDB + GEN2 --> BR + BLD --> CB + CB --> S3P + CB --> KMS + BLD --> DDB + BLD -.build success event.-> PPKG + PPKG --> S3P + PPKG --> GGR + PPKG --> DDB + APIGW --> SIM + SIM --> S3P + PR --> DDB + PKG --> S3P + PKG --> KMS + PKG --> GGR + DEP --> GGR + WV --> DDB + GGR -.installs Plugin_Components +
Workflow_Components.-> LS +``` + +### The Plugin_Record pipeline + +Every path produces the same artifact flow: + +```mermaid +stateDiagram-v2 + [*] --> dev : create / generate / import
(review = pending) + dev --> dev : edit source, rebuild, simulate + dev --> test : UseCaseAdmin promote
guard - at least one built Plugin_Artifact + test --> prod : UseCaseAdmin promote
guard - approved security review + test --> dev : demote + prod --> test : demote + note right of test : palette visible with test marker
cloud test runs allowed
deploy to Test_Devices only + note right of prod : deployable to any device +``` + +New source, changed declaration, or a rebuild creates a **new version item** whose Lifecycle_State is `dev` and review decision `pending`, independent of prior versions (Requirements 9.13, 10.5). Deployed Workflow_Components are never touched by demotion — gates apply only to subsequent packaging/deployment requests (9.12). + +### Dynamic Node_Type_Catalog extension + +The static catalog stays exactly as it is. A new module `workflow_core.catalog.custom` adds: + +- `descriptor_from_declaration(decl: dict) -> NodeTypeDescriptor` — converts a stored Custom_Node_Type declaration (same wire shape the node-catalog endpoint already serves) into a frozen `NodeTypeDescriptor`, validating port types against `PORT_TYPES`, categories against `CATEGORIES`, parameter descriptors against `PARAMETER_TYPES`, and mappings against `ARCHITECTURES`. Invalid declarations raise `DeclarationError` identifying the offending field (Requirement 8.5). +- `resolve_catalog(custom_descriptors: Sequence[NodeTypeDescriptor]) -> tuple` — returns `NODE_CATALOG + tuple(custom_descriptors)` with duplicate `type_id` rejection (built-ins always win). + +Consumers change as follows (all portal-side; the sandbox and LocalServer vendored copies gain the module but the edge never resolves custom catalogs — compiled documents are self-contained): + +| Consumer | Change | +|---|---| +| `GET /workflows/node-catalog` | Loads the Use_Case's registered Custom_Node_Types (Lifecycle_State test/prod only; dev excluded per 9.2), merges, serves; test-state entries carry `lifecycleState: "test"` for the palette marker (9.6) | +| `workflow_validation.py` | Validates against the merged catalog for the workflow's Use_Case | +| `workflow_generator.py` | Serializes the merged catalog into the system prompt so generated workflows may use registered custom nodes | +| `workflow_packaging.py` | Compiles against the merged catalog; custom-node plugin dependencies resolve to Plugin_Component dependencies declared in the Workflow_Component recipe, with artifact verification against the Plugin_Record (below) | +| test runner (`workflow_test_steps.py`) | Compile step uses the merged catalog; the sandbox task downloads custom x86_64 Plugin_Artifacts into its plugin scan path (Requirement 12.1) | + +A custom `GstMapping` for a plugin-backed node is mechanical: `element_chain = [{factory: , args_template: }]`, `plugin_dependencies = ["custom:/"]`. The `custom:` prefix routes the packager to the plugin's Plugin_Component: instead of bundling the `.so` inline it declares a Greengrass `ComponentDependencies` entry on `dda.plugin.{pluginId}` and verifies the depended-on artifacts (Requirements 8.6, 11.1, 16.4 — see "Reconciling Requirements 11 and 16"); DeepStream-backed types simply omit mappings for architectures without a matching runtime, which makes the existing compiler error path (`CompileError{nodeId, arch}`) satisfy Requirement 5.4 with no new compiler logic. + +### Target_Architecture: x86_64_nvidia + +The requirements glossary defines five Target_Architectures; `x86_64_nvidia` (x86_64 with an NVIDIA GPU runtime) is new to the implementation and touches every layer that enumerates architectures: + +- **`workflow_core` constants** (`catalog/models.py`): a new `ARCH_X86_64_NVIDIA = "x86_64_nvidia"` is added to `ARCHITECTURES` and `DEVICE_ARCHITECTURES` (and to the vendored copies in the test sandbox and `src/backend/workflow_engine/vendor/`). Because built-in `NODE_CATALOG` descriptors generate one `GstMapping` per `DEVICE_ARCHITECTURES` entry (`nodes.py`), every built-in node type automatically gains an `x86_64_nvidia` mapping identical to its `x86_64` one; nodes with an NVIDIA-accelerated variant may declare a distinct chain later without schema change. The bundled-plugin manifest (`bundled_plugins_for`) gains an `x86_64_nvidia` entry (initially mirroring `x86_64` — the LocalServer.amd64 GPU build bundles the same base plugin set). The compiler, validator, and packaging arch validation (`DEVICE_ARCHITECTURES` membership) then accept `x86_64_nvidia` with no further change. +- **Build matrix**: a sixth CodeBuild project with a CUDA-enabled x86_64 image — the sandbox's Ubuntu 22.04/GStreamer 1.20 base plus the CUDA toolkit and NVIDIA GStreamer/codec headers. Custom_Node_Type declarations and imports may select `x86_64_nvidia` like any other arch (DeepStream records remain restricted to `arm64_jp4/jp5/jp6` per Requirement 5.1 — DeepStream targets Jetson; plain CUDA/NVIDIA plugins use `x86_64_nvidia`). +- **Plugin_Library layout**: artifacts store under `workflow-plugins/custom/{usecase_id}/x86_64_nvidia/{plugin}.so` (+ `.sig`), same pattern as the other arches. +- **Greengrass platform matching**: `ARCH_TO_GG_PLATFORM` maps `x86_64_nvidia → amd64`. Since plain `x86_64` also maps to `amd64`, recipes disambiguate with a custom platform attribute `runtime: nvidia` on `x86_64_nvidia` manifests — the same mechanism `build_recipe` already uses (`variant`) to split the three JetPack builds within `aarch64`. Devices with the NVIDIA runtime declare `runtime: nvidia` in their Nucleus platform overrides; when both x86_64 flavors are packaged, the plain `x86_64` manifest is listed after the `x86_64_nvidia` one so attribute-less amd64 devices match plain `x86_64` while NVIDIA devices match the more specific manifest first. +- **Deployment matching**: the portal records each device's Target_Architecture (existing Devices table attribute); the deployment-time architecture check (below) treats `x86_64` and `x86_64_nvidia` as distinct — a Plugin_Component with only an `x86_64` artifact is deployable to an `x86_64_nvidia` device only if the component also publishes an `x86_64_nvidia` manifest; no implicit fallback is designed in (a CUDA plugin genuinely differs from its CPU build, and silent substitution would mask that). +- **Simulator/test sandbox**: unchanged — both execute plain `x86_64` builds (glossary; Fargate has no GPU here), so simulation and cloud test runs of an `x86_64_nvidia`-only plugin follow the existing missing-x86_64 guard and stubbing paths (7.5, 12.2). + +### Plugin_Build_Service + +```mermaid +sequenceDiagram + participant UI as Node_Designer UI + participant BLD as plugin_builds.py + participant CB as CodeBuild (per arch) + participant KMS as KMS + participant S3 as Portal S3 + participant DDB as PluginRecords + + UI->>BLD: POST /plugins/{id}/versions/{v}/build {architectures} + BLD->>DDB: mark per-arch build status = building + loop each Target_Architecture + BLD->>CB: StartBuild (source = plugin source S3 key,
image = arch build image, env = arch) + CB->>CB: meson/autotools build in ephemeral container
(no cross-Use_Case credentials or data) + CB->>S3: upload .so to staging key + CB->>KMS: Sign(SHA-256 digest) + CB->>S3: promote to Plugin_Library custom/{usecase}/{arch}/ + CB-->>BLD: build result webhook (EventBridge) + BLD->>DDB: record artifact {s3Key, checksum, signature, status} + end + BLD->>BLD: all requested arch builds settled +
at least one succeeded + BLD-)PPKG: plugin_components.py: package Plugin_Component
dda.plugin.{pluginId} v{pluginVersion}.0.0 (Requirement 16.1) + BLD-->>UI: per-arch build status (succeeded/failed + log excerpt) +``` + +- **Isolation (3.2)**: each build runs in a fresh CodeBuild container with a role scoped to exactly the source prefix and staging prefix of that build — no Use_Case account credentials, no other builds' prefixes. Build projects run without VPC access to portal internals; outbound internet is allowed for source-declared dependency fetches during import builds. +- **Failure (3.4)**: failed builds store the CloudWatch log tail in the Plugin_Record's per-arch entry; no artifact is stored for that arch. +- **Prebuilt binaries (3.6)**: an upload path accepts a `.so` per arch, checksums and signs it identically (provenance records `prebuilt: true`). +- **DeepStream (5.1, 5.2)**: records flagged `deepstream: true` restrict selectable architectures to `arm64_jp4/jp5/jp6`; the JetPack build images pin the DeepStream SDK version matching each JetPack release. + +### Plugin_Scaffold and Node_Generator + +- Scaffold generation is pure templating in a new `workflow_core.scaffold` module (also usable in tests without AWS): given a validated declaration (name, category, ports, parameters, architectures), it renders a GStreamer plugin project — a C skeleton element wrapping an embedded Python `process_frame(frame, params) -> frame` Frame_Processing_Hook file (the same appsink/appsrc bridge approach as the existing `emlpython` custom-python element), `meson.build` per selected architecture, and a README. Declared parameters surface as GObject properties plumbed into the hook's `params` dict (Requirements 1.2–1.4). The scaffold is stored under portal S3 `plugin-sources/{usecase_id}/{plugin_id}/{version}/` and downloadable as a zip (1.5). +- The Node_Generator (`node_generator.py`) mirrors `workflow_generator.py`: chat sessions in a TTL'd DynamoDB table with the current scaffold source snapshot in S3; Converse invocation with a forced `create_plugin_scaffold` tool whose input schema is the scaffold file map (`{files: {path: content}}`) plus the declaration; the system prompt embeds the scaffold template conventions and the Frame_Processing_Hook contract. Follow-up prompts include the current source and instruct modification (2.4). Output that fails scaffold validation (missing hook file, undeclared files) returns an error with the prompt preserved (2.6); Bedrock failures/timeouts follow the existing ≤ 60 s clamped timeout handling (2.7). Accepted source enters the standard build/simulate/lifecycle path with the prompt recorded as provenance (2.5). + +### Plugin_Importer and Module_Listing + +- **Repository import (Requirement 4)**: `plugin_importer.py` validates the URL, then starts a lightweight CodeBuild "fetch" step that clones the repository at the requested revision (default branch when omitted) and syncs the source tree to `plugin-sources/...`. Unreachable repo / missing revision fails before any Plugin_Record is created (4.4). A source-tree scan (presence of a GStreamer plugin build definition: `meson.build`/`configure.ac` with a `gst_plugin` target, or prebuilt `.so`) marks unbuildable imports failed with the finding reported (4.5). Successful fetch creates the Plugin_Record with provenance `{repoUrl, revision, importedBy, importedAt, classification}` (4.2, 15.5) and submits builds (4.3). First successful build prompts Custom_Node_Type declaration (4.6). +- **Module_Listing import (Requirement 6)**: `GET /plugin-modules` returns the parsed module index — fetched from `https://gstreamer.freedesktop.org/modules/`, parsed into `{name, description, repoUrl, classification}` entries, cached in `ModuleIndexCache` with `fetchedAt` and reused for 24 h (6.4). Fetch/parse failure returns an error and the UI offers manual URL entry (6.3). Selecting a module feeds its published repository location into the Requirement 4 path (6.2). +- **Classification (Requirement 15)**: `classify_plugin_set` maps the official plugin-set modules to `good`/`bad`/`ugly` and everything else to `unclassified`. The UI shows the classification as a color-coded risk badge in the module list (15.1) and on the import confirmation view together with the fixed plain-language explanations (15.2, 15.3); the Plugin_Importer stamps it into provenance (15.4, 15.5); the security review screen displays it with the other provenance (15.6). + +### Plugin_Simulator + +A new Step Functions state machine (in a `node-designer-stack.ts` following `test-runner-stack.ts` patterns) runs the existing sandbox image with `HARNESS_MODE=simulate`: + +1. **Guard**: refuse to start when the Plugin_Record version has no successful x86_64 Plugin_Artifact (7.5). +2. **Prepare**: stage the selected Test_Dataset (reusing the existing dataset staging) or the uploaded sample frames; stage the plugin `.so` from the Plugin_Library to the task's plugin scan directory. +3. **RunSandbox** (Fargate, isolated subnet, task role limited to the run's S3 prefix — no Plugin_Library write, no other Use_Case data; Requirement 7.2): the harness renders `multifilesrc ! decode ! ! frame capture + metadata tap`, executes via `Gst.parse_launch` exactly like the test harness, and flushes per-frame results `{frameIndex, inputRef, outputRef, metadata}` incrementally to S3. +4. **Collect**: the UI renders input/output frames side by side with per-frame metadata (7.3), offers parameter re-configuration and re-run (7.4). Abnormal termination reports the plugin's stderr/bus error, contained to the task (7.6). The state machine enforces the 5-minute timeout, marking the run failed-with-timeout and retaining flushed partial results (7.7). + +### Signing, verification, and packaging integration + +- **Sign (3.3)**: after each successful build, SHA-256 checksum + KMS signature are recorded in the Plugin_Record per-arch entry and stored alongside the artifact in the Plugin_Library (`custom/{usecase_id}/{arch}/{plugin}.so` + `.sig`). +- **Package-time verify (10.4)**: `workflow_packaging.py` gains a branch for `custom:` plugin dependencies. For each one it resolves the pinned Custom_Node_Type version to its Plugin_Record, streams the artifact bytes for every selected architecture from the Use_Case custom prefix, recomputes the SHA-256, and `KMS Verify`s the signature — failing packaging (existing `PackagingError` path — stage cleanup, no partial component) on either mismatch. Verified custom plugins are **not** bundled into the arch zip (see "Reconciling Requirements 11 and 16"); instead the recipe declares a dependency on the plugin's Plugin_Component, and the per-plugin checksum is written into `manifest.json` (`pluginChecksums: {/: }`) so the edge can verify the installed files. Built-in/curated (non-`custom:`) plugins keep the existing inline `plugins/{arch}/*.so` bundling in `build_arch_zip`, unchanged. +- **Lifecycle gates at packaging (11.2, 11.3)**: before assembly, every custom node's backing Plugin_Record is loaded; `dev` state, a missing per-arch artifact, or a missing Plugin_Component version rejects with the Custom_Node_Type and arch/state identified. +- **Edge verify (10.6)**: `workflow_engine/gst_plugins.py` verifies each plugin `.so` referenced by `manifest.json` `pluginChecksums` — whether delivered inline under `plugins//` or installed by a depended-on Plugin_Component under `/aws_dda/plugins/{pluginId}/{version}/{arch}/` — before the registry scan; a mismatch skips the plugin, fails the workflow registration with the file identified, and reports through the existing status path. +- **Deployment gate (9.8, 9.11, 16.3, 16.6)**: `deployments.py`'s pre-submit check loads the manifest's plugin records; a test-state plugin restricts targets to devices flagged `test_device` (a new attribute a UseCaseAdmin sets on the existing Devices table); violations are rejected identifying the Custom_Node_Type and its Lifecycle_State. The same pre-submit pass performs the Plugin_Component architecture check (below). + +### Plugin_Component packaging and deployment (Requirement 16) + +#### Reconciling Requirements 11 and 16 + +Requirement 11 says the Component_Packager includes each Custom_Node_Type's Plugin_Artifact per packaged architecture; Requirement 16 says Workflow_Components declare Greengrass dependencies on Plugin_Components that carry those artifacts. Two delivery models were considered: + +| | Inline bundling (status quo, extended to custom plugins) | Plugin_Component dependencies (chosen for custom plugins) | +|---|---|---| +| Delivery | `.so` copied into every Workflow_Component zip per arch | `.so` installed once per device by the depended-on Plugin_Component; Greengrass resolves and installs it with the deployment (16.5) | +| Duplication | N workflows sharing a plugin ship it N times per deployment | One installed copy per plugin version per device | +| Standalone deployability (16.2) | None — plugins only ride workflows | Plugin_Components appear on the deployment screen and can be deployed on their own | +| Version immutability (16.7) | Rebuild silently changes bytes inside future workflow zips | Rebuild publishes a new immutable Plugin_Component version; existing versions untouched | +| Failure surface | One artifact path | Adds Greengrass dependency resolution to workflow deploys | + +**Decision**: built-in/curated Plugin_Library plugins stay bundled inline exactly as `workflow_packaging.py` does today (`plugins/{arch}/*.so`) — they version with the platform, not per Use_Case. Custom_Node_Type plugins are delivered **exclusively** via Plugin_Component dependencies — no double-shipping. Requirement 11's "include the Plugin_Artifact" is satisfied for custom plugins by inclusion-by-dependency: the Component_Packager verifies each required artifact exists and passes checksum/signature verification (11.1, 11.2, 10.4), records its checksum in the manifest, and declares the recipe dependency that makes Greengrass deliver it with the deployment (16.4, 16.5); Requirement 11.4 (LocalServer loads the delivered plugin) is met by extending the plugin scan path to the Plugin_Component install roots named in the manifest. + +#### Automatic packaging on build completion (16.1, 16.7) + +A new `plugin_components.py` Lambda, invoked by `plugin_builds.py` when all requested arch builds for a Plugin_Record version have settled with at least one success, packages the successfully built Plugin_Artifacts into a Greengrass component in the Use_Case account, following the `workflow_packaging.py` conventions: + +- **Naming**: `dda.plugin.{pluginId}` version `{pluginVersion}.0.0` (mirroring `dda.workflow.{workflowId}` / `{workflowVersion}.0.0`). +- **Recipe**: install-only (no Run lifecycle — installing or removing a plugin never restarts LocalServer), one platform manifest per successfully built Target_Architecture, built with the same `ARCH_TO_GG_PLATFORM` + platform-attribute scheme as `build_recipe` (`variant` for the JetPack arm64 builds, `runtime: nvidia` for x86_64_nvidia). Each manifest's artifact is the signed `.so` (plus a small `plugin-manifest.json` carrying name, version, arch, and checksum), installed to `/aws_dda/plugins/{pluginId}/{pluginVersion}/{arch}/`. +- **All-or-nothing**: artifacts stage → promote → register, reusing the staging pattern; a failed registration deletes the component version so nothing partial exists. +- **Immutability (16.7)**: a rebuild or source change always creates a new Plugin_Record version (existing design), which packages as a new Plugin_Component version; previously published Plugin_Component versions are never modified or deleted by packaging (removal only through Requirement 14's reference-checked path). +- **Registry metadata**: tagged `dda-portal:managed`, `dda-portal:usecase-id`, `dda-portal:plugin-id`, `dda-portal:plugin-version` so listings can attribute components to Plugin_Records. + +#### Deployment screen listing (16.2) + +The existing component listing (`components.py` `list_components`, consumed by the deployment screen) is extended to recognize `dda.plugin.*` components and join them with their Plugin_Record (via the registry tags): each entry shows name, version, the backing Plugin_Record's Lifecycle_State, and the supported Target_Architectures (derived from the recipe's platform manifests). Deploying a Plugin_Component standalone goes through the same `deployments.py` submission path as other components, subject to the gates below. + +#### Workflow_Component dependency declaration (16.4, 16.5) + +`workflow_packaging.py`'s `build_recipe` gains a `ComponentDependencies` block: for each Custom_Node_Type in the compiled workflow, an entry + +```json +"ComponentDependencies": { + "dda.plugin.{pluginId}": { + "VersionRequirement": ">={pluginVersion}.0.0 <{pluginVersion+1}.0.0", + "DependencyType": "HARD" + } +} +``` + +pinned to the Plugin_Record version recorded by the workflow's pinned Custom_Node_Type versions (Requirement 14.2 resolution). Greengrass then includes the depended-on Plugin_Component versions in every deployment of the Workflow_Component automatically (16.5) — `deployments.py` needs no change for delivery, only for the gates. + +#### Deployment-time gates (16.3, 16.6) + +Both gates extend the existing pre-submit check in `deployments.py` (the `check_local_server_compatibility` pass that already inspects each target device before submission): + +- **Lifecycle (16.3)**: deploying a Plugin_Component (standalone, or transitively via a Workflow_Component dependency) whose backing Plugin_Record is in `test` state is permitted only to devices flagged `test_device`; `prod` state deploys anywhere in the Use_Case. `dev`-state Plugin_Components exist (auto-packaging runs on build completion, before promotion) but are rejected for any deployment target, consistent with the Requirement 9/11.3 dev gates. This is the same gate as 9.8 evaluated over the dependency closure. +- **Architecture (16.6)**: for each target device, the device's recorded Target_Architecture (Devices table) is checked against the platform manifests of every depended-on Plugin_Component version; a device whose architecture has no published Plugin_Artifact in some depended-on Plugin_Component version rejects the submission identifying the Plugin_Component and the unsupported Target_Architecture. `x86_64` and `x86_64_nvidia` are matched distinctly (no fallback). This runs pre-submit — before Greengrass would otherwise fail the deployment device-side with an opaque no-matching-manifest error. + +### Versioning, deprecation, removal (Requirement 14) + +Custom_Node_Type versions parallel Plugin_Record versions (a declaration change or new plugin version creates a new CustomNodeTypes item; prior versions retained, 14.1). Saved workflows already pin node parameters per workflow version; the Workflow_Definition node entry for a custom node gains a `typeVersion` attribute recorded at save and honored at packaging (14.2). Deprecation flips a `deprecated` flag that excludes the type from the palette merge while keeping it resolvable for loading/validation/packaging of existing workflows (14.3). Removal scans WorkflowVersions for references (an inverted-index GSI on node type ids maintained at save); zero references → delete catalog items + Plugin_Library artifacts + the plugin's Plugin_Component versions from the Use_Case Greengrass registry (the only path that ever deletes a published Plugin_Component version); otherwise reject listing the referencing workflows (14.4, 14.5). + +### Access control and audit (Requirement 13) + +New RBAC permission actions in `rbac_middleware`, mapped to existing roles: + +| Action | UseCaseAdmin | PortalAdmin | DataScientist / Operator / Viewer | +|---|---|---|---| +| node-designer:read | ✓ | ✓ | ✓ (read-only, 13.3) | +| node-designer:create / generate / import / simulate / register / promote-demote (dev↔test) / manage | ✓ (own Use_Case) | ✓ | – | +| node-designer:security-review (approve/reject, promote to prod gate) | – | ✓ | – | + +Every create/generate/import/simulate/register/promote/demote/approve/reject/update/deprecate/remove writes the existing AuditLog table with action, acting user, timestamp (13.5); denials return the standard authorization error envelope (13.4). + +## Components and Interfaces + +### New portal backend Lambdas (`edge-cv-portal/backend/functions/`) + +| File | Routes | Responsibility | +|---|---|---| +| `plugin_records.py` | `GET/POST /plugins`, `GET/PUT /plugins/{id}`, `GET /plugins/{id}/versions/{v}`, `POST .../promote`, `POST .../demote`, `POST .../review` | Plugin_Record CRUD, lifecycle transitions with guards, security review decisions, provenance display | +| `plugin_importer.py` | `POST /plugins/import`, `GET /plugin-modules` | Repository fetch orchestration, Module_Listing fetch/parse/cache, classification stamping | +| `plugin_builds.py` | `POST /plugins/{id}/versions/{v}/build`, `GET .../builds`, EventBridge handler for CodeBuild results | Build orchestration, per-arch status, artifact + signature recording, prebuilt upload; triggers `plugin_components.py` when a version's builds settle with ≥ 1 success | +| `plugin_components.py` | Async invoke from `plugin_builds.py`; `GET /plugins/{id}/versions/{v}/component` | Automatic Plugin_Component packaging (`dda.plugin.{pluginId}`, install-only recipe, one platform manifest per built arch), stage/promote/register in the Use_Case account, component status recording on the Plugin_Record | +| `node_generator.py` | `POST /plugins/generate`, `POST /plugins/generate/{session}/message` | Bedrock Converse scaffold generation sessions | +| `plugin_simulator.py` | `POST /plugins/{id}/versions/{v}/simulate`, `GET /simulations/{runId}` | Simulator run start (guarded), status/results | +| `custom_node_types.py` | `POST /custom-node-types`, `GET/PUT/DELETE /custom-node-types/{id}`, `POST .../deprecate` | Custom_Node_Type declaration validation, registration, versioning, deprecation, removal with reference check | + +### Extended existing components + +- `workflow_core.catalog` — new `custom.py` (`descriptor_from_declaration`, `resolve_catalog`, `DeclarationError`) and `classification.py` (`classify_plugin_set`, explanation texts); `workflow_core.scaffold` — new module (template rendering, scaffold validation). +- `workflow_core.catalog.models` — new `ARCH_X86_64_NVIDIA` in `ARCHITECTURES`/`DEVICE_ARCHITECTURES`, `bundled_plugins_for` entry for `x86_64_nvidia` (propagated to the sandbox and LocalServer vendored copies). +- `workflow_packaging.py` — `custom:` plugin dependency resolution to Plugin_Component `ComponentDependencies` in the recipe, checksum/signature verification, lifecycle/artifact/component gates, `pluginChecksums` in the manifest, `x86_64_nvidia` in `ARCH_TO_GG_PLATFORM` with the `runtime: nvidia` platform attribute. +- `deployments.py` — test-state target gating against `test_device` device flags; pre-submit Plugin_Component gates (lifecycle over the dependency closure, per-device architecture coverage) alongside the existing `minLocalServerVersion` check. +- `components.py` — deployment-screen listing of `dda.plugin.*` components with name, version, backing Lifecycle_State, and supported architectures. +- `workflow_validation.py`, `workflow_generator.py`, node-catalog endpoint, `workflow_test_steps.py` — merged catalog resolution. +- `src/backend/workflow_engine/gst_plugins.py` — manifest checksum verification before registry scan. +- `test-sandbox/harness` — `simulate` mode (single-plugin pipeline, per-frame input/output/metadata capture). + +### Frontend (`edge-cv-portal/frontend/src/pages/node-designer/`) + +- **Plugin library list**: Plugin_Records with lifecycle badges, per-arch build status, classification risk badges. +- **Create wizard**: declaration form (name, category, ports, parameters, architectures) → scaffold preview/download → source editor → build. +- **Generate panel**: chat interface for prompt-based scaffolds with review-before-accept (2.1, 2.3). +- **Import views**: repository URL form and the Module_Listing select with classification badges + plain-language explanations; import confirmation view repeating the classification and explanation before proceeding (15.1–15.3); fallback to manual URL entry on listing failure (6.3). +- **Simulator view**: dataset/upload picker, side-by-side input/output frame strips with per-frame metadata, parameter editor + re-run (7.1, 7.3, 7.4). +- **Registration wizard**: Custom_Node_Type declaration (ports from `PORT_TYPES`, parameters, element property mapping per built arch, hardware-dependence flag, Use_Case scoping) (8.1). +- **Review queue (PortalAdmin)**: pending Plugin_Records with provenance, classification, per-arch checksums/signatures, and source inspection (10.2, 15.6). +- **Deployment screen (existing `pages/deployments`, extended)**: `dda.plugin.*` Plugin_Components listed with name, version, backing Lifecycle_State badge, and supported Target_Architecture chips; pre-submit gate rejections surfaced with the Plugin_Component and unsupported architecture or lifecycle violation identified (16.2, 16.3, 16.6). + +## Data Models + +### DynamoDB tables (new, additive) + +| Table | Key | Attributes | +|---|---|---| +| `PluginRecords` | `plugin_id` + `version` | usecase_id, name, kind (scaffold/generated/imported), deepstream flag, provenance {repoUrl, revision, prompt, scaffoldDeclaration, importedBy/createdBy, timestamps, classification, prebuilt}, lifecycle_state (dev/test/prod), review {decision: pending/approved/rejected, reviewer, reviewedAt}, artifacts {arch: {s3Key, checksum, signature, buildStatus, logTail}} (arch ∈ x86_64, x86_64_nvidia, arm64_jp4/jp5/jp6), component {name, version, arn, architectures, status: packaging/registered/failed, packagedAt, failure}, source_s3_prefix, GSI: usecase_id | +| `CustomNodeTypes` | `node_type_id` + `version` | usecase_ids, plugin_id + plugin_version, declaration (NodeTypeDescriptor wire JSON), deprecated flag, created_by/at, GSI: usecase_id | +| `ModuleIndexCache` | `cache_key` (`gst-modules`) | modules [{name, description, repoUrl, classification}], fetchedAt, TTL 24 h | +| `SimulationRuns` | `run_id` | plugin_id, version, usecase_id, dataset ref, parameters, status, results_s3_key, failure {message, timeout}, started_at/finished_at | +| `NodeGenSessions` | `session_id` | usecase_id, messages, current_source_key, ttl | + +### S3 layout (portal artifacts bucket) + +``` +plugin-sources/{usecase_id}/{plugin_id}/{version}/... source trees / scaffolds +workflow-plugins/custom/{usecase_id}/{arch}/{plugin}.so Plugin_Library (custom prefix) +workflow-plugins/custom/{usecase_id}/{arch}/{plugin}.so.sig detached signature +plugin-simulations/{usecase_id}/{run_id}/... sim inputs/outputs/results +``` + +The existing built-in prefix `workflow-plugins/{arch}/{plugin}.so` is untouched; `split_plugin_dependencies` in the packager routes `custom:{usecase}/{name}` dependencies to the custom prefix. `{arch}` ranges over all five Target_Architectures including `x86_64_nvidia`. + +### Plugin_Component storage (no new table) + +Plugin_Components deliberately ride existing storage rather than a new table: + +- **Component definition**: the Use_Case account **Greengrass registry** is the source of truth (name `dda.plugin.{pluginId}`, version `{pluginVersion}.0.0`, recipe with platform manifests), exactly as `dda.workflow.*` components work today. Registry tags (`dda-portal:plugin-id`, `dda-portal:plugin-version`, `dda-portal:usecase-id`) link back to the Plugin_Record. +- **Portal-side pointer**: the `component` attribute on the `PluginRecords` item (above) records name/version/ARN/architectures/packaging status so the portal can render status and join listings without describing every component. +- **Recipe artifacts**: the signed `.so` files + `plugin-manifest.json` are copied to the Use_Case account bucket under `plugins/components/{pluginId}/{pluginVersion}/{arch}/` (staging under `plugins/staging/...`), mirroring the `workflows/components` / `workflows/staging` layout — recipe artifact URIs must live in the account Greengrass pulls from, so the portal Plugin_Library copy alone does not suffice. +- **Deployments**: standalone Plugin_Component deployments are recorded in the existing **Deployments** table with `component_type: 'plugin'` plus `plugin_id`/`plugin_version` — the same shape `component_type: 'workflow'` records use, so listing, status polling, and revision flows are reused. + +### Custom_Node_Type declaration (wire JSON, identical shape to the catalog endpoint) + +```json +{ + "typeId": "custom.blur_regions", + "category": "preprocessing", + "displayName": "Blur Regions", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [{"name": "radius", "paramType": "int", "required": true, + "default": 5, "constraints": {"min": 1, "max": 64}}], + "mappings": [{"arch": "x86_64", + "elementChain": [{"factory": "blurregions", + "argsTemplate": {"radius": "{radius}"}}], + "pluginDependencies": ["custom:uc-123/blurregions"]}], + "hardwareDependent": false, + "typeVersion": 1, + "lifecycleState": "test" +} +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The feature's pure core — declaration validation and conversion, catalog resolution, classification, scaffold templating, the lifecycle state machine, gate predicates, checksum/signature handling, and Plugin_Component recipe/dependency assembly — is highly amenable to property-based testing. Generators produce random valid/invalid Custom_Node_Type declarations, Plugin_Records in random lifecycle/build/review states, random operation sequences, and synthetic module listings. + +### Property 1: Declaration conversion accepts exactly the valid declarations + +For all Custom_Node_Type declarations (valid ones, and ones corrupted with a random known defect — port type outside PORT_TYPES, category outside CATEGORIES, parameter type outside PARAMETER_TYPES, architecture outside ARCHITECTURES, default violating its own constraints), `descriptor_from_declaration` succeeds if and only if the declaration is valid; on success the resulting descriptor satisfies the same catalog well-formedness predicate as built-in node types, and DeepStream-flagged declarations yield mappings only for arm64_jp4/jp5/jp6; on failure the error identifies the offending field. + +**Validates: Requirements 1.7, 5.3, 8.4, 8.5** + +### Property 2: Scaffold generation is complete for the declaration + +For all valid Custom_Node_Type declarations, the generated Plugin_Scaffold contains the Frame_Processing_Hook source file, exactly one build configuration per selected Target_Architecture, and parameter plumbing that exposes every declared parameter name to the hook. + +**Validates: Requirements 1.2, 1.4** + +### Property 3: Scaffold validation rejects non-buildable source + +For all scaffold file maps produced by corrupting a valid scaffold with a random defect (removing the Frame_Processing_Hook file, removing all build configurations, emptying required files), scaffold validation rejects the source with a description of the failure, and accepts every uncorrupted scaffold. + +**Validates: Requirements 2.6** + +### Property 4: Import buildability scan matches source-tree construction + +For all synthetic source trees generated with or without a GStreamer plugin build definition (meson/autotools plugin target, or prebuilt .so), the Plugin_Importer's buildability scan reports buildable if and only if the tree was constructed with one. + +**Validates: Requirements 4.5** + +### Property 5: Module listing parse covers every module + +For all synthetic Module_Listing documents generated from a random set of module entries, parsing produces exactly that set of modules, each with its name and repository location. + +**Validates: Requirements 6.1** + +### Property 6: Plugin-set classification is exact + +For all plugin sources, `classify_plugin_set` returns good, bad, or ugly exactly for modules belonging to the corresponding official GStreamer plugin set (by module name or known repository location), and unclassified for every other source — including arbitrary public repository URLs; and every classification value has a non-empty plain-language explanation. + +**Validates: Requirements 15.1, 15.3, 15.4** + +### Property 7: Import provenance records the classification + +For all import sources (Module_Listing selections and arbitrary repository URLs), the created Plugin_Record's provenance contains repository URL, revision, importing user, retrieval timestamp, and a classification equal to `classify_plugin_set` applied to that source. + +**Validates: Requirements 4.2, 15.5** + +### Property 8: Sign-then-verify round trip with tamper detection + +For all artifact byte strings, recording an artifact (built or prebuilt) stores a checksum equal to the SHA-256 of the bytes and a signature that verifies against them, and the Component_Packager's verification accepts the artifact; for any tampering of the bytes, the recorded checksum, or the signature, verification rejects the packaging request. + +**Validates: Requirements 3.3, 3.6, 10.4** + +### Property 9: Edge checksum verification gates plugin loading + +For all plugin file contents and manifest checksum entries, LocalServer's plugin loader accepts the file if and only if the SHA-256 of the delivered bytes equals the manifest checksum, and every rejection identifies the failing plugin file. + +**Validates: Requirements 10.6** + +### Property 10: Lifecycle state machine conformance + +For all random sequences of operations (create record, create new version, record build success/failure per arch, promote, demote, approve review, reject review) applied to Plugin_Records, the implementation agrees with a reference model: every new record and every new version starts in dev with review pending regardless of prior versions; dev→test succeeds if and only if at least one successfully built Plugin_Artifact exists (otherwise rejected identifying the missing build); test→prod succeeds if and only if the security review is approved (otherwise rejected identifying the missing approval); demotion always succeeds and only changes the state. + +**Validates: Requirements 9.1, 9.4, 9.5, 9.9, 9.10, 9.13, 10.1, 10.5** + +### Property 11: Resolved catalog membership is exact + +For all sets of registered Custom_Node_Types with random lifecycle states, deprecation flags, and Use_Case scopes, the palette catalog resolved for a Use_Case equals the built-in NODE_CATALOG plus exactly those non-deprecated custom types scoped to that Use_Case whose backing Plugin_Record is in test or prod state — test-state entries carrying the test marker — while resolution for loading/validating/packaging existing workflows additionally includes deprecated types. + +**Validates: Requirements 8.2, 9.2, 9.6, 14.3** + +### Property 12: Merged-catalog compilation includes custom plugin dependencies + +For all valid Workflow_Definitions over a merged catalog containing custom node types, compilation for an architecture the custom types map to includes each custom node's declared plugin dependency in the compiled document's pluginDependencies, and compilation for an architecture a custom type has no mapping for fails with an error identifying that node and the unsupported architecture. + +**Validates: Requirements 5.4, 8.6** + +### Property 13: Packaging gates on lifecycle state and artifact presence + +For all workflows containing Custom_Node_Types and all combinations of backing Plugin_Record lifecycle states and per-architecture artifact availability, packaging succeeds if and only if every backing record is in test or prod state and has a Plugin_Artifact for every selected architecture; on success every custom node's verified artifact checksum appears in each arch manifest's pluginChecksums (delivery itself is by Plugin_Component dependency — Property 21), and on failure the rejection identifies the Custom_Node_Type and the missing architecture or the offending lifecycle state. + +**Validates: Requirements 11.1, 11.2, 11.3** + +### Property 14: Deployment gate restricts test-state plugins to Test_Devices + +For all sets of workflow plugin lifecycle states and target devices with random test_device flags, deployment submission is permitted if and only if no plugin is in dev state and every test-state plugin targets only devices flagged as Test_Devices (prod-state plugins deploy anywhere); every rejection identifies the Custom_Node_Type and its Lifecycle_State. + +**Validates: Requirements 9.7, 9.8, 9.11** + +### Property 15: Simulator start guard equals x86_64 artifact presence + +For all Plugin_Record versions with random per-architecture artifact sets, the Plugin_Simulator permits starting a run if and only if a successfully built x86_64 Plugin_Artifact exists, and every refusal describes the missing x86_64 build. + +**Validates: Requirements 7.5** + +### Property 16: Simulation results cover every input frame + +For all simulated runs over random input frame sets (pipeline layer mocked), the results report contains exactly one entry per input frame, each carrying the input frame reference, the output frame reference, and the emitted metadata. + +**Validates: Requirements 7.3** + +### Property 17: Test-run stubbing is exactly the unavailable custom nodes + +For all valid Workflow_Definitions over merged catalogs and all custom-node x86_64 artifact availabilities, simulation-mode compilation substitutes a pass-through recording stub for exactly those Custom_Node_Types lacking an x86_64 Plugin_Artifact (in addition to the hardware-dependent stubbing rules), and the test run report identifies exactly the stubbed nodes. + +**Validates: Requirements 12.2** + +### Property 18: Version retention and pinned resolution + +For all random sequences of Custom_Node_Type updates and workflow saves, every prior version remains retrievable with version numbers strictly increasing, every saved workflow records the Custom_Node_Type version current at save time, and packaging any saved workflow version resolves the recorded Custom_Node_Type version regardless of later updates. + +**Validates: Requirements 14.1, 14.2** + +### Property 19: Reference-counted removal + +For all sets of saved workflows and Custom_Node_Types, removal of a Custom_Node_Type succeeds if and only if no saved workflow references it — on success the type is absent from the catalog and its Plugin_Artifacts are deleted from the Plugin_Library; on failure the rejection lists exactly the referencing workflows. + +**Validates: Requirements 14.4, 14.5** + +### Property 20: Plugin_Component manifests are exactly the built architectures + +For all Plugin_Record versions with random per-architecture build outcomes (over x86_64, x86_64_nvidia, arm64_jp4/jp5/jp6, at least one success), the assembled Plugin_Component recipe is named `dda.plugin.{pluginId}` at version `{pluginVersion}.0.0`, is install-only, and contains exactly one platform manifest per successfully built Target_Architecture — each with the correct Greengrass platform attributes (amd64/aarch64, `variant` for the JetPack builds, `runtime: nvidia` for x86_64_nvidia) — and no manifest for any failed or unselected architecture. + +**Validates: Requirements 16.1** + +### Property 21: Workflow_Component dependencies are exactly the custom plugins + +For all valid Workflow_Definitions over merged catalogs containing custom node types, the packaged Workflow_Component recipe declares a Greengrass component dependency on the Plugin_Component of every distinct Custom_Node_Type plugin in the workflow and on no others, each with a version requirement compatible with the Custom_Node_Type version recorded in the workflow; and the per-arch artifact zips contain inline `plugins/{arch}/*.so` entries for exactly the built-in/curated plugins (custom plugins are never bundled inline). + +**Validates: Requirements 16.4, 11.1** + +### Property 22: Plugin_Component deployment gates on lifecycle and architecture coverage + +For all deployment submissions (standalone Plugin_Components, or Workflow_Components with Plugin_Component dependencies) over random backing Plugin_Record lifecycle states, random per-component published-architecture sets, and random target devices with architectures and test_device flags, submission is permitted if and only if no component in the dependency closure is backed by a dev-state record, every test-state component targets only Test_Devices (prod deploys anywhere), and every target device's Target_Architecture (x86_64 and x86_64_nvidia matched distinctly) has a published Plugin_Artifact in every depended-on Plugin_Component version; every rejection identifies the offending Plugin_Component and the unsupported Target_Architecture or Lifecycle_State. + +**Validates: Requirements 16.3, 16.6** + +### Property 23: Plugin_Component versions are immutable under rebuild + +For all sequences of source-change and rebuild operations on a plugin, every publish produces a Plugin_Component version not previously registered, and the recipes and artifact references of all previously published Plugin_Component versions are unchanged after each publish. + +**Validates: Requirements 16.7** + +## Error Handling + +### Portal backend + +- **API errors** follow the existing envelope (`{error: {code, message, details}}`): 400 for declaration/scaffold validation failures (field identified — Requirements 1.7, 8.5), 403 for RBAC denials (13.4), 404 scoped per Use_Case, 409 for guarded operations (promotion without build/review — 9.5, 9.10; removal with references — 14.5, listing referencing workflow ids). +- **Import failures**: unreachable repository or missing revision aborts before record creation (4.4); unbuildable source marks the record failed with the scan finding (4.5); Module_Listing fetch/parse failure returns a distinct `MODULE_LISTING_UNAVAILABLE` code so the UI offers manual URL entry (6.3). +- **Builds**: CodeBuild failures store the log tail per arch, never a partial artifact (3.4); EventBridge delivery is idempotent on build id so retries cannot double-record artifacts. +- **Bedrock**: identical handling to `workflow_generator.py` — clamped ≤ 60 s client-side timeout, no retries, descriptive error with prompt preserved client-side (2.6, 2.7). +- **Packaging**: verification failures raise the existing `PackagingError` — stage cleanup, failing artifact identified, no partial component version (10.4 reusing workflow-manager 7.5 atomicity). +- **Simulator**: guard failures return 409 with the missing-x86_64 explanation (7.5); harness failures flush the plugin's error output and are contained to the Fargate task (7.6); the state machine's 5-minute timeout stops the task and marks the run failed-with-timeout, retaining incrementally flushed partial results (7.7). +- **Plugin_Component packaging**: auto-packaging failures (artifact copy, registration not reaching DEPLOYABLE) follow the `workflow_packaging.py` all-or-nothing pattern — stage cleanup, failed component version deleted, `component.status = failed` with the failure recorded on the Plugin_Record; build artifacts and the record itself are unaffected, and packaging is retryable (idempotent on plugin id + version — an already-`registered` component short-circuits, and a Greengrass `ConflictException` from a concurrent retry resolves by re-describing the existing version). Auto-packaging failure never fails the build itself; the UI shows the component status separately from build status. +- **Plugin_Component deployment gates**: pre-submit rejections use distinct codes — `PLUGIN_LIFECYCLE_VIOLATION` (dev-state component, or test-state component targeting a non-Test_Device; 16.3) and `PLUGIN_ARCH_UNSUPPORTED` listing each offending `{pluginComponent, version, device, deviceArch}` (16.6) — alongside the existing LocalServer-compatibility rejection shape, so the deployment screen can render actionable per-device reasons before anything is submitted to Greengrass. + +### Edge (LocalServer) + +- Checksum mismatch on a delivered plugin skips the registry scan for that file, registers the workflow as invalid with the file identified, and reports through the existing status path (10.6) — bundled plugins and Pipeline_Configuration execution are never affected (the scan remains additive and env changes per-run, as today). + +## Testing Strategy + +Dual approach: property-based tests for the pure logic (declaration conversion, catalog resolution, classification, scaffolding, lifecycle machine, gates, checksum/signature handling) and example/integration tests for wiring, UI, CodeBuild/KMS/Bedrock/S3 interactions, and device behavior. + +### Property-based tests + +- **Library**: `hypothesis` for Python (Properties 1–23 backend portions), `fast-check` for TypeScript where frontend logic is tested (badge/classification rendering helpers if extracted). +- **Configuration**: minimum 100 iterations per property test. +- **Traceability**: each property implemented by a single property-based test tagged `**Feature: custom-node-designer, Property {number}: {property_text}**`. +- **Generators**: declaration strategies (valid + defect-seeded), Plugin_Record state strategies (lifecycle × review × per-arch artifacts, arch drawn from all five Target_Architectures including x86_64_nvidia), operation-sequence strategies for the lifecycle model and for rebuild/publish sequences (Property 23), per-arch build-outcome maps for recipe assembly (Property 20), published-arch-set × device-arch strategies for the deployment gates (Property 22), synthetic module-listing and source-tree builders, random artifact byte strings with tamper combinators, and reuse of the existing workflow-manager `graph_strategy` extended with custom node types for merged-catalog properties (Properties 12, 13, 21). +- **Mocks**: KMS sign/verify mocked with a real ECDSA keypair (cryptography lib) so the round trip is genuine; S3/DynamoDB via moto; the GStreamer pipeline layer mocked for results-assembly properties. + +### Unit and example-based tests + +- Wizard/registration form field coverage (1.1, 8.1), scaffold download (1.5), generated-source review flow (2.1, 2.3), provenance prompt recording (2.5), Bedrock failure handling (2.7), build failure reporting (3.4), status display (3.5), import error paths (4.4, 4.6), DeepStream arch restriction (5.1), module selection wiring and cache TTL boundary (6.2, 6.4), listing-failure fallback (6.3), simulator UI flows (7.1, 7.4, 7.6), palette category display (8.3), review screen completeness including classification (10.2, 15.6), stub limitation text (12.3), RBAC role×action parameterized matrix (13.1–13.4), classification explanations present for all four values (15.3), import confirmation view (15.2), deployment-screen Plugin_Component listing fields — name, version, Lifecycle_State, supported architectures (16.2), amd64 manifest ordering/attribute matching for x86_64 vs x86_64_nvidia recipes (example fixtures for both flavors present and absent). + +### Integration tests + +- CodeBuild orchestration with mocked StartBuild/EventBridge results (1.6, 3.1, 4.3); build-project IAM policy static assertions (3.2, smoke); DeepStream build image SDK pinning assertions (5.2, smoke); x86_64_nvidia build image CUDA toolchain presence (smoke); all five build projects exist in the stack (snapshot). +- Plugin_Component auto-packaging trigger on build settlement, stage/promote/register against mocked Greengrass, failure cleanup and retry idempotency (16.1, 16.7 wiring); workflow deployment resolves the depended-on Plugin_Component version (16.5, mocked Greengrass dependency resolution end-to-end where feasible). +- Bedrock Converse mocked: prompt/template assembly, follow-up source inclusion (2.2, 2.4). +- Repository fetch against local git fixtures (4.1). +- Simulator state machine: task-role policy assertions (7.2, smoke), timeout behavior with a shortened limit (7.7), containerized single-plugin run in the sandbox image (1.3). +- Audit log writes per operation (10.3, 13.5). +- Packaging + deployment against moto S3/Greengrass with custom plugins (11.4 device-side deferred to edge integration; 12.1 sandbox run with a real custom x86_64 artifact). +- Edge: checksum-verified load and element execution on a device/CI image (10.6 positive path, 11.4). diff --git a/.kiro/specs/custom-node-designer/requirements.md b/.kiro/specs/custom-node-designer/requirements.md new file mode 100644 index 00000000..c3200e53 --- /dev/null +++ b/.kiro/specs/custom-node-designer/requirements.md @@ -0,0 +1,255 @@ +# Requirements Document + +## Introduction + +The Custom Node Designer extends the Workflow Manager (spec: workflow-manager) with a tool for adding new node types to the Workflow_Builder palette without a platform release. Users can create a custom node from generated template code that exposes a per-frame processing hook (frame in → user code → frame out), generate that starter code from a natural-language description using a configured Amazon Bedrock model, or import an existing native plugin: a GStreamer plugin from a public source repository, an NVIDIA DeepStream plugin for Jetson devices, or a well-known GStreamer module selected from the official GStreamer module listing. Created and imported plugins are built per target device architecture in an isolated build environment, exercised against sample inputs in a visual plugin simulator before entering the library, and progressed through a dev → test → prod lifecycle in which promotion to prod requires an explicit security review (they are native code executed on edge devices). Approved plugin artifacts are signed, stored in the curated Plugin_Library used by workflow packaging, and registered in the Node_Type_Catalog so they appear in the Node_Palette with ports, parameters, and help like any built-in node type. The feature also defines how custom nodes behave in cloud test runs, which portal roles may manage them, and how custom node types are versioned, deprecated, and removed. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend, DynamoDB storage) used to manage DDA use cases, models, components, deployments, devices, and workflows. +- **LocalServer**: The Greengrass component (aws.edgeml.dda.LocalServer.<arch>) running on an edge device. It embeds the NVIDIA Triton Inference Server and executes GStreamer pipelines, loading bundled GStreamer plugins and plugins delivered with a Workflow_Component. +- **Workflow_Builder**: The graphical canvas UI within the Portal where users place Nodes and draw Connections to compose a Workflow_Definition. +- **Node**: A single processing stage in a workflow represented as a box on the Workflow_Builder canvas. +- **Node_Palette**: The categorized list of available Node types shown in the Workflow_Builder, organized into input, preprocessing, model inference, post-processing, and output sections. +- **Port**: A typed attachment point on a Node where a Connection begins or ends. Each Port declares a media or data type (for example video frames, inference metadata, or event signals). +- **Node_Type_Catalog**: The catalog of Node type declarations shared by the Portal, the cloud test sandbox, and LocalServer. Each declaration specifies a Node type's category, input and output Ports, parameters with types, defaults, constraints, descriptions, and examples, per-architecture GStreamer mappings, plugin dependencies, and hardware-dependence flag. +- **Node_Designer**: The Portal capability introduced by this feature that lets authorized users create Custom_Node_Types from template code, generated code, or imported native plugins, and manage those Custom_Node_Types through their lifecycle. +- **Custom_Node_Type**: A Node type added to the Node_Type_Catalog through the Node_Designer rather than shipped with the platform. A Custom_Node_Type is backed by one or more Plugin_Artifacts. +- **Plugin_Scaffold**: The generated project produced by the Node_Designer for a new Custom_Node_Type, containing template plugin source code with a Frame_Processing_Hook and per-architecture build configuration. +- **Frame_Processing_Hook**: The designated function within a Plugin_Scaffold where the user writes processing logic. The hook receives each frame arriving at the Node's input Port and returns the frame content to emit on the Node's output Port. +- **Node_Generator**: The Portal backend component that invokes the Amazon Bedrock model specified in the Bedrock_Configuration to produce Plugin_Scaffold source code from a natural-language description of the desired node behavior. +- **Bedrock_Configuration**: The existing Portal settings (spec: workflow-manager) identifying which Amazon Bedrock model, region, and inference parameters generation features use. +- **GStreamer_Plugin**: A native GStreamer plugin (shared library exposing one or more GStreamer elements) that can be loaded by the GStreamer runtime on a device or in the cloud test sandbox. +- **DeepStream_Plugin**: A GStreamer_Plugin built against the NVIDIA DeepStream SDK, executable only on device architectures with a matching DeepStream runtime (Jetson JetPack 4, 5, or 6). +- **Plugin_Importer**: The Portal backend component that retrieves plugin source or binaries from a user-specified public repository or from the Module_Listing, records provenance, and submits the plugin for building. +- **Module_Listing**: The official GStreamer module index published at https://gstreamer.freedesktop.org/modules/, from which the Portal offers a selectable list of well-known public GStreamer modules. +- **Plugin_Set_Classification**: The upstream GStreamer quality taxonomy for a plugin's official plugin set: good (gst-plugins-good: well-maintained, well-tested, properly licensed), bad (gst-plugins-bad: lacking review, testing, or active maintenance), ugly (gst-plugins-ugly: good quality but with licensing or distribution concerns), or unclassified (not part of an official GStreamer plugin set). +- **Plugin_Build_Service**: The Portal backend component that compiles plugin source into Plugin_Artifacts for each selected Target_Architecture within an isolated build environment and signs the resulting artifacts. +- **Plugin_Artifact**: A built plugin binary (.so shared library) for one Target_Architecture, stored in the Plugin_Library with an integrity checksum and a signature produced by the Plugin_Build_Service. +- **Plugin_Library**: The curated per-account plugin storage (portal S3) from which the Component_Packager retrieves plugin artifacts (plugins/<arch>/*.so) when packaging a Workflow_Component. +- **Plugin_Record**: The stored metadata for a created, generated, or imported plugin: name, version, provenance (source repository URL and revision, scaffold origin, or generation prompt), importing or creating user, timestamps, per-architecture Plugin_Artifacts with checksums and signatures, Lifecycle_State, and security review decision. +- **Plugin_Simulator**: The Portal capability that executes a plugin's x86_64 Plugin_Artifact in a sandboxed cloud environment against user-selected sample inputs and displays the input frames, output frames, and emitted metadata for comparison. +- **Lifecycle_State**: The state of a Plugin_Record version within the dev → test → prod progression. dev: under development, usable only inside the Node_Designer and Plugin_Simulator. test: available in the Node_Palette for building and cloud test runs, deployable only to Test_Devices. prod: fully released, deployable to any device. +- **Test_Device**: An edge device designated by a UseCaseAdmin within a Use_Case as a non-production device for evaluating workflows and custom nodes. +- **Target_Architecture**: A device architecture a plugin can be built for: x86_64 (amd64), x86_64 with NVIDIA GPU runtime (x86_64_nvidia), arm64 JetPack 4 (arm64_jp4), arm64 JetPack 5 (arm64_jp5), or arm64 JetPack 6 (arm64_jp6). The cloud test sandbox and the Plugin_Simulator execute x86_64 builds. +- **Plugin_Component**: The versioned Greengrass component automatically produced from a Plugin_Record's built Plugin_Artifacts, carrying one platform manifest per successfully built Target_Architecture. Plugin_Components appear on the deployment screen and are the unit Workflow_Components declare Greengrass dependencies on. +- **Workflow_Compiler**: The Workflow Manager component that translates a valid Workflow_Definition into a GStreamer pipeline configuration, including the list of plugin dependencies beyond those bundled with LocalServer. +- **Component_Packager**: The Portal backend component that packages a compiled workflow and its plugin dependencies into a versioned Greengrass component (Workflow_Component). +- **Workflow_Component**: The Greengrass component produced by the Component_Packager for a specific workflow version. +- **Workflow_Test_Runner**: The Portal backend component that executes a compiled workflow against a Test_Dataset in the cloud-side simulated environment (x86_64), substituting stubs for hardware-dependent Nodes. +- **Test_Dataset**: The existing named collection of canned sample inputs (spec: workflow-manager) scoped to a Use_Case, selected or uploaded by a user. +- **Use_Case**: The existing Portal tenancy unit (per-account production line) to which workflows, models, devices, and Custom_Node_Types are scoped. +- **PortalAdmin**: The existing Portal role with administrative rights across the Portal installation. +- **UseCaseAdmin**: The existing Portal role with administrative rights within a Use_Case. + +## Requirements + +### Requirement 1: Create a Custom Node from Template Code + +**User Story:** As a computer vision engineer, I want the portal to scaffold a new custom node as a GStreamer plugin with template code exposing a frame-processing hook, so that I can add my own per-frame processing logic to the palette by writing only the code inside the hook. + +#### Acceptance Criteria + +1. WHEN a user initiates custom node creation, THE Node_Designer SHALL collect a Custom_Node_Type name, description, palette category, input and output Port declarations, parameter declarations, and one or more Target_Architectures. +2. WHEN a user confirms the collected custom node details, THE Node_Designer SHALL generate a Plugin_Scaffold containing template plugin source code with a Frame_Processing_Hook and build configuration for each selected Target_Architecture. +3. THE Frame_Processing_Hook in a generated Plugin_Scaffold SHALL receive each frame arriving at the Node's input Port and SHALL return the frame content that the plugin emits on the Node's output Port. +4. THE Plugin_Scaffold SHALL expose the Custom_Node_Type's declared parameters to the Frame_Processing_Hook as named values readable by the user's hook code. +5. WHEN a Plugin_Scaffold is generated, THE Node_Designer SHALL make the Plugin_Scaffold source available to the user for download and editing. +6. WHEN a user submits Plugin_Scaffold source (original or edited), THE Node_Designer SHALL forward the source to the Plugin_Build_Service for the Target_Architectures selected for the Custom_Node_Type. +7. IF Plugin_Scaffold generation fails, THEN THE Node_Designer SHALL display an error identifying the failing input and SHALL create no Plugin_Record. + +### Requirement 2: Prompt-Based Custom Node Generation + +**User Story:** As a process engineer without programming experience, I want to describe the node I need in natural language and receive working starter plugin code, so that I can create a custom node without writing code myself. + +#### Acceptance Criteria + +1. WHERE prompt-based node generation is enabled, THE Node_Designer SHALL provide a chat interface that accepts a natural-language description of the desired node behavior. +2. WHEN a user submits a node description prompt, THE Node_Generator SHALL invoke the Amazon Bedrock model specified in the Bedrock_Configuration with the prompt and the Plugin_Scaffold template conventions, and SHALL return complete Plugin_Scaffold source code whose Frame_Processing_Hook implements the described behavior. +3. WHEN the Node_Generator returns Plugin_Scaffold source code, THE Node_Designer SHALL display the generated source to the user for review and optional editing before submission to the Plugin_Build_Service. +4. WHEN a user submits a follow-up prompt in the same chat session, THE Node_Generator SHALL apply the requested modification to the current generated Plugin_Scaffold source rather than generating new source from scratch. +5. WHEN a user accepts generated Plugin_Scaffold source, THE Node_Designer SHALL process the generated source through the same build, simulation, lifecycle, and security review path as user-written Plugin_Scaffold source, and THE Plugin_Record SHALL record the generation prompt as provenance. +6. IF the Node_Generator output does not form a buildable Plugin_Scaffold, THEN THE Node_Designer SHALL display an error describing the failure and SHALL preserve the user's prompt for retry. +7. IF the Bedrock model invocation fails or exceeds a configurable timeout of at most 60 seconds, THEN THE Node_Designer SHALL display an error message identifying the failure and SHALL preserve the user's prompt for retry. + +### Requirement 3: Per-Architecture Plugin Builds + +**User Story:** As a computer vision engineer, I want my created or imported plugins built for each device architecture I target, so that the same custom node runs on x86_64 and Jetson devices and in cloud tests. + +#### Acceptance Criteria + +1. WHEN plugin source is submitted for building, THE Plugin_Build_Service SHALL compile the source into one Plugin_Artifact per selected Target_Architecture. +2. THE Plugin_Build_Service SHALL execute each build in an isolated build environment that has no access to the credentials, data, or build environments of other Use_Cases. +3. WHEN a build completes for a Target_Architecture, THE Plugin_Build_Service SHALL sign the resulting Plugin_Artifact and store the Plugin_Artifact in the Plugin_Library under that Target_Architecture together with an integrity checksum and the signature recorded in the Plugin_Record. +4. IF the build fails for a Target_Architecture, THEN THE Plugin_Build_Service SHALL report the failure with the failing Target_Architecture identified and the compiler output included, and SHALL store no Plugin_Artifact for that Target_Architecture. +5. WHEN builds complete, THE Node_Designer SHALL display the per-architecture build status (succeeded or failed) for the Plugin_Record. +6. WHERE a plugin source distribution already provides prebuilt binaries for a Target_Architecture, THE Plugin_Build_Service SHALL accept the prebuilt binary as the Plugin_Artifact for that Target_Architecture and record its checksum and signature in the Plugin_Record. + +### Requirement 4: Import a GStreamer Plugin from a Public Repository + +**User Story:** As a computer vision engineer, I want to import an existing GStreamer plugin from a public source repository and add it to the node library, so that I can use community plugins in my workflows without waiting for a platform release. + +#### Acceptance Criteria + +1. WHEN a user submits a public repository URL and an optional revision identifier for import, THE Plugin_Importer SHALL retrieve the plugin source from the repository at the specified revision or at the repository default revision when no revision is specified. +2. WHEN plugin source is retrieved, THE Plugin_Importer SHALL create a Plugin_Record containing the repository URL, the retrieved revision identifier, the importing user, and the retrieval timestamp. +3. WHEN a Plugin_Record is created from an import, THE Plugin_Importer SHALL submit the retrieved source to the Plugin_Build_Service for the Target_Architectures selected by the user. +4. IF the repository is unreachable or the specified revision does not exist, THEN THE Plugin_Importer SHALL display an error identifying the failure and SHALL create no Plugin_Record. +5. IF the retrieved source does not contain a buildable GStreamer_Plugin, THEN THE Plugin_Importer SHALL report the finding to the user and SHALL mark the Plugin_Record as failed. +6. WHEN an imported plugin's builds succeed for at least one Target_Architecture, THE Node_Designer SHALL prompt the user to declare the Custom_Node_Type details for the plugin's element as specified in Requirement 8. + + +### Requirement 5: Import an NVIDIA DeepStream Plugin + +**User Story:** As a computer vision engineer, I want to import NVIDIA DeepStream plugins for my Jetson devices, so that I can use DeepStream-accelerated processing stages in workflows targeting Jetson hardware. + +#### Acceptance Criteria + +1. WHEN a user marks an import as a DeepStream_Plugin, THE Node_Designer SHALL restrict the selectable Target_Architectures to arm64 JetPack 4, arm64 JetPack 5, and arm64 JetPack 6. +2. WHEN a DeepStream_Plugin is built for a Jetson Target_Architecture, THE Plugin_Build_Service SHALL build against the DeepStream SDK version matching that Target_Architecture's JetPack release. +3. WHEN a Custom_Node_Type backed by a DeepStream_Plugin is registered, THE Node_Type_Catalog SHALL record the Custom_Node_Type as unavailable on Target_Architectures without a matching DeepStream runtime. +4. IF a user requests packaging of a workflow containing a DeepStream-backed Custom_Node_Type for a Target_Architecture without a matching DeepStream runtime, THEN THE Workflow_Compiler SHALL report a compilation error identifying the Node and the unsupported Target_Architecture. + +### Requirement 6: Import from the Official GStreamer Module Listing + +**User Story:** As a computer vision engineer, I want to pick a well-known GStreamer module from a select box populated from the official GStreamer module listing, so that I can import public plugins by browsing instead of typing repository URLs. + +#### Acceptance Criteria + +1. WHEN a user opens the module import view, THE Portal SHALL retrieve the current module index from the Module_Listing and present the retrieved modules in a selectable list with module names. +2. WHEN a user selects a module from the list, THE Plugin_Importer SHALL use the selected module's published repository location as the import source and proceed as specified in Requirement 4. +3. IF the Module_Listing is unreachable or returns an unparseable response, THEN THE Portal SHALL display an error identifying the failure and SHALL offer manual repository URL entry as specified in Requirement 4 as the alternative import path. +4. WHEN the module index is retrieved, THE Portal SHALL cache the retrieved index and reuse the cached index for subsequent module import views for at most 24 hours before retrieving a fresh index. + +### Requirement 7: Visual Plugin Simulator + +**User Story:** As a computer vision engineer, I want to feed sample input into my plugin and view the resulting output frames and metadata, so that I can verify the plugin's behavior before adding the node to the library. + +#### Acceptance Criteria + +1. WHEN a user opens the Plugin_Simulator for a Plugin_Record that has an x86_64 Plugin_Artifact, THE Plugin_Simulator SHALL prompt the user to select an existing Test_Dataset scoped to the selected Use_Case or upload sample input frames or video. +2. WHEN a user starts a simulation run, THE Plugin_Simulator SHALL execute the plugin's x86_64 Plugin_Artifact against the selected sample input within a sandboxed environment that has no access to other Portal workloads, other Use_Cases' data, or the Plugin_Library write path. +3. WHEN a simulation run completes, THE Plugin_Simulator SHALL display the input frames and the corresponding output frames side by side together with the metadata the plugin emitted for each frame. +4. WHEN a simulation run completes, THE Plugin_Simulator SHALL allow the user to configure the plugin's declared parameters and start another simulation run with the changed parameter values. +5. IF a Plugin_Record has no x86_64 Plugin_Artifact, THEN THE Plugin_Simulator SHALL refuse to start a simulation run and SHALL describe that simulation requires a successful x86_64 build. +6. IF plugin execution fails or terminates abnormally during a simulation run, THEN THE Plugin_Simulator SHALL report the failure with the plugin's error output included and SHALL contain the failure within the sandboxed environment. +7. IF a simulation run exceeds 5 minutes, THEN THE Plugin_Simulator SHALL terminate the run, mark the run as failed with a timeout indication, and display the partial results produced before termination. + +### Requirement 8: Node Type Declaration and Palette Integration + +**User Story:** As a computer vision engineer, I want my created or imported plugin to appear in the workflow builder palette as a regular node with ports, parameters, and help text, so that anyone in my use case can use the custom node like a built-in node. + +#### Acceptance Criteria + +1. WHEN a user registers a Custom_Node_Type for a plugin, THE Node_Designer SHALL collect the display name, palette category, input and output Ports with declared Port types, parameters with types, defaults, constraints, descriptions, and examples, the hardware-dependence flag, and the mapping from the plugin's element and its properties to the declared parameters for each built Target_Architecture. +2. WHEN a Custom_Node_Type registration is completed for a Plugin_Record whose Lifecycle_State is test or prod, THE Node_Type_Catalog SHALL include the Custom_Node_Type with the same declaration structure as built-in Node types, scoped to the Use_Cases selected at registration. +3. WHEN a Custom_Node_Type is included in the Node_Type_Catalog for a Use_Case, THE Node_Palette SHALL display the Custom_Node_Type in its declared category for users of that Use_Case. +4. WHEN a user places a Custom_Node_Type Node on the canvas, THE Workflow_Builder SHALL provide the same configuration panel behavior as for built-in Node types, including parameter validation, field-level descriptions, and example values. +5. IF a Custom_Node_Type registration declares a Port type outside the Port types defined by the Node_Type_Catalog, THEN THE Node_Designer SHALL reject the registration and identify the invalid Port declaration. +6. WHEN a Custom_Node_Type is registered, THE Node_Type_Catalog SHALL record the plugin dependency of the Custom_Node_Type so that the Workflow_Compiler includes the plugin in the compiled pipeline's dependency list for each Target_Architecture. + +### Requirement 9: Plugin Lifecycle States (dev, test, prod) + +**User Story:** As a use case administrator, I want every custom or imported node to progress through dev, test, and prod states with usage gated per state, so that unproven native code cannot reach production devices. + +#### Acceptance Criteria + +1. WHEN a Plugin_Record is created (from a Plugin_Scaffold, generated source, or an import), THE Portal SHALL set the Plugin_Record's Lifecycle_State to dev. +2. WHILE a Plugin_Record's Lifecycle_State is dev, THE Node_Type_Catalog SHALL exclude Custom_Node_Types backed by that Plugin_Record from the Node_Palette. +3. WHILE a Plugin_Record's Lifecycle_State is dev, THE Node_Designer SHALL permit editing the plugin source, rebuilding, and Plugin_Simulator runs for that Plugin_Record. +4. WHEN a UseCaseAdmin promotes a Plugin_Record from dev to test, THE Portal SHALL verify that the Plugin_Record has at least one successfully built Plugin_Artifact and set the Lifecycle_State to test. +5. IF promotion from dev to test is requested for a Plugin_Record with no successfully built Plugin_Artifact, THEN THE Portal SHALL reject the promotion and identify the missing build. +6. WHILE a Plugin_Record's Lifecycle_State is test, THE Node_Palette SHALL display Custom_Node_Types backed by that Plugin_Record with a visible test-state marker. +7. WHILE a Plugin_Record's Lifecycle_State is test, THE Portal SHALL permit workflows containing Custom_Node_Types backed by that Plugin_Record to be saved, validated, executed by the Workflow_Test_Runner, and deployed to Test_Devices. +8. IF a user requests deployment of a Workflow_Component containing a Custom_Node_Type backed by a test-state Plugin_Record to a device that is not a Test_Device, THEN THE Deployment_Service SHALL reject the deployment and identify the Custom_Node_Type and its Lifecycle_State. +9. WHEN a UseCaseAdmin promotes a Plugin_Record from test to prod, THE Portal SHALL verify that the Plugin_Record has an approved security review as specified in Requirement 10 and set the Lifecycle_State to prod. +10. IF promotion from test to prod is requested for a Plugin_Record without an approved security review, THEN THE Portal SHALL reject the promotion and identify the missing security review approval. +11. WHILE a Plugin_Record's Lifecycle_State is prod, THE Deployment_Service SHALL permit deployment of Workflow_Components containing Custom_Node_Types backed by that Plugin_Record to any device in the Use_Case. +12. WHEN a UseCaseAdmin demotes a Plugin_Record from prod to test or from test to dev, THE Portal SHALL apply the demoted state's gates to subsequent packaging and deployment requests WHILE Workflow_Components already deployed to devices continue to run unchanged. +13. WHEN a new Plugin_Record version is created from changed source or a changed declaration, THE Portal SHALL set the new version's Lifecycle_State to dev independently of the Lifecycle_State of prior versions. + +### Requirement 10: Security Review, Signing, and Approval of Native Plugins + +**User Story:** As a portal administrator, I want every created, generated, or imported plugin security-reviewed, approved, and signed before it can reach production devices, so that untrusted native code cannot run in production without an explicit trust decision. + +#### Acceptance Criteria + +1. WHEN a Plugin_Record version is created, THE Portal SHALL set the Plugin_Record version's security review decision to pending. +2. WHEN a PortalAdmin reviews a pending Plugin_Record, THE Portal SHALL display the Plugin_Record provenance (source repository URL and revision, scaffold origin, or generation prompt, plus the importing or creating user and timestamps), the per-architecture Plugin_Artifact checksums and signatures, and the plugin source for inspection. +3. WHEN a PortalAdmin approves or rejects a Plugin_Record's security review, THE Portal SHALL record the decision, the acting PortalAdmin, and a timestamp in the existing audit log. +4. WHEN the Component_Packager includes a Plugin_Artifact in a Workflow_Component, THE Component_Packager SHALL verify the Plugin_Artifact against the checksum and signature recorded in the Plugin_Record and SHALL reject the packaging request when either verification fails. +5. WHEN a new version of a plugin is created from changed source or rebuilt, THE Portal SHALL set the new Plugin_Record version's security review decision to pending independently of prior approvals. +6. WHEN a LocalServer loads a plugin delivered by a Workflow_Component, THE LocalServer SHALL verify the plugin file against the checksum recorded in the Workflow_Component manifest and SHALL refuse to load a plugin whose checksum verification fails, reporting the failure through its existing status reporting path. + +### Requirement 11: Packaging and Delivery of Custom Node Plugins + +**User Story:** As an operator, I want workflows that use custom nodes packaged with the required plugin binaries per architecture, so that a single deployment delivers everything the edge device needs to run the custom node. + +#### Acceptance Criteria + +1. WHEN a user requests packaging of a workflow containing a Custom_Node_Type, THE Component_Packager SHALL include the Custom_Node_Type's Plugin_Artifact from the Plugin_Library for each Target_Architecture selected for packaging. +2. IF the Plugin_Library contains no Plugin_Artifact for a Custom_Node_Type on a Target_Architecture selected for packaging, THEN THE Component_Packager SHALL reject the packaging request and identify the Custom_Node_Type and the missing Target_Architecture. +3. IF a user requests packaging of a workflow containing a Custom_Node_Type whose backing Plugin_Record's Lifecycle_State is dev, THEN THE Component_Packager SHALL reject the packaging request and identify the Custom_Node_Type and its Lifecycle_State. +4. WHEN a Workflow_Component containing a Custom_Node_Type's Plugin_Artifact is deployed to a device, THE LocalServer SHALL load the delivered plugin and execute the Custom_Node_Type's element within the compiled pipeline. + +### Requirement 12: Custom Nodes in Cloud Test Runs + +**User Story:** As a computer vision engineer, I want to test workflows containing custom nodes in the cloud test sandbox, so that I can verify custom node behavior before deploying to a device. + +#### Acceptance Criteria + +1. WHEN a test run executes a workflow containing a Custom_Node_Type that has an x86_64 Plugin_Artifact, THE Workflow_Test_Runner SHALL load the x86_64 Plugin_Artifact and execute the Custom_Node_Type within the simulated pipeline. +2. IF a test run executes a workflow containing a Custom_Node_Type that has no x86_64 Plugin_Artifact, THEN THE Workflow_Test_Runner SHALL substitute a stub for that Node that records the data the Node would have consumed and passes input frames through unchanged, and SHALL identify the Node as stubbed in the test run report. +3. WHEN a test run report includes a stubbed Custom_Node_Type, THE Workflow_Builder SHALL describe the limitation that the Custom_Node_Type was simulated because no x86_64 build exists. + +### Requirement 13: Access Control for Node Designer Operations + +**User Story:** As a portal administrator, I want custom node creation, import, and library management restricted to administrative roles, so that only trusted users can introduce native code into the platform. + +#### Acceptance Criteria + +1. THE Portal SHALL permit users with the UseCaseAdmin role in a Use_Case or the PortalAdmin role to create Plugin_Scaffolds, use the Node_Generator, import plugins, run the Plugin_Simulator, register Custom_Node_Types, promote or demote Plugin_Records between dev and test, and manage Plugin_Records scoped to that Use_Case. +2. THE Portal SHALL permit only users with the PortalAdmin role to approve or reject Plugin_Record security reviews. +3. THE Portal SHALL permit users with the DataScientist, Operator, or Viewer role in a Use_Case to view the Custom_Node_Types and Plugin_Records of that Use_Case in read-only form. +4. IF a user without a permitted role attempts a create, generate, import, simulate, register, promote, demote, approve, update, or remove operation on a Plugin_Record or Custom_Node_Type, THEN THE Portal SHALL deny the operation and return an authorization error. +5. WHEN a Plugin_Record or Custom_Node_Type is created, generated, imported, simulated, registered, promoted, demoted, approved, rejected, updated, deprecated, or removed, THE Portal SHALL record the action, the acting user, and a timestamp in the existing audit log. + +### Requirement 14: Custom Node Type Versioning, Deprecation, and Removal + +**User Story:** As a use case administrator, I want to version, deprecate, and remove custom node types, so that I can evolve the node library without breaking saved workflows. + +#### Acceptance Criteria + +1. WHEN plugin source or a Custom_Node_Type declaration is updated, THE Node_Designer SHALL create a new Custom_Node_Type version and retain prior versions. +2. WHEN a workflow is saved with a Custom_Node_Type Node, THE Workflow_Store SHALL record the Custom_Node_Type version used, and packaging of that workflow version SHALL use the recorded Custom_Node_Type version. +3. WHEN a UseCaseAdmin deprecates a Custom_Node_Type, THE Node_Palette SHALL stop offering the Custom_Node_Type for new placement WHILE existing saved workflows referencing the Custom_Node_Type remain loadable, packagable, and deployable. +4. WHEN a UseCaseAdmin requests removal of a Custom_Node_Type that no saved workflow references, THE Node_Designer SHALL remove the Custom_Node_Type from the Node_Type_Catalog and its Plugin_Artifacts from the Plugin_Library. +5. IF a UseCaseAdmin requests removal of a Custom_Node_Type that at least one saved workflow references, THEN THE Node_Designer SHALL reject the removal and identify the referencing workflows. + +### Requirement 15: Upstream Quality Classification Display for Public Plugins + +**User Story:** As a computer vision engineer, I want the portal to show each public GStreamer plugin's upstream good/bad/ugly classification when I browse or import it, so that I understand the maintenance and licensing risk of what I am bringing into the library. + +#### Acceptance Criteria + +1. WHEN the Portal presents the module list retrieved from the Module_Listing, THE Portal SHALL display each module's Plugin_Set_Classification as a risk indicator beside the module name. +2. WHEN a user confirms an import from the Module_Listing or from a public repository URL, THE Plugin_Importer SHALL display the plugin's Plugin_Set_Classification and a plain-language explanation of what that classification means on the import confirmation view before the import proceeds. +3. THE Portal SHALL present the following plain-language explanation with each Plugin_Set_Classification: good indicates a well-maintained, well-tested, properly licensed plugin set; bad indicates a plugin set lacking upstream review, testing, or active maintenance; ugly indicates a plugin set of good quality that carries licensing or distribution concerns; unclassified indicates a plugin outside the official GStreamer plugin sets that warrants the highest caution. +4. WHEN a plugin is imported from a public repository that is not part of an official GStreamer plugin set, THE Plugin_Importer SHALL assign the Plugin_Set_Classification unclassified to the plugin. +5. WHEN the Plugin_Importer creates a Plugin_Record for an imported plugin, THE Plugin_Importer SHALL record the plugin's Plugin_Set_Classification in the Plugin_Record provenance. +6. WHEN a PortalAdmin reviews a pending Plugin_Record as specified in Requirement 10, THE Portal SHALL display the Plugin_Record's recorded Plugin_Set_Classification alongside the other provenance details. +7. WHEN a user confirms an import of a plugin whose Plugin_Set_Classification is bad, ugly, or unclassified, THE Portal SHALL require the user to acknowledge the displayed classification explanation before the import proceeds. + +### Requirement 16: Automatic Plugin Component Packaging and Deployment + +**User Story:** As an operator, I want built plugins automatically packaged per architecture as deployable components that appear on the deployment screen, and workflows that use them to carry the right Greengrass dependencies, so that deploying a workflow always delivers the plugins its custom nodes need. + +#### Acceptance Criteria + +1. WHEN a Plugin_Record's builds complete, THE Component_Packager SHALL automatically package the successfully built Plugin_Artifacts into a versioned Plugin_Component whose Greengrass recipe carries one platform manifest per successfully built Target_Architecture (x86_64, x86_64_nvidia, arm64_jp4, arm64_jp5, and arm64_jp6). +2. WHEN a Plugin_Component version is registered, THE Portal SHALL list the Plugin_Component on the deployment screen with its name, version, Lifecycle_State, and the Target_Architectures it supports. +3. WHILE a Plugin_Component's backing Plugin_Record is in the test Lifecycle_State, THE Deployment_Service SHALL permit deploying that Plugin_Component only to Test_Devices; WHILE the backing Plugin_Record is in the prod Lifecycle_State, THE Deployment_Service SHALL permit deploying it to any device in the Use_Case. +4. WHEN a workflow containing Custom_Node_Types is packaged, THE Component_Packager SHALL declare a Greengrass component dependency in the Workflow_Component recipe on each required Plugin_Component at a version compatible with the Custom_Node_Type versions recorded in the workflow. +5. WHEN a Workflow_Component with Plugin_Component dependencies is deployed, THE Deployment_Service SHALL include the depended-on Plugin_Component versions in the Greengrass deployment so the device installs the plugins together with the workflow. +6. IF a Workflow_Component is deployed to a device whose Target_Architecture has no published Plugin_Artifact in a depended-on Plugin_Component version, THEN THE Deployment_Service SHALL reject the deployment and identify the Plugin_Component and the unsupported Target_Architecture. +7. WHEN a Plugin_Record is rebuilt or its source changes, THE Component_Packager SHALL publish the resulting artifacts as a new Plugin_Component version and SHALL leave previously published Plugin_Component versions unchanged. diff --git a/.kiro/specs/custom-node-designer/tasks.md b/.kiro/specs/custom-node-designer/tasks.md new file mode 100644 index 00000000..5653b508 --- /dev/null +++ b/.kiro/specs/custom-node-designer/tasks.md @@ -0,0 +1,327 @@ +# Implementation Plan: Custom Node Designer + +## Overview + +Implementation proceeds on the `workflow_manager` git branch. The shared `workflow_core` additions (catalog.custom, classification, scaffold module, `ARCH_X86_64_NVIDIA` constants) are built first because the portal Lambdas, the packager, the test sandbox, and the LocalServer vendored copies all depend on them. Portal backend Lambdas and build infrastructure follow, then the Plugin_Simulator, Custom_Node_Type registration and catalog integration, packaging/deployment integration, frontend, cloud test runs, edge verification, and finally backward-compatibility verification. Python code uses `hypothesis` and TypeScript code uses `fast-check` for property-based tests, each configured for a minimum of 100 iterations and tagged `**Feature: custom-node-designer, Property {number}: {property_text}**`. + +## Tasks + +- [x] 1. Extend workflow_core for custom node types + - [x] 1.1 Add the x86_64_nvidia Target_Architecture to workflow_core constants + - Add `ARCH_X86_64_NVIDIA = "x86_64_nvidia"` to `ARCHITECTURES` and `DEVICE_ARCHITECTURES` in `edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/models.py` + - Extend `bundled_plugins_for` with an `x86_64_nvidia` entry (initially mirroring `x86_64`); verify built-in `NODE_CATALOG` descriptors in `nodes.py` gain an `x86_64_nvidia` GstMapping via the per-`DEVICE_ARCHITECTURES` generation + - Assert the existing compiler, validator, and packaging arch checks accept `x86_64_nvidia` with no further change + - _Requirements: 3.1, 16.1_ + + - [x] 1.2 Implement workflow_core.catalog.custom + - Write `descriptor_from_declaration(decl) -> NodeTypeDescriptor` converting a stored Custom_Node_Type declaration (node-catalog wire shape) into a frozen descriptor, validating port types against `PORT_TYPES`, categories against `CATEGORIES`, parameter descriptors against `PARAMETER_TYPES`, and mappings against `ARCHITECTURES`; raise `DeclarationError` identifying the offending field; DeepStream-flagged declarations restricted to arm64_jp4/jp5/jp6 mappings + - Write `resolve_catalog(custom_descriptors) -> tuple` returning `NODE_CATALOG + tuple(custom_descriptors)` with duplicate `type_id` rejection (built-ins win) + - _Requirements: 5.3, 8.2, 8.5, 8.6_ + + - [x] 1.3 Write property test for declaration conversion + - **Feature: custom-node-designer, Property 1: Declaration conversion accepts exactly the valid declarations** + - **Validates: Requirements 1.7, 5.3, 8.4, 8.5** + + - [x] 1.4 Implement workflow_core.catalog.classification + - Write `classify_plugin_set(module_name, repo_url) -> good|bad|ugly|unclassified` derived from the official plugin-set module names (`gst-plugins-good`, `gst-plugins-bad`, `gst-plugins-ugly`) and their known repository locations; everything else is `unclassified` + - Define the fixed plain-language explanation text for each of the four classification values + - _Requirements: 15.3, 15.4_ + + - [x] 1.5 Write property test for plugin-set classification + - **Feature: custom-node-designer, Property 6: Plugin-set classification is exact** + - **Validates: Requirements 15.1, 15.3, 15.4** + + - [x] 1.6 Implement workflow_core.scaffold + - Write pure template rendering: given a validated declaration (name, category, ports, parameters, architectures), render a GStreamer plugin project — C skeleton element wrapping an embedded Python `process_frame(frame, params) -> frame` Frame_Processing_Hook file (appsink/appsrc bridge per the existing `emlpython` approach), one `meson.build` build configuration per selected Target_Architecture, and a README; declared parameters surface as GObject properties plumbed into the hook's `params` dict + - Write scaffold validation rejecting non-buildable source (missing hook file, missing build configurations, empty required files) with a description of the failure + - _Requirements: 1.2, 1.3, 1.4, 1.5_ + + - [x] 1.7 Write property test for scaffold completeness + - **Feature: custom-node-designer, Property 2: Scaffold generation is complete for the declaration** + - **Validates: Requirements 1.2, 1.4** + + - [x] 1.8 Write property test for scaffold validation + - **Feature: custom-node-designer, Property 3: Scaffold validation rejects non-buildable source** + - **Validates: Requirements 2.6** + + - [x] 1.9 Write property test for merged-catalog compilation + - **Feature: custom-node-designer, Property 12: Merged-catalog compilation includes custom plugin dependencies** + - **Validates: Requirements 5.4, 8.6** + +- [x] 2. Checkpoint - workflow_core extensions complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Implement plugin records, lifecycle, and access control backend + - [x] 3.1 Implement plugin_records.py + - Plugin_Record CRUD over the `PluginRecords` DynamoDB table (`plugin_id` + `version`): new records and new versions start with `lifecycle_state = dev` and `review.decision = pending` independently of prior versions; provenance, per-arch artifact entries, and component pointer per the data model + - Lifecycle transitions with guards: dev→test requires at least one successfully built Plugin_Artifact (409 identifying the missing build); test→prod requires an approved security review (409 identifying the missing approval); demotion (prod→test, test→dev) always succeeds and applies gates only to subsequent packaging/deployment requests, leaving deployed Workflow_Components untouched + - Security review endpoints: pending-record display with full provenance (repo URL/revision, scaffold origin, or generation prompt, user, timestamps, classification), per-arch checksums and signatures, and source inspection; approve/reject recording decision, acting PortalAdmin, and timestamp in the existing AuditLog table + - _Requirements: 9.1, 9.3, 9.4, 9.5, 9.9, 9.10, 9.12, 9.13, 10.1, 10.2, 10.3, 10.5, 15.6_ + + - [x] 3.2 Write property test for the lifecycle state machine + - **Feature: custom-node-designer, Property 10: Lifecycle state machine conformance** + - **Validates: Requirements 9.1, 9.4, 9.5, 9.9, 9.10, 9.13, 10.1, 10.5** + + - [x] 3.3 Register node-designer RBAC actions + - Add `node-designer:read` (all roles, read-only for DataScientist/Operator/Viewer), `node-designer:create/generate/import/simulate/register/promote-demote/manage` (UseCaseAdmin within own Use_Case, PortalAdmin), and `node-designer:security-review` (PortalAdmin only) to `rbac_middleware`, mapped to existing roles; denials return the standard authorization error envelope + - _Requirements: 13.1, 13.2, 13.3, 13.4_ + + - [x] 3.4 Write RBAC and audit tests + - Parameterized role×action matrix covering UseCaseAdmin, PortalAdmin, DataScientist, Operator, and Viewer against create/generate/import/simulate/register/promote/demote/approve/update/remove; audit log write per create/generate/import/simulate/register/promote/demote/approve/reject/update/deprecate/remove operation + - _Requirements: 10.3, 13.1, 13.2, 13.3, 13.4, 13.5_ + +- [x] 4. Implement the Plugin_Importer and Module_Listing + - [x] 4.1 Implement repository import in plugin_importer.py + - `POST /plugins/import`: validate the URL, run the lightweight CodeBuild fetch step cloning the repository at the requested revision (default branch when omitted) and syncing the tree to `plugin-sources/{usecase_id}/{plugin_id}/{version}/`; unreachable repository or missing revision fails before any Plugin_Record is created + - Buildability scan (presence of a GStreamer plugin build definition: `meson.build`/`configure.ac` with a plugin target, or prebuilt `.so`); unbuildable imports mark the Plugin_Record failed with the finding reported + - Successful fetch creates the Plugin_Record with provenance `{repoUrl, revision, importedBy, importedAt, classification}` (classification via `classify_plugin_set`) and submits builds for the user-selected Target_Architectures + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 15.4, 15.5_ + + - [x] 4.2 Write property test for the buildability scan + - **Feature: custom-node-designer, Property 4: Import buildability scan matches source-tree construction** + - **Validates: Requirements 4.5** + + - [x] 4.3 Write property test for import provenance + - **Feature: custom-node-designer, Property 7: Import provenance records the classification** + - **Validates: Requirements 4.2, 15.5** + + - [x] 4.4 Implement the Module_Listing endpoint + - `GET /plugin-modules`: fetch `https://gstreamer.freedesktop.org/modules/`, parse server-side into `{name, description, repoUrl, classification}` entries, cache in the `ModuleIndexCache` DynamoDB item with `fetchedAt` and a 24-hour TTL, reusing the cached index for subsequent views; fetch/parse failure returns the distinct `MODULE_LISTING_UNAVAILABLE` code so the UI offers manual URL entry; selecting a module feeds its published repository location into the repository import path + - _Requirements: 6.1, 6.2, 6.3, 6.4_ + + - [x] 4.5 Write property test for module listing parsing + - **Feature: custom-node-designer, Property 5: Module listing parse covers every module** + - **Validates: Requirements 6.1** + + - [x] 4.6 Write unit tests for import error paths and cache behavior + - Unreachable repository and missing revision creating no record; unbuildable source marking the record failed; listing fetch/parse failure returning `MODULE_LISTING_UNAVAILABLE`; cache TTL boundary at 24 hours + - _Requirements: 4.4, 4.5, 6.3, 6.4_ + +- [x] 5. Implement the Node_Generator + - [x] 5.1 Implement node_generator.py + - Mirror `workflow_generator.py`: chat sessions in the TTL'd `NodeGenSessions` table with the current scaffold source snapshot in S3; Bedrock Converse invocation via `get_bedrock_configuration()` (timeout clamped ≤ 60 s, cached client, no retries) with a forced `create_plugin_scaffold` tool whose input schema is the scaffold file map plus the declaration; system prompt embeds the scaffold template conventions and the Frame_Processing_Hook contract + - Follow-up prompts include the current source and instruct modification rather than regeneration; output failing scaffold validation returns an error with the prompt preserved; Bedrock failures/timeouts return descriptive errors with the prompt preserved; accepted source enters the standard build/simulate/lifecycle path with the generation prompt recorded as provenance + - _Requirements: 2.1, 2.2, 2.4, 2.5, 2.6, 2.7_ + + - [x] 5.2 Write integration tests for generation + - Mocked Converse API asserting prompt/template-convention assembly, tool-use output handling, follow-up source inclusion, scaffold-validation rejection with prompt preservation, and timeout behavior + - _Requirements: 2.2, 2.4, 2.6, 2.7_ + +- [x] 6. Implement build infrastructure and the Plugin_Build_Service + - [x] 6.1 Add node-designer infrastructure to CDK + - Create `node-designer-stack.ts` in `edge-cv-portal/infrastructure/lib` following `test-runner-stack.ts` patterns: DynamoDB tables (`PluginRecords`, `CustomNodeTypes`, `ModuleIndexCache`, `SimulationRuns`, `NodeGenSessions` with TTL) with GSIs; the KMS asymmetric signing key (ECDSA P-256); five CodeBuild projects with per-arch custom build images — x86_64 (Ubuntu 22.04/GStreamer 1.20 matching the sandbox image), x86_64_nvidia (same base plus CUDA toolkit and NVIDIA GStreamer runtime headers), arm64 JetPack 4/5/6 cross-build images pinning the DeepStream SDK version matching each JetPack release — each build running in a fresh container with a role scoped to exactly that build's source and staging prefixes, no VPC access to portal internals; the lightweight fetch project; EventBridge rules for build results; new Lambda functions and API Gateway routes + - _Requirements: 3.1, 3.2, 5.2_ + + - [x] 6.2 Implement plugin_builds.py + - `POST /plugins/{id}/versions/{v}/build`: mark per-arch build status building, StartBuild per selected Target_Architecture (source S3 key, arch image); EventBridge result handler (idempotent on build id) recording per-arch `{s3Key, checksum, signature, buildStatus, logTail}` — successful builds SHA-256 checksummed, KMS-signed, and promoted to the Plugin_Library `workflow-plugins/custom/{usecase_id}/{arch}/{plugin}.so` + `.sig`; failed builds store the CloudWatch log tail with no artifact + - Prebuilt binary upload path accepting a `.so` per arch, checksummed and signed identically with `prebuilt: true` provenance; DeepStream-flagged records restrict selectable architectures to arm64_jp4/jp5/jp6; per-arch build status endpoint for the UI; trigger `plugin_components.py` when all requested arch builds settle with at least one success + - _Requirements: 1.6, 3.1, 3.3, 3.4, 3.5, 3.6, 5.1, 5.2_ + + - [x] 6.3 Implement plugin_components.py + - Auto-package successfully built Plugin_Artifacts into the Greengrass component `dda.plugin.{pluginId}` version `{pluginVersion}.0.0` in the Use_Case account: install-only recipe (no Run lifecycle), one platform manifest per successfully built Target_Architecture using `ARCH_TO_GG_PLATFORM` plus platform attributes (`variant` for JetPack arm64, `runtime: nvidia` for x86_64_nvidia, plain `x86_64` manifest ordered after `x86_64_nvidia`); artifacts (signed `.so` + `plugin-manifest.json` with name, version, arch, checksum) copied to the account bucket under `plugins/components/{pluginId}/{pluginVersion}/{arch}/` via staging, installed to `/aws_dda/plugins/{pluginId}/{pluginVersion}/{arch}/` + - All-or-nothing stage/promote/register with failed-registration cleanup deleting the component version; registry tags (`dda-portal:managed`, `usecase-id`, `plugin-id`, `plugin-version`); `component` status pointer recorded on the Plugin_Record; retry idempotent on plugin id + version (registered short-circuits, `ConflictException` re-describes); rebuilds publish a new component version leaving prior versions unchanged; auto-packaging failure never fails the build + - _Requirements: 16.1, 16.7_ + + - [x] 6.4 Write property test for Plugin_Component recipe assembly + - **Feature: custom-node-designer, Property 20: Plugin_Component manifests are exactly the built architectures** + - **Validates: Requirements 16.1** + + - [x] 6.5 Write property test for Plugin_Component version immutability + - **Feature: custom-node-designer, Property 23: Plugin_Component versions are immutable under rebuild** + - **Validates: Requirements 16.7** + + - [x] 6.6 Write integration tests for builds and auto-packaging + - CodeBuild orchestration with mocked StartBuild/EventBridge results including idempotent double-delivery; build-project IAM policy static assertions and all-five-projects stack snapshot; failure log-tail recording with no stored artifact; auto-packaging trigger on build settlement, stage/promote/register against mocked Greengrass, failure cleanup and retry idempotency + - _Requirements: 3.1, 3.2, 3.4, 4.3, 16.1, 16.7_ + +- [x] 7. Checkpoint - records, importer, generator, and builds complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Implement the Plugin_Simulator + - [x] 8.1 Implement the sandbox harness simulate mode + - Add `HARNESS_MODE=simulate` to `edge-cv-portal/test-sandbox/`: render a single-plugin pipeline `multifilesrc ! decode ! ! frame capture + metadata tap` via `Gst.parse_launch`, staging the plugin `.so` into the task's plugin scan directory; flush per-frame results `{frameIndex, inputRef, outputRef, metadata}` incrementally to S3; abnormal plugin termination captures stderr/bus error output, contained to the task + - _Requirements: 7.2, 7.3, 7.6_ + + - [x] 8.2 Implement plugin_simulator.py and the simulator state machine + - Add the Step Functions state machine to `node-designer-stack.ts`: Guard (refuse when no successful x86_64 Plugin_Artifact, 409 describing the missing build) → Prepare (stage the selected Test_Dataset or uploaded sample frames, stage the plugin from the Plugin_Library) → RunSandbox (Fargate, isolated subnet, task role limited to the run's S3 prefix — no Plugin_Library write, no other Use_Case data) → Collect; 5-minute execution timeout stopping the task, marking the run failed-with-timeout, retaining flushed partial results + - `POST /plugins/{id}/versions/{v}/simulate` starting a run with parameter values (re-run with changed parameters supported); `GET /simulations/{runId}` returning status and results; `SimulationRuns` records per the data model + - _Requirements: 7.1, 7.2, 7.4, 7.5, 7.6, 7.7_ + + - [x] 8.3 Write property test for the simulator start guard + - **Feature: custom-node-designer, Property 15: Simulator start guard equals x86_64 artifact presence** + - **Validates: Requirements 7.5** + + - [x] 8.4 Write property test for simulation result coverage + - **Feature: custom-node-designer, Property 16: Simulation results cover every input frame** + - **Validates: Requirements 7.3** + + - [x] 8.5 Write simulator integration tests + - Task-role policy assertions (no Plugin_Library write path); timeout behavior with a shortened limit retaining partial results; containerized single-plugin run in the sandbox image; failure containment with plugin error output reported + - _Requirements: 7.2, 7.6, 7.7_ + +- [x] 9. Implement Custom_Node_Type registration and catalog integration + - [x] 9.1 Implement custom_node_types.py + - Registration collecting display name, category, Ports with types, parameters (types, defaults, constraints, descriptions, examples), hardware-dependence flag, element/property mapping per built Target_Architecture, and Use_Case scoping; declarations validated through `descriptor_from_declaration` with invalid Port declarations rejected identifying the offense; plugin dependency recorded as `custom:{usecase_id}/{plugin_name}` in the mapping + - Versioning: declaration updates create a new `CustomNodeTypes` version item retaining prior versions; deprecation flips the `deprecated` flag; removal scans WorkflowVersions references via the inverted-index GSI maintained at save — zero references deletes catalog items, Plugin_Library artifacts, and the plugin's Plugin_Component versions; otherwise rejects listing the referencing workflows + - _Requirements: 8.1, 8.2, 8.5, 8.6, 14.1, 14.3, 14.4, 14.5_ + + - [x] 9.2 Wire merged catalog resolution into existing consumers + - `GET /workflows/node-catalog`: load the Use_Case's registered Custom_Node_Types (test/prod only, dev excluded), merge via `resolve_catalog`, serve with `lifecycleState: "test"` markers on test-state entries; `workflow_validation.py` and `workflow_generator.py` validate/generate against the merged catalog for the workflow's Use_Case; workflow save records the Custom_Node_Type `typeVersion` on custom node entries and maintains the reference GSI; deprecated types excluded from the palette merge but resolvable for loading/validating/packaging existing workflows + - _Requirements: 8.2, 8.3, 9.2, 9.6, 14.2, 14.3_ + + - [x] 9.3 Write property test for resolved catalog membership + - **Feature: custom-node-designer, Property 11: Resolved catalog membership is exact** + - **Validates: Requirements 8.2, 9.2, 9.6, 14.3** + + - [x] 9.4 Write property test for version retention and pinning + - **Feature: custom-node-designer, Property 18: Version retention and pinned resolution** + - **Validates: Requirements 14.1, 14.2** + + - [x] 9.5 Write property test for reference-counted removal + - **Feature: custom-node-designer, Property 19: Reference-counted removal** + - **Validates: Requirements 14.4, 14.5** + +- [x] 10. Integrate packaging and deployment + - [x] 10.1 Extend workflow_packaging.py for custom plugins + - Compile against the merged catalog resolving pinned Custom_Node_Type versions; `split_plugin_dependencies` routes `custom:{usecase}/{name}` dependencies to the custom prefix while built-in/curated plugins keep the existing inline `plugins/{arch}/*.so` bundling unchanged + - For each `custom:` dependency: load the backing Plugin_Record and reject on `dev` lifecycle state, missing per-arch artifact, or missing Plugin_Component version (Custom_Node_Type and arch/state identified); stream artifact bytes per selected architecture, recompute SHA-256, and KMS-Verify the signature, failing packaging via the existing `PackagingError` path (stage cleanup, no partial component) on either mismatch + - Declare Greengrass `ComponentDependencies` on `dda.plugin.{pluginId}` with `VersionRequirement` pinned to the recorded Custom_Node_Type version (custom `.so` files never bundled inline); write per-plugin `pluginChecksums` into each arch `manifest.json`; add `x86_64_nvidia` to `ARCH_TO_GG_PLATFORM` with the `runtime: nvidia` platform attribute and the manifest ordering placing plain `x86_64` after `x86_64_nvidia` + - _Requirements: 10.4, 11.1, 11.2, 11.3, 16.4_ + + - [x] 10.2 Write property test for sign-then-verify + - **Feature: custom-node-designer, Property 8: Sign-then-verify round trip with tamper detection** + - **Validates: Requirements 3.3, 3.6, 10.4** + + - [x] 10.3 Write property test for packaging gates + - **Feature: custom-node-designer, Property 13: Packaging gates on lifecycle state and artifact presence** + - **Validates: Requirements 11.1, 11.2, 11.3** + + - [x] 10.4 Write property test for Workflow_Component dependencies + - **Feature: custom-node-designer, Property 21: Workflow_Component dependencies are exactly the custom plugins** + - **Validates: Requirements 16.4, 11.1** + + - [x] 10.5 Extend deployments.py with plugin lifecycle and architecture gates + - Add the `test_device` flag on the Devices table (set by a UseCaseAdmin); extend the pre-submit check alongside the existing `minLocalServerVersion` pass: lifecycle gate over the dependency closure (dev-state components rejected for any target; test-state components permitted only to devices flagged `test_device`; prod deploys anywhere in the Use_Case) rejecting with `PLUGIN_LIFECYCLE_VIOLATION` identifying the Custom_Node_Type/Plugin_Component and Lifecycle_State + - Architecture gate: each target device's recorded Target_Architecture checked against the platform manifests of every depended-on Plugin_Component version (`x86_64` and `x86_64_nvidia` matched distinctly, no fallback), rejecting with `PLUGIN_ARCH_UNSUPPORTED` listing each offending `{pluginComponent, version, device, deviceArch}`; standalone Plugin_Component deployments recorded in the Deployments table with `component_type: 'plugin'`; Greengrass dependency resolution delivers depended-on Plugin_Component versions with workflow deployments + - _Requirements: 9.7, 9.8, 9.11, 16.3, 16.5, 16.6_ + + - [x] 10.6 Write property test for the test-device deployment gate + - **Feature: custom-node-designer, Property 14: Deployment gate restricts test-state plugins to Test_Devices** + - **Validates: Requirements 9.7, 9.8, 9.11** + + - [x] 10.7 Write property test for Plugin_Component deployment gates + - **Feature: custom-node-designer, Property 22: Plugin_Component deployment gates on lifecycle and architecture coverage** + - **Validates: Requirements 16.3, 16.6** + + - [x] 10.8 Extend components.py for Plugin_Component listing + - Recognize `dda.plugin.*` components in `list_components`, join with the backing Plugin_Record via registry tags, and return name, version, the backing Lifecycle_State, and supported Target_Architectures derived from the recipe's platform manifests for the deployment screen + - _Requirements: 16.2_ + + - [x] 10.9 Write packaging and deployment unit tests + - Recipe fixtures for amd64 manifest ordering and attribute matching with x86_64 and x86_64_nvidia flavors present and absent; packaging rejection messages identifying node/arch/state; deployment gate rejection codes per device; Plugin_Component listing fields + - _Requirements: 16.1, 16.2, 16.6_ + +- [x] 11. Checkpoint - backend integration complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 12. Implement the Node_Designer frontend + - [x] 12.1 Implement the plugin library list and create wizard + - New pages under `edge-cv-portal/frontend/src/pages/node-designer/`: Plugin_Record list with lifecycle badges, per-arch build status (succeeded/failed with log excerpt), and classification risk badges; create wizard collecting name, description, category, Port declarations, parameter declarations, and Target_Architectures → scaffold preview and zip download → source editor → submit to build; scaffold generation failures display the failing input with no record created + - _Requirements: 1.1, 1.5, 1.6, 1.7, 3.5_ + + - [x] 12.2 Implement the generate panel + - Chat interface accepting natural-language node descriptions; generated scaffold source displayed for review and optional editing before acceptance; generation and Bedrock failures display the error and preserve the prompt for retry + - _Requirements: 2.1, 2.3, 2.6, 2.7_ + + - [x] 12.3 Implement the import views + - Repository URL form (with optional revision) and the Module_Listing select populated from `GET /plugin-modules` with classification risk badges beside module names; import confirmation view displaying the classification and its plain-language explanation before proceeding, with required acknowledgment for bad/ugly/unclassified imports; DeepStream toggle restricting selectable architectures to arm64 JetPack 4/5/6; listing failure surfaces the error and falls back to manual URL entry + - _Requirements: 5.1, 6.1, 6.3, 15.1, 15.2, 15.3, 15.7_ + + - [x] 12.4 Implement the simulator view + - Test_Dataset picker (Use_Case-scoped) or sample frame/video upload; side-by-side input/output frame strips with per-frame emitted metadata; parameter editor and re-run with changed values; missing-x86_64 refusal and failure/timeout display with partial results + - _Requirements: 7.1, 7.3, 7.4, 7.5_ + + - [x] 12.5 Implement the registration wizard and review queue + - Registration wizard prompted after the first successful build: Custom_Node_Type declaration with ports from `PORT_TYPES`, parameters with descriptions and examples, element property mapping per built arch, hardware-dependence flag, Use_Case scoping; invalid Port declarations surfaced with the offending field + - PortalAdmin review queue: pending Plugin_Records with provenance, classification, per-arch checksums/signatures, and source inspection; approve/reject actions + - _Requirements: 4.6, 8.1, 8.5, 10.2, 15.6_ + + - [x] 12.6 Extend the deployment screen for Plugin_Components + - List `dda.plugin.*` components in the existing `pages/deployments` with name, version, backing Lifecycle_State badge, and supported Target_Architecture chips; surface pre-submit gate rejections with the Plugin_Component and unsupported architecture or lifecycle violation identified + - _Requirements: 16.2, 16.3, 16.6_ + + - [x] 12.7 Write frontend tests + - Create wizard field coverage and scaffold download; generate panel review-before-accept and prompt preservation; module list classification badges, explanations, and acknowledgment; listing-failure fallback to manual URL; simulator flows; palette display of custom types in their declared category with test-state markers and built-in configuration panel behavior; review screen completeness including classification; deployment screen Plugin_Component fields + - _Requirements: 1.1, 1.5, 2.3, 2.6, 6.3, 7.1, 7.4, 8.3, 8.4, 9.6, 10.2, 15.1, 15.2, 15.7, 16.2_ + +- [x] 13. Integrate custom nodes into cloud test runs + - [x] 13.1 Extend the Workflow_Test_Runner for custom plugins + - Compile step in `workflow_test_steps.py` uses the merged catalog; the sandbox task downloads custom x86_64 Plugin_Artifacts into its plugin scan path and executes them within the simulated pipeline; Custom_Node_Types lacking an x86_64 Plugin_Artifact substitute a pass-through recording stub (in addition to the hardware-dependent stubbing rules) identified as stubbed in the test run report + - _Requirements: 12.1, 12.2_ + + - [x] 13.2 Write property test for custom-node stubbing + - **Feature: custom-node-designer, Property 17: Test-run stubbing is exactly the unavailable custom nodes** + - **Validates: Requirements 12.2** + + - [x] 13.3 Write tests for stub reporting + - Test report and Workflow_Builder display describing the limitation that a stubbed Custom_Node_Type was simulated because no x86_64 build exists; sandbox integration run with a real custom x86_64 artifact + - _Requirements: 12.1, 12.2, 12.3_ + +- [x] 14. Implement edge plugin verification + - [x] 14.1 Re-sync vendored workflow_core copies + - Propagate the `ARCH_X86_64_NVIDIA` constants, `bundled_plugins_for` entry, and new catalog modules to the vendored copies in `edge-cv-portal/test-sandbox/` and `src/backend/workflow_engine/vendor/` (the edge never resolves custom catalogs — compiled documents remain self-contained) + - _Requirements: 11.4, 12.1_ + + - [x] 14.2 Add checksum verification to the LocalServer plugin loader + - Extend `src/backend/workflow_engine/gst_plugins.py`: before the registry scan, verify each plugin `.so` referenced by `manifest.json` `pluginChecksums` — whether delivered inline under `plugins//` or installed by a depended-on Plugin_Component under `/aws_dda/plugins/{pluginId}/{version}/{arch}/` (scan path extended to the Plugin_Component install roots named in the manifest); a mismatch skips the plugin, registers the workflow as invalid with the file identified, and reports through the existing status path; bundled plugins and Pipeline_Configuration execution unaffected + - _Requirements: 10.6, 11.4_ + + - [x] 14.3 Write property test for edge checksum verification + - **Feature: custom-node-designer, Property 9: Edge checksum verification gates plugin loading** + - **Validates: Requirements 10.6** + + - [x] 14.4 Write edge integration tests + - Checksum-verified load and custom element execution within a compiled pipeline on a device/CI image; mismatch rejection identifying the file and reporting through the status path; Plugin_Component install-root loading + - _Requirements: 10.6, 11.4_ + +- [x] 15. Verify backward compatibility + - [x] 15.1 Run existing workflow-manager and LocalServer test suites unchanged against the new build + - Assert same outcomes: the static `NODE_CATALOG`, built-in plugin bundling, existing packaging/deployment flows, and devices without Plugin_Components behave identically; existing workflows without custom nodes validate, package, and deploy unchanged + - _Requirements: 8.2, 11.4_ + +- [x] 16. Final checkpoint + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Development happens on the `workflow_manager` git branch +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Property tests use hypothesis (Python) and fast-check (TypeScript) with a minimum of 100 iterations, tagged `**Feature: custom-node-designer, Property {number}: {property_text}**` +- workflow_core extensions are built first as they are shared dependencies of the portal Lambdas, the Component_Packager, the test sandbox, and the LocalServer vendored copies +- KMS sign/verify is mocked in property tests with a real ECDSA keypair so the round trip is genuine; S3/DynamoDB via moto; the GStreamer pipeline layer is mocked for results-assembly properties +- Built-in/curated plugin bundling, the static NODE_CATALOG, and the existing `src/backend/gstreamer/` Pipeline_Configuration path are never modified; all changes are additive + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2", "1.4"] }, + { "id": 2, "tasks": ["1.3", "1.5", "1.6", "6.1"] }, + { "id": 3, "tasks": ["1.7", "1.8", "1.9", "3.1", "3.3"] }, + { "id": 4, "tasks": ["3.2", "3.4", "4.1", "4.4", "5.1", "6.2"] }, + { "id": 5, "tasks": ["4.2", "4.3", "4.5", "4.6", "5.2", "6.3", "8.1"] }, + { "id": 6, "tasks": ["6.4", "6.5", "6.6", "8.2", "9.1"] }, + { "id": 7, "tasks": ["8.3", "8.4", "8.5", "9.2", "10.1"] }, + { "id": 8, "tasks": ["9.3", "9.4", "9.5", "10.2", "10.3", "10.4", "10.5"] }, + { "id": 9, "tasks": ["10.6", "10.7", "10.8", "12.1", "13.1"] }, + { "id": 10, "tasks": ["10.9", "12.2", "12.3", "13.2", "13.3", "14.1"] }, + { "id": 11, "tasks": ["12.4", "12.5", "14.2"] }, + { "id": 12, "tasks": ["12.6", "14.3"] }, + { "id": 13, "tasks": ["12.7", "14.4"] }, + { "id": 14, "tasks": ["15.1"] } + ] +} +``` diff --git a/.kiro/specs/custom-python-frames/.config.kiro b/.kiro/specs/custom-python-frames/.config.kiro new file mode 100644 index 00000000..b8d6c7e2 --- /dev/null +++ b/.kiro/specs/custom-python-frames/.config.kiro @@ -0,0 +1 @@ +{"specId": "d107061e-32ed-4bda-a91c-f96a823ff1e7", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/custom-python-frames/design.md b/.kiro/specs/custom-python-frames/design.md new file mode 100644 index 00000000..24fa663b --- /dev/null +++ b/.kiro/specs/custom-python-frames/design.md @@ -0,0 +1,344 @@ +# Design Document: Custom Python Frames + +## Overview + +This feature adds a Custom Python **preprocessing** node type (`custom_python_preprocess`, VideoFrames in → VideoFrames out) to the workflow node catalog and upgrades the shared Custom Python edge runtime into a practical OpenCV frame-processing environment: + +- a new ndarray-based handler contract `process_frame(frame, metadata)` alongside the existing raw-bytes `handle(frame_bytes, metadata)` contract, +- `cv2`, `np`, and `numpy` pre-bound in the handler module's namespace, +- a `dda_frames` helper module (injected by the runner, no extra artifact files) with `to_array` / `to_bytes` conversion, `frame_info()`, and `load_image()` for local paths and `s3://` URIs, +- frame width/height/format delivered to every handler via `metadata["frame"]`. + +The design deliberately reuses the entire existing Custom Python delivery chain — the `emlpython` element mapping, the compiler's per-node `{python_handler_path}` derivation, the packager's `python/{nodeId}/handler.py` layout, and the Python_Bridge subprocess isolation — so the new node type requires **no compiler changes, no new edge wire format, and no frontend code changes** (the palette, code editor, port compatibility, and inline validation are all generic over the catalog descriptor). The work concentrates in two files: the catalog (`workflow_core/catalog/nodes.py`, both copies) and the Python_Bridge runner (`src/backend/workflow_engine/python_bridge.py`), plus a one-line predicate change in the packager. + +### Key findings from investigation + +- **Catalog** (`edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py`): `custom_python` is post-processing category with per-instance `input_port_type`/`output_port_type` enum parameters; it maps on all architectures to a single `emlpython` element with `handler-path: {python_handler_path}` and plugin dependency `dda-emlpython`. Its `code` parameter description advertises `process(data, metadata)` — a contract that does not exist in the runtime (Requirement 8 fixes this). +- **Compiler** (`workflow_core/compiler/compiler.py`): `python_handler_path` is derived per node id for *every* node (`_derived_values`), so any node type whose mapping uses the `emlpython` template compiles correctly with zero compiler changes. +- **Packager** (`edge-cv-portal/backend/functions/workflow_packaging.py`): `gather_custom_python_nodes` filters `node.type == 'custom_python'` — the only place the node type id is hardcoded on the portal side. It must also accept `custom_python_preprocess`. +- **Edge bridge** (`src/backend/workflow_engine/python_bridge.py`): the framed protocol header already carries `width`, `height`, `format` from the appsink caps, but the runner never exposes them to the handler (`run_bridged_pipeline` passes `metadata={}`). The runner (`RUNNER_SOURCE`, executed via `python -c` in the subprocess) loads `handler.py` with `importlib` and calls `handle(frame_bytes, metadata)`. All new runtime behavior lands inside `RUNNER_SOURCE`; the bridge (parent) side needs no protocol change. +- **Frontend** (`edge-cv-portal/frontend/src/pages/workflows/`): NodePalette groups by descriptor category; NodeConfigPanel renders a code editor for `paramType === 'code'`; connection acceptance and inline checks key off parameter names (`input_port_type`) and declared ports generically. A fixed-port VideoFrames node needs no frontend changes — only test coverage. +- **Vendored mirror**: `src/backend/workflow_engine/vendor/workflow_core/` is a byte-identical copy of the portal layer package (verified with diff); the test sandbox Docker image COPYies the portal layer copy, so only the two copies exist. +- **Device environment**: `opencv-python` is in `src/backend/requirements.txt` and the handler subprocess runs the executor's interpreter (`sys.executable`), so `cv2`/`numpy` are importable on devices; `boto3` is available via the LocalServer environment and Greengrass provides AWS credentials through the environment, which the bridge already passes through to the subprocess. + +## Architecture + +```mermaid +graph LR + subgraph Portal + CAT[Node_Catalog nodes.py
+ custom_python_preprocess] --> API[node-catalog API] + API --> PAL[Node_Palette / NodeConfigPanel
no code changes] + CAT --> VAL[Workflow_Validator
generic port checks] + CAT --> CMP[Workflow_Compiler
emlpython + python_handler_path] + CMP --> PKG[Component_Packager
gather predicate widened] + end + PKG -->|"artifact zip: python/{nodeId}/handler.py + requirements.txt"| EDGE + subgraph EDGE[LocalServer edge device] + VEN[vendored workflow_core mirror
byte-identical catalog] --- EXE[WorkflowExecutor] + EXE --> BR[Python_Bridge
appsink/appsrc pair per node] + BR -->|framed stdin/stdout
width/height/format in header| RUN[Python_Runner subprocess] + RUN --> HLP[dda_frames Frame_Helpers
to_array / to_bytes / frame_info / load_image] + RUN --> USR[handler.py
process_frame or handle
cv2 / np pre-bound] + HLP -->|load_image| S3[(local disk / S3)] + end +``` + +Frame flow on the edge for a `process_frame` handler: + +1. The bridge's appsink pulls a sample, reads caps (`width`, `height`, `format`), and sends one framed message (header + raw frame bytes) to the subprocess — unchanged from today. +2. The runner merges `{"frame": {"width": w, "height": h, "format": f}}` into the metadata dict and publishes the same triple to the `dda_frames` per-frame context. +3. If the handler defines `process_frame`, the runner converts the raw bytes to a NumPy uint8 array (handling row padding by slicing each stride-sized row to `width × channels` bytes), invokes `process_frame(frame, metadata)`, verifies the returned array's shape and dtype, and writes the pixels back into a copy of the original byte buffer (preserving padding and total byte length so the appsrc caps stay valid). +4. If the handler defines only `handle`, the existing raw-bytes path runs unchanged (with metadata now carrying the `frame` key). +5. The response message travels back over the existing protocol; the bridge pushes the output buffer with the input buffer's timestamps — unchanged. + +### Design decisions + +- **New node type, not a category flag.** A separate `custom_python_preprocess` descriptor with fixed VideoFrames ports gives users an unambiguous frames-in/frames-out node in the preprocessing palette section, while `custom_python` keeps its flexible per-instance port typing for post-processing. Both share the `emlpython` mapping, so downstream behavior is identical. +- **All runtime additions live in `RUNNER_SOURCE`.** The runner is self-contained by design (the subprocess must not import LocalServer). Embedding the `dda_frames` module source in the runner (registered in `sys.modules` before the handler loads) keeps artifacts unchanged (Requirement 5.1) and works for already-deployed workflows the moment LocalServer updates. +- **`process_frame` output must match input shape/dtype.** The appsrc caps are fixed from the first input sample, so emitting frames of a different size or format would corrupt the pipeline. The runner enforces the constraint and produces a descriptive per-node error instead (Requirement 3.4). +- **Row padding preserved by write-back.** GStreamer buffers may carry row stride padding. `to_array` slices rows by stride; the `process_frame` path writes returned pixels back into a copy of the original buffer bytes, so output byte length always equals input byte length (Requirement 3.2). +- **Pre-imports are best-effort bindings.** `cv2`/`np`/`numpy` are set as module attributes on the handler module *before* `exec_module`, so top-level handler code can use them; import failures leave the binding absent without failing handlers that do not reference it (Requirement 4.3). +- **`load_image` decodes via `cv2.imdecode`** for both local files and S3 objects (uniform behavior, BGR order, honors any format OpenCV can decode). The S3 client is created lazily with boto3 and is injectable for tests. +- **No compiler/serializer/schema changes.** The workflow definition schema stores node `type` as an open string; validation resolves it against the catalog. Adding a descriptor is sufficient. +- **Test sandbox unchanged.** The sandbox inherits whatever behavior `custom_python` has today for the `emlpython` element; the new node type compiles to the identical element chain, so sandbox behavior is identical by construction. Extending sandbox simulation of Custom Python handlers is out of scope. + +## Components and Interfaces + +### 1. Catalog descriptor (portal layer + vendored mirror) + +`workflow_core/catalog/nodes.py` — add after `FORMAT_CONVERT`: + +```python +CUSTOM_PYTHON_PREPROCESS = NodeTypeDescriptor( + type_id="custom_python_preprocess", + category=CATEGORY_PREPROCESSING, + display_name="Custom Python (Frames)", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("code", "code", required=True, default=None, + constraints={"min_length": 1}, + description="Python run for every video frame. Define " + "process_frame(frame, metadata) and return the " + "processed frame; frame is a NumPy uint8 array " + "(rows x cols x channels) and cv2/np are " + "pre-imported. Return None to pass the frame " + "through. Helpers: import dda_frames for " + "frame_info(), load_image(path or s3:// URI), " + "to_array(), to_bytes().", + examples=["def process_frame(frame, metadata):\n" + " return cv2.GaussianBlur(frame, (5, 5), 0)"]), + ParameterDescriptor("requirements", "string", required=False, default="", + constraints={}, + description="Extra pip packages the code needs, one per " + "line in requirements.txt form.", + examples=["scikit-image==0.24.0"]), + ], + mappings=_same_on_all_archs( + element_chain=[_element("emlpython", **{"handler-path": "{python_handler_path}"})], + plugin_dependencies=["dda-emlpython"], + ), + hardware_dependent=False, +) +``` + +`CUSTOM_PYTHON_PREPROCESS` is inserted into `NODE_CATALOG` with the other preprocessing types (after `FORMAT_CONVERT`), so `nodes_by_category()` places it in the preprocessing palette section. The `custom_python` descriptor's `code` description/examples are rewritten to state the real contract (Requirement 8): + +``` +description: "Python run for every item passing through the node. Define + process_frame(frame, metadata) to work with video frames as NumPy + arrays (cv2/np pre-imported; import dda_frames for helpers), or + handle(frame_bytes, metadata) -> (frame_bytes, metadata) to work + with raw bytes." +examples: ["def process_frame(frame, metadata):\n return frame", + "def handle(frame_bytes, metadata):\n return frame_bytes, metadata"] +``` + +The vendored copy `src/backend/workflow_engine/vendor/workflow_core/catalog/nodes.py` is updated to stay byte-identical (Requirement 1.6). + +### 2. Component_Packager (`edge-cv-portal/backend/functions/workflow_packaging.py`) + +```python +CUSTOM_PYTHON_NODE_TYPES = ('custom_python', 'custom_python_preprocess') + +def gather_custom_python_nodes(graph) -> List[Dict]: + ... + if node.type in CUSTOM_PYTHON_NODE_TYPES: +``` + +Everything downstream (`build_arch_zip` writing `python/{nodeId}/handler.py` + `requirements.txt`, `build_manifest` emitting `customPythonNodeIds`) is already generic over the gathered list (Requirements 2.4, 2.5). + +### 3. Python_Runner (`src/backend/workflow_engine/python_bridge.py`, `RUNNER_SOURCE`) + +The runner script gains three pieces. The bridge (parent process) and the framed protocol are untouched. + +**(a) `dda_frames` helper module** — embedded as a separate module-level constant `HELPERS_SOURCE` in `python_bridge.py` and prepended into the assembled runner source. Registered before the handler loads: + +```python +_helpers = types.ModuleType("dda_frames") +exec(HELPERS_SOURCE, _helpers.__dict__) +sys.modules["dda_frames"] = _helpers +``` + +Helper API (all functions raise `ValueError` with descriptive messages on bad input): + +```python +FORMAT_CHANNELS = {"RGB": 3, "BGR": 3, "RGBA": 4, "GRAY8": 1} + +def to_array(frame_bytes, width, height, format): + """Raw frame bytes -> NumPy uint8 array (H x W x C; H x W for GRAY8). + Row stride = len(frame_bytes) // height; each row's first + width*channels bytes are taken, tolerating stride padding.""" + +def to_bytes(array): + """NumPy uint8 array -> contiguous raw frame bytes (no padding).""" + +def frame_info(): + """{'width': int, 'height': int, 'format': str} for the frame whose + handler invocation is in progress; None outside an invocation.""" + +def load_image(source, s3_client=None): + """Local path or s3://bucket/key -> BGR uint8 array via cv2.imdecode. + Raises ValueError naming the source on missing file, malformed URI, + fetch failure, or undecodable content. s3_client is injectable for + tests; by default a lazily created boto3 client using the device's + ambient AWS credentials.""" +``` + +`load_image` reads local files with `open(..., 'rb')` (so missing-file errors are uniform) and S3 objects with `get_object`, then decodes with `cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_UNCHANGED)`; a 3-channel result stays BGR, 4-channel input is reduced with `cv2.cvtColor(..., cv2.COLOR_BGRA2BGR)` only when decoded via IMREAD_COLOR — the design uses `cv2.IMREAD_COLOR` for multi-channel sources and `IMREAD_UNCHANGED` yields 2-D arrays for grayscale PNGs, satisfying Requirement 6.1's BGR/2-D contract. PNG round-trip equality (Requirement 6.3) holds for 3-channel BGR arrays written with `cv2.imwrite`. + +**(b) Pre-imports and handler resolution** — in the runner's `main()` after `module_from_spec` and before `exec_module`: + +```python +for name, binding in (("cv2", "cv2"), ("numpy", "np"), ("numpy", "numpy")): + try: + setattr(module, binding, importlib.import_module(name)) + except Exception: + pass # absent binding; handlers not using it are unaffected +``` + +After `exec_module`, entry-point resolution (Requirements 3.6–3.8): + +```python +process_frame = getattr(module, "process_frame", None) +handle = getattr(module, "handle", None) +if not callable(process_frame) and not callable(handle): + error: "handler.py defines neither process_frame(frame, metadata) " + "nor handle(frame_bytes, metadata)" +``` + +`process_frame` wins when both are defined (Requirement 3.7). + +**(c) Frame loop changes** — per message, before invocation: + +```python +metadata = header.get("metadata") or {} +info = {"width": header.get("width"), "height": header.get("height"), + "format": header.get("format")} +metadata["frame"] = info # Requirement 3.9 +dda_frames._set_current(info) # Requirement 5.6 (cleared in finally) +``` + +`process_frame` invocation path: + +- format not in `FORMAT_CHANNELS` or width/height missing → error naming the node's unsupported format (Requirement 3.5); numpy unimportable → error naming the missing library. +- `arr = dda_frames.to_array(frame, w, h, fmt)`; `result = process_frame(arr, metadata)`. +- `result is None` → emit input bytes unchanged (Requirement 3.3). +- ndarray with `result.shape == arr.shape and result.dtype == arr.dtype` → write rows back into `bytearray(frame)` at the original stride, emit (Requirement 3.2). +- anything else → error describing expected vs. actual shape/dtype (Requirement 3.4); the runner reports `status: error` and the bridge raises `CustomPythonNodeError` naming the node, exactly like today's handler exceptions. + +`handle` invocation is unchanged apart from the enriched metadata. Handler-returned metadata continues to flow back in the response header for both contracts. + +### 4. Frontend (no code changes; test coverage only) + +- NodePalette test fixture gains the new descriptor and asserts it renders in the Preprocessing section (Requirement 7.1). +- NodeConfigPanel test asserts the code editor renders for the new type's `code` parameter (Requirement 7.2). +- connectionAcceptance property test domain extended with a fixed-VideoFrames-port node shape (Requirement 7.3). +- inlineChecks test asserts a missing `code` yields a required-parameter marker (Requirement 7.4) — generic behavior, pinned by an example. + +## Data Models + +**Catalog descriptor** — one new `NodeTypeDescriptor` record (shape above); no schema changes to `serializer/schema.py` (node `type` is an open string resolved against the catalog). + +**Framed protocol** — unchanged. Executor → runner header: `{nodeId, width, height, format, metadata, frameSize}`; runner → executor: `{status, metadata, frameSize}` or `{status: "error", error}`. + +**Metadata frame info** (new, runner-side): `metadata["frame"] = {"width": int|None, "height": int|None, "format": str|None}` delivered to every handler invocation. + +**Frame array convention**: NumPy uint8, shape `(height, width, channels)` for RGB/BGR (3) and RGBA (4); shape `(height, width)` for GRAY8. Row stride handling: input bytes of length `L` for height `h` have stride `s = L // h`; row `i` pixels are bytes `[i*s, i*s + width*channels)`. + +**Manifest**: `customPythonNodeIds` (existing key) now lists node ids of both Custom Python node types. + +**Artifact layout** (existing, now also for the new type): `python/{nodeId}/handler.py`, `python/{nodeId}/requirements.txt`. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Validator acceptance of preprocessing node connections + +*For any* workflow graph wiring a source node's output port into a `custom_python_preprocess` node's input port, the Workflow_Validator accepts the connection exactly when `are_port_types_compatible(source_type, VideoFrames)` holds under the catalog's declared coercion rules (VideoFrames exactly, and InferenceMeta via the declared coercion), and otherwise reports a port type compatibility error identifying that connection. + +**Validates: Requirements 2.1, 2.2** + +### Property 2: Legacy handle contract is preserved + +*For any* frame bytes and metadata dict, a handler defining only `handle(frame_bytes, metadata)` invoked through the CustomPythonBridge receives the frame bytes unchanged, receives the metadata enriched with the `frame` info key, and its returned `(frame_bytes, metadata)` round-trips back to the bridge caller exactly as under the pre-existing contract. + +**Validates: Requirements 3.6, 3.9** + +### Property 3: Custom Python node gathering and manifest membership + +*For any* workflow graph containing an arbitrary mix of `custom_python` nodes, `custom_python_preprocess` nodes, and other node types with arbitrary node ids, code strings, and requirements strings, `gather_custom_python_nodes` returns exactly the Custom Python nodes of both types with their code and requirements preserved, and `build_manifest`'s `customPythonNodeIds` equals exactly those nodes' ids. + +**Validates: Requirements 2.4, 2.5** + +### Property 4: process_frame contract round trip + +*For any* frame dimensions, supported Pixel_Format, pixel content, and row padding, a `process_frame` handler invoked through the CustomPythonBridge receives a NumPy uint8 array of the shape implied by the caps (rows × columns × channels; 2-D for GRAY8); returning that array unchanged emits output bytes equal to the input bytes (padding included); returning None emits the input bytes unchanged; and returning a deterministic transformation (bitwise inversion) emits bytes whose pixel regions are the transformed pixels while padding bytes and total byte length are preserved. + +**Validates: Requirements 3.1, 3.2, 3.3** + +### Property 5: process_frame contract violations fail the node identifiably + +*For any* frame and any non-compliant `process_frame` outcome — a returned array of different shape, a returned array of different dtype, a non-array non-None return, or an input frame whose Pixel_Format is outside the supported set — the CustomPythonBridge raises `CustomPythonNodeError` carrying the node id and a message describing the mismatch or the unsupported format. + +**Validates: Requirements 3.4, 3.5** + +### Property 6: Frame info delivery + +*For any* frame width, height, and supported Pixel_Format dispatched by the bridge, the handler observes that exact triple both in `metadata["frame"]` and from `dda_frames.frame_info()` during the invocation. + +**Validates: Requirements 3.9, 5.6** + +### Property 7: Frame/array conversion round trip + +*For any* frame dimensions, supported Pixel_Format, and pixel content, `to_bytes(to_array(frame_bytes, width, height, format))` equals the original unpadded frame bytes, and `to_array` of padded frame bytes returns the same array as `to_array` of the unpadded bytes; *for any* unsupported format string or byte string shorter than the dimensions require, `to_array` raises an error describing the format or size problem. + +**Validates: Requirements 5.2, 5.3, 5.4, 5.5** + +### Property 8: Disk image load round trip + +*For any* uint8 BGR image array written losslessly to a PNG file with `cv2.imwrite`, `dda_frames.load_image` of that path returns an array equal to the original. + +**Validates: Requirements 6.1, 6.3** + +### Property 9: S3 image load round trip + +*For any* bucket name, object key, and uint8 BGR image array PNG-encoded and served by an injected fake S3 client, `dda_frames.load_image("s3://bucket/key", s3_client=fake)` requests exactly that bucket and key and returns an array equal to the original. + +**Validates: Requirements 6.2** + +### Property 10: load_image failures identify the source + +*For any* failing source — a non-existent local path, a malformed `s3://` URI, an S3 client raising on fetch, or existing content that does not decode as an image — `dda_frames.load_image` raises an error whose message contains the source. + +**Validates: Requirements 6.4** + +### Property 11: Designer connection acceptance for fixed VideoFrames ports + +*For any* generated pair of nodes where the target is a `custom_python_preprocess`-shaped descriptor (fixed VideoFrames ports, no port type override parameters), the Workflow_Builder connection acceptance function accepts the connection exactly when the source output port type is compatible with VideoFrames under the declared coercion rules (VideoFrames exactly, or InferenceMeta via the declared coercion — mirroring the backend validator per Requirement 2.1) and otherwise rejects it with a reason. + +**Validates: Requirements 7.3** + +### Property 12: Compiled emlpython element per Custom Python preprocessing node + +*For any* valid workflow graph embedding `custom_python_preprocess` nodes with arbitrary node ids, compiling for any architecture yields exactly one `emlpython` element per such node carrying `handler-path` equal to `python/{nodeId}/handler.py` and tagged with that node's id. + +**Validates: Requirements 2.3** + +## Error Handling + +All new failure modes flow through the existing containment path: the runner reports `{status: "error", error: }`, the bridge raises `CustomPythonNodeError(node_id, message)`, and the executor fails only that workflow run with the node identified (workflow-manager Requirements 9.8/13.7 behavior, unchanged). + +| Failure | Where detected | Behavior | +|---|---|---| +| Handler defines neither `process_frame` nor `handle` | Runner, at module load | Error names both accepted entry points; subprocess exits; bridge raises `CustomPythonNodeError` before the pipeline goes to PLAYING | +| `process_frame` with unsupported Pixel_Format (e.g. NV12) or missing caps dims | Runner, per frame | Error names the format; run fails with node identified | +| `process_frame` defined but NumPy unimportable | Runner, per frame | Error names the missing library | +| Returned array shape/dtype mismatch, or non-array non-None return | Runner, per frame | Error describes expected vs. actual; run fails with node identified | +| `to_array`/`to_bytes` misuse from handler code | Frame_Helpers (`ValueError`) | Propagates as a handler exception → existing error path | +| `load_image`: missing file, malformed URI, S3 fetch failure, undecodable bytes | Frame_Helpers (`ValueError` naming the source) | Propagates as a handler exception → existing error path | +| `cv2`/`numpy` import failure at pre-import | Runner, module load | Binding silently absent; only handlers referencing it fail (with a normal NameError traceback) | +| boto3 unavailable when `load_image` gets an `s3://` URI | Frame_Helpers | `ValueError` naming the source and the missing boto3 dependency | + +Existing failure modes (wall-clock timeout, memory limit, protocol violations, subprocess death) are untouched. + +## Testing Strategy + +The feature is covered by a dual approach: property-based tests for the universal contracts above and example-based tests for fixed catalog content, dispatch rules, and UI rendering. Property tests use **hypothesis** (Python) and **fast-check** (TypeScript), inherit each suite's iteration profile from its conftest/setup (no hardcoded `max_examples`; the CI profile runs ≥100 iterations), and each carries a comment tag in the form **Feature: custom-python-frames, Property {number}: {property_text}**. Each correctness property is implemented by a single property-based test. + +**Portal backend** (`pytest` + `hypothesis`): +- `edge-cv-portal/backend/layers/workflow_core/tests/` — catalog content examples (descriptor shape, mapping parity with `custom_python`, description/example contract checks: Requirements 1.1–1.5, 8.1, 8.2), Property 1 (validator), Property 12 (compiler). +- `edge-cv-portal/backend/tests/` — Property 3 as `test_property_*.py` over the pure `gather_custom_python_nodes` + `build_manifest`; one example extension of `test_workflow_packaging_deployment_integration.py` covering the new node type's files in the arch zips (Requirements 2.4, 2.5 integration). + +**Edge LocalServer** (`pytest` + `hypothesis`, run as `PYTHONPATH=src/backend:test/backend-test pytest test/backend-test/workflow_engine/...` — scoped to the workflow_engine suites; the broader edge suite has pre-existing environment-dependent failures on this host): +- `test/backend-test/workflow_engine/` — Properties 2, 4, 5, 6, 7, 8, 9, 10 against the real `CustomPythonBridge` (real subprocesses, as the existing `test_workflow_python_bridge.py` does) and against `dda_frames` executed from `HELPERS_SOURCE`; example tests for entry-point dispatch (both defined → `process_frame` wins; neither → error naming both), pre-imported `cv2`/`np` usage, sibling-module import regression, missing-cv2 resilience, and `load_image` failure containment through the bridge (Requirements 3.7, 3.8, 4.1–4.5, 5.1, 6.5). +- Host prerequisites verified: numpy 2.0.2, cv2 4.13.0, boto3, hypothesis are importable with the system interpreter that spawns the handler subprocesses. + +**Frontend** (`vitest` + RTL + `fast-check`, `edge-cv-portal/frontend/src/pages/workflows/`): +- Property 11 as an extension of the existing `connectionAcceptance.property.test.ts` domain. +- Example tests: palette section rendering, code editor rendering, inline required-`code` marker (Requirements 7.1, 7.2, 7.4). + +**Vendored mirror** (Requirement 1.6): a byte-equality check between the two `nodes.py` copies executed as part of the edge test additions (smoke). + +Baselines to keep green: portal backend tests (moto-backed conftest), workflow_core layer tests, frontend vitest + `npm run build`, and the edge `test/backend-test/workflow_engine` suites that pass on this host. diff --git a/.kiro/specs/custom-python-frames/requirements.md b/.kiro/specs/custom-python-frames/requirements.md new file mode 100644 index 00000000..db2c9569 --- /dev/null +++ b/.kiro/specs/custom-python-frames/requirements.md @@ -0,0 +1,128 @@ +# Requirements Document + +## Introduction + +The workflow designer's Custom Python support today consists of a single post-processing node type (`custom_python`) whose user code runs on the edge device inside a subprocess bridge (`emlpython` → executor-managed appsink/appsrc pair) with a raw-bytes contract: `handle(frame_bytes, metadata) -> (frame_bytes, metadata)`. Writing image-processing code against raw bytes is impractical — the handler receives no frame dimensions, has no conversion helpers, and the catalog's own parameter description advertises a `process(data, metadata)` function that does not exist in the runtime. + +This feature adds a Custom Python **preprocessing** node type that takes video frames in and emits processed frames out, and upgrades the Custom Python runtime (shared by the preprocessing and post-processing node types) into a practical OpenCV frame-processing environment: an ndarray-based `process_frame` handler contract, OpenCV and NumPy pre-imported into the handler namespace, helper functions for converting frames to and from NumPy arrays, current-frame dimension/format access, and an image loader that reads a JPEG/PNG from local disk or from S3 into an OpenCV array. Existing `handle`-based handlers keep working unchanged. + +The feature spans the Portal (node catalog, packaging) and the LocalServer edge runtime (the Python bridge runner), with the vendored `workflow_core` catalog mirror kept in sync. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend) used to design, package, and deploy workflows. +- **LocalServer**: The Greengrass component running on an edge device that executes compiled workflow pipelines through GStreamer. +- **Node_Catalog**: The data catalog of node type descriptors in `workflow_core.catalog.nodes` (`NODE_CATALOG`), maintained in the Portal Lambda layer at `edge-cv-portal/backend/layers/workflow_core/` and mirrored verbatim in the LocalServer vendored copy at `src/backend/workflow_engine/vendor/workflow_core/`. +- **Workflow_Builder**: The graphical canvas UI (Node_Palette, canvas, NodeConfigPanel) where users compose workflows. +- **Node_Palette**: The categorized node type list in the Workflow_Builder, populated from the Node_Catalog via the node-catalog API. +- **Workflow_Validator**: The `workflow_core.validator` component checking graph structure and port type compatibility. +- **Workflow_Compiler**: The `workflow_core.compiler` component translating a workflow definition into per-architecture compiled pipeline documents. +- **Component_Packager**: The Portal backend packaging Lambda (`workflow_packaging.py`) that assembles per-architecture Workflow_Component artifact zips, including `python/{nodeId}/handler.py` and `python/{nodeId}/requirements.txt` for Custom Python nodes. +- **Custom_Python_Node**: The existing post-processing node type (`custom_python`) with per-instance input/output port type parameters, executed on the edge through the Python_Bridge. +- **Custom_Python_Preprocess_Node**: The new preprocessing node type (`custom_python_preprocess`) added by this feature, with fixed VideoFrames input and output ports. +- **Python_Bridge**: The LocalServer component (`src/backend/workflow_engine/python_bridge.py`) that replaces each `emlpython` element with an executor-managed appsink/appsrc pair and pumps frames through a handler subprocess over a framed stdin/stdout protocol. +- **Python_Runner**: The self-contained script (`RUNNER_SOURCE` in the Python_Bridge) executed inside the handler subprocess; it loads the user's `handler.py` and invokes the handler function once per frame. +- **Frame_Helpers**: The helper module added by this feature, importable from handler code as `dda_frames`, providing frame/array conversion, current-frame info, and image loading functions. +- **Pixel_Format**: The GStreamer video format string carried in the stream caps (this feature supports array conversion for RGB, BGR, RGBA, and GRAY8). + +## Requirements + +### Requirement 1: Custom Python Preprocessing Node Type + +**User Story:** As a computer vision engineer, I want a Custom Python preprocessing node that takes video frames in and emits processed frames out, so that I can apply OpenCV transformations to the video stream before inference. + +#### Acceptance Criteria + +1. THE Node_Catalog SHALL provide a node type with type id `custom_python_preprocess`, display name "Custom Python (Frames)", and the preprocessing category. +2. THE Custom_Python_Preprocess_Node descriptor SHALL declare exactly one input port and one output port, both of Pixel_Format-carrying type VideoFrames, without per-instance port type override parameters. +3. THE Custom_Python_Preprocess_Node descriptor SHALL declare a required `code` parameter of parameter type `code` and an optional `requirements` parameter accepting extra pip packages in requirements.txt form. +4. THE Custom_Python_Preprocess_Node descriptor SHALL map on every architecture to the same `emlpython` element chain (carrying the `{python_handler_path}` argument) and `dda-emlpython` plugin dependency as the Custom_Python_Node. +5. THE Custom_Python_Preprocess_Node descriptor's `code` parameter description and examples SHALL document the `process_frame(frame, metadata)` contract with OpenCV, where `frame` is a NumPy array. +6. THE Node_Catalog copy in the LocalServer vendored mirror (`src/backend/workflow_engine/vendor/workflow_core/catalog/nodes.py`) SHALL be byte-identical to the Portal layer copy after the change. + +### Requirement 2: Validation, Compilation, and Packaging + +**User Story:** As a computer vision engineer, I want workflows containing the new preprocessing node to validate, compile, and package exactly like other Custom Python nodes, so that I can deploy them to devices without special handling. + +#### Acceptance Criteria + +1. WHEN a workflow connects an output port whose type is compatible with VideoFrames under the Node_Catalog's declared coercion rules (VideoFrames, or InferenceMeta via the declared coercion) to a Custom_Python_Preprocess_Node input port, THE Workflow_Validator SHALL accept the connection. +2. WHEN a workflow connects an EventSignal output port to a Custom_Python_Preprocess_Node input port, THE Workflow_Validator SHALL report a port type compatibility error identifying the connection. +3. WHEN a workflow containing a Custom_Python_Preprocess_Node is compiled for any architecture, THE Workflow_Compiler SHALL emit an `emlpython` element for that node carrying handler path `python/{nodeId}/handler.py`. +4. WHEN a workflow containing Custom_Python_Preprocess_Nodes is packaged, THE Component_Packager SHALL write each such node's `code` to `python/{nodeId}/handler.py` and its `requirements` to `python/{nodeId}/requirements.txt` in every architecture artifact zip. +5. WHEN the per-architecture manifest is built, THE Component_Packager SHALL list the node ids of Custom_Python_Nodes and Custom_Python_Preprocess_Nodes together in `customPythonNodeIds`. + +### Requirement 3: Frame-Based Handler Contract + +**User Story:** As a computer vision engineer, I want to write my per-frame code as `process_frame(frame, metadata)` receiving a NumPy array, so that I can use OpenCV operations directly instead of decoding raw bytes myself. + +#### Acceptance Criteria + +1. WHEN a handler file defines a callable `process_frame`, THE Python_Runner SHALL invoke `process_frame(frame, metadata)` once per frame with `frame` as a NumPy uint8 array whose shape is derived from the stream's width, height, and Pixel_Format (rows × columns × channels; single-channel formats yield a rows × columns array). +2. WHEN `process_frame` returns a NumPy array with the same shape and dtype as the input array, THE Python_Runner SHALL emit the returned array's pixels as the node's output frame, preserving the frame's byte length including any row padding present in the input buffer. +3. WHEN `process_frame` returns None, THE Python_Runner SHALL emit the input frame unchanged. +4. IF `process_frame` returns a value whose shape or dtype differs from the input array, THEN THE Python_Bridge SHALL fail the workflow run with an error identifying the node and describing the mismatch. +5. IF a handler file defines `process_frame` and the frame's Pixel_Format is outside the supported conversion set (RGB, BGR, RGBA, GRAY8), THEN THE Python_Bridge SHALL fail the workflow run with an error identifying the node and the unsupported Pixel_Format. +6. WHEN a handler file defines a callable `handle` and no `process_frame`, THE Python_Runner SHALL invoke `handle(frame_bytes, metadata)` under the existing raw-bytes contract, unchanged in behavior. +7. WHEN a handler file defines both `process_frame` and `handle`, THE Python_Runner SHALL invoke `process_frame` and ignore `handle`. +8. IF a handler file defines neither `process_frame` nor `handle`, THEN THE Python_Bridge SHALL fail the workflow run with an error identifying the node and naming both accepted entry points. +9. WHEN THE Python_Bridge dispatches a frame to the handler subprocess, THE Python_Runner SHALL deliver the frame's width, height, and Pixel_Format to the handler inside the `metadata` dict under the key `frame`. +10. THE frame-based handler contract SHALL apply identically to handlers of Custom_Python_Nodes and Custom_Python_Preprocess_Nodes. + +### Requirement 4: Pre-Imported OpenCV and Device Library Imports + +**User Story:** As a computer vision engineer, I want OpenCV available in my handler code without boilerplate and the freedom to import other Python libraries installed on the device, so that I can write concise processing code. + +#### Acceptance Criteria + +1. WHEN a handler module is loaded and OpenCV is importable in the handler subprocess, THE Python_Runner SHALL bind `cv2` in the handler module's global namespace before the handler code executes. +2. WHEN a handler module is loaded and NumPy is importable in the handler subprocess, THE Python_Runner SHALL bind `np` and `numpy` in the handler module's global namespace before the handler code executes. +3. IF OpenCV or NumPy is not importable in the handler subprocess, THEN THE Python_Runner SHALL still load handler modules whose code does not reference the missing binding. +4. THE Python_Runner SHALL execute handler code with the device Python interpreter, so that a standard `import` statement in handler code resolves any Python library installed on the device. +5. WHEN handler code imports a Python module shipped beside `handler.py` in the node's artifact directory, THE Python_Runner SHALL resolve the import. + +### Requirement 5: Frame Input and Output Helper Functions + +**User Story:** As a computer vision engineer, I want helper functions for getting frames into and out of NumPy arrays, so that I can also work with frames from the raw-bytes `handle` contract or convert data explicitly. + +#### Acceptance Criteria + +1. THE Frame_Helpers module SHALL be importable from handler code as `dda_frames` without the node shipping additional files in its artifacts. +2. THE Frame_Helpers module SHALL provide `to_array(frame_bytes, width, height, format)` converting raw frame bytes to a NumPy uint8 array for the RGB, BGR, RGBA, and GRAY8 Pixel_Formats, tolerating row padding in the frame bytes. +3. THE Frame_Helpers module SHALL provide `to_bytes(array)` converting a NumPy uint8 array to raw frame bytes. +4. FOR ALL frames in the supported Pixel_Formats without row padding, `to_bytes(to_array(frame_bytes, width, height, format))` SHALL equal the original frame bytes (round-trip property). +5. IF `to_array` is called with a Pixel_Format outside the supported set or with frame bytes too short for the stated dimensions, THEN THE Frame_Helpers SHALL raise an error describing the format or size problem. +6. WHILE a handler invocation is in progress, THE Frame_Helpers module SHALL return the current frame's width, height, and Pixel_Format from a `frame_info()` function. +7. THE Frame_Helpers module SHALL be available to handlers of Custom_Python_Nodes and Custom_Python_Preprocess_Nodes alike. + +### Requirement 6: Image Loading from Disk or S3 + +**User Story:** As a computer vision engineer, I want to load a reference image (for example a golden-sample JPEG) from the device file system or from S3 into an OpenCV array, so that my handler can compare or combine it with live frames. + +#### Acceptance Criteria + +1. WHEN `dda_frames.load_image(source)` is called with a local file path of a decodable image file, THE Frame_Helpers SHALL return the decoded image as a NumPy uint8 array in OpenCV BGR channel order (single-channel images decode to a two-dimensional array). +2. WHEN `dda_frames.load_image(source)` is called with an `s3://bucket/key` URI, THE Frame_Helpers SHALL download the object using the device's AWS credentials and return the decoded image as a NumPy uint8 array in OpenCV BGR channel order. +3. FOR ALL image arrays written losslessly to disk as PNG, `load_image` of the written file SHALL return an array equal to the original array (round-trip property). +4. IF the local file does not exist, the S3 object cannot be fetched, the URI is malformed, or the content cannot be decoded as an image, THEN THE Frame_Helpers SHALL raise an error identifying the source and describing the failure. +5. WHEN a handler raises the `load_image` error, THE Python_Bridge SHALL fail only that workflow run with the node identified, consistent with existing handler failure containment. + +### Requirement 7: Workflow Designer Support + +**User Story:** As a computer vision engineer, I want the new preprocessing node to appear and behave correctly in the workflow designer, so that I can place, configure, and connect it like any other node. + +#### Acceptance Criteria + +1. WHEN a user opens the Node_Palette, THE Workflow_Builder SHALL display the Custom_Python_Preprocess_Node in the preprocessing section. +2. WHEN a user selects a Custom_Python_Preprocess_Node, THE Workflow_Builder SHALL render a code editor for the `code` parameter. +3. WHEN a user attempts to connect an output port whose type is not compatible with VideoFrames under the declared coercion rules (VideoFrames exactly, or InferenceMeta via the declared coercion, consistent with Requirement 2.1) to a Custom_Python_Preprocess_Node input port, THE Workflow_Builder SHALL reject the connection and display the reason. +4. WHILE a Custom_Python_Preprocess_Node has no `code` value, THE Workflow_Builder SHALL display an inline required-parameter validation marker on the node. + +### Requirement 8: Accurate Custom Python Contract Documentation + +**User Story:** As a computer vision engineer, I want the Custom Python node's in-designer documentation to match the actual runtime contract, so that code I write from the parameter description runs on the device. + +#### Acceptance Criteria + +1. THE Custom_Python_Node descriptor's `code` parameter description SHALL state the actual runtime entry points (`process_frame(frame, metadata)` for NumPy-array processing and `handle(frame_bytes, metadata)` for raw bytes) in place of the current non-existent `process(data, metadata)` contract. +2. THE Custom_Python_Node descriptor's `code` parameter examples SHALL contain at least one example that is a valid handler under the runtime contract. diff --git a/.kiro/specs/custom-python-frames/tasks.md b/.kiro/specs/custom-python-frames/tasks.md new file mode 100644 index 00000000..5e9d76e5 --- /dev/null +++ b/.kiro/specs/custom-python-frames/tasks.md @@ -0,0 +1,161 @@ +# Implementation Plan: Custom Python Frames + +## Overview + +Work proceeds along two independent tracks that meet at the vendored-mirror sync: the Portal track (catalog descriptor + documentation fixes, then packaging) and the Edge track (the `dda_frames` helper module, then the runner contract changes), followed by the mirror sync, frontend coverage, and a final full-suite checkpoint. There are no compiler, serializer, schema, or frontend production-code changes — the compiler already derives `python_handler_path` per node, and the designer UI is generic over the catalog descriptor. + +Test baselines that must stay green: portal backend `edge-cv-portal/backend/tests` and `edge-cv-portal/backend/layers/workflow_core/tests` (pytest + hypothesis, moto-backed conftest), frontend vitest (`edge-cv-portal/frontend`, vitest + RTL + fast-check) plus `npm run build`, and the edge suites run as `PYTHONPATH=src/backend:test/backend-test pytest test/backend-test/workflow_engine/` (scoped to the workflow_engine suites — the broader edge suite has pre-existing environment-dependent failures on this host). Python property tests use `hypothesis` and TypeScript property tests use `fast-check`; iteration counts come from each suite's registered profile (no hardcoded `max_examples`; the CI profile runs ≥100 iterations) and every property test is tagged `**Feature: custom-python-frames, Property {number}: {property_text}**`. + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. Catalog descriptor + contract docs] --> T2[2. Packaging gather + manifest] + T4[4. dda_frames Frame_Helpers] --> T5[5. Runner contract changes] + T2 --> T3[3. Checkpoint: portal backend] + T5 --> T6[6. Edge examples + vendored mirror sync] + T1 --> T6 + T3 --> T7[7. Frontend designer coverage] + T1 --> T7 + T6 --> T8[8. Final checkpoint] + T7 --> T8 +``` + +```json +{ + "waves": [ + { "wave": 1, "tasks": ["1", "4"], "description": "Independent foundations: the catalog descriptor with its validator/compiler properties (portal), and the dda_frames helper module with its conversion/load properties (edge)" }, + { "wave": 2, "tasks": ["2", "5"], "description": "Consumers of the foundations: packaging gather/manifest (portal), and the runner contract changes with their bridge-level properties (edge)" }, + { "wave": 3, "tasks": ["3"], "description": "Checkpoint: portal backend and workflow_core layer suites pass" }, + { "wave": 4, "tasks": ["6"], "description": "Edge example tests, load_image failure containment, and the vendored workflow_core mirror sync with byte-equality smoke test" }, + { "wave": 5, "tasks": ["7"], "description": "Frontend designer coverage (palette, code editor, inline marker examples; connection acceptance property)" }, + { "wave": 6, "tasks": ["8"], "description": "Final checkpoint: all suites pass" } + ] +} +``` + +## Tasks + +- [x] 1. Add the Custom Python preprocessing node type to the catalog (portal layer) + - [x] 1.1 Add the `custom_python_preprocess` descriptor and fix the Custom Python contract documentation + - In `edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py`: add `CUSTOM_PYTHON_PREPROCESS` (type id `custom_python_preprocess`, category `CATEGORY_PREPROCESSING`, display name "Custom Python (Frames)", fixed `in`/`out` VideoFrames ports, required `code` parameter of type `code` documenting `process_frame(frame, metadata)` with a cv2 example, optional `requirements` parameter, `_same_on_all_archs` mapping to the `emlpython` element with `handler-path: {python_handler_path}` and plugin dependency `dda-emlpython`, `hardware_dependent=False`); insert it into `NODE_CATALOG` after `FORMAT_CONVERT` + - Rewrite the `custom_python` descriptor's `code` parameter description and examples to state the actual runtime entry points (`process_frame(frame, metadata)` and `handle(frame_bytes, metadata) -> (frame_bytes, metadata)`) in place of the non-existent `process(data, metadata)` contract + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 8.1, 8.2_ + + - [x] 1.2 Write catalog content unit tests for the new descriptor and documentation + - In `edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_content.py`: descriptor present with preprocessing category and display name; exactly one VideoFrames input and one VideoFrames output with no `input_port_type`/`output_port_type` parameters; required `code` + optional `requirements` parameters; per-architecture mappings identical to `custom_python`'s (same element chain and plugin dependencies); `code` descriptions of both Custom Python types name `process_frame` (and `handle` for `custom_python`) and not `process(data, metadata)`; every `code` example of both types exec's to a module defining a callable `process_frame` or `handle` + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 8.1, 8.2_ + + - [x] 1.3 Write property test for validator acceptance of preprocessing node connections + - **Feature: custom-python-frames, Property 1: Validator acceptance of preprocessing node connections** + - **Validates: Requirements 2.1, 2.2** + - New `test_property_*` module in `edge-cv-portal/backend/layers/workflow_core/tests/`: hypothesis-generated graphs wiring a random source node type's output into a `custom_python_preprocess` input; `validate` accepts exactly when `are_port_types_compatible(source_port_type, VideoFrames)` holds under the catalog's declared coercion rules (VideoFrames, and InferenceMeta via the declared coercion), otherwise reports a V2 port-compatibility finding identifying the connection + + - [x] 1.4 Write property test for the compiled emlpython element + - **Feature: custom-python-frames, Property 12: Compiled emlpython element per Custom Python preprocessing node** + - **Validates: Requirements 2.3** + - Hypothesis-generated valid workflows embedding `custom_python_preprocess` nodes with random node ids, compiled per architecture; exactly one `emlpython` element per node, tagged with the node id, carrying `handler-path` = `python/{nodeId}/handler.py` + +- [x] 2. Include the new node type in packaging + - [x] 2.1 Widen the Custom Python gather predicate + - In `edge-cv-portal/backend/functions/workflow_packaging.py`: introduce `CUSTOM_PYTHON_NODE_TYPES = ('custom_python', 'custom_python_preprocess')` and use it in `gather_custom_python_nodes`; no other packaging changes (`build_arch_zip` and `build_manifest` are generic over the gathered list) + - _Requirements: 2.4, 2.5_ + + - [x] 2.2 Write property test for gathering and manifest membership + - **Feature: custom-python-frames, Property 3: Custom Python node gathering and manifest membership** + - **Validates: Requirements 2.4, 2.5** + - New `test_property_*` module in `edge-cv-portal/backend/tests/`: hypothesis-generated graphs mixing both Custom Python node types and other types with random ids/code/requirements; `gather_custom_python_nodes` returns exactly the Custom Python nodes with code and requirements preserved; `build_manifest`'s `customPythonNodeIds` equals exactly those ids + + - [x] 2.3 Extend the packaging integration test with the new node type + - In `edge-cv-portal/backend/tests/test_workflow_packaging_deployment_integration.py`: a definition containing one `custom_python_preprocess` node packages `python/{nodeId}/handler.py` and `python/{nodeId}/requirements.txt` into every architecture zip with the node id in the manifest's `customPythonNodeIds` + - _Requirements: 2.3, 2.4, 2.5_ + +- [x] 3. Checkpoint — portal backend suites pass + - Run `edge-cv-portal/backend/layers/workflow_core/tests` and `edge-cv-portal/backend/tests`; ensure all tests pass, ask the user if questions arise. + +- [x] 4. Implement the `dda_frames` Frame_Helpers module (edge) + - [x] 4.1 Add `HELPERS_SOURCE` to the Python bridge + - In `src/backend/workflow_engine/python_bridge.py`: a new module-level constant `HELPERS_SOURCE` containing the `dda_frames` module source — `FORMAT_CHANNELS` (RGB/BGR 3, RGBA 4, GRAY8 1), `to_array(frame_bytes, width, height, format)` (uint8 array `(h, w, c)`, 2-D for GRAY8, row stride = `len(frame_bytes) // height` with per-row slicing to tolerate padding; `ValueError` naming the unsupported format or the size shortfall), `to_bytes(array)` (contiguous bytes; `ValueError` on non-uint8/non-array input), `frame_info()` returning the per-invocation `{'width', 'height', 'format'}` context (set/cleared via a private `_set_current`), and `load_image(source, s3_client=None)` (local path via `open(..., 'rb')`, `s3://bucket/key` via a lazily created boto3 client or the injected one; decode with `cv2.imdecode` — `IMREAD_COLOR` for color sources yielding BGR, grayscale PNGs yielding 2-D arrays; `ValueError` containing the source string on missing file, malformed URI, fetch failure, undecodable content, or missing boto3) + - Keep `HELPERS_SOURCE` dependency-free of LocalServer imports (it executes inside the handler subprocess) and importable standalone for direct unit/property testing via `exec` + - _Requirements: 5.1, 5.2, 5.3, 5.5, 6.1, 6.2, 6.4_ + + - [x] 4.2 Write property test for the frame/array conversion round trip + - **Feature: custom-python-frames, Property 7: Frame/array conversion round trip** + - **Validates: Requirements 5.2, 5.3, 5.4, 5.5** + - New `test_property_*` module in `test/backend-test/workflow_engine/` exec'ing `HELPERS_SOURCE`; hypothesis over dims × supported formats × pixel bytes × padding: `to_bytes(to_array(...))` equals unpadded input; padded and unpadded inputs decode to equal arrays; unsupported formats and short buffers raise `ValueError` describing the problem + + - [x] 4.3 Write property test for the disk image load round trip + - **Feature: custom-python-frames, Property 8: Disk image load round trip** + - **Validates: Requirements 6.1, 6.3** + - Hypothesis over uint8 `(h, w, 3)` arrays written to tmp PNGs with `cv2.imwrite`; `load_image(path)` returns an equal array + + - [x] 4.4 Write property test for the S3 image load round trip + - **Feature: custom-python-frames, Property 9: S3 image load round trip** + - **Validates: Requirements 6.2** + - Hypothesis over bucket/key names and image arrays; injected fake S3 client serving PNG bytes records the requested bucket/key; `load_image("s3://...", s3_client=fake)` returns an equal array and requested exactly that bucket and key + + - [x] 4.5 Write property test for load_image failure identification + - **Feature: custom-python-frames, Property 10: load_image failures identify the source** + - **Validates: Requirements 6.4** + - Hypothesis over missing paths, malformed `s3://` URIs, raising fake clients, and non-image byte content; every case raises `ValueError` whose message contains the source + +- [x] 5. Implement the runner contract changes (edge) + - [x] 5.1 Extend `RUNNER_SOURCE` with helpers injection, pre-imports, entry-point dispatch, and the process_frame path + - In `src/backend/workflow_engine/python_bridge.py` `RUNNER_SOURCE` (assembled with `HELPERS_SOURCE`): register `dda_frames` in `sys.modules` before the handler loads; best-effort bind `cv2`, `np`, and `numpy` on the handler module before `exec_module` (import failures leave the binding absent); resolve the entry point after `exec_module` — `process_frame` preferred, `handle` fallback, neither → error naming both entry points reported through the existing `status: error` path + - Per frame: merge `{"frame": {"width", "height", "format"}}` into the metadata dict and set the `dda_frames` per-invocation context (cleared in `finally`); for `process_frame`: unsupported/missing format or unimportable numpy → descriptive error; convert bytes via `to_array`; `None` return → emit input bytes; matching shape/dtype array → write rows back into a `bytearray` copy of the input at the original stride (byte length preserved); any other return → error describing expected vs. actual shape/dtype; for `handle`: existing raw-bytes behavior unchanged apart from the enriched metadata + - No changes to the framed protocol, the bridge (parent) class, `rewrite_document`, or `run_bridged_pipeline` + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4.1, 4.2, 4.3, 4.4, 5.1, 5.6_ + + - [x] 5.2 Write property test for the process_frame contract round trip + - **Feature: custom-python-frames, Property 4: process_frame contract round trip** + - **Validates: Requirements 3.1, 3.2, 3.3** + - Hypothesis over dims × formats × pixels × padding through a real `CustomPythonBridge.process_frame`: an identity handler asserts received shape/dtype and output bytes equal input bytes; a None-returning handler passes bytes through; a `bitwise inversion` handler inverts pixel regions while padding bytes and byte length are preserved + + - [x] 5.3 Write property test for process_frame contract violations + - **Feature: custom-python-frames, Property 5: process_frame contract violations fail the node identifiably** + - **Validates: Requirements 3.4, 3.5** + - Hypothesis over frames and violation kinds (wrong-shape array, wrong-dtype array, non-array return, unsupported Pixel_Format input); each raises `CustomPythonNodeError` carrying the node id and a message describing the mismatch or format + + - [x] 5.4 Write property test for frame info delivery + - **Feature: custom-python-frames, Property 6: Frame info delivery** + - **Validates: Requirements 3.9, 5.6** + - Hypothesis over (width, height, format); a handler returning `metadata["frame"]` and `dda_frames.frame_info()` through its response metadata; both equal the dispatched caps triple + + - [x] 5.5 Write property test for legacy handle contract preservation + - **Feature: custom-python-frames, Property 2: Legacy handle contract is preserved** + - **Validates: Requirements 3.6, 3.9** + - Hypothesis over frame bytes and metadata dicts; a `handle`-only handler receives the bytes unchanged and metadata enriched with the `frame` key, and its returned `(frame_bytes, metadata)` round-trips to the bridge caller; the existing `test_workflow_python_bridge.py` suite passes unchanged + +- [x] 6. Edge example tests and vendored mirror sync + - [x] 6.1 Write example tests for dispatch, pre-imports, and library imports + - In `test/backend-test/workflow_engine/test_workflow_python_bridge.py` (or a sibling module): both entry points defined → `process_frame` output wins; neither defined → `CustomPythonNodeError` naming `process_frame` and `handle`; handler using `cv2` and `np` without import statements processes successfully; handler importing a stdlib module and a sibling module shipped beside `handler.py` succeeds; a subprocess where `cv2` import is blocked (import-raising stub on `PYTHONPATH`) still runs a `handle`-only handler; `import dda_frames` succeeds with only `handler.py` shipped + - _Requirements: 3.7, 3.8, 4.1, 4.2, 4.3, 4.4, 4.5, 5.1, 5.7_ + + - [x] 6.2 Write example test for load_image failure containment through the bridge + - A handler calling `dda_frames.load_image` on a missing path raises through the bridge as `CustomPythonNodeError` carrying the node id and the source in its message + - _Requirements: 6.5_ + + - [x] 6.3 Sync the vendored workflow_core mirror and add the byte-equality smoke test + - Copy the changed catalog file to `src/backend/workflow_engine/vendor/workflow_core/catalog/nodes.py` so the mirror is byte-identical to the portal layer copy; add a smoke test in `test/backend-test/workflow_engine/` asserting byte equality of the two `nodes.py` files + - _Requirements: 1.6, 3.10_ + +- [x] 7. Frontend designer coverage (no production code changes) + - [x] 7.1 Write example tests for palette, code editor, and inline marker + - `NodePalette.test.tsx`: catalog fixture including the `custom_python_preprocess` descriptor renders it in the Preprocessing section; `NodeConfigPanel.test.tsx`: selecting the node renders the code editor for the `code` parameter; `inlineChecks.test.ts`: a node instance without `code` yields a required-parameter marker + - _Requirements: 7.1, 7.2, 7.4_ + + - [x] 7.2 Write property test for connection acceptance with fixed VideoFrames ports + - **Feature: custom-python-frames, Property 11: Designer connection acceptance for fixed VideoFrames ports** + - **Validates: Requirements 7.3** + - Extend `connectionAcceptance.property.test.ts`'s fast-check domain with a fixed-VideoFrames-port descriptor shape (no port type override parameters); acceptance exactly when the source output port type is VideoFrames, rejection carries a reason otherwise + +- [x] 8. Final checkpoint — all suites pass + - Run the portal backend suites (`edge-cv-portal/backend/tests`, `edge-cv-portal/backend/layers/workflow_core/tests`), the frontend suite (`npx vitest run` in `edge-cv-portal/frontend`) plus `npm run build`, and the edge suites (`PYTHONPATH=src/backend:test/backend-test pytest test/backend-test/workflow_engine/`); ensure all tests pass, ask the user if questions arise. + +## Notes + +- No compiler, serializer, schema, or frontend production-code changes are required: the compiler already derives `python_handler_path` per node, the workflow definition schema stores node `type` as an open string, and the designer UI (palette grouping, code editor, connection acceptance, inline markers) is generic over the catalog descriptor. +- The framed stdin/stdout protocol and the bridge (parent) class are unchanged — all runtime additions live in `RUNNER_SOURCE`/`HELPERS_SOURCE`, so already-deployed workflow artifacts gain the new contract when LocalServer updates. +- Property tests inherit iteration counts from each suite's hypothesis/fast-check profile (CI profile ≥100 iterations); no `max_examples` hardcoding. +- Edge test runs are scoped to `test/backend-test/workflow_engine/` — the broader edge suite has pre-existing environment-dependent failures on this host. +- Checkpoints (tasks 3 and 8) validate incrementally; each task references the granular requirements it implements for traceability. diff --git a/.kiro/specs/edge-workflow-run-ux/.config.kiro b/.kiro/specs/edge-workflow-run-ux/.config.kiro new file mode 100644 index 00000000..1de9bbdf --- /dev/null +++ b/.kiro/specs/edge-workflow-run-ux/.config.kiro @@ -0,0 +1 @@ +{"specId": "274a7fd2-6800-4369-b820-3d849778d9f4", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/edge-workflow-run-ux/bugfix.md b/.kiro/specs/edge-workflow-run-ux/bugfix.md new file mode 100644 index 00000000..bd50ec0a --- /dev/null +++ b/.kiro/specs/edge-workflow-run-ux/bugfix.md @@ -0,0 +1,45 @@ +# Bugfix Requirements Document + +## Introduction + +Portal-built workflows deployed to an edge device as Greengrass components are discovered and registered by LocalServer's workflow engine, and the edge HTTP API fully supports listing registrations, viewing registration details with executions, triggering runs, and checking run status. However, the LocalServer web frontend has no page for any of this: its routes only cover the legacy Pipeline_Configuration workflow pages. An operator standing at the device therefore has no way to see cloud-deployed workflow registrations or run them from the UI, even though the backend capability exists and works. This fix adds the missing UI (a LocalServer frontend page plus API client over the existing registrations/executions endpoints) without touching the legacy Pipeline_Configuration pages or the backend. + +## Bug Analysis + +### Current Behavior (Defect) + +When one or more cloud-deployed workflow registrations exist on the device (status `registered` or `invalid`), the LocalServer UI renders no view or control for them. + +1.1 WHEN a cloud-deployed workflow registration exists on the device THEN the LocalServer UI displays no page or list showing that registration or its status + +1.2 WHEN an operator wants to run a registered cloud-deployed workflow from the device THEN the LocalServer UI provides no control to trigger the run, forcing use of the raw HTTP API + +1.3 WHEN executions of a cloud-deployed workflow have been triggered THEN the LocalServer UI displays no execution status or history for that workflow + +1.4 WHEN a cloud-deployed workflow registration is invalid THEN the LocalServer UI gives the operator no indication that the registration exists, is invalid, or why it is invalid + +### Expected Behavior (Correct) + +The LocalServer UI surfaces cloud-deployed workflow registrations and lets the operator run registered workflows and monitor executions, using the existing edge HTTP API (`GET /workflows/registrations`, `GET /workflows/registrations/{id}`, `POST /workflows/registrations/{id}/trigger`, `GET /workflows/executions/{id}`). + +2.1 WHEN a cloud-deployed workflow registration exists on the device THEN the LocalServer UI SHALL display it in a list of workflow registrations showing its identity (workflow, version) and status + +2.2 WHEN an operator selects a registration with status `registered` THEN the LocalServer UI SHALL provide a control that triggers a run of that workflow via the trigger endpoint + +2.3 WHEN executions exist for a cloud-deployed workflow registration THEN the LocalServer UI SHALL display the execution status and history for that registration, including failure details when an execution failed + +2.4 WHEN a cloud-deployed workflow registration is invalid THEN the LocalServer UI SHALL display the registration with its invalid status and invalid reason, and SHALL NOT offer a control to trigger a run of it + +2.5 WHEN no cloud-deployed workflow registrations exist on the device THEN the LocalServer UI SHALL display an empty state on the registrations page without error + +### Unchanged Behavior (Regression Prevention) + +The fix is UI-only and additive. The legacy Pipeline_Configuration pages and endpoints, all other LocalServer UI pages, and the backend workflow engine must be untouched. + +3.1 WHEN a user navigates the legacy Pipeline_Configuration workflow pages (list, details, edit) THEN the LocalServer UI SHALL CONTINUE TO render them with unchanged behavior, calling the legacy `/workflows` endpoints via the existing API client + +3.2 WHEN a user navigates any other existing LocalServer UI page (image sources, models, live results, result history, image capture, application health) THEN the LocalServer UI SHALL CONTINUE TO render and behave as before + +3.3 WHEN the edge HTTP API serves the registrations and executions endpoints THEN the backend SHALL CONTINUE TO behave exactly as it does today, with no backend changes made by this fix + +3.4 WHEN a trigger is requested for an invalid registration THEN the system SHALL CONTINUE TO reject the run (the existing backend 409 rejection per the workflow-manager spec's rule that invalid registrations can never be run) diff --git a/.kiro/specs/edge-workflow-run-ux/design.md b/.kiro/specs/edge-workflow-run-ux/design.md new file mode 100644 index 00000000..3448145a --- /dev/null +++ b/.kiro/specs/edge-workflow-run-ux/design.md @@ -0,0 +1,268 @@ +# Edge Workflow Run UX Bugfix Design + +## Overview + +Portal-built workflows deployed to an edge device are discovered and registered by LocalServer's workflow engine, and the edge HTTP API (`src/backend/workflow_engine/api.py`) fully supports listing registrations, viewing a registration with its executions, triggering runs, and checking run status. The LocalServer frontend, however, has no UI over these endpoints: its routes cover only the legacy Pipeline_Configuration workflow pages (`/workflows` → `ListWorkflows`/`WorkflowDetails`/`EditWorkflow`), so an operator at the device cannot see cloud-deployed registrations or run them. + +The fix is UI-only and additive in `src/frontend`: a new API client module over the existing registrations/executions endpoints, a "Deployed workflows" list page, and a registration details page with a trigger control and execution history. Integration with the rest of the app is limited to three small additive edits (one route subtree in `App.tsx`, one nav link in `SideNav.tsx`, plus new files). No backend changes and no changes to the legacy Pipeline_Configuration pages or the existing `WorkflowAPI.ts` client. + +## Glossary + +- **Bug_Condition (C)**: The condition that triggers the bug — a Workflow_Component registration (or its executions) exists on the device, but the LocalServer UI renders no view or control for it +- **Property (P)**: The desired behavior — the UI lists registrations with identity and status, offers a trigger control only for `registered` registrations, shows invalid reasons, and displays execution status/history including failure details +- **Preservation**: The legacy Pipeline_Configuration pages, all other LocalServer UI pages, and the backend workflow engine must behave exactly as before +- **Workflow_Component registration**: A cloud-deployed workflow discovered on the device, served by `GET /workflows/registrations`; shape per `registration_to_dict`: `{registrationId, workflowId, version, arch, artifactPath, status, registeredAt, invalidReason?}` with `status ∈ {registered, invalid}` (`invalidReason` present only when not `registered`) +- **Workflow execution**: A run of a registration, shape per `execution_to_dict`: `{executionId, registrationId, status, startedAt, finishedAt, failingNodeId, error}` with `status ∈ {pending, running, completed, failed}` (from `pipeline_executor.py`) +- **Legacy Pipeline_Configuration pages**: The existing `/workflows` UI (`components/workflow/**`, `api/WorkflowAPI.ts`) over the legacy `/workflows` endpoints — untouched by this fix +- **Edge HTTP API**: `GET /workflows/registrations`, `GET /workflows/registrations/{id}` (includes `executions`), `POST /workflows/registrations/{id}/trigger` (409 for invalid registrations), `GET /workflows/executions/{id}` in `src/backend/workflow_engine/api.py` + +## Bug Details + +### Bug Condition + +The bug manifests whenever at least one Workflow_Component registration exists on the device. The frontend router (`App.tsx`) defines no route for registrations, the side navigation (`SideNav.tsx`) has no entry, and no API client module calls the registrations/executions endpoints — so the data the backend serves is unreachable from the UI. + +**Formal Specification:** +``` +FUNCTION isBugCondition(input) + INPUT: input of type DeviceUiState + -- registrations: list of Workflow_Component registrations on the device + -- ui: the set of routes/pages the LocalServer frontend renders + OUTPUT: boolean + + RETURN input.registrations IS NOT EMPTY + AND NOT EXISTS page IN input.ui THAT displays input.registrations + (identity, status, invalidReason, trigger control, executions) +END FUNCTION +``` + +Equivalently, for every UI input touching cloud-deployed registrations — viewing the list, viewing a registration's status/executions, triggering a run, seeing an invalid reason — the UI has no behavior at all (defect clauses 1.1–1.4). + +### Examples + +- A device has one registration `{workflowId: "wf-1", version: "3", status: "registered"}`. Expected: a UI page lists it with identity and status and offers a Run control. Actual: no page in the LocalServer UI shows it; the operator must curl `GET /workflows/registrations`. +- A registration is `invalid` with `invalidReason: "Missing required artifact file: manifest.json"`. Expected: the UI shows the registration with invalid status and the reason, with no Run control. Actual: the operator has no indication the registration exists at all. +- Executions exist for a registration, the latest with `status: "failed"`, `failingNodeId: "node-7"`, `error: "..."`. Expected: the UI shows execution history with the failure details. Actual: nothing is shown; the operator must curl `GET /workflows/registrations/{id}`. +- Edge case: a device with zero registrations. Expected: the registrations page renders an empty state without error (2.5). Actual: there is no registrations page. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** +- Legacy Pipeline_Configuration pages (list, details, edit under `/workflows`) continue to render and call the legacy `/workflows` endpoints via the existing `WorkflowAPI.ts` client, unchanged (3.1) +- All other LocalServer UI pages (image sources, models, live results, result history, image capture, application health) continue to render and behave as before (3.2) +- The backend workflow engine and its HTTP API are not modified in any way (3.3) +- Triggering an invalid registration continues to be rejected by the existing backend 409; the UI must surface, not bypass or duplicate-in-conflict, that rejection (3.4) + +**Scope:** +All inputs that do NOT involve the new deployed-workflows pages are completely unaffected by this fix. This includes: +- Any navigation to existing routes (`/workflows`, `/image-sources`, `/models`, `/result`, `/history`, `/capture`, `/capture-results`, `/application-health`) +- All existing API calls made by the frontend (legacy `/workflows` CRUD/run, cameras, streams, system health, etc.) +- All backend request handling, including the trigger endpoint's 409 behavior for invalid registrations + +**Structural preservation:** the fix touches existing files only additively and minimally: +- `App.tsx`: add one new route subtree (`deployed-workflows`) alongside the existing routes; no existing route is edited +- `SideNav.tsx`: add one new link item; no existing item is edited +- Everything else is new files (`api/WorkflowRegistrationAPI.ts`, `components/deployed-workflow/**`) +- `config/Interface.tsx`, `api/WorkflowAPI.ts`, `components/workflow/**` are not modified (the new API module imports the existing `Connection.ENDPOINT` read-only) + +## Hypothesized Root Cause + +This is a missing-feature bug: the UI layer was never built for the workflow engine's registration/execution endpoints. The specific gaps are: + +1. **No routes**: `App.tsx` defines routes only for legacy features; nothing maps to the registrations/executions concepts +2. **No navigation entry**: `SideNav.tsx` has no link, so even a hidden page would be undiscoverable +3. **No API client**: `src/frontend/src/api/` has no module calling `/workflows/registrations` or `/workflows/executions`; `WorkflowAPI.ts` covers only the legacy Pipeline_Configuration endpoints +4. **No pages/components**: there is no component tree rendering registrations, trigger controls, or execution history + +The backend endpoints were verified working (`src/backend/workflow_engine/api.py`), confirming the root cause lives entirely in `src/frontend`. + +## Correctness Properties + +Property 1: Bug Condition - Deployed workflow registrations are visible and runnable from the UI + +_For any_ set of registrations and executions returned by the edge HTTP API (any mix of `registered`/`invalid` registrations, including the empty set, and any mix of execution statuses), the fixed UI SHALL render the registrations list showing each registration's identity (workflowId, version) and status; SHALL render an empty state without error when the set is empty; SHALL offer a trigger control if and only if a registration's status is `registered`; SHALL display `invalidReason` for invalid registrations; and SHALL display execution status and history including `failingNodeId` and `error` for failed executions. + +**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5** + +Property 2: Preservation - Existing pages, API calls, and backend behavior unchanged + +_For any_ input that does NOT involve the new deployed-workflows pages (navigation to any pre-existing route, any legacy Pipeline_Configuration operation, any backend request), the fixed application SHALL produce the same result as the original application, preserving the legacy `/workflows` pages and API client, all other UI pages, unmodified backend behavior, and the backend's 409 rejection of trigger requests for invalid registrations. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4** + +## Fix Implementation + +### Changes Required + +All paths under `src/frontend/src/`. + +**New file**: `api/WorkflowRegistrationAPI.ts` + +API client following the sibling module conventions (axios, typed request/response functions). It imports `Connection` from `config/Interface` and builds its own endpoint constants so `Interface.tsx` is untouched: + +```typescript +export type RegistrationStatus = "registered" | "invalid"; +export type ExecutionStatus = "pending" | "running" | "completed" | "failed"; + +export interface WorkflowRegistration { + registrationId: string; + workflowId: string; + version: string; + arch: string; + artifactPath: string; + status: RegistrationStatus; + registeredAt: number; + invalidReason?: string; +} + +export interface WorkflowExecution { + executionId: string; + registrationId: string; + status: ExecutionStatus; + startedAt: number | null; + finishedAt: number | null; + failingNodeId: string | null; + error: string | null; +} + +export interface WorkflowRegistrationDetails extends WorkflowRegistration { + executions: WorkflowExecution[]; +} + +listWorkflowRegistrations(): Promise // GET /workflows/registrations +getWorkflowRegistration(id): Promise // GET /workflows/registrations/{id} +triggerWorkflowRegistration(id): Promise // POST /workflows/registrations/{id}/trigger +getWorkflowExecution(id): Promise // GET /workflows/executions/{id} +``` + +**New files**: `components/deployed-workflow/` + +1. **`presentation.ts`** — pure presentational logic, kept free of React/DOM so it is directly property-testable: + - `canTrigger(registration): boolean` — true iff `status === "registered"` (2.2, 2.4) + - `registrationStatusIndicator(registration): { type: StatusIndicatorProps.Type; text: string }` — maps `registered`/`invalid` to Cloudscape status indicator props + - `executionStatusIndicator(execution)` — maps `pending`/`running`/`completed`/`failed` to indicator props + - `sortExecutions(executions): WorkflowExecution[]` — newest first by `startedAt` (stable for ties/nulls) for the history table (2.3) + - `executionFailureDetails(execution): { failingNodeId?: string; error?: string } | undefined` — present iff `status === "failed"` (2.3) + - `isExecutionActive(execution): boolean` — true iff status is `pending` or `running` + - `shouldPoll(executions): boolean` — true iff any execution is active; drives the detail page's refetch interval + +2. **`list/ListDeployedWorkflows.tsx`** — registrations list page: + - `useQuery(["listWorkflowRegistrations"], listWorkflowRegistrations)` (react-query, matching `ListWorkflows.tsx` conventions) + - Cloudscape `Table` with columns: workflow (workflowId, link to details), version, arch, status (`StatusIndicator` via `registrationStatusIndicator`; invalid rows show `invalidReason` in a description/popover) (2.1, 2.4) + - Cloudscape `Table` `empty` slot renders the empty state ("No deployed workflows") without error when the list is empty (2.5) + +3. **`details/DeployedWorkflowDetails.tsx`** — registration details page (`:registrationId` from `useParams`): + - `useQuery(["getWorkflowRegistration", registrationId], ...)` with `refetchInterval: shouldPoll(data.executions) ? EXECUTION_POLL_INTERVAL_MS : false` (matching the `refetchInterval` pattern used by `ApplicationHealthOverview`/`RefreshDisplay`) so running/pending executions refresh automatically and polling stops once all executions are terminal + - Header shows identity, status, `registeredAt`; invalid registrations show an alert with `invalidReason` and render no trigger control (2.4) + - Trigger control: a "Run workflow" `Button` rendered only when `canTrigger(registration)`; wired to `useMutation(triggerWorkflowRegistration)`; on success invalidates the detail query so the new `pending` execution appears immediately and polling starts (2.2) + - Trigger error handling: on failure (including the backend 409 for registrations that became invalid between render and click) shows the backend's `detail` message via the existing `AppLayoutContext` flashbar (`addError`), reflecting rather than masking the backend rejection (3.4) + - Executions table: `sortExecutions` order; columns for execution id, status indicator, started/finished timestamps; failed rows show `failingNodeId` and `error` via `executionFailureDetails` (2.3); empty slot when no executions exist yet + +**Modified file**: `App.tsx` (additive only — one new route subtree) + +```tsx + + } /> + } + handle={{ breadcrumb: "Deployed workflow details" }} + /> + +``` + +The path `deployed-workflows` is a new top-level route distinct from the legacy `workflows` subtree, so no legacy route or breadcrumb changes (3.1). + +**Modified file**: `components/layout/SideNav.tsx` (additive only — one new link) + +Add `{ type: "link", text: "Deployed workflows", href: "/deployed-workflows" }` to the existing "Configure" section, after "Workflows". The `activeHref` prefix logic already handles the new top-level route without changes. + +**Not modified**: `config/Interface.tsx`, `api/WorkflowAPI.ts`, `components/workflow/**`, anything under `src/backend/` (3.1–3.3). + +## Testing Strategy + +### Validation Approach + +The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the bug on unfixed code, then verify the fix works correctly and preserves existing behavior. + +The frontend uses Create React App, so Jest with `@testing-library/react` and `@testing-library/jest-dom` is available via `react-scripts test` (there are currently no unit tests under `src/frontend/src`; these will be the first). For property-based tests, **fast-check** will be added as a devDependency — it is the standard PBT library for Jest/TypeScript and requires no runner changes. Cypress (already configured) is available for integration-level checks. API calls in Jest tests are mocked at the API-module boundary (`jest.mock("api/WorkflowRegistrationAPI")`). + +### Exploratory Bug Condition Checking + +**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm or refute the root cause analysis (missing routes, nav entry, API client, and pages). If we refute, we will need to re-hypothesize. + +**Test Plan**: Write tests that render the app router / side navigation and assert the deployed-workflows surface exists, plus a static check that an API client for the registrations endpoints exists. Run these tests on the UNFIXED code to observe failures. + +**Test Cases**: +1. **Route Existence Test**: Render the router at `/deployed-workflows` and assert a registrations page renders rather than falling through (will fail on unfixed code) +2. **Navigation Entry Test**: Render `SideNav` and assert a "Deployed workflows" link exists (will fail on unfixed code) +3. **API Client Test**: Assert `api/WorkflowRegistrationAPI` exports `listWorkflowRegistrations`/`triggerWorkflowRegistration` targeting `/workflows/registrations` (will fail on unfixed code — module does not exist) +4. **Invalid Registration Visibility Test**: With a mocked API returning an invalid registration, assert the UI surfaces its status and reason somewhere (will fail on unfixed code — nothing renders it) + +**Expected Counterexamples**: +- No route matches `/deployed-workflows`; no nav link; no API module +- Possible causes: feature never implemented in the frontend (confirmed root cause), routes hidden behind a flag (refuted — no such flag exists), backend endpoints absent (refuted — verified in `api.py`) + +### Fix Checking + +**Goal**: Verify that for all inputs where the bug condition holds, the fixed UI produces the expected behavior. + +**Pseudocode:** +``` +FOR ALL registrationSets WHERE isBugCondition(deviceState) DO + render fixed UI with mocked API returning registrationSet + ASSERT list shows every registration's identity and status -- 2.1 + ASSERT trigger control present IFF status == "registered" -- 2.2, 2.4 + ASSERT invalid registrations show invalidReason -- 2.4 + ASSERT executions shown with status/history and failure details -- 2.3 + ASSERT empty registration set renders empty state without error -- 2.5 +END FOR +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed application produces the same result as the original application. + +**Pseudocode:** +``` +FOR ALL input WHERE NOT isBugCondition(input) DO + ASSERT originalApp(input) = fixedApp(input) +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for preservation checking because: +- It generates many test cases automatically across the input domain +- It catches edge cases that manual unit tests might miss +- It provides strong guarantees that behavior is unchanged for all non-buggy inputs + +Preservation is additionally guaranteed structurally: the fix adds new files plus one route subtree and one nav link. A diff review confirming that no existing route, nav item, API module, or backend file changed is the strongest preservation evidence for 3.1–3.3; 3.4 is preserved because the backend is untouched and the UI surfaces the existing 409. + +**Test Plan**: Observe behavior on UNFIXED code first for the legacy pages and navigation (route rendering, nav items, legacy API endpoint constants), then write tests capturing that behavior to verify it is identical after the fix. + +**Test Cases**: +1. **Legacy Workflow Route Preservation**: Observe that `/workflows`, `/workflows/:id`, `/workflows/:id/edit` render the legacy components on unfixed code, then verify identical rendering and breadcrumbs after the fix (3.1) +2. **Navigation Preservation**: Observe the existing `SideNav` sections/links on unfixed code, then verify all pre-existing items are unchanged (same text, href, order) after the fix, with only the one new link added (3.2) +3. **Legacy API Client Preservation**: Verify `WorkflowAPI.ts` and `config/Interface.tsx` are byte-identical (no diff) and legacy endpoints still target `${Connection.ENDPOINT}/workflows` (3.1, 3.3) +4. **Backend Preservation**: Verify no file under `src/backend/` is modified; existing backend tests for the trigger 409 continue to pass unchanged (3.3, 3.4) + +### Unit Tests + +- `presentation.ts`: `canTrigger` for both statuses; status-indicator mappings for all four execution statuses and both registration statuses; `executionFailureDetails` present only for `failed`; `shouldPoll` true/false cases; `sortExecutions` ordering with null `startedAt` +- `ListDeployedWorkflows`: renders rows for mocked registrations (identity + status), invalid reason shown, empty state on `[]`, error state on API failure +- `DeployedWorkflowDetails`: trigger button present only for `registered`; invalid alert with reason; trigger click calls the API and refreshes; 409 mutation error shows the backend `detail` message; failed execution row shows `failingNodeId` and `error` + +### Property-Based Tests + +Using fast-check arbitraries over `WorkflowRegistration`/`WorkflowExecution` (random statuses, optional/null fields, arbitrary timestamps), targeting the pure logic in `presentation.ts`: + +- **Property 1 (fix)**: for any generated registration, `canTrigger(r) === (r.status === "registered")`; for any registration list rendered into the list page, every registration's workflowId/version/status appears and a trigger affordance exists iff `canTrigger` (validates 2.1, 2.2, 2.4) +- **Property 1 (fix)**: for any generated execution list, `sortExecutions` returns a permutation ordered newest-first, `executionFailureDetails` is defined iff status is `failed` and echoes `failingNodeId`/`error`, and `shouldPoll` is true iff some execution is `pending` or `running` (validates 2.3) +- **Property 2 (preservation)**: for any generated app state not involving deployed workflows (random legacy route from the pre-existing route set), the fixed router resolves it to the same component as the original route table (validates 3.1, 3.2) + +### Integration Tests + +- Cypress (or RTL with the full router): navigate Side nav → Deployed workflows → select a registration → trigger a run → observe the new `pending` execution appear and (with a mocked/stubbed API sequence pending → running → completed) the status update via polling +- Trigger an invalid registration path: stub a 409 response and verify the UI shows the backend rejection message and no execution is added +- Legacy flow smoke test: navigate to `/workflows` list and details and verify the legacy pages still render and call the legacy endpoints diff --git a/.kiro/specs/edge-workflow-run-ux/tasks.md b/.kiro/specs/edge-workflow-run-ux/tasks.md new file mode 100644 index 00000000..22e3b62f --- /dev/null +++ b/.kiro/specs/edge-workflow-run-ux/tasks.md @@ -0,0 +1,218 @@ +# Implementation Plan + +## Overview + +UI-only, additive bugfix in `src/frontend`: a new API client over the existing edge registrations/executions endpoints, a "Deployed workflows" list page, a details page with trigger control and execution history, plus one new route subtree in `App.tsx` and one new `SideNav` link. No backend changes. + +There are currently NO unit tests under `src/frontend/src`, so task 1 establishes the Jest test setup first (react-scripts test works out of the box; `fast-check` is added as a devDependency for the property tests). All frontend tests run from `src/frontend` with `CI=true npm test -- --watchAll=false` (optionally scoped with `--testPathPattern=...`). + +**Build-box constraint (IMPORTANT)**: a heavy Greengrass docker build may be running on this machine. Tasks MUST NOT touch `greengrass-build/`, `custom-build/`, or repo-root `src/backend` Docker artifacts, and MUST NOT run any `docker` commands. All work in this plan lives under `src/frontend` (plus this spec directory) and does not conflict with that build. + +## Task Dependency Graph + +```mermaid +graph TD + T1[Task 1: Jest test setup + fast-check] --> T2[Task 2: Bug condition exploration test] + T1 --> T3[Task 3: Preservation property tests] + T2 --> T4[Task 4: Implement the fix] + T3 --> T4 + subgraph T4 [Task 4: Implement the fix] + T41[4.1 WorkflowRegistrationAPI.ts] + T42[4.2 presentation.ts] + T41 --> T43[4.3 ListDeployedWorkflows.tsx] + T42 --> T43 + T41 --> T44[4.4 DeployedWorkflowDetails.tsx] + T42 --> T44 + T43 --> T45[4.5 App.tsx route + SideNav link] + T44 --> T45 + T45 --> T46[4.6 Verify exploration test passes] + T45 --> T47[4.7 Verify preservation tests pass] + end + T4 --> T5[Task 5: Fix-checking unit + property tests] + T4 --> T6[Task 6: Checkpoint] + T5 --> T6 +``` + +Tasks 2 and 3 are independent of each other and may run in parallel once task 1 is done. Within task 4, sub-tasks 4.1 and 4.2 touch disjoint new files and may proceed in parallel; 4.3 and 4.4 both depend on 4.1 + 4.2 and are mutually independent; 4.5 is the only edit to existing files and comes last before verification. + +```json +{ + "waves": [ + { + "wave": 1, + "tasks": ["1"], + "description": "Establish the frontend Jest test setup (first unit tests in src/frontend/src) and add fast-check as a devDependency." + }, + { + "wave": 2, + "tasks": ["2", "3"], + "description": "Exploration and preservation tests, written and run against the UNFIXED code. Both tasks are mutually independent (disjoint new test files)." + }, + { + "wave": 3, + "tasks": ["4"], + "description": "Implement the fix (new API module, presentation logic, two pages, route + nav link) and verify the exploration test now passes and preservation tests still pass. Depends on tasks 2 and 3." + }, + { + "wave": 4, + "tasks": ["5"], + "description": "Fix-checking unit tests and fast-check property tests over presentation.ts and the new pages. Depends on task 4." + }, + { + "wave": 5, + "tasks": ["6"], + "description": "Final checkpoint: full frontend test suite, production build, and no-diff check outside src/frontend. Depends on tasks 4 and 5." + } + ] +} +``` + +## Tasks + +- [x] 1. Establish the frontend Jest test setup + - There are currently no unit tests under `src/frontend/src`; this task makes `react-scripts test` runnable and repeatable + - Add `fast-check` as a devDependency from `src/frontend`: `npm install --save-dev --save-exact fast-check@3.23.2` (v3 line — compatible with the project's TypeScript 4.9; do NOT use fast-check v4, which requires TS ≥ 5). `@testing-library/react`, `@testing-library/jest-dom`, and `@testing-library/user-event` are already in `package.json` + - Create `src/frontend/src/setupTests.ts` importing `@testing-library/jest-dom` (CRA picks this file up automatically) + - Add a trivial smoke test (e.g. `src/frontend/src/setup.smoke.test.ts` asserting a fast-check property such as string round-trip) to prove the runner + fast-check work end to end; keep or delete it once real tests exist + - Run: `CI=true npm test -- --watchAll=false` from `src/frontend` and confirm the suite executes green + - Do NOT touch `greengrass-build/`, `custom-build/`, `src/backend`, or run docker commands + - _Requirements: 1.1, 2.1 (test infrastructure prerequisite)_ + +- [x] 2. Write bug condition exploration test + - **Property 1: Bug Condition** - Deployed workflow registrations are visible and runnable from the UI + - **Feature: edge-workflow-run-ux, Property 1: Deployed workflow registrations are visible and runnable from the UI** + - **CRITICAL**: This test MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior - it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples that demonstrate the bug (expected: no route matches `/deployed-workflows`, no "Deployed workflows" nav link, no `api/WorkflowRegistrationAPI` module, invalid registrations invisible) + - **Scoped PBT Approach**: The bug is deterministic (the UI surface is entirely missing), so scope the property to the concrete cases from the design's Exploratory section rather than broad generation + - Create `src/frontend/src/components/deployed-workflow/deployedWorkflowSurface.exploration.test.tsx` with the four test cases from the design's Exploratory Bug Condition Checking section: + 1. **Route Existence Test**: render the app's route tree at `/deployed-workflows` (via `createMemoryRouter`/`MemoryRouter` over the routes defined in `App.tsx`) and assert a registrations page renders rather than falling through + 2. **Navigation Entry Test**: render `components/layout/SideNav.tsx` and assert a "Deployed workflows" link with href `/deployed-workflows` exists + 3. **API Client Test**: assert `api/WorkflowRegistrationAPI` exports `listWorkflowRegistrations` / `getWorkflowRegistration` / `triggerWorkflowRegistration` / `getWorkflowExecution` targeting `${Connection.ENDPOINT}/workflows/registrations` and `/workflows/executions` + 4. **Invalid Registration Visibility Test**: with the API module mocked (`jest.mock("api/WorkflowRegistrationAPI")`) to return an invalid registration `{status: "invalid", invalidReason: "Missing required artifact file: manifest.json"}`, assert the UI surfaces its status and reason + - Bug condition from design: `isBugCondition(input)` — registrations exist on the device AND no page in the UI displays them (identity, status, invalidReason, trigger control, executions) + - The assertions encode Property 1's expected behavior: list shows identity (workflowId, version) and status; empty set renders an empty state; trigger control iff `status === "registered"`; `invalidReason` shown for invalid; execution history with `failingNodeId`/`error` for failed executions + - Run: `CI=true npm test -- --watchAll=false --testPathPattern=deployed-workflow` from `src/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Test FAILS (this is correct - it proves the bug exists: module not found, no route match, no nav link, nothing rendered) + - Document counterexamples found to confirm the root cause (missing routes, nav entry, API client, pages — not a hidden flag, not missing backend endpoints) + - Mark task complete when test is written, run, and failure is documented + - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_ + +- [x] 3. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - Existing pages, API calls, and backend behavior unchanged + - **Feature: edge-workflow-run-ux, Property 2: Existing pages, API calls, and backend behavior unchanged** + - **IMPORTANT**: Follow observation-first methodology — observe behavior on UNFIXED code first, then encode it + - Create `src/frontend/src/legacySurfacePreservation.test.tsx` capturing the design's Preservation Checking test cases: + 1. **Legacy Workflow Route Preservation**: observe on unfixed code that `/workflows`, `/workflows/:id`, `/workflows/:id/edit` render the legacy `components/workflow/**` components (mock legacy `WorkflowAPI` at the module boundary), then encode assertions on rendered components and breadcrumb handles (3.1) + 2. **Navigation Preservation**: observe the existing `SideNav` sections/links (text, href, order) on unfixed code and encode them as the expected pre-existing item list — after the fix the only allowed delta is the one new "Deployed workflows" link (3.2) + 3. **Legacy API Client Preservation**: assert `api/WorkflowAPI.ts` endpoints still target `${Connection.ENDPOINT}/workflows` and `config/Interface.tsx` exports are unchanged (3.1, 3.3) + 4. **Fast-check route property**: for any route drawn from the pre-existing route set (`/workflows`, `/image-sources`, `/models`, `/result`, `/history`, `/capture`, `/capture-results`, `/application-health`, ...as observed in `App.tsx`), the router resolves it to the same component as the original route table (3.1, 3.2) + - Record the backend baseline for 3.3/3.4: `git status --porcelain -- src/backend` shows no changes now and must still show none after the fix (no backend diff; the existing backend 409 rejection of triggers on invalid registrations is preserved by not touching the backend) + - If rendering the route tree requires exporting the route definitions from `App.tsx` for testability, defer that to task 4.5 and drive these tests through the app root instead — this task must not modify production files + - Run: `CI=true npm test -- --watchAll=false --testPathPattern=legacySurfacePreservation` from `src/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 4. Fix: add the deployed-workflows UI surface + + - [x] 4.1 Implement `api/WorkflowRegistrationAPI.ts` + - New file `src/frontend/src/api/WorkflowRegistrationAPI.ts` following sibling module conventions (axios, typed functions) + - Types per design: `RegistrationStatus`, `ExecutionStatus`, `WorkflowRegistration` (with optional `invalidReason`), `WorkflowExecution` (nullable `startedAt`/`finishedAt`/`failingNodeId`/`error`), `WorkflowRegistrationDetails extends WorkflowRegistration { executions }` + - Functions: `listWorkflowRegistrations()` → GET `/workflows/registrations`; `getWorkflowRegistration(id)` → GET `/workflows/registrations/{id}`; `triggerWorkflowRegistration(id)` → POST `/workflows/registrations/{id}/trigger`; `getWorkflowExecution(id)` → GET `/workflows/executions/{id}` + - Import `Connection` from `config/Interface` read-only and build endpoint constants locally — `config/Interface.tsx` is NOT modified + - _Bug_Condition: isBugCondition(input) from design — no API client module calls the registrations/executions endpoints_ + - _Expected_Behavior: Property 1 from design — the data the backend serves becomes reachable from the UI_ + - _Preservation: Property 2 from design — `WorkflowAPI.ts` and `config/Interface.tsx` untouched_ + - _Requirements: 2.1, 2.2, 2.3, 3.1, 3.3_ + + - [x] 4.2 Implement `components/deployed-workflow/presentation.ts` + - New file: pure presentational logic, free of React/DOM so it is directly property-testable + - `canTrigger(registration)` — true iff `status === "registered"` (2.2, 2.4) + - `registrationStatusIndicator(registration)` and `executionStatusIndicator(execution)` — Cloudscape `StatusIndicatorProps.Type` + text mappings for both registration statuses and all four execution statuses + - `sortExecutions(executions)` — newest first by `startedAt`, stable for ties/nulls (2.3) + - `executionFailureDetails(execution)` — `{failingNodeId?, error?}` present iff `status === "failed"` (2.3) + - `isExecutionActive(execution)` — true iff `pending` or `running`; `shouldPoll(executions)` — true iff any execution is active + - _Bug_Condition: isBugCondition(input) from design — no component logic renders registrations/executions_ + - _Expected_Behavior: Property 1 from design — trigger iff registered, failure details iff failed, newest-first history, polling while active_ + - _Preservation: new file only; nothing existing modified_ + - _Requirements: 2.2, 2.3, 2.4_ + + - [x] 4.3 Implement `components/deployed-workflow/list/ListDeployedWorkflows.tsx` + - `useQuery(["listWorkflowRegistrations"], listWorkflowRegistrations)` matching `ListWorkflows.tsx` react-query conventions + - Cloudscape `Table` columns: workflow (workflowId link to details), version, arch, status via `registrationStatusIndicator`; invalid rows surface `invalidReason` (2.1, 2.4) + - `empty` slot renders "No deployed workflows" without error for an empty list (2.5) + - _Bug_Condition: isBugCondition(input) from design — no page lists registrations_ + - _Expected_Behavior: Property 1 from design — every registration's identity and status rendered; empty state without error_ + - _Preservation: new file only_ + - _Requirements: 2.1, 2.4, 2.5_ + + - [x] 4.4 Implement `components/deployed-workflow/details/DeployedWorkflowDetails.tsx` + - `:registrationId` from `useParams`; `useQuery(["getWorkflowRegistration", registrationId], ...)` with `refetchInterval: shouldPoll(data.executions) ? EXECUTION_POLL_INTERVAL_MS : false` (pattern per `ApplicationHealthOverview`/`RefreshDisplay`) + - Header: identity, status, `registeredAt`; invalid registrations show an alert with `invalidReason` and NO trigger control (2.4) + - "Run workflow" `Button` rendered only when `canTrigger(registration)`, wired to `useMutation(triggerWorkflowRegistration)`; on success invalidate the detail query so the new `pending` execution appears and polling starts (2.2) + - On trigger failure (including backend 409 for registrations that became invalid) show the backend `detail` message via the existing `AppLayoutContext` flashbar `addError` — surface, don't mask, the backend rejection (3.4) + - Executions table in `sortExecutions` order: execution id, status indicator, started/finished timestamps; failed rows show `failingNodeId` and `error` via `executionFailureDetails` (2.3); empty slot when no executions exist + - _Bug_Condition: isBugCondition(input) from design — no page shows registration details, trigger control, or executions_ + - _Expected_Behavior: Property 1 from design — trigger iff registered, invalidReason for invalid, execution history with failure details_ + - _Preservation: Property 2 from design — backend 409 behavior reflected, not duplicated or bypassed_ + - _Requirements: 2.2, 2.3, 2.4, 3.4_ + + - [x] 4.5 Wire route subtree in `App.tsx` and nav link in `SideNav.tsx` (additive only) + - `App.tsx`: add the new top-level `deployed-workflows` route subtree exactly as specified in the design (index → `ListDeployedWorkflows`, `:registrationId` → `DeployedWorkflowDetails`, with breadcrumb handles); NO existing route is edited (3.1) + - `components/layout/SideNav.tsx`: add `{ type: "link", text: "Deployed workflows", href: "/deployed-workflows" }` to the "Configure" section after "Workflows"; NO existing item is edited (3.2) + - If task 3's tests need the route definitions exported for `createMemoryRouter`, do that here as a behavior-neutral export + - Confirm the diff for this sub-task touches ONLY these two files, additively; `config/Interface.tsx`, `api/WorkflowAPI.ts`, `components/workflow/**`, and everything under `src/backend/` remain untouched (3.3) + - _Bug_Condition: isBugCondition(input) from design — no route and no nav entry exist_ + - _Expected_Behavior: Property 1 from design — the pages are routable and discoverable_ + - _Preservation: Property 2 from design — one new route subtree + one new link, nothing else changed_ + - _Requirements: 2.1, 3.1, 3.2, 3.3_ + + - [x] 4.6 Verify bug condition exploration test now passes + - **Property 1: Expected Behavior** - Deployed workflow registrations are visible and runnable from the UI + - **Feature: edge-workflow-run-ux, Property 1: Deployed workflow registrations are visible and runnable from the UI** + - **IMPORTANT**: Re-run the SAME test from task 2 - do NOT write a new test + - The test from task 2 encodes the expected behavior; when it passes, the expected behavior is satisfied + - Run: `CI=true npm test -- --watchAll=false --testPathPattern=deployed-workflow` from `src/frontend` + - **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [x] 4.7 Verify preservation tests still pass + - **Property 2: Preservation** - Existing pages, API calls, and backend behavior unchanged + - **Feature: edge-workflow-run-ux, Property 2: Existing pages, API calls, and backend behavior unchanged** + - **IMPORTANT**: Re-run the SAME tests from task 3 - do NOT write new tests (the only permitted update is registering the one new "Deployed workflows" link as the expected nav delta, per task 3's encoding) + - Run: `CI=true npm test -- --watchAll=false --testPathPattern=legacySurfacePreservation` from `src/frontend` + - Verify no backend diff: `git status --porcelain -- src/backend` is empty (3.3, 3.4) + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - _Requirements: 3.1, 3.2, 3.3, 3.4_ + +- [x] 5. Write fix-checking unit and property tests over the new code + - **Feature: edge-workflow-run-ux, Property 1: Deployed workflow registrations are visible and runnable from the UI** + - Create `src/frontend/src/components/deployed-workflow/presentation.property.test.ts` with fast-check arbitraries over `WorkflowRegistration`/`WorkflowExecution` (random statuses, optional `invalidReason`, nullable timestamps/failure fields): + - For any generated registration: `canTrigger(r) === (r.status === "registered")` (2.2, 2.4) + - For any generated execution list: `sortExecutions` returns a permutation ordered newest-first; `executionFailureDetails(e)` is defined iff `e.status === "failed"` and echoes `failingNodeId`/`error`; `shouldPoll(list)` is true iff some execution is `pending` or `running` (2.3) + - For any generated registration list rendered into `ListDeployedWorkflows` (mocked API), every registration's workflowId/version/status appears and a trigger affordance exists iff `canTrigger` (2.1, 2.2, 2.4) + - Create unit tests per the design's Unit Tests section: + - `presentation.test.ts`: `canTrigger` both statuses; indicator mappings for all four execution statuses and both registration statuses; `executionFailureDetails` only for `failed`; `shouldPoll` true/false; `sortExecutions` with null `startedAt` + - `list/ListDeployedWorkflows.test.tsx`: rows for mocked registrations (identity + status), invalid reason shown, empty state on `[]`, error state on API failure (2.1, 2.4, 2.5) + - `details/DeployedWorkflowDetails.test.tsx`: trigger button only for `registered`; invalid alert with reason; trigger click calls the API and refreshes; 409 mutation error shows the backend `detail` message; failed execution row shows `failingNodeId` and `error` (2.2, 2.3, 2.4, 3.4) + - Mock all API calls at the module boundary (`jest.mock("api/WorkflowRegistrationAPI")`); no live backend, no docker + - Run: `CI=true npm test -- --watchAll=false --testPathPattern=deployed-workflow` from `src/frontend` + - **EXPECTED OUTCOME**: All tests PASS + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.4_ + +- [x] 6. Checkpoint - Ensure all tests pass + - Run the full frontend suite: `CI=true npm test -- --watchAll=false` from `src/frontend` + - Run the production build: `npm run build` from `src/frontend` (this only builds the CRA bundle — it does not touch `greengrass-build/`, `custom-build/`, or docker) + - Confirm the overall diff is additive and frontend-only: `git status --porcelain` shows changes only under `src/frontend` (plus this spec directory); `git status --porcelain -- src/backend` is empty + - Ensure all tests pass, ask the user if questions arise + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2, 3.3, 3.4_ + +## Notes + +- **Build-box constraint**: a heavy Greengrass docker build may be running. Do NOT touch `greengrass-build/`, `custom-build/`, or repo-root `src/backend` Docker artifacts, and do NOT run docker commands in any task. All work here is confined to `src/frontend` and does not conflict. +- Frontend tests: Jest via `react-scripts test` (CRA 5), React Testing Library (already in `package.json`), fast-check (added in task 1, v3 line for TypeScript 4.9 compatibility). Run with `CI=true npm test -- --watchAll=false` (or `npx react-scripts test --watchAll=false`) from `src/frontend`; scope with `--testPathPattern`. +- The exploration test (task 2) is expected to FAIL on unfixed code — that failure confirms the bug, not a problem to fix at that stage. +- Requirement numbers reference `bugfix.md`: 1.x = current defective behavior, 2.x = expected behavior, 3.x = unchanged behavior (regression prevention). Properties 1–2 reference the Correctness Properties in `design.md`. +- Preservation for 3.3/3.4 is primarily structural: no file under `src/backend/` changes, so backend behavior (including the 409 trigger rejection for invalid registrations) is preserved by construction; the git no-diff checks in tasks 4.7 and 6 are the evidence. diff --git a/.kiro/specs/gst-parameter-prepopulation/.config.kiro b/.kiro/specs/gst-parameter-prepopulation/.config.kiro new file mode 100644 index 00000000..65e56777 --- /dev/null +++ b/.kiro/specs/gst-parameter-prepopulation/.config.kiro @@ -0,0 +1 @@ +{"specId": "4ccf9d6e-1d33-4802-9d30-0a7f928c2253", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/gst-parameter-prepopulation/design.md b/.kiro/specs/gst-parameter-prepopulation/design.md new file mode 100644 index 00000000..42a9ecf0 --- /dev/null +++ b/.kiro/specs/gst-parameter-prepopulation/design.md @@ -0,0 +1,388 @@ +# Design Document: GStreamer Parameter Pre-population + +## Overview + +This feature removes the hand-typing from the Node_Designer's Parameters wizard step by capturing each plugin's real GObject property metadata (the `gst-inspect-1.0` view: name, GType, default, flags, ranges, enum values, blurb) from the built binary and pre-populating the parameter declaration list with correctly typed Parameter_Suggestions. + +The pipeline is: **capture at build time → store with the artifact → serve mapped suggestions on demand → merge in the wizard without overwriting user work**. + +- **Capture** happens inside the existing x86_64 CodeBuild plugin build (`dda-plugin-build`): after the artifact is promoted, a new Python GI script loads the freshly built `.so` in the build container's GStreamer runtime and emits an Introspection_Report JSON, uploaded next to the artifact (Requirement 1). Capture is best-effort — a failed introspection never fails a successful build (1.4). +- **Storage** is an S3 object next to the promoted `.so` plus a small status stanza on the Plugin_Record's x86_64 artifact entry, following the existing `s3Key`/`signature` artifact-entry pattern. +- **Serving** is a new `GET /plugins/{id}/versions/{v}/gst-properties` route on the existing `plugin_records.py` Lambda. It loads the report, filters Base_Class_Properties (Requirement 4), applies the pure Type_Mapping (Requirements 2, 3), and returns per-element Parameter_Suggestions in the exact `ParameterDeclaration` wire shape `declaration.ts` and `catalog.custom` already validate. +- **The wizards** share a new scan module (`scan.ts`) with pure merge logic (Requirement 6) and a scan panel used by the Parameters step. The Registration_Wizard auto-scans on reaching the step when the list is empty and offers manual refresh (Requirement 5); the Create_Wizard shows the scan-requires-a-build notice since no Plugin_Artifact exists yet (5.6). Every degraded case keeps the manual flow untouched (Requirement 7). + +### Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Where introspection runs | **At build time in the x86_64 CodeBuild container** (extend `dda-plugin-build` + `Dockerfile.x86_64`), not on-demand in the Fargate simulate sandbox | Evaluated both (see below). Build-time capture gives the wizard instant data (S3 read, no cold start), reuses the container that just built the `.so` with a matching GStreamer 1.20 runtime, adds zero new infrastructure, and naturally versions the report with the artifact. The Fargate sandbox route would cost a 1–2 minute task cold start *per wizard visit*, a new state-machine/Lambda orchestration path, and per-scan Fargate spend — bad UX for a step the user reaches interactively | +| Introspection arch | x86_64 only | GObject property declarations are made in element class-init code and are architecture-independent; the x86_64 build is the gating artifact everywhere else in the designer (simulator, registration guard), so it is the single source of truth. Per-arch capture would add JetPack GI tooling to four more images for no informational gain | +| Introspection mechanism | A JSON-emitting **Python GI script** (`dda-gst-introspect`) shipped in the build image, run with `GST_PLUGIN_PATH` pointed at the build output; not text-parsing `gst-inspect-1.0` output | `Gst.Registry` + `GObject.list_properties()` yields structured pspec data (owner type, ranges, enum values) directly; parsing `gst-inspect-1.0`'s human-oriented text is fragile across versions. Requires adding `python3-gi` + `gir1.2-gstreamer-1.0` to `Dockerfile.x86_64` only | +| Report storage | S3 object `{artifact}.gstinspect.json` next to the promoted `.so`; artifact entry gains `gstIntrospection: {status, s3Key?, message?, gstVersion?, capturedAt}` | Follows the artifact-adjacent `.sig` pattern; keeps the Plugin_Record item small; the serving Lambda already has read access to the Plugin_Library custom prefix | +| Where Type_Mapping runs | Server-side pure module `gst_properties.py` (Lambda), returning ready-to-use `ParameterDeclaration` wire shapes | One implementation validated by hypothesis property tests against the same rules `catalog.custom` enforces; the frontend only converts wire shape → form rows and merges, keeping its logic trivially fast-check-testable | +| Base-class filtering rule | A property is a Base_Class_Property iff its pspec owner GType differs from the element's own GType (recorded at capture time as `owner` per property; filtering decided at serve time) | `g_param_spec` ownership is the ground truth GStreamer itself uses; overridden properties get the subclass as owner, which satisfies Requirement 4.2 automatically. Recording the raw owner (not a boolean only) keeps the report re-interpretable if the rule evolves | +| Required heuristic | Required ⇔ no usable default: string default `NULL`/empty, or default unconvertible to the mapped paramType; everything else optional with the default carried | GObject properties always technically have defaults, so "has a meaningful default" is the only reliable signal (Requirement 3); the user can flip the flag — pre-population is an aid, not a lock (3.3) | +| Merge semantics | Pure function over form rows: existing rows always win, new names append, name collisions reported as `alreadyDeclared` | "No silent overwrite" (Requirement 6) reduced to a total, order-preserving, idempotent merge that is easy to property-test | + +### Evaluated Alternative: On-demand Introspection in the Plugin_Simulator Sandbox + +The Fargate simulate sandbox (`HARNESS_MODE=simulate`, `node-designer-stack.ts`) already stages a plugin's x86_64 `.so` and has GStreamer + Python GI, so a `HARNESS_MODE=introspect` mode was considered: + +| | Build-time CodeBuild capture (chosen) | On-demand Fargate sandbox | +|---|---|---| +| Wizard latency | ~0 (S3 read of stored report) | 1–2 min Fargate cold start per scan | +| New infrastructure | None (script + package in one Dockerfile) | New state-machine branch or ECS RunTask path, run tracking, polling API | +| Cost | Seconds inside an already-running build | A Fargate task per scan | +| Freshness | Report always matches the exact artifact bytes just built | Same artifact, no freshness gain | +| Existing-plugin backfill | Old builds lack reports until rebuilt (handled as scan-unavailable, Requirement 7.4) | Could introspect old artifacts on demand | + +The only advantage of the sandbox route is backfilling pre-existing builds, which Requirement 7.4 explicitly handles as a degraded case (rebuild to get a report). Chosen: build-time capture. + +## Architecture + +```mermaid +graph TB + subgraph "x86_64 CodeBuild (dda-plugin-build)" + BUILD[meson/autotools build] --> PROMOTE[promote .so to Plugin_Library] + PROMOTE --> INTRO[dda-gst-introspect
Python GI, GST_PLUGIN_PATH=build output] + INTRO -->|best effort| RPT[upload {plugin}.so.gstinspect.json
next to the artifact] + end + RPT --> S3[(Portal S3
Plugin_Library custom prefix)] + EVT[EventBridge build result] --> PB[plugin_builds.py
SUCCEEDED path] + PB -->|record gstIntrospection stanza
on the x86_64 artifact entry| DDB[(PluginRecords)] + PB -.->|validates report exists,
size + JSON shape| S3 + + subgraph "Serving" + API[GET /plugins/id/versions/v/gst-properties
plugin_records.py] --> DDB + API --> S3 + API --> MAP[gst_properties.py
pure: filter base-class,
Type_Mapping, required heuristic] + end + + subgraph "Frontend Parameters_Step" + RW[RegistrationWizard] --> PANEL[ParameterScanPanel + useParameterScan] + CW[CreateWizard] --> PANEL + PANEL --> APIC[nodeDesignerApi.getGstProperties] + APIC --> API + PANEL --> MERGE[scan.ts pure merge:
existing rows win, new append] + end +``` + +Flow summary: + +1. `dda-plugin-build` (x86_64 project only) runs `dda-gst-introspect ` after artifact promotion. The script writes `report.json`; the build script uploads it as `{PLUGIN_NAME}.so.gstinspect.json` under the same Plugin_Library custom prefix. Any introspection failure logs a warning and uploads a `{"status":"failed",...}` report — the build result is untouched (1.4). +2. `plugin_builds.py`'s SUCCEEDED handler (the same code path that re-checksums and re-signs the promoted artifact) heads the report object, parses and bounds-checks it, and records `gstIntrospection` on the x86_64 artifact entry: `{status: "captured", s3Key, gstVersion, capturedAt}` or `{status: "failed"|"missing", message}`. +3. `GET /plugins/{id}/versions/{v}/gst-properties` (node-designer read permission, same RBAC/404 conventions as the other plugin routes) resolves the artifact entry and either returns `{available: false, reason}` — distinguishing `no_x86_64_build` / `introspection_failed` / `not_captured` (1.6, 7.4, 8.3) — or loads the report from S3 and returns it with derived suggestions per element: base-class-filtered (4.1), type-mapped (2.x), required-classified (3.x), plus the skipped list with reasons (2.5). +4. The wizards' Parameters step renders `ParameterScanPanel`. The Registration_Wizard fetches on step entry; if the report is available and the parameter list is empty it merges automatically (5.1); a "Scan plugin properties" button re-runs on demand (5.2); a factory selector appears when the report has multiple elements, defaulting to the wizard's element factory (5.4). The Create_Wizard passes no plugin context, so the panel renders the static "scanning requires a built plugin" notice (5.6). All scan states are additive UI — the Add/edit/remove controls and step navigation never depend on scan state (5.5, 7.x). + +## Components and Interfaces + +### 1. `plugin-build-images/dda-gst-introspect` (new script, shipped in `Dockerfile.x86_64`) + +Python 3 GI script; stdout is exactly one Introspection_Report JSON document. + +``` +usage: dda-gst-introspect [plugin-file-basename] +``` + +- Sets `GST_PLUGIN_PATH` to ``, calls `Gst.init`, then enumerates element factories whose plugin filename lives under the scan dir (so only the freshly built plugin's elements are reported, never the distro's). +- For each factory: `factory.load()`, create the element (`Gst.ElementFactory.make`), and for every `GObject.list_properties()` pspec record: + `name`, `gtype` (`pspec.value_type.name`, e.g. `gint`, `gchararray`, or the GEnum type name), `owner` (`pspec.owner_type.name`), `writable` (`GObject.ParamFlags.WRITABLE`), `blurb`, `default` (from `pspec.default_value`, JSON-scalar or null), `min`/`max` for ranged numeric pspecs, and `enumValues: [{value, nick}]` for GEnum pspecs. +- Elements that cannot be instantiated are recorded with `instantiationError` and an empty property list; a scan dir registering no factories yields `status: "failed"` with a diagnostic (1.4). +- Never exits non-zero for content problems: content failures are encoded in the document so the calling build script stays a dumb pipe. + +`Dockerfile.x86_64` additions: `python3-gi`, `gir1.2-gstreamer-1.0` (runtime GI bindings; the GStreamer runtime libraries are already present via the dev packages). + +### 2. `dda-plugin-build` (extended, x86_64 behavior) + +After the promote step, when `TARGET_ARCH=x86_64`: + +```sh +dda-gst-introspect "$PROMOTE_STAGE_DIR" "$PRIMARY_SO_BASENAME" > report.json || echo '{"status":"failed",...}' > report.json +aws s3 cp report.json "s3://$ARTIFACTS_BUCKET/$PLUGIN_LIBRARY_CUSTOM_PREFIX/$USECASE_ID/x86_64/$PLUGIN_NAME.so.gstinspect.json" +``` + +Both steps are wrapped so no failure propagates to the build exit code (1.4). Non-x86_64 architectures are untouched. + +### 3. `plugin_builds.py` (extended SUCCEEDED path) + +`record_promoted_artifact` (the existing re-checksum/re-sign step) additionally: + +- `get_object` of the report key; enforce a size cap (256 KiB) and `json.loads` + shape check via the pure validator (component 4). +- Writes the artifact-entry stanza: + - parseable report with `status: "captured"` → `gstIntrospection: {status: "captured", s3Key, gstVersion, capturedAt}` + - report with `status: "failed"` → `{status: "failed", message}` + - missing/oversized/malformed object → `{status: "failed", message: }` (8.3 handled at write time too) +- Never alters the build status (1.4). + +### 4. `backend/functions/gst_properties.py` (new pure module + route logic) + +Pure functions (no boto3 at module scope; hypothesis-testable): + +```python +GTYPE_INT = {"gint", "guint", "gint64", "guint64", "glong", "gulong", "guchar"} +GTYPE_FLOAT = {"gfloat", "gdouble"} + +def parse_report(document: Any) -> Report # 8.1, 8.3 — raises ReportError on malformed input +def serialize_report(report: Report) -> dict # 8.1, 8.2 — inverse of parse_report +def is_base_class_property(prop, element_gtype) -> bool # 4.1, 4.2: prop.owner != element_gtype +def map_property(prop) -> Suggestion | Skipped # 2.1–2.6, 3.1, 3.2 +def suggestions_for_element(element) -> {suggestions: [ParameterDeclaration], skipped: [{name, reason}]} +``` + +`map_property` rules (Requirements 2, 3): + +| GType | paramType | constraints | notes | +|---|---|---|---| +| gint, guint, gint64, guint64, glong, gulong, guchar | `int` | `{min, max}` when ranged | default carried as int | +| gfloat, gdouble | `float` | `{min, max}` when ranged | default carried as float | +| gboolean | `bool` | — | default carried as bool | +| gchararray | `string` | — | `NULL`/empty default ⇒ required, no default (3.1) | +| GEnum (`enumValues` present) | `enum` | `{values: [nicks]}` | default carried as the default's nick | +| anything else, or `writable: false` | **skipped** | — | `{name, reason}` in the skipped list (2.5) | + +- `description` = blurb, else `" () property of the plugin element"` (2.4). +- `examples` = `[default]` when a usable default exists, else a type-appropriate synthesized example (`min` for ranged ints/floats, first enum nick, `"value"` for required strings) so every suggestion satisfies `parametersStepErrors` and `catalog.custom` (2.6). +- `required` = true iff no usable default (3.1); otherwise `required: false` and `default` set (3.2). + +Route handler (registered in `plugin_records.py`'s router): + +``` +GET /plugins/{id}/versions/{v}/gst-properties + → 404 (uniform, cross-tenant safe) when record/read-permission missing + → 200 {available: false, reason: "no_x86_64_build" | "not_captured" | "introspection_failed", + message?} (1.6, 7.1, 7.2, 7.4, 8.3) + → 200 {available: true, gstVersion, capturedAt, + elements: [{factory, suggestions: [ParameterDeclaration...], + skipped: [{name, reason}...]}]} (1.5) +``` + +Malformed stored JSON maps to `introspection_failed` (8.3), never a 500. + +### 5. `frontend/src/pages/node-designer/scan.ts` (new pure module) + +```ts +export interface ScanElement { factory: string; suggestions: ParameterDeclaration[]; + skipped: { name: string; reason: string }[]; } +export interface GstPropertiesResponse { available: boolean; reason?: string; message?: string; + elements?: ScanElement[]; } + +/** ParameterDeclaration wire shape -> ParameterForm raw-text rows. */ +export function formFromSuggestion(s: ParameterDeclaration): ParameterForm; + +/** Requirement 6: existing rows unchanged, new names appended, collisions reported. */ +export function mergeSuggestions(existing: ParameterForm[], suggestions: ParameterDeclaration[]): + { parameters: ParameterForm[]; added: string[]; alreadyDeclared: string[] }; + +/** 5.4: pick the element whose factory matches, else the single element, else null (user chooses). */ +export function pickElement(elements: ScanElement[], preferredFactory?: string): ScanElement | null; +``` + +Name matching is exact on the trimmed parameter name (declaration names are launch-safe identifiers already). + +### 6. `frontend/src/pages/node-designer/ParameterScanPanel.tsx` (new component) + wizard wiring + +Props: `{ pluginId?: string; version?: number; preferredFactory?: string; parameters: ParameterForm[]; onMerge(result): void }`. + +- **No plugin context** (Create_Wizard): renders a static info `Alert` — "Property scanning pre-populates this list from the built plugin. This plugin has not been built yet; declare parameters manually, or rescan from the registration wizard after the first successful build." (5.6, 7.1). +- **With plugin context** (Registration_Wizard): on mount fetches `nodeDesignerApi.getGstProperties(pluginId, version)`; + - `available: false` → informational (`no_x86_64_build` / `not_captured`) or error (`introspection_failed` with message) Alert; manual flow untouched (7.1, 7.2, 7.4); + - fetch failure → error Alert, manual flow untouched (7.3); + - available + empty parameter list → auto-merge once (5.1); otherwise wait for the user; + - "Scan plugin properties" button re-runs the merge any time (5.2); + - multi-element reports render a factory `Select`, pre-selected via `pickElement` with the wizard's default element factory (5.4); + - after a merge: outcome summary "Added N parameters from ``; M already declared: …; K skipped: name (reason)…" (5.3, 6.3, 2.5); + - scanned-row provenance: the panel reports `added` names up to the wizard, which tags those rows with a "from scan" badge until the row is edited (6.4). +- The panel is purely additive: it renders above the existing Add-parameter UI, never disables it, and step gating (`parametersStepErrors`) is unchanged (5.5). + +`RegistrationWizard.tsx` embeds the panel in the Parameters step with `plugin.plugin_id`, `plugin.version`, and `defaultElementFactory(plugin.name)`; `CreateWizard.tsx` embeds it without plugin context. + +### 7. `frontend/src/pages/node-designer/api.ts` (extended) + +```ts +getGstProperties(pluginId: string, version: number): Promise +// GET /plugins/{pluginId}/versions/{version}/gst-properties +``` + +## Data Models + +### Introspection_Report (S3 JSON, version 1) + +```json +{ + "reportVersion": 1, + "status": "captured", + "message": null, + "gstVersion": "1.20.3", + "capturedAt": "2026-02-14T12:00:00Z", + "elements": [ + { + "factory": "myblur", + "elementGType": "GstMyBlur", + "instantiationError": null, + "properties": [ + { "name": "radius", "gtype": "gint", "owner": "GstMyBlur", "writable": true, + "blurb": "Blur radius in pixels", "default": 5, "min": 0, "max": 100, + "enumValues": null }, + { "name": "mode", "gtype": "GstMyBlurMode", "owner": "GstMyBlur", "writable": true, + "blurb": "Blur mode", "default": "gaussian", "min": null, "max": null, + "enumValues": [ { "value": 0, "nick": "gaussian" }, { "value": 1, "nick": "box" } ] }, + { "name": "name", "gtype": "gchararray", "owner": "GstObject", "writable": true, + "blurb": "The name of the object", "default": null, "min": null, "max": null, + "enumValues": null } + ] + } + ] +} +``` + +Failed capture: `{"reportVersion": 1, "status": "failed", "message": "", "elements": []}`. + +### Plugin_Record x86_64 artifact-entry addition + +```json +"artifacts": { + "x86_64": { + "buildStatus": "succeeded", "s3Key": "...", "checksum": "...", "signature": "...", + "gstIntrospection": { + "status": "captured", + "s3Key": "workflow-plugins/custom/{usecase}/x86_64/{plugin}.so.gstinspect.json", + "gstVersion": "1.20.3", + "capturedAt": "2026-02-14T12:00:00Z" + } + } +} +``` + +`status` ∈ `captured | failed`; absent stanza = build predates this feature (`not_captured`, 7.4). + +### Parameter_Suggestion (API response entry) + +Exactly the `ParameterDeclaration` wire shape (`declaration.ts` / `catalog.custom`): + +```json +{ "name": "radius", "paramType": "int", "required": false, "default": 5, + "constraints": { "min": 0, "max": 100 }, + "description": "Blur radius in pixels", "examples": [5] } +``` + +### ParameterForm mapping (frontend, `formFromSuggestion`) + +| ParameterDeclaration | ParameterForm (raw text) | +|---|---| +| `name` | `name` | +| `paramType` | `paramType` | +| `required` | `required` | +| `default` (absent → `''`) | `defaultValue` = `String(default)` | +| `description` | `description` | +| `examples[0]` | `example` = `String(examples[0])` | +| `constraints.values` (enum) | `enumValues` = values joined with `', '` | + +Numeric `min`/`max` constraints ride along in the assembled declaration on submit (the form keeps them attached to the row so `buildRegistrationDeclaration` re-emits them); they have no editable UI in this feature. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Introspection report round-trip + +*For any* valid Introspection_Report structure, `parse_report(serialize_report(report))` SHALL produce an equivalent report, and `serialize_report` output SHALL survive a JSON dump/load cycle unchanged. + +**Validates: Requirements 8.1, 8.2** + +### Property 2: Malformed report documents are rejected, not crashed on + +*For any* arbitrary JSON value that does not satisfy the Introspection_Report shape, `parse_report` SHALL raise `ReportError` (and the route SHALL therefore answer with the `introspection_failed` unavailability reason), never an unhandled exception. + +**Validates: Requirements 8.3, 1.6** + +### Property 3: Type mapping is total and correctly typed over writable known GTypes + +*For any* generated GStreamer_Property whose GType is in the known mapping set and which is writable, `map_property` SHALL produce a Parameter_Suggestion whose `paramType` matches the GType class per the mapping table (int/float/bool/string/enum), whose `constraints` carry the property's min/max when the property is ranged and the enum nicks when it is a GEnum, and whose `default`, when present, is convertible to the mapped paramType. + +**Validates: Requirements 2.1, 2.2, 2.3** + +### Property 4: Unknown or non-writable properties are always skipped with a reason + +*For any* generated GStreamer_Property that has an unmapped GType or is not writable, `map_property` SHALL yield a skipped entry carrying the property name and a non-empty reason, and SHALL yield no Parameter_Suggestion. + +**Validates: Requirements 2.5** + +### Property 5: Every suggestion passes declaration validation + +*For any* generated GStreamer_Property that maps to a Parameter_Suggestion, the suggestion SHALL satisfy the declaration validation rules: non-empty name, non-empty description (blurb or synthesized fallback), at least one example value valid for the paramType, non-empty `values` for enum, and `min <= max` when both are present — i.e. `catalog.custom`'s parameter validation accepts it and `parametersStepErrors` reports no error for its form row. + +**Validates: Requirements 2.4, 2.6** + +### Property 6: Required classification follows the usable-default rule + +*For any* generated GStreamer_Property that maps to a Parameter_Suggestion, the suggestion SHALL be required exactly when the property lacks a usable default (string default null/empty, or default not convertible to the mapped paramType), and when optional it SHALL carry the property default as the declaration default. + +**Validates: Requirements 3.1, 3.2** + +### Property 7: Base-class filtering keeps exactly the element's own properties + +*For any* generated element with a mix of properties whose `owner` equals or differs from the element's GType, the derived suggestions SHALL include every writable mappable property owned by the element's own GType (including names that shadow base-class names) and none owned by a different GType. + +**Validates: Requirements 4.1, 4.2** + +### Property 8: Merge never changes existing declarations + +*For any* existing parameter form list and any suggestion list, every entry of `mergeSuggestions(existing, suggestions).parameters` at an index below `existing.length` SHALL deep-equal the corresponding existing entry, in the same order. + +**Validates: Requirements 6.1** + +### Property 9: Merge appends exactly the new names and reports the rest + +*For any* existing parameter form list and any suggestion list, the merged list SHALL equal the existing list plus, in suggestion order, exactly those suggestions whose trimmed name matches no existing trimmed name; `added` SHALL list those appended names; `alreadyDeclared` SHALL list exactly the colliding suggestion names. + +**Validates: Requirements 6.2, 6.3** + +### Property 10: Merge is idempotent + +*For any* existing parameter form list and any suggestion list, merging the same suggestions into an already-merged list SHALL return the list unchanged with an empty `added` set (running the scan twice adds nothing). + +**Validates: Requirements 6.1, 6.2** + +### Property 11: Suggestion-to-form conversion round-trips through form assembly + +*For any* Parameter_Suggestion, converting it with `formFromSuggestion` and assembling it back with `declaration.ts`'s `parameterFromForm` conversion path SHALL reproduce the suggestion's name, paramType, required flag, default, description, first example, and enum values. + +**Validates: Requirements 2.6, 3.3** + +### Property 12: Element picking prefers the wizard's factory + +*For any* non-empty element list and any preferred factory name, `pickElement` SHALL return the element whose factory equals the preferred name when one exists; else the sole element when the list has exactly one; else null. + +**Validates: Requirements 5.4** + +## Error Handling + +| Failure | Where | Behavior | +|---|---|---| +| Introspection script fails / no factories register / element won't instantiate | CodeBuild | `status: "failed"` (or per-element `instantiationError`) report uploaded; build success untouched (1.4) | +| Report upload fails | CodeBuild | Logged warning; build success untouched; `plugin_builds.py` records `{status: "failed", message: "report missing"}` (1.4, 1.6) | +| Report object missing / oversized / malformed JSON | `plugin_builds.py` SUCCEEDED path | Artifact stanza `{status: "failed", message}`; never alters build status | +| Stored report malformed at read time | `gst_properties` route | `{available: false, reason: "introspection_failed"}` — no 500 (8.3) | +| No successful x86_64 artifact | route | `{available: false, reason: "no_x86_64_build"}` (1.6); wizard shows the build-first notice (7.1) | +| Artifact predates feature (no stanza) | route | `{available: false, reason: "not_captured"}` (7.4) | +| RBAC / missing record | route | Uniform 404, matching the existing cross-tenant-safe convention | +| Scan API request fails | wizard | Error Alert; manual flow and navigation unchanged (7.3, 5.5) | +| Introspection failed status | wizard | Failure Alert with diagnostic; manual flow unchanged (7.2) | + +## Testing Strategy + +Both test layers follow the repo's existing conventions: **backend** pytest + hypothesis under `edge-cv-portal/backend/tests/` (moto for S3/DynamoDB where handlers are exercised), **frontend** vitest + fast-check under the existing node-designer test layout. + +**Property-based tests** (one test per Correctness Property, ≥ 100 iterations, tagged `Feature: gst-parameter-prepopulation, Property {n}: {title}`): + +- Backend (hypothesis): Properties 1–7 against `gst_properties.py` pure functions, with generators for reports, GStreamer_Property records (random GTypes drawn from mapped + unmapped sets, optional ranges, enum value lists, null/empty/valued defaults, owner GTypes), and arbitrary JSON for Property 2. Property 5 cross-checks against `workflow_core.catalog.custom`'s real parameter validator. +- Frontend (fast-check): Properties 8–12 against `scan.ts` (and `declaration.ts` conversion for Property 11), with arbitraries for `ParameterForm` rows and `ParameterDeclaration` suggestions. + +**Unit / example tests**: + +- `dda-gst-introspect`: exercised in the test-sandbox container image (which has GStreamer + GI) against a stock element (e.g. `videoflip`: GEnum `method`, base-class `name`/`qos`) asserting report shape, owner recording, enum capture — integration-style, 1–2 examples. +- `plugin_builds.py` stanza recording: examples for captured / failed / missing / oversized report objects (moto S3). +- Route handler: examples for each unavailability reason, the available path, RBAC 404s (matching `test_plugin_simulator.py` conventions). +- `ParameterScanPanel` + wizard wiring (vitest, jsdom): auto-scan on empty list, no auto-scan when rows exist, manual rescan, factory selector on multi-element reports, degraded notices for each reason, "from scan" badge cleared on edit, Create_Wizard static notice, navigation never blocked by scan state. + +**Integration tests**: extend the existing sandbox/container integration suite with one end-to-end capture example (build-image script → report JSON → parse_report). No property tests for CodeBuild/S3 wiring — infrastructure behavior does not vary meaningfully with input. diff --git a/.kiro/specs/gst-parameter-prepopulation/requirements.md b/.kiro/specs/gst-parameter-prepopulation/requirements.md new file mode 100644 index 00000000..647daeaa --- /dev/null +++ b/.kiro/specs/gst-parameter-prepopulation/requirements.md @@ -0,0 +1,115 @@ +# Requirements Document + +## Introduction + +This feature extends the Custom Node Designer (spec: custom-node-designer) so that the Parameters wizard step no longer starts as an empty hand-typed list. Every GStreamer element declares its configurable properties as standard GObject property metadata (what `gst-inspect-1.0` shows: property name, GType, default value, readable/writable flags, numeric ranges, enum values, and a description). The Node_Designer captures that metadata from the plugin's actual built binary and uses it to pre-populate the parameter declaration list in both the Create wizard and the Registration wizard with correct parameter names, data types, defaults, constraints, and descriptions. Pre-population is an aid, not a lock: users can edit, remove, or add parameters exactly as before, and the existing manual flow keeps working whenever no introspection data is available (no successful x86_64 build yet, or introspection failed). + +## Glossary + +- **Node_Designer**: The Portal capability (spec: custom-node-designer) for creating, importing, building, simulating, and registering Custom_Node_Types. +- **Parameters_Step**: The parameter-declaration step of the Node_Designer wizards — the Create wizard (`CreateWizard.tsx`, declaring parameters before scaffold generation) and the Registration wizard (`RegistrationWizard.tsx`, declaring parameters of the Custom_Node_Type registration). +- **Registration_Wizard**: The Node_Designer wizard that registers a Custom_Node_Type for a built Plugin_Record version. +- **Create_Wizard**: The Node_Designer wizard that collects a declaration and generates a Plugin_Scaffold; no Plugin_Record or Plugin_Artifact exists while it runs. +- **Plugin_Record**: The stored metadata for a created, generated, or imported plugin, including per-architecture Plugin_Artifact entries (spec: custom-node-designer). +- **Plugin_Artifact**: A built plugin binary (`.so`) for one Target_Architecture stored in the Plugin_Library (spec: custom-node-designer). +- **Plugin_Build_Service**: The per-Target_Architecture CodeBuild-based build pipeline (`plugin_builds.py`, `dda-plugin-build`, `plugin-build-images/`) that compiles plugin source into Plugin_Artifacts. +- **GStreamer_Property**: One GObject property declared by a GStreamer element class: property name, GType name, default value, readable/writable/controllable flags, numeric range (minimum/maximum) where applicable, enum values with nicks where applicable, and blurb (description). +- **Property_Introspection**: The act of loading a built Plugin_Artifact into a GStreamer runtime and reading each registered element's GStreamer_Property metadata (the `gst-inspect-1.0` equivalent via `Gst.ElementFactory` / GObject class property listing). +- **Introspection_Report**: The stored, structured result of Property_Introspection for one Plugin_Artifact: per element factory, the list of GStreamer_Property entries, plus the GStreamer version and a capture status. +- **Base_Class_Property**: A GStreamer_Property inherited from GStreamer base classes rather than declared by the element's own class (for example `name` and `parent` from GstObject, `qos` from GstVideoFilter/GstBaseTransform) — noise for parameter declaration. +- **Parameter_Suggestion**: One pre-populated parameter declaration derived from a GStreamer_Property: the declaration wire shape (name, paramType, required, default, constraints, description, examples) used by `declaration.ts` and validated by the Custom_Node_Type registration backend. +- **Type_Mapping**: The deterministic conversion from a GStreamer_Property's GType metadata to a Parameter_Suggestion's paramType and constraints (for example gint→int with min/max, GEnum→enum with allowed values). +- **Parameter_Scan**: The Parameters_Step action that fetches the Introspection_Report for the wizard's Plugin_Record version, applies the Type_Mapping, and merges the resulting Parameter_Suggestions into the parameter list. +- **Merge**: Combining Parameter_Suggestions with parameters already present in the wizard form without overwriting any user-entered declaration. + +## Requirements + +### Requirement 1: Capture Property Metadata from Built Plugins + +**User Story:** As a computer vision engineer, I want the portal to read my plugin's actual GStreamer element properties from the built binary, so that parameter pre-population reflects what the plugin really declares instead of what I remember to type. + +#### Acceptance Criteria + +1. WHEN the Plugin_Build_Service completes a successful x86_64 build of a Plugin_Artifact, THE Plugin_Build_Service SHALL perform Property_Introspection on the built Plugin_Artifact and SHALL store the resulting Introspection_Report with the Plugin_Record version's x86_64 artifact entry. +2. THE Introspection_Report SHALL record, for each element factory the Plugin_Artifact registers, every GStreamer_Property with property name, GType name, default value, writability flag, numeric minimum and maximum where the GType is a ranged numeric type, enum values with their nicks where the GType is a GEnum type, and the property blurb. +3. THE Introspection_Report SHALL record which GStreamer_Property entries are Base_Class_Properties, determined by the GObject class that declared the property. +4. IF Property_Introspection fails or produces no element factories, THEN THE Plugin_Build_Service SHALL store an Introspection_Report with a failure status and diagnostic message and SHALL preserve the build's success status. +5. WHEN a Plugin_Record version's Introspection_Report is requested through the Node_Designer API by a user with node-designer read access, THE Node_Designer SHALL return the stored Introspection_Report together with the derived Parameter_Suggestions. +6. IF a Plugin_Record version has no successful x86_64 Plugin_Artifact or no stored Introspection_Report, THEN THE Node_Designer API SHALL respond with a machine-readable unavailability reason distinguishing "no successful x86_64 build" from "introspection failed" from "introspection not captured". + +### Requirement 2: GType to Parameter Type Mapping + +**User Story:** As a computer vision engineer, I want each scanned property converted to the correct parameter declaration type with its default, constraints, and description, so that the pre-populated declarations pass validation and match the plugin's real contract. + +#### Acceptance Criteria + +1. THE Type_Mapping SHALL convert integer GTypes (gint, guint, gint64, guint64, glong, gulong, guchar) to paramType `int`, floating-point GTypes (gfloat, gdouble) to paramType `float`, gboolean to paramType `bool`, gchararray to paramType `string`, and GEnum GTypes to paramType `enum` with the enum's value nicks as the allowed values constraint. +2. WHEN a GStreamer_Property declares a numeric range, THE Type_Mapping SHALL record the minimum and maximum in the Parameter_Suggestion's constraints. +3. WHEN a GStreamer_Property declares a default value, THE Type_Mapping SHALL carry the default value into the Parameter_Suggestion converted to the mapped paramType, and SHALL use the default value as the Parameter_Suggestion's example value. +4. WHEN a GStreamer_Property declares a blurb, THE Type_Mapping SHALL use the blurb as the Parameter_Suggestion's description; IF the blurb is empty, THEN THE Type_Mapping SHALL supply a description naming the property and its GType. +5. IF a GStreamer_Property's GType has no defined conversion (for example GstCaps, GstStructure, object, or boxed types) or the property is not writable, THEN THE Type_Mapping SHALL exclude the property from the Parameter_Suggestions and SHALL record it in the Parameter_Scan result as skipped with the reason. +6. THE Type_Mapping SHALL produce Parameter_Suggestions that satisfy the Parameters_Step validation rules for their paramType (non-empty name, non-empty description, a valid example value, and non-empty allowed values for enum). + +### Requirement 3: Required versus Optional Classification + +**User Story:** As a computer vision engineer, I want scanned parameters sensibly classified as required or optional, so that the declaration guides workflow authors without me re-deriving which properties must be set. + +#### Acceptance Criteria + +1. THE Type_Mapping SHALL classify a Parameter_Suggestion as required when the GStreamer_Property has no usable default for its mapped paramType (a string property whose default is NULL or empty, or a property whose default value cannot be converted to the mapped paramType). +2. THE Type_Mapping SHALL classify every other Parameter_Suggestion as optional with the property's default value carried as the declaration default. +3. WHEN Parameter_Suggestions are merged into the Parameters_Step, THE Parameters_Step SHALL leave the required flag, and every other field of each pre-populated parameter, editable by the user exactly like a manually added parameter. + +### Requirement 4: Filter Inherited Base-Class Properties + +**User Story:** As a computer vision engineer, I want only the plugin's own properties offered as parameters, so that the list is not polluted with GStreamer plumbing like `name`, `parent`, or `qos`. + +#### Acceptance Criteria + +1. WHEN deriving Parameter_Suggestions from an Introspection_Report, THE Node_Designer SHALL exclude every Base_Class_Property. +2. WHEN an element's own class re-declares (overrides) a property name that also exists on a base class, THE Node_Designer SHALL treat the property as the element's own and include it. + +### Requirement 5: Parameter Scan in the Wizards + +**User Story:** As a computer vision engineer, I want the Parameters step pre-populated automatically when scan data exists, and a manual refresh control, so that I start from the plugin's real parameters with minimal clicking. + +#### Acceptance Criteria + +1. WHEN a user reaches the Parameters_Step of the Registration_Wizard and the wizard's Plugin_Record version has an available Introspection_Report and the form's parameter list is empty, THE Parameters_Step SHALL automatically run the Parameter_Scan and populate the list with the Parameter_Suggestions. +2. THE Parameters_Step SHALL provide a manual scan control that runs the Parameter_Scan on demand. +3. WHEN a Parameter_Scan completes, THE Parameters_Step SHALL display the scan outcome: the number of parameters added, the element factory scanned, and any skipped properties with their reasons. +4. WHERE a Plugin_Artifact registers more than one element factory, THE Parameters_Step SHALL let the user choose which element factory's Parameter_Suggestions to merge, defaulting to the wizard's element factory when one matches. +5. WHILE a Parameter_Scan is in progress, THE Parameters_Step SHALL keep the manual parameter controls (add, edit, remove) usable and SHALL never block step navigation on the scan. +6. WHEN a user reaches the Parameters_Step of the Create_Wizard, THE Parameters_Step SHALL display that scanning requires a built plugin and SHALL keep the manual parameter flow unchanged, since no Plugin_Artifact exists during creation. + +### Requirement 6: Merge Without Silent Overwrite + +**User Story:** As a computer vision engineer, I want scan results merged with parameters I already declared without losing my edits, so that refreshing the scan never destroys my work. + +#### Acceptance Criteria + +1. WHEN a Parameter_Scan merges Parameter_Suggestions into a parameter list, THE Merge SHALL keep every existing parameter declaration unchanged. +2. WHEN a Parameter_Suggestion's name does not match any existing parameter name, THE Merge SHALL append the Parameter_Suggestion to the list. +3. WHEN a Parameter_Suggestion's name matches an existing parameter name, THE Merge SHALL keep the existing declaration and SHALL report the name in the scan outcome as already declared. +4. WHEN a Parameter_Scan adds parameters, THE Parameters_Step SHALL identify which entries came from the scan until the user edits them. + +### Requirement 7: Degraded and Failure Behavior + +**User Story:** As a computer vision engineer, I want the wizard to work exactly as before when scanning is impossible or fails, so that pre-population never becomes a gate on declaring parameters. + +#### Acceptance Criteria + +1. IF the wizard's Plugin_Record version has no successful x86_64 Plugin_Artifact, THEN THE Parameters_Step SHALL display an informational notice that scanning requires a successful x86_64 build and SHALL keep the manual parameter flow unchanged. +2. IF the stored Introspection_Report has a failure status, THEN THE Parameters_Step SHALL display the introspection failure with its diagnostic message and SHALL keep the manual parameter flow unchanged. +3. IF the Parameter_Scan API request fails, THEN THE Parameters_Step SHALL display the error and SHALL keep the manual parameter flow unchanged. +4. WHEN a Plugin_Record version predates Property_Introspection (a successful build with no stored Introspection_Report), THE Parameters_Step SHALL treat it as scan-unavailable per the unavailability reason and SHALL keep the manual parameter flow unchanged. + +### Requirement 8: Introspection Report Serialization + +**User Story:** As a developer, I want the Introspection_Report stored and served in a stable JSON shape, so that the build pipeline, the API, and both wizards agree on the data. + +#### Acceptance Criteria + +1. THE Node_Designer SHALL serialize Introspection_Reports to JSON for storage and SHALL parse stored JSON back into the Introspection_Report structure. +2. FOR ALL valid Introspection_Reports, serializing then parsing SHALL produce an equivalent Introspection_Report (round-trip property). +3. IF a stored Introspection_Report document is malformed, THEN THE Node_Designer API SHALL respond with the "introspection failed" unavailability reason instead of an internal error. diff --git a/.kiro/specs/gst-parameter-prepopulation/tasks.md b/.kiro/specs/gst-parameter-prepopulation/tasks.md new file mode 100644 index 00000000..58c61f8d --- /dev/null +++ b/.kiro/specs/gst-parameter-prepopulation/tasks.md @@ -0,0 +1,185 @@ +# Implementation Plan: GStreamer Parameter Pre-population + +## Overview + +Implementation proceeds bottom-up along the data flow: the backend pure mapping module first (report parsing, Type_Mapping, base-class filtering — the core logic everything consumes), then the serving API route, then build-time capture (introspection script, build image, `dda-plugin-build`, `plugin_builds.py` recording), then the frontend scan module and the wizard wiring. Property tests sit directly beside the code they validate. Backend: Python (pytest + hypothesis, `edge-cv-portal/backend/tests/`). Frontend: TypeScript (vitest + fast-check, existing node-designer test layout). + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. Backend pure module gst_properties.py] --> T2[2. Checkpoint] + T2 --> T3[3. Serving API route] + T2 --> T4[4. Build-time capture] + T3 --> T5[5. Checkpoint] + T4 --> T5 + T5 --> T6[6. Frontend scan module] + T6 --> T7[7. Scan panel + wizard wiring] + T7 --> T8[8. Final checkpoint] +``` + +```json +{ + "waves": [ + { "wave": 1, "tasks": ["1"], "description": "Backend pure module: report parsing/serialization, Type_Mapping, base-class filtering, and their property tests" }, + { "wave": 2, "tasks": ["2"], "description": "Checkpoint: all backend pure-module tests pass" }, + { "wave": 3, "tasks": ["3", "4"], "description": "Independent consumers of the pure module: the serving API route and the build-time capture pipeline (can proceed in parallel)" }, + { "wave": 4, "tasks": ["5"], "description": "Checkpoint: backend route and capture tests pass" }, + { "wave": 5, "tasks": ["6"], "description": "Frontend scan module (types, API client, pure merge/convert/pick functions) and its property tests" }, + { "wave": 6, "tasks": ["7"], "description": "ParameterScanPanel and wiring into both wizards, with component tests" }, + { "wave": 7, "tasks": ["8"], "description": "Final checkpoint: full test suite passes" } + ] +} +``` + +## Tasks + +- [x] 1. Implement the backend pure module `gst_properties.py` + - [x] 1.1 Create `edge-cv-portal/backend/functions/gst_properties.py` with report parsing and serialization + - Define the Introspection_Report structure (reportVersion, status, message, gstVersion, capturedAt, elements with factory/elementGType/instantiationError/properties) + - Implement `parse_report(document)` raising a typed `ReportError` on any non-conforming input, and `serialize_report(report)` as its inverse + - _Requirements: 8.1, 8.3_ + + - [x] 1.2 Write property test for report round-trip + - **Property 1: Introspection report round-trip** + - hypothesis generators for valid reports; assert `parse_report(serialize_report(r))` equivalence through a real `json.dumps`/`json.loads` cycle; ≥100 iterations + - **Validates: Requirements 8.1, 8.2** + + - [x] 1.3 Write property test for malformed report rejection + - **Property 2: Malformed report documents are rejected, not crashed on** + - Arbitrary-JSON generators plus mutations of valid reports; assert `parse_report` raises `ReportError` and nothing else + - **Validates: Requirements 8.3, 1.6** + + - [x] 1.4 Implement `map_property` with the Type_Mapping and required heuristic + - GType mapping table (gint/guint/gint64/guint64/glong/gulong/guchar→int, gfloat/gdouble→float, gboolean→bool, gchararray→string, GEnum→enum with nick values), min/max constraints for ranged numerics, default conversion, blurb-or-synthesized description, example synthesis + - Required ⇔ no usable default (NULL/empty string default, or default unconvertible to the mapped paramType); optional carries the default + - Unmapped GTypes and non-writable properties yield skipped entries `{name, reason}` + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.1, 3.2_ + + - [x] 1.5 Write property test for the type mapping + - **Property 3: Type mapping is total and correctly typed over writable known GTypes** + - Generators over mapped GTypes with random ranges, enum value lists, and defaults; assert paramType, constraints, and default conversion per the mapping table + - **Validates: Requirements 2.1, 2.2, 2.3** + + - [x] 1.6 Write property test for skipped properties + - **Property 4: Unknown or non-writable properties are always skipped with a reason** + - **Validates: Requirements 2.5** + + - [x] 1.7 Write property test for required classification + - **Property 6: Required classification follows the usable-default rule** + - Generators including null/empty/whitespace string defaults and wrong-typed defaults + - **Validates: Requirements 3.1, 3.2** + + - [x] 1.8 Implement `suggestions_for_element` with Base_Class_Property filtering + - Filter properties whose `owner` differs from the element's own GType (owner equality keeps overridden/shadowed names); map the remainder; return `{suggestions, skipped}` in the `ParameterDeclaration` wire shape + - _Requirements: 4.1, 4.2, 1.5_ + + - [x] 1.9 Write property test validating every suggestion + - **Property 5: Every suggestion passes declaration validation** + - Cross-check each produced suggestion against `workflow_core.catalog.custom`'s real parameter validation (non-empty name/description, valid example, enum values, min ≤ max) + - **Validates: Requirements 2.4, 2.6** + + - [x] 1.10 Write property test for base-class filtering + - **Property 7: Base-class filtering keeps exactly the element's own properties** + - Generators mixing own-owner and base-owner properties including shadowed names + - **Validates: Requirements 4.1, 4.2** + +- [x] 2. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 3. Implement the serving API route + - [x] 3.1 Add `GET /plugins/{id}/versions/{v}/gst-properties` to `plugin_records.py` + - Resolve the Plugin_Record version with the existing RBAC and uniform-404 conventions (node-designer read) + - Classify unavailability from the x86_64 artifact entry: `no_x86_64_build` (no succeeded artifact), `not_captured` (no `gstIntrospection` stanza), `introspection_failed` (stanza failed, or stored report missing/malformed at read time) + - Available path: load the report from S3, `parse_report`, derive per-element `{factory, suggestions, skipped}` via `suggestions_for_element`, return with gstVersion/capturedAt + - _Requirements: 1.5, 1.6, 7.4, 8.3_ + + - [x] 3.2 Write unit tests for the route + - Examples for each unavailability reason (no build, absent stanza, failed stanza, malformed stored JSON), the available path, RBAC denial, and cross-tenant 404 — following `test_plugin_simulator.py` conventions with moto + - _Requirements: 1.5, 1.6, 7.4, 8.3_ + +- [x] 4. Implement build-time property capture + - [x] 4.1 Create `edge-cv-portal/plugin-build-images/dda-gst-introspect` + - Python GI script: `GST_PLUGIN_PATH` to the scan dir, enumerate only factories from plugins under that dir, `factory.load()` + `Gst.ElementFactory.make`, record per property name/gtype/owner/writable/blurb/default/min/max/enumValues; per-element `instantiationError`; content failures encoded as `status: "failed"` documents, never a non-zero exit + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + + - [x] 4.2 Add GI runtime packages to `Dockerfile.x86_64` and ship the script + - `python3-gi`, `gir1.2-gstreamer-1.0`; COPY `dda-gst-introspect` to `/usr/local/bin/` + - _Requirements: 1.1_ + + - [x] 4.3 Extend `dda-plugin-build` with the best-effort introspection step (x86_64 only) + - After promotion: run `dda-gst-introspect` against the built artifacts, upload the report as `{PLUGIN_NAME}.so.gstinspect.json` next to the promoted `.so`; wrap both so no failure changes the build exit code + - _Requirements: 1.1, 1.4_ + + - [x] 4.4 Record the `gstIntrospection` stanza in `plugin_builds.py` + - In the SUCCEEDED path (`record_promoted_artifact`): fetch the report key, enforce the 256 KiB cap, validate via `parse_report`; write `{status: "captured", s3Key, gstVersion, capturedAt}` or `{status: "failed", message}` on the x86_64 artifact entry; never alter build status + - _Requirements: 1.1, 1.4, 1.6_ + + - [x] 4.5 Write unit tests for stanza recording + - Examples with moto S3: captured report, `status: failed` report, missing object, oversized object, malformed JSON — assert stanza content and untouched build status + - _Requirements: 1.4, 1.6_ + + - [x] 4.6 Write container integration test for the introspection script + - In the existing sandbox/container integration suite: run `dda-gst-introspect` against a stock element with a GEnum property (e.g. `videoflip`); assert report shape, enum capture, blurbs, and base-class owners (`name`/`parent` owned by GstObject); feed the output through `parse_report` + - _Requirements: 1.2, 1.3_ + +- [x] 5. Checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Implement the frontend scan module + - [x] 6.1 Add scan types and the API client method + - `GstPropertiesResponse` / `ScanElement` types; `nodeDesignerApi.getGstProperties(pluginId, version)` calling `GET /plugins/{id}/versions/{v}/gst-properties` + - _Requirements: 1.5, 1.6_ + + - [x] 6.2 Create `edge-cv-portal/frontend/src/pages/node-designer/scan.ts` pure functions + - `formFromSuggestion` (wire shape → `ParameterForm` raw-text rows, min/max constraints retained on the row for re-emission at submit), `mergeSuggestions` (existing rows unchanged, new names appended in order, collisions reported as `alreadyDeclared`, appended names as `added`), `pickElement` (preferred factory match, else sole element, else null) + - _Requirements: 6.1, 6.2, 6.3, 5.4, 3.3_ + + - [x] 6.3 Write property test for merge preservation + - **Property 8: Merge never changes existing declarations** + - fast-check arbitraries for `ParameterForm` lists and suggestion lists; ≥100 runs + - **Validates: Requirements 6.1** + + - [x] 6.4 Write property test for exact merge characterization + - **Property 9: Merge appends exactly the new names and reports the rest** + - **Validates: Requirements 6.2, 6.3** + + - [x] 6.5 Write property test for merge idempotence + - **Property 10: Merge is idempotent** + - **Validates: Requirements 6.1, 6.2** + + - [x] 6.6 Write property test for the suggestion-form round trip + - **Property 11: Suggestion-to-form conversion round-trips through form assembly** + - `formFromSuggestion` then `declaration.ts`'s parameter assembly path reproduces name, paramType, required, default, description, first example, and enum values + - **Validates: Requirements 2.6, 3.3** + + - [x] 6.7 Write property test for element picking + - **Property 12: Element picking prefers the wizard's factory** + - **Validates: Requirements 5.4** + +- [x] 7. Implement the scan panel and wire both wizards + - [x] 7.1 Create `ParameterScanPanel.tsx` + - Cloudscape panel rendered above the existing parameter controls: no-plugin-context static notice; fetch on mount with plugin context; unavailability Alerts per reason (`no_x86_64_build`, `not_captured` informational; `introspection_failed` and fetch errors as error Alerts with message); auto-merge once when available and the list is empty; "Scan plugin properties" button; factory `Select` for multi-element reports pre-picked via `pickElement`; outcome summary (added count, factory, alreadyDeclared, skipped with reasons); reports `added` names upward for the "from scan" badge + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 6.3, 6.4, 7.1, 7.2, 7.3, 7.4, 2.5_ + + - [x] 7.2 Wire the panel into `RegistrationWizard.tsx` + - Embed in the Parameters step with `plugin.plugin_id`, `plugin.version`, and `defaultElementFactory(plugin.name)`; hold scanned-row names in wizard state, render the "from scan" badge on those rows, clear the badge when a row is edited; merge results applied through the existing `patch({parameters})` path so step gating (`parametersStepErrors`) is unchanged + - _Requirements: 5.1, 5.2, 5.5, 6.4, 3.3_ + + - [x] 7.3 Wire the panel into `CreateWizard.tsx` + - Embed in the Parameters step without plugin context so the scanning-requires-a-built-plugin notice renders; manual parameter flow unchanged + - _Requirements: 5.6, 7.1_ + + - [x] 7.4 Write vitest component tests for the panel and wizard wiring + - Auto-scan populates an empty list; no auto-merge when rows exist; manual rescan; factory selector on multi-element reports; each degraded notice (no build, not captured, failed with diagnostic, fetch rejection) with Add-parameter still usable and step navigation unblocked; scan outcome rendering including alreadyDeclared and skipped; badge appears after scan and clears on edit; Create wizard static notice; editing a scanned row works like a manual row + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 6.4, 7.1, 7.2, 7.3, 7.4, 3.3_ + +- [x] 8. Final checkpoint - Ensure all tests pass + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Property tests run ≥100 iterations and carry the tag `Feature: gst-parameter-prepopulation, Property {n}: {title}` +- Checkpoints ensure incremental validation; the build-image change (4.2) only takes effect for plugins rebuilt after the image is republished — earlier builds surface as `not_captured` (Requirement 7.4) diff --git a/.kiro/specs/imported-plugin-revision-adjustment-fix/.config.kiro b/.kiro/specs/imported-plugin-revision-adjustment-fix/.config.kiro new file mode 100644 index 00000000..26aa8299 --- /dev/null +++ b/.kiro/specs/imported-plugin-revision-adjustment-fix/.config.kiro @@ -0,0 +1 @@ +{"specId": "d43ef98d-d697-48c4-a611-48f197c543d7", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/imported-plugin-revision-adjustment-fix/bugfix.md b/.kiro/specs/imported-plugin-revision-adjustment-fix/bugfix.md new file mode 100644 index 00000000..889a76bf --- /dev/null +++ b/.kiro/specs/imported-plugin-revision-adjustment-fix/bugfix.md @@ -0,0 +1,49 @@ +# Bugfix Requirements Document + +## Introduction + +The Node Designer plugin import feature records an advisory per-platform compatibility check on imported plugins. When an imported source requires a newer GStreamer than a target platform's build image provides (e.g. gst-plugins-good `main` requires >= 1.19.0, but arm64 JetPack 4 ships 1.14 and arm64 JetPack 5 ships 1.16), the plugin detail page shows a warning under the affected build entry with a suggested revision: "Import revision 1.14 for this platform instead." + +The bug is that this advice is a dead end. Per-architecture source revisions (`arch_revisions`) can only be set at import time on POST /plugins/import. Once a plugin is imported, there is no way to apply a different source revision to specific platforms: the detail page's "Retry build" simply re-submits the same already-fetched source tree, which fails again for the same reason. The user's only recourse is to delete the record and re-import from scratch with the per-architecture overrides they now know they need. + +The fix gives the user a way to act on the warning: adjust the imported plugin's source revision for the affected platform(s) directly from the plugin detail page (defaulting to the recorded `suggestedRevision`), fetch the adjusted revision's source tree, and re-run the affected builds so they can succeed. + +## Bug Analysis + +### Current Behavior (Defect) + +When an imported plugin has platforms whose compatibility check failed with a known suggested revision, the system offers no action to apply that suggestion: + +1.1 WHEN an imported plugin's build entry shows an incompatible-platform warning with a suggested revision THEN the system displays the advice but provides no control to apply the suggested revision to that platform + +1.2 WHEN the user retries a failed build for a platform whose warning suggests a different revision THEN the system re-submits the build from the same incompatible source tree and the build fails again for the same reason + +1.3 WHEN the user attempts to change an imported plugin's per-platform source revision after import THEN the system provides no API or UI path to do so (per-architecture revisions are accepted only at import time on POST /plugins/import) + +1.4 WHEN a source needs different revisions per platform and the user did not set per-architecture overrides at import time THEN the system leaves the affected platforms permanently unable to build, forcing the user to delete the record and re-import from scratch + +### Expected Behavior (Correct) + +2.1 WHEN an imported plugin's build entry shows an incompatible-platform warning with a suggested revision THEN the system SHALL offer an action on the plugin detail page to apply a revision override for that platform, pre-filled with the recorded suggested revision and editable by the user + +2.2 WHEN the user applies a per-platform revision override to an imported plugin THEN the system SHALL fetch the overridden revision's source tree (reusing an already-fetched tree when the same revision is already recorded in the fetches map) and record the platform's arch_revisions mapping so its builds resolve to the adjusted tree + +2.3 WHEN the adjusted revision's source fetch succeeds THEN the system SHALL re-run the affected platform's build from the adjusted source tree so the build can succeed + +2.4 WHEN the adjusted revision's source fetch fails (unreachable repository or nonexistent revision) THEN the system SHALL surface the fetch failure on the affected platform's entry without altering the plugin's other platforms or its existing successful builds + +2.5 WHEN the user applies a revision override THEN the system SHALL require the same permission as build submission (node-designer:manage) and reject the request for records that are not imports or whose import has not settled + +### Unchanged Behavior (Regression Prevention) + +3.1 WHEN a plugin is imported with per-architecture revision overrides at import time THEN the system SHALL CONTINUE TO fetch each distinct revision once, map architectures to their revision slugs, and build each architecture from its own source tree + +3.2 WHEN a platform's compatibility entry is compatible (or carries no suggested revision) THEN the system SHALL CONTINUE TO display the build entry (and any warning) as it does today, without altering its revision or source tree + +3.3 WHEN the user retries a failed build without applying a revision override THEN the system SHALL CONTINUE TO re-submit the build from the platform's currently recorded source tree + +3.4 WHEN a single-revision import has no per-architecture overrides THEN the system SHALL CONTINUE TO use the flat source_s3_prefix layout for builds, source inspection, and record display + +3.5 WHEN builds for other platforms are running or have succeeded THEN the system SHALL CONTINUE TO leave their build status, artifacts, checksums, and signatures untouched by another platform's revision adjustment + +3.6 WHEN component auto-packaging is triggered after all requested builds settle THEN the system SHALL CONTINUE TO trigger it exactly once per build round, including rounds completed by a revision-adjusted retry diff --git a/.kiro/specs/imported-plugin-revision-adjustment-fix/design.md b/.kiro/specs/imported-plugin-revision-adjustment-fix/design.md new file mode 100644 index 00000000..8a384622 --- /dev/null +++ b/.kiro/specs/imported-plugin-revision-adjustment-fix/design.md @@ -0,0 +1,241 @@ +# Imported Plugin Revision Adjustment Bugfix Design + +## Overview + +The Node Designer import flow records an advisory per-platform compatibility check (`platform_compatibility`) on imported plugins. When a source requires a newer GStreamer than a platform's build image provides, the plugin detail page shows a warning with a `suggestedRevision` (e.g. "arm64 JetPack 4 provides 1.14. Import revision 1.14 for this platform instead."). The bug is that this advice is a dead end: per-architecture source revisions (`arch_revisions`) are accepted only at import time on POST /plugins/import, and the detail page's "Retry build" re-submits the same incompatible source tree, which fails again for the same reason. The user's only recourse today is deleting the record and re-importing from scratch. + +The fix adds a post-import revision adjustment path: + +1. **Backend**: a new `POST /plugins/{id}/versions/{v}/adjust-revision` route on `plugin_importer.py` (body `{architecture, revision}`) that fetches the adjusted revision's source tree into the record's `fetches` map (reusing an already-fetched tree when the revision is already recorded there), maps the platform through `arch_revisions[arch] -> fetches[slug]`, and re-runs the affected platform's build. A fetch failure surfaces on the affected platform's build entry only. +2. **Frontend**: an "Adjust revision" action under the incompatible-platform warning on the plugin detail page, pre-filled with the recorded `suggestedRevision` and editable, wired to the new endpoint. +3. **Infrastructure**: the new API Gateway route on the node-designer API stack, integrated with the existing importer Lambda. + +The fix deliberately reuses the existing multi-revision machinery (`fetches` map, `revision_slug`, `arch_source_prefix`, `start_fetch` with `REVISION_SLUG`, `start_queued_builds`) so a post-import adjustment converges on exactly the same record shape an import-time `arch_revisions` produces, and all downstream behavior (build source resolution, revision labels on the detail page) works unchanged. + +## Glossary + +- **Bug_Condition (C)**: A user holding node-designer:manage wants an imported, settled plugin's specific platform to build from a different source revision (typically the recorded `suggestedRevision`), but no API or UI path exists to apply it — every available action leaves the platform building from the same incompatible tree. +- **Property (P)**: Applying a per-platform revision override fetches (or reuses) the adjusted revision's source tree, records the platform's `arch_revisions` mapping, and re-runs that platform's build from the adjusted tree; a fetch failure surfaces only on the affected platform's entry. +- **Preservation**: The import-time multi-revision flow, compatible platforms' display, plain retries, the single-revision flat `source_s3_prefix` layout, other platforms' builds/artifacts, and once-per-round component auto-packaging must all remain unchanged. +- **Plugin_Record**: The DynamoDB version item (`plugin_records.py` shape) carrying `import_status`, `provenance`, `artifacts` (per-arch build entries), `requested_architectures`, `platform_compatibility`, and — for multi-revision imports — `fetches` and `arch_revisions`. +- **fetches map**: `{slug: {revision, source_prefix, status, fetch_build_id}}` — one entry per distinct fetched source revision, each synced to its own `rev-{slug}/` S3 prefix (`plugin_importer.revision_fetch_plan`). +- **arch_revisions**: `{arch: slug}` — maps a Target_Architecture to the `fetches` entry whose tree its builds read (`plugin_builds.arch_source_prefix` resolves `arch_revisions[arch] -> fetches[slug].source_prefix`, falling back to the flat `source_s3_prefix` for unmapped architectures). +- **suggestedRevision**: The upstream release branch matching a platform's GStreamer minor version, recorded on incompatible `platform_compatibility` entries for official GStreamer modules (`plugin_importer.platform_compatibility`). +- **start_fetch / handle_fetch_result**: The asynchronous CodeBuild fetch step (clone + sync to S3) and the EventBridge result handler that settles it (`plugin_importer.py`). +- **start_queued_builds**: `plugin_builds.py` helper that StartBuilds every requested architecture whose artifact entry is `queued`; never raises to the caller. +- **components_triggered**: The conditional once-per-build-round marker for Plugin_Component auto-packaging; `start_builds` REMOVEs it when opening a new build round. + +## Bug Details + +### Bug Condition + +The bug manifests when an imported plugin's settled record carries an incompatible `platform_compatibility` entry with a `suggestedRevision` and the user wants that platform to build from the suggested (or another) revision. The system offers no operation that changes the platform's effective source revision: the adjust capability simply does not exist post-import — `arch_revisions` is only read from the POST /plugins/import body, `handle_fetch_result` only processes records whose `import_status` is still `fetching`, the build endpoint re-submits whatever `arch_source_prefix` currently resolves, and the detail page renders the warning as plain text. + +**Formal Specification:** +``` +FUNCTION isBugCondition(input) + INPUT: input of type (record: Plugin_Record, arch: Target_Architecture, + requestedRevision: string) + OUTPUT: boolean + + RETURN record.kind == 'imported' + AND record.import_status == 'imported' + AND record.platform_compatibility[arch].compatible == false + AND record.platform_compatibility[arch].suggestedRevision != null + AND requestedRevision != effectiveRevision(record, arch) + -- and, on unfixed code, no operation exists that changes + -- effectiveRevision(record, arch): every reachable action + -- (retry, poll, lifecycle, delete) leaves + -- arch_source_prefix(record, arch) unchanged +END FUNCTION + +WHERE effectiveRevision(record, arch) = + record.fetches[record.arch_revisions[arch]].revision when mapped, + record.provenance.revision otherwise +``` + +### Examples + +- **gst-plugins-good on arm64 JetPack 4**: import `main` (requires GStreamer >= 1.24) for all five platforms. The arm64_jp4 build fails; its entry warns "The source requires GStreamer >= 1.24; arm64 JetPack 4 provides 1.14. Import revision 1.14 for this platform instead." Expected: an action to apply revision `1.14` to arm64_jp4 and rebuild. Actual: only "Retry build", which re-submits the same `main` tree and fails identically. +- **Two incompatible platforms**: same import, arm64_jp4 (suggests 1.14) and arm64_jp5 (suggests 1.16) both warn. Expected: adjust each platform independently to its own revision. Actual: no action; the user deletes the record and re-imports with `arch_revisions` set for both. +- **Editable suggestion**: the user knows branch `1.16` also works on JetPack 4 for their plugin subset. Expected: the pre-filled suggestion is editable before applying. Actual: no input exists at all. +- **Edge case — revision already fetched**: a multi-revision import fetched `1.16` for arm64_jp5; the user now adjusts arm64_jp4 to `1.16` too. Expected: the already-synced `rev-1.16/` tree is reused (no second fetch) and arm64_jp4 rebuilds from it immediately. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors:** +- Import-time `arch_revisions` on POST /plugins/import: `revision_fetch_plan`, one fetch per distinct revision, `arch -> slug` mapping, per-arch builds from their own trees (bugfix 3.1). +- Compatible platforms (or entries without a suggested revision) display exactly as today — their revision, source tree, and build entries are never altered by the fix or by another platform's adjustment (3.2). +- Plain "Retry build" / "Retry failed builds" (POST .../build) re-submits from the platform's currently recorded source tree via `arch_source_prefix` (3.3). +- Single-revision imports without overrides keep the flat `source_s3_prefix` layout for builds, source inspection, and record display; the record's `source_s3_prefix` (the tree the buildability scan, source view, and node-generator read) is never rewritten by an adjustment (3.4). +- Other platforms' build status, artifacts, checksums, signatures, and `logTail` are untouched by one platform's adjustment — on success and on fetch failure alike (3.5). +- Component auto-packaging triggers exactly once per build round, including rounds completed by a revision-adjusted retry (the adjustment opens a new round by REMOVE-ing `components_triggered`, exactly like `start_builds`) (3.6). + +**Scope:** +All inputs that do NOT go through the new adjust-revision action are completely unaffected. This includes: +- POST /plugins/import (with or without `arch_revisions`), select-plugins, module listings +- POST .../build (plain retries, prebuilt uploads) and GET .../builds +- EventBridge results for import fetches (`import_status == 'fetching'`) and per-arch builds +- The detail page's rendering of builds, warnings, revision labels, source view, lifecycle and delete actions + +## Hypothesized Root Cause + +This is a capability gap rather than a defect in an existing computation. The analysis is high-confidence because the absence is structural: + +1. **API surface gap**: `arch_revisions` is only parsed from the POST /plugins/import body (`import_repository`). No route mutates `fetches` / `arch_revisions` after import; `plugin_importer.handler` routes only `/plugins/import`, `/plugin-modules`, and `.../select-plugins`. + +2. **Fetch-result handler scoped to imports in flight**: `handle_fetch_result` and `_handle_multi_fetch_result` guard on `import_status == 'fetching'` (and per-slug `status == 'fetching'`), so even if a fetch were started for a settled record, its result would be skipped as "already recorded". The handler needs a distinct path for post-import adjustment fetches. + +3. **Retry rebuilds the same tree by design**: POST .../build resolves each architecture's source through `arch_source_prefix(item, arch)`, which is pure over the record's current `arch_revisions`/`fetches`/`source_s3_prefix`. Without a record mutation, retrying can only reproduce the failure. + +4. **UI renders advice without an action**: `PluginDetail.tsx` shows `platformWarningMessage(arch, compat)` as a `StatusIndicator` line; no control exists, and `nodeDesignerApi` has no method to call even if one did. + +The good news shaping the fix: `arch_source_prefix` already resolves per-arch trees with a flat-prefix fallback for unmapped architectures, so a settled single-revision record can gain a `fetches` map + `arch_revisions` entry for just the adjusted platform without disturbing anything else. + +## Correctness Properties + +Property 1: Bug Condition - Applying a Per-Platform Revision Override Adjusts the Tree and Re-runs the Build + +_For any_ imported, settled Plugin_Record with an incompatible platform entry carrying a suggested revision, and any non-empty requested revision for that platform (isBugCondition returns true), the fixed system SHALL offer the adjustment action (pre-filled with the suggested revision, editable), and applying it SHALL: fetch the requested revision's source tree into the record's fetches map — reusing an existing succeeded fetches entry recording the same revision instead of fetching again — record arch_revisions[arch] as the slug of the fetches entry whose revision equals the requested revision, and re-run the affected platform's build so that its source resolves through the adjusted tree (arch_source_prefix returns the adjusted entry's source_prefix); when the fetch fails, the failure SHALL be recorded on the affected platform's build entry only, the platform's prior arch_revisions mapping left unchanged. The action SHALL require node-designer:manage and SHALL be rejected (409) for records that are not imports or whose import status is not 'imported'. + +**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5** + +Property 2: Preservation - Non-Adjusted Flows Are Unchanged + +_For any_ input where the bug condition does NOT hold (isBugCondition returns false) — import-time multi-revision imports, compatible platforms, plain build retries, single-revision flat-layout records, other platforms' build entries during and after another platform's adjustment, and the component auto-packaging trigger — the fixed system SHALL produce the same result as the original system: revision_fetch_plan output, arch_source_prefix resolution for non-adjusted architectures, builds_view content for untouched entries, and the once-per-round components_triggered semantics are all preserved bit-for-bit. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6** + +## Fix Implementation + +### Changes Required + +#### 1. Backend endpoint — `edge-cv-portal/backend/functions/plugin_importer.py` + +**New route**: `POST /plugins/{id}/versions/{v}/adjust-revision`, body `{architecture: string, revision: string}`, dispatched from `handler`. + +**New handler `adjust_revision(event, user, plugin_id, version)`**: +1. **Validate**: `architecture` must be one of the record's `requested_architectures`; `revision` must be a non-empty string (trimmed). 400 `INVALID_ARCHITECTURE` / `INVALID_REVISION` otherwise. +2. **Authorize**: `authorize_record_access(user, event, item, manage=True, permission=Permission.NODE_DESIGNER_MANAGE)` — same permission as build submission (2.5). +3. **Reject non-adjustable records** (409 `REVISION_ADJUSTMENT_NOT_AVAILABLE`): `kind != 'imported'`, missing `provenance.repoUrl`, or `import_status != 'imported'` (fetching / pending_selection / failed imports have no settled build round to adjust) (2.5). +4. **Resolve the target slug** (new pure helper `adjustment_fetch_slot(item, revision)` so it is unit/property testable): + - If an existing `fetches[slug]` records the same revision string with status `succeeded` → **reuse**: no new fetch (2.2). + - If an existing entry records the same revision with status `fetching` (a concurrent adjustment) → join it: add the arch to its `pending_archs`. + - Otherwise allocate a fresh slug via `revision_slug(revision)`, disambiguating numeric-suffix collisions against existing slugs exactly like `revision_fetch_plan`; the new entry is `{revision, source_prefix: f'{source_s3_prefix(...)}rev-{slug}/', status: 'fetching', pending_archs: [arch]}`. A previously **failed** entry for the same revision is re-fetched in place (status reset, new build id, `pending_archs` set). +5. **Persist + act**: + - **Reuse path**: set `arch_revisions[arch] = slug` (creating the map if the record is flat), set `artifacts[arch] = {'buildStatus': 'queued'}`, `REMOVE components_triggered` (new build round, 3.6), then call `plugin_builds.start_queued_builds(plugin_id, version)` (lazy import, mirrors `_start_queued_builds`). The queued->building start touches only the adjusted arch because only its entry is `queued`. + - **Fetch path**: write the `fetches[slug]` entry, set `artifacts[arch] = {'buildStatus': 'queued'}` (the UI shows the platform as queued while the fetch runs), `REMOVE components_triggered`, then `start_fetch(repoUrl, revision, source_prefix, ..., revision_slug_id=slug)` and record its `fetch_build_id` on the entry. `arch_revisions[arch]` is NOT changed yet — it flips only when the fetch succeeds, so a fetch failure leaves the prior mapping intact (2.4, 3.3). + - The record's `source_s3_prefix`, `default_fetch_slug`, `plugins_found`, `selected_plugins`, and every other architecture's `artifacts` entry are never written (3.4, 3.5). +6. **Audit + respond**: `log_audit_event(action='adjust_plugin_revision', ...)`; answer 202 with `{plugin: import_detail(updated), builds: plugin_builds.builds_view(updated)}` so the page refreshes in one round trip. + +**Adjustment fetch results — extend `handle_fetch_result`**: before the existing `import_status == 'fetching'` paths, route fetch results whose `REVISION_SLUG` names a `fetches` entry carrying `pending_archs` (adjustment marker) to a new `_handle_adjustment_fetch_result(item, build_id, build_status, env)`: +- **Idempotency**: per-slug conditional write guarded on `fetches[slug].fetch_build_id == build_id AND fetches[slug].status == 'fetching'`, mirroring `_handle_multi_fetch_result`; superseded/duplicate deliveries are skipped. +- **SUCCEEDED**: set `fetches[slug].status = 'succeeded'` and clear `pending_archs`; set `arch_revisions[a] = slug` for each pending arch; call `plugin_builds.start_queued_builds` to start the queued entries (their source now resolves through the adjusted tree via `arch_source_prefix`) (2.3). +- **FAILED / FAULT / STOPPED / TIMED_OUT**: set `fetches[slug].status = 'failed'` and clear `pending_archs`; for each pending arch set its artifact entry to `{'buildStatus': 'failed', 'logTail': 'The adjusted revision {revision} could not be fetched: the repository is unreachable or the revision does not exist'}` via `set_arch_entry`-style per-arch writes. `arch_revisions` and `import_status` are untouched; no other platform's entry is written (2.4, 3.5). +- Never raises to the EventBridge handler beyond what `handle_build_result` already tolerates; audit-logged as the record's `created_by` (no authenticated user on this path). + +#### 2. Infrastructure — `edge-cv-portal/infrastructure/lib/node-designer-api-stack.ts` + +Add `addMethod(versionResource.addResource('adjust-revision'), 'POST', importerIntegration);` next to the existing `select-plugins` route (the compiled `.js`/`.d.ts` regenerate on build). + +#### 3. Frontend API client — `edge-cv-portal/frontend/src/pages/node-designer/api.ts` (+ `types.ts`) + +New method `adjustRevision(pluginId, version, architecture, revision)` → `POST /plugins/{id}/versions/{v}/adjust-revision`, returning `{plugin: PluginVersionDetail, builds: PluginBuildsView}` (new `AdjustRevisionResponse` type). + +#### 4. Frontend pure helpers — `edge-cv-portal/frontend/src/pages/node-designer/importFlow.ts` + +- `canAdjustRevision(detail, arch)`: true exactly when the detail is an imported record with `import_status === 'imported'` and `platform_compatibility[arch]` is incompatible with a non-null `suggestedRevision` (2.1, mirrors the backend gate). +- `adjustRevisionError(value)`: validation for the input (`null` when trimmed non-empty, message otherwise). +- Existing `platformWarningMessage`, `archRevisionLabel`, `incompatiblePlatformWarnings` are unchanged (3.2); after a successful adjustment `archRevisionLabel` automatically shows the adjusted revision because the record now carries `arch_revisions`/`fetches`. + +#### 5. Frontend UI — `edge-cv-portal/frontend/src/pages/node-designer/PluginDetail.tsx` + +Under each incompatible-platform warning where `canAdjustRevision` holds, render an inline "Adjust revision for this platform" action that expands to an `Input` pre-filled with `compat.suggestedRevision` (editable) plus Apply/Cancel buttons. Apply calls `nodeDesignerApi.adjustRevision`, replaces `plugin` and `builds` state from the response (the existing poll resumes because the builds view is no longer settled), and disables while a retry or another adjustment is in flight. Per-arch adjustment errors surface in an alert on the affected platform's entry only (2.4). Plain retry buttons and all other page behavior are untouched (3.3). + +## Testing Strategy + +### Validation Approach + +Two-phase: first surface counterexamples on the UNFIXED code demonstrating the dead end (confirming the root-cause analysis), then verify the fix satisfies Property 1 and preserve-check Property 2. Backend tests live in `edge-cv-portal/backend/tests/test_plugin_importer.py` (pytest + moto/stubs, Hypothesis for properties); frontend tests in `importFlow.test.ts` and `PluginDetail.test.tsx` (vitest, run with `--run`). + +### Exploratory Bug Condition Checking + +**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm or refute the root cause analysis. If we refute, we will need to re-hypothesize. + +**Test Plan**: Exercise the unfixed handlers and components with a settled imported record carrying an incompatible platform entry with a suggested revision, and assert the adjustment paths that should exist do not. + +**Test Cases**: +1. **No adjust route**: POST `/plugins/{id}/versions/{v}/adjust-revision` against `plugin_importer.handler` returns 404 NOT_FOUND (will fail on unfixed code once asserted as 202) +2. **Retry re-uses the incompatible tree**: on a flat single-revision record, `arch_source_prefix(item, 'arm64_jp4')` before and after a POST .../build retry is the identical flat prefix — no operation changed the effective revision (will fail on unfixed code when asserted against the adjusted prefix) +3. **UI offers no action**: `PluginDetail` rendered with an incompatible `arm64_jp4` entry (suggestedRevision '1.14') shows the warning text but no adjust control (will fail on unfixed code once the control is expected) +4. **Settled-record fetch results are dropped** (edge): a fetch result for a record whose `import_status` is 'imported' is skipped as "already recorded" by `handle_fetch_result` (confirms root cause 2; may fail on unfixed code once adjustment results are expected to be processed) + +**Expected Counterexamples**: +- Every path returns the record to the same `arch_source_prefix` for the affected platform; the adjustment surface is absent end to end +- Possible causes confirmed: missing route, fetch-result guard on `import_status == 'fetching'`, UI renders advice as plain text + +### Fix Checking + +**Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior. + +**Pseudocode:** +``` +FOR ALL (record, arch, revision) WHERE isBugCondition(record, arch, revision) DO + result := adjust_revision_fixed(record, arch, revision) + ASSERT result.fetches contains a slot whose revision == revision + IF fetch succeeds (or tree reused) THEN + ASSERT result.arch_revisions[arch] names that slot + ASSERT arch_source_prefix(result, arch) == fetches[slot].source_prefix + ASSERT the arch's build was re-run (queued -> building) + ELSE + ASSERT only artifacts[arch] records the fetch failure + ASSERT result.arch_revisions == record.arch_revisions + END IF +END FOR +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function. + +**Pseudocode:** +``` +FOR ALL input WHERE NOT isBugCondition(input) DO + ASSERT originalBehavior(input) = fixedBehavior(input) +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for preservation checking because: +- It generates many test cases automatically across the input domain (record shapes, fetches maps, arch subsets, revision strings) +- It catches edge cases that manual unit tests might miss (slug collisions, records with and without fetches maps, partial arch mappings) +- It provides strong guarantees that behavior is unchanged for all non-buggy inputs + +**Test Plan**: Observe behavior on UNFIXED code first for import-time multi-revision flows, plain retries, and flat-layout resolution, then write property-based tests capturing that behavior and re-run them on the fixed code. + +**Test Cases**: +1. **arch_source_prefix preservation**: for randomly generated records (with/without fetches maps and arch_revisions), every architecture NOT adjusted resolves to the same prefix before and after another architecture's adjustment (3.1, 3.4, 3.5) +2. **Untouched-entry preservation**: applying an adjustment to arch A leaves every other architecture's artifact entry (status, s3Key, checksum, signature, logTail) byte-identical (3.5) +3. **Plain retry preservation**: POST .../build without an adjustment produces the same StartBuild source locations and record writes as the original code (3.3) +4. **Import-flow preservation**: `revision_fetch_plan` and the import-time multi-revision persistence are byte-identical to the original (3.1); compatible platform entries and their warnings render unchanged (3.2) + +### Unit Tests + +- `adjust_revision` handler: happy path with a new revision (fetches entry written with `pending_archs`, arch queued, fetch started with `REVISION_SLUG`, `components_triggered` removed); reuse path (existing succeeded slot: no fetch, `arch_revisions` mapped, build started); failed-slot re-fetch; concurrent-fetch join (arch appended to `pending_archs`) +- Rejections: 403 without node-designer:manage; 409 for scaffolds/generated records, missing repoUrl, and `import_status` in {fetching, pending_selection, failed}; 400 for unknown architecture or empty revision +- `_handle_adjustment_fetch_result`: success maps the arch and starts the queued build; failure records the fetch-failure logTail on the affected arch only and leaves `arch_revisions` unchanged; duplicate/superseded deliveries are idempotent +- `adjustment_fetch_slot`: slug reuse by identical revision, numeric-suffix collision handling, failed-slot reset +- Frontend: `canAdjustRevision` / `adjustRevisionError` truth tables; `PluginDetail` renders the action exactly for incompatible+suggested entries, pre-fills and permits editing, applies via the API, and surfaces per-platform errors + +### Property-Based Tests + +- **Property 1 (fix)**: Hypothesis-generated settled imported records × architectures × revision strings satisfying the bug condition — after adjustment (with the fetch result simulated succeeded), `arch_source_prefix` for the arch equals the adjusted entry's prefix and its build entry was re-queued; with the fetch simulated failed, only that arch's entry changed +- **Property 2 (preservation)**: Hypothesis-generated records and non-bug-condition operations — non-adjusted architectures' source resolution, artifact entries, and `builds_view` output are identical to the original implementation's; `revision_fetch_plan` behavior at import time is unchanged +- Slug allocation: for any set of existing fetches slugs and any revision, `adjustment_fetch_slot` never clobbers an entry recording a different revision + +### Integration Tests + +- Full flow: import (flat, single revision) → simulate incompatible platform with suggestedRevision → adjust via the endpoint → simulate fetch SUCCEEDED via `handle_fetch_result` → assert the arch's StartBuild used the `rev-{slug}/` prefix → simulate build SUCCEEDED → assert auto-packaging triggers exactly once for the round (3.6) +- Fetch-failure flow: adjust → simulate fetch FAILED → assert the affected arch shows the fetch-failure logTail, other archs and the record's `source_s3_prefix` untouched, and a plain retry still builds from the prior tree +- Detail-page flow (`PluginDetail.test.tsx`): warning renders with the action → apply → page state reflects the response's builds view and the poll resumes; revision label shows the adjusted revision once mapped diff --git a/.kiro/specs/imported-plugin-revision-adjustment-fix/tasks.md b/.kiro/specs/imported-plugin-revision-adjustment-fix/tasks.md new file mode 100644 index 00000000..a55945d4 --- /dev/null +++ b/.kiro/specs/imported-plugin-revision-adjustment-fix/tasks.md @@ -0,0 +1,181 @@ +# Implementation Plan + +## Overview + +Fix the imported-plugin revision adjustment dead end using the exploratory bugfix workflow: write bug condition exploration tests (Property 1) and preservation property tests (Property 2) against the UNFIXED code first, then implement the backend adjust-revision endpoint, the adjustment fetch-result branch, the API Gateway route, and the frontend action, then verify the fix with the same tests plus unit and integration coverage. + +## Task Dependency Graph + +```json +{ + "waves": [ + { "wave": 1, "description": "Run on UNFIXED code: surface the missing-adjustment-path counterexamples (task 1 FAILS - Property 1) and capture preservation baselines (task 2 PASSES - Property 2). Independent of each other.", "tasks": ["1", "2"] }, + { "wave": 2, "description": "Backend core: the adjust-revision endpoint with adjustment_fetch_slot and persistence.", "tasks": ["3.1"] }, + { "wave": 3, "description": "Downstream of the endpoint: adjustment fetch-result branch, API Gateway route, and frontend API client + pure helpers. Mutually independent.", "tasks": ["3.2", "3.3", "3.4"] }, + { "wave": 4, "description": "Frontend UI: the inline adjust-revision action on PluginDetail.", "tasks": ["3.5"] }, + { "wave": 5, "description": "Verify the fix: re-run task 1 tests (now PASS) then task 2 tests (still PASS).", "tasks": ["3.6", "3.7"] }, + { "wave": 6, "description": "Unit tests for fix specifics, then end-to-end integration tests.", "tasks": ["4", "5"] }, + { "wave": 7, "description": "Checkpoint: full backend and frontend suites pass.", "tasks": ["6"] } + ] +} +``` + +```mermaid +graph TD + T1[Task 1: Bug condition exploration tests - Property 1] + T2[Task 2: Preservation property tests - Property 2] + T31[Task 3.1: Backend adjust-revision endpoint] + T32[Task 3.2: Adjustment fetch-result branch] + T33[Task 3.3: API Gateway route] + T34[Task 3.4: Frontend API client and helpers] + T35[Task 3.5: PluginDetail adjust action] + T36[Task 3.6: Verify Property 1 passes] + T37[Task 3.7: Verify Property 2 passes] + T4[Task 4: Unit tests] + T5[Task 5: Integration tests] + T6[Task 6: Checkpoint] + + T1 --> T31 + T2 --> T31 + T31 --> T32 + T31 --> T33 + T31 --> T34 + T34 --> T35 + T32 --> T36 + T35 --> T36 + T36 --> T37 + T37 --> T4 + T4 --> T5 + T5 --> T6 +``` + +## Tasks + +- [x] 1. Write bug condition exploration tests + - **Property 1: Bug Condition** - Applying a Per-Platform Revision Override Adjusts the Tree and Re-runs the Build + - **CRITICAL**: These tests MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the tests or the code when they fail** + - **NOTE**: These tests encode the expected behavior - they will validate the fix when they pass after implementation + - **GOAL**: Surface counterexamples that demonstrate the dead end exists and confirm the root cause analysis (missing route, fetch-result guard on `import_status == 'fetching'`, UI renders advice as plain text) + - **Scoped PBT Approach**: This is a deterministic capability gap - scope the property to concrete settled imported records carrying an incompatible `platform_compatibility` entry with a `suggestedRevision` (isBugCondition from design: `kind == 'imported'`, `import_status == 'imported'`, `compatible == false`, `suggestedRevision != null`, `requestedRevision != effectiveRevision(record, arch)`) + - Backend (`edge-cv-portal/backend/tests/test_plugin_importer.py`, pytest + Hypothesis): + - POST `/plugins/{id}/versions/{v}/adjust-revision` against `plugin_importer.handler` - assert 202 with a `fetches` slot recording the requested revision (unfixed code returns 404 NOT_FOUND) + - On a flat single-revision record, assert an adjustment changes `arch_source_prefix(item, 'arm64_jp4')` to the adjusted entry's `rev-{slug}/` prefix (unfixed code: retry re-uses the identical flat prefix - no operation changes the effective revision) + - Fetch result for a settled record (`import_status == 'imported'`) with an adjustment marker (`pending_archs`) - assert it is processed (unfixed `handle_fetch_result` skips it as "already recorded") + - Frontend (`edge-cv-portal/frontend/src/pages/node-designer/PluginDetail.test.tsx`, vitest run with `--run`): + - Render `PluginDetail` with an incompatible `arm64_jp4` entry (suggestedRevision '1.14') - assert an adjust-revision control pre-filled with '1.14' exists (unfixed UI shows the warning text only) + - Run tests on UNFIXED code + - **EXPECTED OUTCOME**: Tests FAIL (this is correct - it proves the adjustment surface is absent end to end) + - Document counterexamples found to understand root cause + - Mark task complete when tests are written, run, and failure is documented + - _Requirements: 1.1, 1.2, 1.3, 1.4_ + +- [x] 2. Write preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - Non-Adjusted Flows Are Unchanged + - **IMPORTANT**: Follow observation-first methodology + - Observe behavior on UNFIXED code for non-bug-condition inputs, then write Hypothesis property-based tests in `edge-cv-portal/backend/tests/test_plugin_importer.py` and vitest tests in `importFlow.test.ts` capturing the observed behavior patterns from the design's Preservation Requirements: + - `arch_source_prefix` preservation: for Hypothesis-generated records (with/without `fetches` maps and `arch_revisions`), every non-adjusted architecture resolves to the same prefix; flat single-revision records keep the flat `source_s3_prefix` layout (3.1, 3.4) + - Untouched-entry preservation: other architectures' artifact entries (status, s3Key, checksum, signature, logTail) and `builds_view` output are byte-identical for non-adjusted entries (3.5) + - Plain retry preservation: POST .../build without an adjustment produces the same StartBuild source locations and record writes as today (3.3) + - Import-flow preservation: `revision_fetch_plan` output and import-time multi-revision persistence (one fetch per distinct revision, `arch -> slug` mapping) are unchanged (3.1) + - Compatible-platform display: `platformWarningMessage`, `archRevisionLabel`, `incompatiblePlatformWarnings` render compatible entries (or entries without a suggested revision) exactly as today (3.2) + - Component auto-packaging: `components_triggered` once-per-round semantics on build settle are unchanged (3.6) + - Property-based testing generates many test cases for stronger guarantees (record shapes, fetches maps, arch subsets, slug collisions) + - Run tests on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + +- [x] 3. Fix the imported-plugin revision adjustment dead end + + - [x] 3.1 Implement the backend adjust-revision endpoint in plugin_importer.py + - Add `POST /plugins/{id}/versions/{v}/adjust-revision` route (body `{architecture, revision}`) dispatched from `handler` + - New handler `adjust_revision(event, user, plugin_id, version)`: validate architecture against `requested_architectures` and non-empty trimmed revision (400 `INVALID_ARCHITECTURE` / `INVALID_REVISION`); authorize via `authorize_record_access` with `Permission.NODE_DESIGNER_MANAGE`; reject non-adjustable records with 409 `REVISION_ADJUSTMENT_NOT_AVAILABLE` (`kind != 'imported'`, missing `provenance.repoUrl`, or `import_status != 'imported'`) + - New pure helper `adjustment_fetch_slot(item, revision)`: reuse an existing succeeded `fetches` entry recording the same revision; join a concurrent `fetching` entry by appending the arch to `pending_archs`; otherwise allocate a fresh slug via `revision_slug` with numeric-suffix collision disambiguation like `revision_fetch_plan`; reset a previously failed entry for the same revision in place + - Persist + act: reuse path sets `arch_revisions[arch] = slug`, queues `artifacts[arch]`, REMOVEs `components_triggered`, and calls `plugin_builds.start_queued_builds`; fetch path writes the `fetches[slug]` entry with `pending_archs`, queues the arch, REMOVEs `components_triggered`, and calls `start_fetch` with `revision_slug_id=slug` recording `fetch_build_id` - `arch_revisions[arch]` flips only on fetch success + - Never write `source_s3_prefix`, `default_fetch_slug`, `plugins_found`, `selected_plugins`, or any other architecture's artifact entry + - Audit via `log_audit_event(action='adjust_plugin_revision', ...)`; respond 202 with `{plugin: import_detail(updated), builds: plugin_builds.builds_view(updated)}` + - _Bug_Condition: isBugCondition(record, arch, revision) from design - settled imported record, incompatible platform entry with suggestedRevision, requested revision differs from effectiveRevision_ + - _Expected_Behavior: fetch or reuse the adjusted revision's tree into fetches, map arch_revisions[arch] on success, re-run the affected platform's build; fetch failure surfaces on the affected arch only (Property 1 from design)_ + - _Preservation: Preservation Requirements from design - non-adjusted architectures, flat layout, import-time flow, and other platforms' entries untouched_ + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 3.4, 3.5, 3.6_ + + - [x] 3.2 Implement the adjustment fetch-result branch in handle_fetch_result + - Before the existing `import_status == 'fetching'` paths, route fetch results whose `REVISION_SLUG` names a `fetches` entry carrying `pending_archs` to a new `_handle_adjustment_fetch_result(item, build_id, build_status, env)` + - Idempotency: per-slug conditional write guarded on `fetches[slug].fetch_build_id == build_id AND fetches[slug].status == 'fetching'`, mirroring `_handle_multi_fetch_result`; skip superseded/duplicate deliveries + - SUCCEEDED: set `fetches[slug].status = 'succeeded'`, clear `pending_archs`, set `arch_revisions[a] = slug` for each pending arch, call `plugin_builds.start_queued_builds` + - FAILED / FAULT / STOPPED / TIMED_OUT: set `fetches[slug].status = 'failed'`, clear `pending_archs`, record the fetch-failure `logTail` on each pending arch's artifact entry only; leave `arch_revisions` and `import_status` untouched + - Audit-logged as the record's `created_by`; never raises beyond what `handle_build_result` tolerates + - _Bug_Condition: isBugCondition(record, arch, revision) from design_ + - _Expected_Behavior: fetch success maps the arch and re-runs its build from the adjusted tree; fetch failure recorded on the affected platform's entry only, prior mapping intact_ + - _Preservation: import fetch results (`import_status == 'fetching'`) and per-arch build results are routed exactly as today_ + - _Requirements: 2.3, 2.4, 3.5_ + + - [x] 3.3 Add the API Gateway route in node-designer-api-stack.ts + - Add `adjust-revision` POST resource on the version resource wired to the existing importer integration, next to the `select-plugins` route + - _Bug_Condition: isBugCondition from design - no API path exists post-import_ + - _Expected_Behavior: the adjust-revision route is reachable through the node-designer API_ + - _Preservation: all existing routes and integrations unchanged_ + - _Requirements: 2.1, 2.5_ + + - [x] 3.4 Implement the frontend API client and pure helpers + - `nodeDesignerApi.adjustRevision(pluginId, version, architecture, revision)` in `api.ts` returning the new `AdjustRevisionResponse` type (`{plugin, builds}`) added to `types.ts` + - `canAdjustRevision(detail, arch)` in `importFlow.ts`: true exactly when the detail is an imported record with `import_status === 'imported'` and `platform_compatibility[arch]` is incompatible with a non-null `suggestedRevision` (mirrors the backend gate) + - `adjustRevisionError(value)` in `importFlow.ts`: `null` when trimmed non-empty, message otherwise + - Leave `platformWarningMessage`, `archRevisionLabel`, `incompatiblePlatformWarnings` unchanged + - _Bug_Condition: isBugCondition from design - UI has no method to call_ + - _Expected_Behavior: the adjustment action is offered exactly for incompatible+suggested entries on settled imports_ + - _Preservation: existing helpers and compatible-platform rendering unchanged (3.2)_ + - _Requirements: 2.1, 2.5, 3.2_ + + - [x] 3.5 Implement the adjust-revision action in PluginDetail.tsx + - Under each incompatible-platform warning where `canAdjustRevision` holds, render an inline "Adjust revision for this platform" action expanding to an `Input` pre-filled with `compat.suggestedRevision` (editable) plus Apply/Cancel buttons + - Apply calls `nodeDesignerApi.adjustRevision`, replaces `plugin` and `builds` state from the response (the poll resumes because the builds view is no longer settled), and disables while a retry or another adjustment is in flight + - Surface per-arch adjustment errors in an alert on the affected platform's entry only + - Leave plain retry buttons and all other page behavior untouched + - _Bug_Condition: isBugCondition from design - UI renders advice as plain text with no control_ + - _Expected_Behavior: action pre-filled with suggestedRevision, editable, wired to the endpoint; errors surface on the affected platform only_ + - _Preservation: plain retries and all other page behavior unchanged (3.3)_ + - _Requirements: 2.1, 2.4, 3.3_ + + - [x] 3.6 Verify bug condition exploration tests now pass + - **Property 1: Expected Behavior** - Applying a Per-Platform Revision Override Adjusts the Tree and Re-runs the Build + - **IMPORTANT**: Re-run the SAME tests from task 1 - do NOT write new tests + - The tests from task 1 encode the expected behavior + - When these tests pass, it confirms the expected behavior is satisfied: the adjustment fetches or reuses the tree, maps `arch_revisions`, re-runs the affected build, and surfaces fetch failures on the affected arch only + - Run backend tests with pytest and frontend tests with vitest `--run` + - **EXPECTED OUTCOME**: Tests PASS (confirms bug is fixed) + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [x] 3.7 Verify preservation tests still pass + - **Property 2: Preservation** - Non-Adjusted Flows Are Unchanged + - **IMPORTANT**: Re-run the SAME tests from task 2 - do NOT write new tests + - Run preservation property tests from task 2 on the fixed code + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - Confirm all tests still pass after fix (no regressions) + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_ + +- [x] 4. Write unit tests for the fix specifics + - `adjust_revision` handler: happy path with a new revision (fetches entry written with `pending_archs`, arch queued, fetch started with `REVISION_SLUG`, `components_triggered` removed); reuse path (existing succeeded slot: no fetch, `arch_revisions` mapped, build started); failed-slot re-fetch; concurrent-fetch join (arch appended to `pending_archs`) + - Rejections: 403 without node-designer:manage; 409 for scaffolds/generated records, missing repoUrl, and `import_status` in {fetching, pending_selection, failed}; 400 for unknown architecture or empty revision + - `_handle_adjustment_fetch_result`: success maps the arch and starts the queued build; failure records the fetch-failure logTail on the affected arch only and leaves `arch_revisions` unchanged; duplicate/superseded deliveries are idempotent + - `adjustment_fetch_slot`: slug reuse by identical revision, numeric-suffix collision handling, failed-slot reset; property: for any set of existing fetches slugs and any revision, it never clobbers an entry recording a different revision + - Frontend: `canAdjustRevision` / `adjustRevisionError` truth tables; `PluginDetail` renders the action exactly for incompatible+suggested entries, pre-fills and permits editing, applies via the API, and surfaces per-platform errors + - _Requirements: 2.1, 2.2, 2.4, 2.5_ + +- [x] 5. Write integration tests for the end-to-end flows + - Full flow: import (flat, single revision) → simulate incompatible platform with suggestedRevision → adjust via the endpoint → simulate fetch SUCCEEDED via `handle_fetch_result` → assert the arch's StartBuild used the `rev-{slug}/` prefix → simulate build SUCCEEDED → assert auto-packaging triggers exactly once for the round + - Fetch-failure flow: adjust → simulate fetch FAILED → assert the affected arch shows the fetch-failure logTail, other archs and the record's `source_s3_prefix` untouched, and a plain retry still builds from the prior tree + - Detail-page flow (`PluginDetail.test.tsx`): warning renders with the action → apply → page state reflects the response's builds view and the poll resumes; revision label shows the adjusted revision once mapped + - _Requirements: 2.2, 2.3, 2.4, 3.3, 3.4, 3.5, 3.6_ + +- [x] 6. Checkpoint - Ensure all tests pass + - Run the full backend suite (pytest) and frontend suite (vitest `--run`) + - Ensure all tests pass, ask the user if questions arise + +## Notes + +- Task 1 tests MUST FAIL on unfixed code (confirms the bug); task 2 tests MUST PASS on unfixed code (confirms the baseline). Do not "fix" either before implementing tasks 3.1-3.5. +- Backend tests live in `edge-cv-portal/backend/tests/test_plugin_importer.py` (pytest + Hypothesis); frontend tests in `importFlow.test.ts` and `PluginDetail.test.tsx` (vitest, always run with `--run`). +- Tasks 3.6 and 3.7 re-run the SAME tests from tasks 1 and 2 - no new tests are written there. +- The adjustment must never write `source_s3_prefix`, `default_fetch_slug`, or any other architecture's artifact entry (preservation requirements 3.4, 3.5). diff --git a/.kiro/specs/port-guidance-and-pad-prepopulation/.config.kiro b/.kiro/specs/port-guidance-and-pad-prepopulation/.config.kiro new file mode 100644 index 00000000..ccb0390a --- /dev/null +++ b/.kiro/specs/port-guidance-and-pad-prepopulation/.config.kiro @@ -0,0 +1 @@ +{"specId": "dbc7fa18-0919-4e3c-b84d-a683e84d7962", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/port-guidance-and-pad-prepopulation/design.md b/.kiro/specs/port-guidance-and-pad-prepopulation/design.md new file mode 100644 index 00000000..8939b339 --- /dev/null +++ b/.kiro/specs/port-guidance-and-pad-prepopulation/design.md @@ -0,0 +1,552 @@ +# Design Document + +## Overview + +This feature extends the Custom Node Designer's Ports step in three coordinated layers: + +1. **Static Port_Guidance** (Requirements 1, 2): a shared, purely static guidance panel rendered inside the Ports step of both wizards. It explains what a Port is, the Workflow_Designer connection rule, the input/output distinction, and each of the three Port_Types with usage examples. It also shows the typical port arrangement for the selected palette category and raises a non-blocking advisory when the declared ports diverge from that arrangement. + +2. **Pad_Template capture** (Requirements 3, 4): the existing build-time introspection pipeline (`dda-gst-introspect` → S3 report → `plugin_builds.py` stanza validation → `gst_properties.py` parsing → `GET /plugins/{id}/versions/{v}/gst-properties`) is extended so each report element additionally carries the element's static Pad_Templates. The extension is strictly additive to the version-1 report shape: an optional per-element `pads` field. Stored legacy reports without the field keep parsing and serving exactly as before. + +3. **Port_Scan pre-population** (Requirements 5, 6, 7): the backend derives Port_Suggestions and Unmapped_Pads from the captured Pad_Templates with a pure, deterministic function, and the Registration wizard's Ports step gains a scan panel (mirroring the existing ParameterScanPanel) that auto-populates untouched default port lists, additively merges into user-edited lists, and surfaces unconfirmed suggestions and unmapped pads. The Create wizard shows guidance only — no scan, since no Plugin_Artifact exists during creation. + +Every degraded state (no x86_64 build, pre-pad-capture report, failed introspection, request failure, zero suggestions) falls back to the exact manual flow that exists today; pad data is never a gate. + +### Key Design Decisions + +- **No report version bump.** Pad data is an optional `pads` field on each report element, kept at `reportVersion: 1`. A version bump would make new reports unreadable by not-yet-deployed readers and force migration handling; an optional field keeps old readers and old reports working in both directions (Requirement 4.2). Absence of the field is the machine-readable signal that the report predates pad capture (Requirement 4.7). +- **Strict validation when the field is present.** `parse_report` validates pad entries as strictly as it validates properties: malformed pad data raises `ReportError`, which the route already maps to the `introspection_failed` unavailability reason (Requirement 4.4). No partial pad data ever leaves the parser. +- **Derivation is server-side and pure.** `gst_properties.py` gains `ports_for_element`, a pure sibling of `suggestions_for_element`, so the derivation is Hypothesis-testable and both scan results ride the same route response (Requirement 4.5). +- **Frontend mirrors the parameter-scan architecture.** Pure merge/detection helpers live in a new `portScan.ts` (like `scan.ts`); a `PortScanPanel` component (like `ParameterScanPanel`) owns fetch/apply state and communicates upward through a single `onApply` callback; provenance indicators (unconfirmed Port_Type) live in wizard state exactly like the existing `scannedNames` set. +- **Guidance is one shared component with static data.** `PortGuidancePanel` renders from a pure data module (`portGuidance.ts`) with no network access, guaranteeing identical content in both wizards (Requirements 1.4, 1.5) and making the category-divergence rule a pure, property-testable function. + +## Architecture + +```mermaid +graph TD + subgraph "x86_64 build (CodeBuild)" + BUILD[dda-plugin-build] --> INTROSPECT[dda-gst-introspect
+ pad template capture] + INTROSPECT -->|report JSON incl. pads| S3[(S3: {plugin}.so.gstinspect.json)] + end + + S3 --> STANZA[plugin_builds.py
build_gst_introspection_stanza
256 KiB cap + parse_report validation
UNCHANGED CODE] + STANZA --> DDB[(Plugin_Record artifact entry
gstIntrospection stanza)] + + subgraph "GET /plugins/{id}/versions/{v}/gst-properties" + ROUTE[plugin_records.py
get_version_gst_properties] + ROUTE --> PARSE[gst_properties.parse_report
+ optional pads field] + PARSE --> PSUG[suggestions_for_element
UNCHANGED] + PARSE --> PORTS[ports_for_element
NEW: Port_Suggestions + Unmapped_Pads] + end + + DDB --> ROUTE + S3 --> ROUTE + + subgraph "Frontend Ports step" + GUIDE[PortGuidancePanel
static portGuidance.ts data
both wizards] + SCAN[PortScanPanel
Registration wizard only] + MERGE[portScan.ts pure helpers
applySuggestions / isUntouchedDefaults] + SCAN --> MERGE + end + + ROUTE -->|portSuggestions, unmappedPads,
padsReason per element| SCAN +``` + +Data flow for a Port_Scan: the panel calls the existing `nodeDesignerApi.getGstProperties` client; the route parses the stored report and returns, per element, the existing `suggestions`/`skipped` plus the new `portSuggestions`/`unmappedPads`/`padsReason`; the panel picks the element with the existing `pickElement` helper (same selection as the Parameter_Scan, Requirement 6.6) and applies the suggestions through the pure merge in `portScan.ts`. + +### What does NOT change + +- `plugin_builds.py`: `gst_report_key`, `build_gst_introspection_stanza`, the 256 KiB cap, and `INTROSPECTION_ARCH` are untouched. The stanza validates the extended report through the same `parse_report` call; an oversized report (now more likely with pads) already yields a failed stanza with the size-cap diagnostic while the build stays succeeded (Requirement 3.3). +- The route's unavailability taxonomy (`no_x86_64_build`, `not_captured`, `introspection_failed`) and the existing per-element `suggestions`/`skipped` response fields (Requirement 4.6). +- `dda-plugin-build`'s `run_introspection` shell step: it remains a dumb pipe for the report document. +- The Parameters step, `ParameterScanPanel`, `scan.ts` merge helpers, and all parameter wire shapes. + +## Components and Interfaces + +### 1. Capture: `dda-gst-introspect` (plugin-build-images/) + +`describe_factory` is extended to capture static pad templates from the **loaded factory** (pad templates are class-level metadata available from `factory.get_static_pad_templates()` — instantiation is not required, so elements with an `instantiationError` can still report pads when the factory loaded). + +New pure helpers (module top level, importable without GI — the `gi` imports stay inside `scan()`): + +```python +MAX_CAPS_LEN = 4096 + +def truncate_caps(caps): + """(caps_str, truncated_flag): first MAX_CAPS_LEN chars and whether + truncation occurred (Requirement 3.4).""" + if len(caps) > MAX_CAPS_LEN: + return caps[:MAX_CAPS_LEN], True + return caps, False + +def describe_pad_templates(factory, Gst): + """(pads, pads_error): the pad list and None, or [] and a diagnostic + when reading any template of this factory fails (Requirement 3.2).""" +``` + +`describe_pad_templates` maps each `Gst.StaticPadTemplate` via the enum tables `Gst.PadDirection.SINK/SRC -> 'sink'/'src'` and `Gst.PadPresence.ALWAYS/SOMETIMES/REQUEST -> 'always'/'sometimes'/'request'`; the caps string comes from `template.get_caps().to_string()`. Any exception while reading an element's templates (including an unmappable direction/presence value) degrades that one element to `pads: []` with a `padsError` diagnostic, leaving its property data and the report status untouched (Requirement 3.2). An element that genuinely declares no templates records `pads: []` with `padsError: null` (Requirement 3.5). + +Both the load-failure and instantiation-failure early returns in `describe_factory` also emit `pads: []` with a `padsError` describing the failure (pad reading was not possible), keeping every element in a new report explicitly pad-captured. + +### 2. Parsing/serialization: `backend/functions/gst_properties.py` + +New constants and dataclass: + +```python +PAD_DIRECTION_SINK, PAD_DIRECTION_SRC = 'sink', 'src' +VALID_PAD_DIRECTIONS = (PAD_DIRECTION_SINK, PAD_DIRECTION_SRC) +PAD_PRESENCE_ALWAYS = 'always' +VALID_PAD_PRESENCES = ('always', 'sometimes', 'request') +MAX_CAPS_LEN = 4096 + +@dataclass(frozen=True) +class PadTemplate: + """One static Pad_Template captured from the element factory.""" + name: str # name template, e.g. 'sink', 'src', 'src_%u' + direction: str # 'sink' | 'src' + presence: str # 'always' | 'sometimes' | 'request' + caps: str # caps string, at most MAX_CAPS_LEN chars + caps_truncated: bool # True when capture truncated the caps (3.4) +``` + +`ReportElement` gains two optional fields with legacy-compatible defaults: + +```python +@dataclass(frozen=True) +class ReportElement: + factory: str + element_gtype: str + instantiation_error: Optional[str] = None + properties: List[GstProperty] = field(default_factory=list) + pads: Optional[List[PadTemplate]] = None # None = not captured (legacy) + pads_error: Optional[str] = None # meaningful only when pads is not None +``` + +**Domain invariant**: `pads_error` is non-None only when `pads == []` (a per-element read failure, Requirement 3.2). Generators and serialization respect this. + +Parsing (`_parse_element`): +- `pads` key **absent** → `pads=None, pads_error=None` (legacy report, Requirement 4.2). Any stray `padsError` without `pads` is ignored. +- `pads` key **present** → must be a list; each entry must be an object with all five fields present and correctly typed: `name` str, `direction` in `VALID_PAD_DIRECTIONS`, `presence` in `VALID_PAD_PRESENCES`, `caps` str of length ≤ `MAX_CAPS_LEN`, `capsTruncated` bool. `padsError` parses as optional str. Any violation raises `ReportError` — the route already maps that to `introspection_failed` (Requirement 4.4). The caps length bound makes the truncation contract enforceable in the pure module. + +Serialization (`_serialize_element`): when `pads is None` both keys are omitted (byte-identical output to today for legacy-shaped reports); otherwise `pads` (each pad as `{name, direction, presence, caps, capsTruncated}`) and `padsError` are emitted explicitly. This preserves the round-trip identity `parse_report(serialize_report(r)) == r` over the whole (extended) valid domain (Requirement 4.3). + +### 3. Derivation: `ports_for_element` (new, pure, in `gst_properties.py`) + +```python +PORT_TYPE_VIDEO_FRAMES = 'VideoFrames' +CONFIDENT_CAPS_PREFIX = 'video/x-raw' # exact, case-sensitive (5.2) + +PADS_REASON_NOT_CAPTURED = 'pads_not_captured' # report predates pad capture (4.7) +PADS_REASON_NO_TEMPLATES = 'no_pad_templates' # element declares none (4.8) +PADS_REASON_READ_FAILED = 'pads_read_failed' # per-element capture failure (3.2) + +def ports_for_element(element: ReportElement) -> Dict[str, Any]: + """{'portSuggestions': [...], 'unmappedPads': [...], + 'padsReason': str | None, 'padsMessage': str | None}""" +``` + +Reason classification (mutually exclusive, Requirements 4.7, 4.8): + +| Element state | `padsReason` | `padsMessage` | +|---|---|---| +| `pads is None` (legacy) | `pads_not_captured` | `None` | +| `pads == []`, `pads_error` set | `pads_read_failed` | the diagnostic | +| `pads == []`, no error | `no_pad_templates` | `None` | +| `pads` non-empty | `None` | `None` | + +Derivation walks `element.pads` **in report order** (Requirement 5.1); each pad lands in exactly one output list: + +1. `presence != 'always'` → **Unmapped_Pad** `{name, direction, presence, caveat}` with the caveat that sometimes/request pads are created at runtime and do not correspond to fixed declared Ports (Requirement 5.4). +2. `presence == 'always'` but `name.strip()` is empty (the existing Ports_Step validation rule: non-empty Port name) → **Unmapped_Pad** with the caveat that the name template is not a valid Port name (Requirement 5.6). +3. Otherwise → **Port_Suggestion**: + - `direction`: `sink` → `'input'`, `src` → `'output'` (Requirement 5.1) + - `name`: the pad's name template verbatim + - `portType`: always `'VideoFrames'` (the only caps-derivable catalog type, Requirement 5.5) + - `confident`: `caps.startswith('video/x-raw')` — truncated caps included, since truncation preserves the prefix (Requirement 5.2) + - `reason`: confident → states the caps begin with `video/x-raw`; unconfirmed → states that InferenceMeta and EventSignal are DDA semantic concepts GStreamer caps cannot express and the Port_Type needs user confirmation (Requirement 5.3) + - `caps`, `capsTruncated`: carried through for display (Requirement 6.4) + +The function is pure over immutable inputs — determinism (Requirement 5.7) is by construction and verified by property test. + +### 4. Route: `get_version_gst_properties` (plugin_records.py) + +The per-element response entry is extended in place; existing fields keep their exact names, structure, and values (Requirement 4.6): + +```python +derived = suggestions_for_element(element) # unchanged +ports = ports_for_element(element) # new +elements.append({ + 'factory': element.factory, + 'suggestions': derived['suggestions'], # unchanged (4.6) + 'skipped': derived['skipped'], # unchanged (4.6) + 'portSuggestions': ports['portSuggestions'], # new (4.5) + 'unmappedPads': ports['unmappedPads'], # new (4.5) + 'padsReason': ports['padsReason'], # new (4.7, 4.8) + 'padsMessage': ports['padsMessage'], # new (3.2 surfacing) +}) +``` + +No other route logic changes: malformed pad data is caught inside the existing `parse_report` call and flows through the existing `ReportError → introspection_failed` mapping (Requirement 4.4). + +### 5. Frontend: static guidance (`portGuidance.ts` + `PortGuidancePanel.tsx`) + +`portGuidance.ts` — pure data and one pure function, no imports beyond `types.ts`: + +```typescript +/** Static Port_Guidance content (1.1–1.3): definition, connection rule, + * input/output distinction, per-type description + usage example. */ +export const PORT_DEFINITION: string; +export const CONNECTION_RULE: string; +export const INPUT_OUTPUT_DISTINCTION: string; +export const PORT_TYPE_GUIDANCE: Record; + +/** Typical arrangement per palette category (2.1). 'at-least-one' models + * the output category's "at least one input of any type". */ +export interface CategoryArrangement { + inputs: PortType[] | 'at-least-one'; + outputs: PortType[]; + summary: string; // human-readable arrangement text +} +export const CATEGORY_ARRANGEMENTS: Record; + +/** Divergence of a declaration from the category arrangement (2.4, 2.5): + * null when counts and the multiset of port types match on both sides; + * otherwise flags exactly the diverging side(s). Pure and deterministic. */ +export function guidanceDivergence( + category: string, + inputs: PortForm[], + outputs: PortForm[] +): { inputs: boolean; outputs: boolean } | null; +``` + +Arrangements: `input` → no inputs, one VideoFrames output; `preprocessing` → one VideoFrames input, one VideoFrames output; `inference` → one VideoFrames input, one InferenceMeta output; `post_processing` → one InferenceMeta input, one EventSignal output; `output` → at least one input, no outputs (Requirement 2.1). Divergence compares port counts and the multiset of declared port types per side (order-insensitive; `'at-least-one'` diverges only when the side is empty). + +`PortGuidancePanel.tsx` — one shared component used verbatim by both wizards (Requirement 1.5): + +```typescript +export interface PortGuidancePanelProps { + category: string; // drives the arrangement box (2.1, 2.2) + inputs: PortForm[]; // divergence advisory input (2.4) + outputs: PortForm[]; +} +``` + +Renders (all static, no network requests — Requirement 1.4): the Port definition + connection rule + input/output distinction and the three Port_Type descriptions inside a Cloudscape `ExpandableSection` (default-expanded header text, collapsible detail); the selected category's arrangement summary (re-renders on the `category` prop, Requirement 2.2); and, when `guidanceDivergence` is non-null, a dismissable non-blocking `Alert type="info"` naming the diverging side(s) (Requirement 2.4) that disappears when the divergence resolves (Requirement 2.5). The panel never contributes to `portsStepErrors` or step gating (Requirement 2.3). + +### 6. Frontend: port scan pure helpers (`portScan.ts`) + +Wire types (extending the `GstPropertiesResponse` shapes in `scan.ts`): + +```typescript +export type PadsReason = 'pads_not_captured' | 'no_pad_templates' | 'pads_read_failed'; + +export interface PortSuggestion { + name: string; + direction: 'input' | 'output'; + portType: string; // always 'VideoFrames' today + confident: boolean; // Confident_Suggestion vs Unconfirmed_Suggestion + caps: string; + capsTruncated: boolean; + reason: string; +} + +export interface UnmappedPad { + name: string; + direction: 'sink' | 'src'; + presence: 'sometimes' | 'request' | 'always'; + caveat: string; +} + +// ScanElement (scan.ts) gains optional fields the old backend simply omits: +// portSuggestions?: PortSuggestion[]; +// unmappedPads?: UnmappedPad[]; +// padsReason?: PadsReason | null; +// padsMessage?: string | null; +``` + +Pure functions: + +```typescript +/** Exactly the wizard-supplied initial lists: one input {name:'in'} and one + * output {name:'out'}, both VideoFrames (Untouched_Defaults, 6.1). */ +export function isUntouchedDefaults(inputs: PortForm[], outputs: PortForm[]): boolean; + +/** Apply Port_Suggestions to the port lists (6.1, 6.2, 6.10, 6.11). + * untouched && suggestions.length > 0: both sides replaced by the + * suggestions partitioned by direction, in suggestion order (6.1). + * Otherwise additive merge: every existing port kept unchanged; each + * suggestion whose trimmed name exactly (case-sensitively) matches an + * existing port name on either side is reported alreadyDeclared (6.2); + * the rest are appended to their side in order and reported applied + * (6.11). Empty suggestions always leave the lists unchanged (6.10). */ +export function applySuggestions( + inputs: PortForm[], + outputs: PortForm[], + suggestions: PortSuggestion[], + untouched: boolean +): { + inputs: PortForm[]; + outputs: PortForm[]; + applied: string[]; // names newly added/applied + alreadyDeclared: string[]; // names kept as declared (6.2) + unconfirmed: string[]; // applied names with confident === false (6.5) +}; + +/** Update-mode removal protection (6.9): the reason a port cannot be + * removed, or null. Blocks removing a port whose trimmed name appears on + * the same side of the existing registered declaration. */ +export function removalBlockReason( + side: 'inputs' | 'outputs', + portName: string, + existingDeclaration: Record | null +): string | null; +``` + +Element selection reuses `pickElement` from `scan.ts` unchanged, so the Port_Scan and Parameter_Scan always agree on the factory (Requirement 6.6). + +### 7. Frontend: `PortScanPanel.tsx` + +Mirrors `ParameterScanPanel` structurally (fetch on mount, one auto-apply per mount, manual button, outcome summary, unavailability alerts, all state local, upward communication through one callback): + +```typescript +export interface PortScanApplyResult { + inputs: PortForm[]; + outputs: PortForm[]; + applied: string[]; + alreadyDeclared: string[]; + unconfirmed: string[]; // names to mark as needing confirmation (6.5) +} + +export interface PortScanPanelProps { + pluginId: string; + version: number; + preferredFactory?: string; // same preference as the Parameter_Scan (6.6) + inputs: PortForm[]; // latest lists via ref, like ParameterScanPanel + outputs: PortForm[]; + onApply: (result: PortScanApplyResult) => void; +} +``` + +Behavior: + +- **Fetch on mount** via `nodeDesignerApi.getGstProperties` (the Wizard renders only the active step, so mount coincides with reaching the Ports step). **Auto-scan** applies at most once per mount, and only when the response is available, the picked element has `padsReason == null` with at least one Port_Suggestion, and `isUntouchedDefaults(inputsRef, outputsRef)` holds at apply time (Requirement 6.1). Like the parameter panel, the merge reads the wizard's latest lists through refs so concurrent user edits are never clobbered (Requirement 6.7). +- **Manual scan button** ("Scan plugin pads") re-fetches and applies on demand (Requirement 6.3); `loading` disables the button so no second scan runs concurrently while manual port controls and navigation stay untouched (Requirement 6.7). The button doubles as the retry control after a failure (Requirement 7.3). +- **Outcome summary** (Requirement 6.4): applied port names per side, already-declared names (Requirement 6.2), each Unconfirmed_Suggestion with its caps string and confirmation guidance, and each Unmapped_Pad with its name, direction, presence, and caveat. A scan yielding zero suggestions reports that outcome — including any Unmapped_Pads — and leaves the lists unchanged (Requirements 6.10, 7.5). +- **Degraded states** (Requirement 7): `available:false, reason:'no_x86_64_build'` → info notice that pre-population requires a successful x86_64 build (7.1); element `padsReason:'pads_not_captured'` → info notice that pad data is unavailable for this build (7.2 — note the report itself is `available: true`, parameters still scan); `reason:'introspection_failed'` or a thrown request error → error alert with the diagnostic and the retry button (7.3); `padsReason:'pads_read_failed'` → error alert with `padsMessage`; `padsReason:'no_pad_templates'` / zero always-pads → info notice (7.5). Every one of these renders beside — never instead of — the untouched manual port controls (7.6). + +### 8. Frontend: wizard integration + +**RegistrationWizard.tsx** Ports step: + +- Renders `PortGuidancePanel` (with `form.category`, `form.inputs`, `form.outputs`) above the port containers, then `PortScanPanel`. +- New state `unconfirmedPortNames: Set` (mirroring `scannedNames`): populated from `onApply`'s `unconfirmed` list; each unconfirmed port row shows a warning `Badge` ("confirm type") plus its caps/reason inline (Requirement 6.5). Editing the port's name or type — including re-selecting the same type, which acts as the confirmation gesture — drops the name from the set, switching the row to the confirmed presentation. +- `onApply` flows through the ordinary `patch({inputs, outputs})` path, so applied ports are indistinguishable from manual ones for editing, removal, validation, and step gating (Requirement 6.8) and `portsStepErrors` is untouched. +- The remove-port handler consults `removalBlockReason(side, port.name, existing?.declaration ?? null)`; a non-null reason disables the remove button and shows the reason as the row's `FormField` warning text (Requirement 6.9). This applies to every port row (scan-applied or manual) in update mode, since the registered declaration's dependence does not distinguish provenance. + +**CreateWizard.tsx** Ports step: + +- Renders `PortGuidancePanel` only, plus a static info line that port pre-population requires a built plugin (available later in the Registration wizard). No `PortScanPanel`, no scan control, no network request (Requirement 7.4) — the same static-only pattern the Parameters step already uses for `ParameterScanPanel`'s no-plugin notice. + +## Data Models + +### Extended Introspection_Report (version 1, additive) + +```json +{ + "reportVersion": 1, + "status": "captured", + "message": null, + "gstVersion": "1.20.3", + "capturedAt": "2025-01-01T00:00:00Z", + "elements": [ + { + "factory": "myfilter", + "elementGType": "GstMyFilter", + "instantiationError": null, + "properties": [ { "...unchanged property shape..." : "..." } ], + "pads": [ + { "name": "sink", "direction": "sink", "presence": "always", + "caps": "video/x-raw, format=(string){ RGB, BGR }", + "capsTruncated": false }, + { "name": "src_%u", "direction": "src", "presence": "request", + "caps": "ANY", "capsTruncated": false } + ], + "padsError": null + } + ] +} +``` + +- `pads` / `padsError` absent entirely → legacy report (pads not captured). +- `pads: [], padsError: ""` → per-element read failure (3.2). +- `pads: [], padsError: null` → element declares no static pad templates (3.5). +- `caps` is at most 4096 characters; `capsTruncated: true` marks capture-time truncation (3.4). The parser rejects longer caps as malformed. + +### gst-properties route response (per element, additive) + +```json +{ + "factory": "myfilter", + "suggestions": [ "...unchanged ParameterDeclaration shapes..." ], + "skipped": [ "...unchanged {name, reason} shapes..." ], + "portSuggestions": [ + { "name": "sink", "direction": "input", "portType": "VideoFrames", + "confident": true, "caps": "video/x-raw, ...", "capsTruncated": false, + "reason": "the pad's caps begin with video/x-raw" } + ], + "unmappedPads": [ + { "name": "src_%u", "direction": "src", "presence": "request", + "caveat": "request pads are created at runtime and do not correspond to fixed declared Ports" } + ], + "padsReason": null, + "padsMessage": null +} +``` + +Top-level response fields (`available`, `reason`, `message`, `gstVersion`, `capturedAt`, `elements`) are unchanged. + +### Frontend form state + +`PortForm` (`declaration.ts`) is unchanged — applied suggestions become ordinary `{name, portType}` rows. Scan provenance lives only in the wizard's `unconfirmedPortNames: Set` state, exactly like the parameter scan's `scannedNames`, so declaration assembly and submission are untouched. + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Extended report round-trip + +*For any* valid Introspection_Report — with pad data (including truncated caps and per-element pad read failures), without pad data, or mixed per element — `parse_report(serialize_report(report))` equals the original report field-for-field, and the serialized document survives a real `json.dumps`/`json.loads` cycle unchanged. + +**Validates: Requirements 4.1, 4.3** + +### Property 2: Legacy reports parse compatibly + +*For any* valid Introspection_Report, serializing it and then deleting the `pads` and `padsError` keys from every element (producing exactly the pre-feature document shape) parses successfully to a report in which every element has `pads=None` and `pads_error=None` and every other report-level, element-level, and property field equals the original. + +**Validates: Requirements 4.2** + +### Property 3: Malformed pad data is rejected, not crashed on + +*For any* valid pad-bearing report document broken by a single targeted pad mutation — a `pads` value that is not a list, a pad entry that is not an object, a dropped or mistyped pad field, a `direction` outside {sink, src}, a `presence` outside {always, sometimes, request}, or a `caps` string longer than 4096 characters — `parse_report` raises `ReportError` (and nothing else). + +**Validates: Requirements 4.4** + +### Property 4: Pad data never changes parameter suggestions + +*For any* report element, `suggestions_for_element` produces identical suggestions and skipped lists whether the element carries pad data or has it stripped (`pads=None, pads_error=None`). + +**Validates: Requirements 4.6** + +### Property 5: Pads-reason classification is total and exclusive + +*For any* report element, `ports_for_element` returns `padsReason == 'pads_not_captured'` iff `pads is None`, `'pads_read_failed'` (with the diagnostic as `padsMessage`) iff `pads == []` with a `pads_error`, `'no_pad_templates'` iff `pads == []` without one, and `None` iff `pads` is non-empty — and whenever a reason is set, both `portSuggestions` and `unmappedPads` are empty. + +**Validates: Requirements 4.7, 4.8** + +### Property 6: Derivation partitions the pads + +*For any* element with a non-empty pad list, every Pad_Template appears in exactly one of `portSuggestions` or `unmappedPads`: a pad with presence `always` and a non-whitespace name template becomes a Port_Suggestion whose direction is `input` for `sink` and `output` for `src` and whose name is the name template verbatim; a pad with presence `sometimes` or `request` becomes an Unmapped_Pad carrying its name, direction, presence, and a caveat; a pad whose name template is empty or whitespace-only becomes an Unmapped_Pad with the invalid-name caveat; and both output lists preserve the pads' report order. + +**Validates: Requirements 5.1, 5.4, 5.6** + +### Property 7: Caps prefix decides confidence + +*For any* derived Port_Suggestion, `portType` is `VideoFrames`, and `confident` is true iff the pad's caps string begins with the exact case-sensitive characters `video/x-raw` (truncated caps included); every non-confident suggestion carries the pad's caps string and a reason stating that InferenceMeta and EventSignal are DDA semantic concepts the caps cannot express. + +**Validates: Requirements 5.2, 5.3** + +### Property 8: Derived suggestions are valid and derivation is deterministic + +*For any* report element, every derived Port_Suggestion satisfies the existing Ports_Step validation rules (non-empty trimmed name, portType in the Node_Type_Catalog), and calling `ports_for_element` twice on the same element yields deeply equal results. + +**Validates: Requirements 5.5, 5.7** + +### Property 9: Caps truncation is bounded and marked + +*For any* string, `truncate_caps` returns a prefix of the input of at most 4096 characters together with a flag that is true iff the input exceeds 4096 characters; when the flag is true the returned string is exactly 4096 characters. + +**Validates: Requirements 3.4** + +### Property 10: Untouched defaults are replaced by the suggestions + +*For any* non-empty Port_Suggestion list, applying it over the Untouched_Defaults (`untouched=true`) yields input and output lists that are exactly the suggestions partitioned by direction, in suggestion order, each as `{name, portType}`; the applied names are exactly the suggestion names and the unconfirmed names are exactly the non-confident suggestions' names. + +**Validates: Requirements 6.1** + +### Property 11: Merge preserves edits and appends exactly the new names + +*For any* user-edited port lists and any Port_Suggestion list, the additive merge (`untouched=false`) keeps every existing port unchanged and in place; every suggestion whose trimmed name exactly (case-sensitively) matches an existing port's trimmed name on either side is reported in `alreadyDeclared` without modifying that port; every other suggestion is appended to its direction's list in order and reported in `applied`; and an empty suggestion list returns both lists identical to the inputs. + +**Validates: Requirements 6.2, 6.10, 6.11** + +### Property 12: Category divergence flags exactly the diverging sides + +*For any* palette category and any port declaration lists, `guidanceDivergence` returns null iff each side's port count and multiset of port types match the category's arrangement (with the output category's `at-least-one` input rule diverging only on an empty input side); otherwise the returned flags are true for exactly the side(s) whose count or type multiset differs. + +**Validates: Requirements 2.4, 2.5** + +## Error Handling + +| Condition | Layer | Behavior | +|---|---|---| +| Pad templates of one factory unreadable | dda-gst-introspect | That element records `pads: []` + `padsError` diagnostic; properties and report status untouched; build success preserved (3.2) | +| Factory load / instantiation failure | dda-gst-introspect | Existing `instantiationError` behavior; element additionally records `pads: []` + `padsError` (pad read impossible) | +| Caps string > 4096 chars | dda-gst-introspect | First 4096 chars stored with `capsTruncated: true`; pad kept (3.4) | +| Report (with pads) > 256 KiB | plugin_builds (unchanged) | Failed stanza with the existing size-cap diagnostic; build stays succeeded (3.3) | +| Malformed pad data in stored report | gst_properties / route | `parse_report` raises `ReportError`; route answers `available:false, reason:'introspection_failed'` with the diagnostic — no partial pad data, never a 500 (4.4) | +| Legacy report (no `pads` keys) | route | `available:true`; per element `padsReason:'pads_not_captured'`, empty suggestion/unmapped lists; parameter fields unchanged (4.2, 4.7) | +| Element declares no pad templates | route | `padsReason:'no_pad_templates'`, empty lists (4.8) | +| Per-element pad read failure in report | route | `padsReason:'pads_read_failed'` with `padsMessage` | +| No successful x86_64 build | PortScanPanel | Info notice (pre-population needs an x86_64 build); guidance shown; manual flow and navigation untouched (7.1, 7.6) | +| `padsReason:'pads_not_captured'` | PortScanPanel | Info notice (pad data unavailable for this build); manual flow untouched (7.2) | +| `reason:'introspection_failed'` or fetch error | PortScanPanel | Error alert with diagnostic; manual scan button remains as the retry control; manual flow untouched (7.3) | +| Scan yields zero suggestions | PortScanPanel | Lists unchanged; outcome notice incl. any Unmapped_Pads with caveats (6.10, 7.5) | +| Create wizard (no Plugin_Artifact) | CreateWizard | Guidance + category guidance + static "requires a built plugin" note; no scan, no scan control, no request (7.4) | +| Blocked port removal (update mode) | RegistrationWizard | Remove control disabled with the displayed reason from `removalBlockReason` (6.9) | +| Declaration diverges from category guidance | PortGuidancePanel | Non-blocking info advisory naming the diverging side(s); never gates steps or submission (2.3, 2.4) | + +## Testing Strategy + +Dual approach, matching the repo's existing conventions: property-based tests verify the universal properties above; example-based tests cover UI wiring, route envelopes, and degraded scenarios. Property tests run a minimum of 100 iterations and each carries the tag comment **Feature: port-guidance-and-pad-prepopulation, Property {number}: {property_text}**, one property per test. + +### Backend (pytest + Hypothesis, `edge-cv-portal/backend/tests/`) + +Property tests (one file per property, following `test_property_gst_*.py`): + +- `test_property_pad_report_roundtrip.py` — Property 1. Extends the report generators of `test_property_gst_report_roundtrip.py` with pad strategies: valid directions/presences, caps up to and at the 4096 boundary with matching `capsTruncated`, elements with `pads=None`, `pads=[]` (± `pads_error`), and populated lists. +- `test_property_pad_legacy_compat.py` — Property 2 (serialize, strip pad keys, re-parse, compare). +- `test_property_pad_report_rejection.py` — Property 3, mirroring the targeted-mutation approach of `test_property_gst_report_rejection.py` with pad-directed mutations. +- `test_property_pad_suggestions_unchanged.py` — Property 4. +- `test_property_pad_reason_classification.py` — Property 5. +- `test_property_pad_derivation_partition.py` — Property 6 (generators include whitespace-only name templates and all presences). +- `test_property_pad_caps_confidence.py` — Property 7 (caps generated with/without the `video/x-raw` prefix, case variants, truncated variants). +- `test_property_pad_suggestion_validity.py` — Property 8. +- `test_property_pad_caps_truncation.py` — Property 9, loading `plugin-build-images/dda-gst-introspect` via `importlib.util.spec_from_file_location` (its top-level imports are GI-free; only `scan()` touches GI) and testing `truncate_caps` directly. + +Example/unit tests: + +- Extend `test_plugin_gst_properties_route.py`: a stored pads-bearing report returns `portSuggestions`/`unmappedPads`/`padsReason` per element with the existing `suggestions`/`skipped` byte-identical to a pad-free control (4.5, 4.6); a legacy stored report answers `pads_not_captured` (4.7); a stored report with malformed pads answers `available:false, reason:'introspection_failed'` (4.4); empty-pad-list element answers `no_pad_templates` (4.8). +- Extend the plugin_builds stanza tests: an oversized pads-bearing report yields the failed size-cap stanza (3.3). + +Pad-template capture itself (3.1, 3.2 capture side) is integration-verified in the x86_64 build image against a real sample plugin; the pytest suite validates everything downstream of the report document. + +### Frontend (vitest + fast-check, `edge-cv-portal/frontend/src/pages/node-designer/`) + +Shared arbitraries in `portScanArbitraries.ts` (mirroring `scanArbitraries.ts`): port forms, Port_Suggestions (confident/unconfirmed, both directions), category/port-list pairs. + +Property tests (one file per property, following `*.property.test.ts`, `numRuns: 100`): + +- `portReplaceDefaults.property.test.ts` — Property 10. +- `portMergePreservation.property.test.ts` — Property 11. +- `categoryDivergence.property.test.ts` — Property 12. + +Component/example tests: + +- `PortGuidancePanel.test.tsx` — guidance content present (all three Port_Types with carries + example, connection rule, input/output distinction) (1.1–1.3); all five category arrangements defined and displayed, swap on category change (2.1, 2.2); divergence advisory appears/disappears and never blocks (2.3–2.5); no network calls (1.4). +- `PortScanPanel.test.tsx` — auto-scan once over untouched defaults (6.1); manual scan button + disabled-while-loading (6.3, 6.7); outcome rendering with unconfirmed caps/guidance and unmapped caveats (6.4); factory selection with `preferredFactory` (6.6); each degraded state (`no_x86_64_build`, `pads_not_captured`, `introspection_failed`, fetch error with retry, `no_pad_templates`/zero suggestions) rendering beside a usable manual flow (7.1–7.3, 7.5, 7.6). +- `RegistrationWizard.test.tsx` (extend) — guidance panel present on the Ports step (1.5); unconfirmed badge shown after apply and cleared on edit/confirm (6.5); applied ports editable/removable via the ordinary controls (6.8); removal blocked with reason in update mode (6.9); navigation unblocked during/after scans (6.7, 7.6). +- `CreateWizard.test.tsx` (extend) — guidance + category guidance rendered, "requires a built plugin" note, no scan control, no gst-properties request (1.5, 7.4). +- `portScan.test.ts` — concrete unit cases for `isUntouchedDefaults`, `removalBlockReason`, and boundary examples complementing the properties. diff --git a/.kiro/specs/port-guidance-and-pad-prepopulation/requirements.md b/.kiro/specs/port-guidance-and-pad-prepopulation/requirements.md new file mode 100644 index 00000000..f692d61b --- /dev/null +++ b/.kiro/specs/port-guidance-and-pad-prepopulation/requirements.md @@ -0,0 +1,133 @@ +# Requirements Document + +## Introduction + +This feature extends the Custom Node Designer (specs: custom-node-designer, gst-parameter-prepopulation) so that users understand what Ports are and choose Port types deliberately instead of guessing. Today the Ports step of the wizards defaults to one input "in" and one output "out", both typed VideoFrames, with no explanation of what a Port is, how workflow connections use it, or which of the three Port_Types (VideoFrames, InferenceMeta, EventSignal) fits which situation. + +The feature has three parts. First, the Ports step explains Ports in place: what they are, how the Workflow_Designer connects them, and how to choose a Port_Type, including typical arrangements per palette category. Second, the build-time GStreamer introspection pipeline (the same pipeline that captures element properties for parameter pre-population) additionally captures each element's static Pad_Templates — name template, direction (sink/src), presence (always/sometimes/request), and caps. Third, the Registration wizard's Ports step uses the captured Pad_Templates to pre-populate and guide the port declaration: always-present sink pads suggest input Ports, always-present src pads suggest output Ports, and caps beginning with `video/x-raw` map confidently to VideoFrames. InferenceMeta and EventSignal are DDA semantic concepts that GStreamer caps cannot express, so those suggestions stay user-confirmed with guidance. Sometimes/request pads (for example `src_%u`) do not correspond to fixed declared Ports and are surfaced as advisory notes with a caveat rather than silently added. + +Pre-population is an aid, not a lock: introspection capture remains x86_64-only, existing stored version-1 reports without pad data must keep working unchanged, and the manual port flow keeps working whenever no pad data is available. + +## Glossary + +- **Node_Designer**: The Portal capability (spec: custom-node-designer) for creating, importing, building, simulating, and registering Custom_Node_Types. +- **Workflow_Designer**: The Portal capability where users assemble workflows by connecting node Ports on a canvas. +- **Port**: One declared connection point of a Custom_Node_Type — an input Port receives data from an upstream node, an output Port sends data to a downstream node. Workflow connections join an output Port to an input Port of a compatible Port_Type. +- **Port_Type**: The declared data type of a Port, one of the Node_Type_Catalog types: VideoFrames (a stream of video frames), InferenceMeta (inference results such as detections or classifications attached to frames), EventSignal (discrete trigger or notification events). +- **Ports_Step**: The port-declaration step of the Node_Designer wizards — the Create wizard (`CreateWizard.tsx`) and the Registration wizard (`RegistrationWizard.tsx`). +- **Registration_Wizard**: The Node_Designer wizard that registers a Custom_Node_Type for a built Plugin_Record version. +- **Create_Wizard**: The Node_Designer wizard that collects a declaration and generates a Plugin_Scaffold; no Plugin_Record or Plugin_Artifact exists while it runs. +- **Port_Guidance**: The explanatory content the Ports_Step displays: what a Port is, how workflow connections use Ports, what each Port_Type carries, and which Port_Type arrangements are typical for each palette category. +- **Plugin_Record**: The stored metadata for a created, generated, or imported plugin, including per-architecture Plugin_Artifact entries (spec: custom-node-designer). +- **Plugin_Artifact**: A built plugin binary (`.so`) for one Target_Architecture stored in the Plugin_Library (spec: custom-node-designer). +- **Plugin_Build_Service**: The per-Target_Architecture CodeBuild-based build pipeline (`plugin_builds.py`, `dda-plugin-build`, `plugin-build-images/`) that compiles plugin source into Plugin_Artifacts and runs build-time introspection on x86_64. +- **Property_Introspection**: The act of loading a built Plugin_Artifact into a GStreamer runtime and reading each registered element's metadata (spec: gst-parameter-prepopulation); this feature extends it to also read static Pad_Templates. +- **Introspection_Report**: The stored, structured result of Property_Introspection for one Plugin_Artifact (spec: gst-parameter-prepopulation); this feature extends it with per-element Pad_Template data. +- **Pad_Template**: One static pad template a GStreamer element class declares: the name template (for example `sink`, `src`, `src_%u`), the Pad_Direction, the Pad_Presence, and the Caps. +- **Pad_Direction**: The data direction of a Pad_Template: sink (the element receives data) or src (the element produces data). +- **Pad_Presence**: The availability of a Pad_Template's pads: always (a pad exists on every element instance), sometimes (a pad may appear during runtime), or request (a pad is created on demand, often with a numbered name template such as `src_%u`). +- **Caps**: The GStreamer capabilities string of a Pad_Template describing the media formats the pad accepts or produces (for example `video/x-raw`, `video/x-raw(memory:NVMM)`, `ANY`). +- **Port_Suggestion**: One pre-populated Port declaration derived from a Pad_Template: the Port name, the Port_Type, whether the mapping is confident or needs user confirmation, and the derivation reason. +- **Confident_Suggestion**: A Port_Suggestion whose Port_Type mapping is derived directly from the Caps (Caps beginning with `video/x-raw` map to VideoFrames). +- **Unconfirmed_Suggestion**: A Port_Suggestion whose Port_Type cannot be derived from the Caps because InferenceMeta and EventSignal are DDA semantic concepts not expressible in Caps; the user confirms the Port_Type with guidance. +- **Unmapped_Pad**: A Pad_Template that does not map to a declared Port (Pad_Presence sometimes or request), surfaced to the user as an advisory note with a caveat. +- **Port_Scan**: The Ports_Step action that fetches the Introspection_Report for the wizard's Plugin_Record version, derives Port_Suggestions and Unmapped_Pads from the Pad_Templates, and applies the Port_Suggestions to the port lists. +- **Untouched_Defaults**: The wizard-supplied initial port lists (one input named "in" and one output named "out", both VideoFrames) that the user has not edited. + +## Requirements + +### Requirement 1: Explain Ports in the Ports Step + +**User Story:** As a computer vision engineer who has never declared a node type before, I want the Ports step to explain what ports are and how workflows use them, so that I understand what I am declaring instead of guessing. + +#### Acceptance Criteria + +1. WHEN a user reaches the Ports_Step of the Create_Wizard or the Registration_Wizard, THE Ports_Step SHALL display Port_Guidance within the Ports_Step view, without requiring navigation away from the Ports_Step, explaining what a Port is and stating the connection rule the Workflow_Designer enforces: a workflow connection joins an output Port to an input Port of a compatible Port_Type. +2. THE Port_Guidance SHALL describe each Port_Type in the Node_Type_Catalog (VideoFrames, InferenceMeta, EventSignal) with the data the Port_Type carries and at least one usage example per Port_Type that names a node role and states whether that role uses the Port_Type as an input or an output. +3. THE Port_Guidance SHALL describe the distinction between input Ports (data the node receives) and output Ports (data the node produces). +4. THE Ports_Step SHALL display the Port_Guidance when no Plugin_Record, Plugin_Artifact, or Introspection_Report exists, and SHALL NOT issue any network request solely to render the Port_Guidance. +5. THE Ports_Step SHALL display identical Port_Guidance content for the Port definition, the input/output distinction, and the Port_Type descriptions in both the Create_Wizard and the Registration_Wizard. + +### Requirement 2: Pre-Selection Guidance by Palette Category + +**User Story:** As a computer vision engineer, I want the Ports step to tell me which port arrangement is typical for the kind of node I am building, so that I pick sensible port types for my node's role without trial and error. + +#### Acceptance Criteria + +1. WHEN the Ports_Step is displayed and a palette category is selected, THE Ports_Step SHALL display the typical input and output Port_Type arrangement defined for the selected category, with a guidance arrangement defined for each of the five palette categories (input, preprocessing, inference, post_processing, output; for example: preprocessing nodes typically declare one VideoFrames input and one VideoFrames output, inference nodes typically declare one VideoFrames input and one InferenceMeta output, output nodes typically declare at least one input and no outputs). +2. WHEN the user changes the selected palette category, THE Ports_Step SHALL replace the displayed category guidance with the newly selected category's guidance without requiring the user to navigate away from and back to the Ports_Step. +3. THE Ports_Step SHALL accept any port declaration that passes the existing port validation rules, regardless of whether the declaration matches the displayed category guidance. +4. IF the user's declared ports differ from the displayed category guidance in the count of input ports, the count of output ports, or the Port_Type of any declared port, THEN THE Ports_Step SHALL display a non-blocking advisory warning that identifies which side of the declaration diverges (inputs, outputs, or both) while continuing to accept the declaration. +5. WHEN the user edits the port declaration so that it no longer diverges from the displayed category guidance, THE Ports_Step SHALL remove the advisory warning. + +### Requirement 3: Capture Pad Templates in the Introspection Report + +**User Story:** As a computer vision engineer, I want the portal to read my plugin's actual pad templates from the built binary, so that port pre-population reflects what the element really declares instead of what I remember from the source. + +#### Acceptance Criteria + +1. WHEN the Plugin_Build_Service performs Property_Introspection on a successfully built x86_64 Plugin_Artifact, THE Plugin_Build_Service SHALL record in the Introspection_Report, for each element factory, every static Pad_Template with its name template, Pad_Direction, Pad_Presence, and Caps string. +2. IF reading the Pad_Templates of one element factory fails, THEN THE Plugin_Build_Service SHALL record that element with an empty Pad_Template list and a diagnostic message describing the read failure, SHALL preserve the element's property data unchanged, and SHALL preserve the build's success status. +3. IF an Introspection_Report including Pad_Template data exceeds the existing 256 KiB size cap, THEN THE Plugin_Build_Service SHALL record the introspection outcome as failed with a diagnostic message indicating the size cap was exceeded and SHALL preserve the build's success status. +4. WHERE a Pad_Template's Caps string exceeds 4096 characters, THE Plugin_Build_Service SHALL record the first 4096 characters of the Caps string together with a machine-readable truncation indicator on that Pad_Template rather than omitting the Pad_Template. +5. WHEN an element factory declares no static Pad_Templates, THE Plugin_Build_Service SHALL record that element with an empty Pad_Template list and no diagnostic message, distinguishable from the read-failure case in criterion 2. + +### Requirement 4: Report Compatibility and Serialization + +**User Story:** As a developer, I want the extended report shape parsed and served without breaking existing stored reports, so that plugins built before this feature keep working exactly as before. + +#### Acceptance Criteria + +1. WHEN the Node_Designer parses a stored Introspection_Report that contains Pad_Template data, THE Node_Designer SHALL parse, for each element factory, every Pad_Template with its name template, Pad_Direction, Pad_Presence, Caps string, and Caps truncation marker, alongside the element's existing property data. +2. WHEN the Node_Designer parses a stored version-1 Introspection_Report that contains no Pad_Template data, THE Node_Designer SHALL parse the report successfully, SHALL report an empty Pad_Template list for every element factory, and SHALL produce element property data field-for-field identical to the parse result produced before this feature for the same stored report. +3. WHEN the Node_Designer serializes a valid Introspection_Report containing Pad_Template data and then parses the serialized document, THE Node_Designer SHALL produce an Introspection_Report in which every report-level field, every element field, and every Pad_Template field (name template, Pad_Direction, Pad_Presence, Caps string, Caps truncation marker) equals the corresponding field of the original report. +4. IF a stored Introspection_Report contains malformed Pad_Template data — a Pad_Template collection that is not a list, a Pad_Template entry that is not a record, or a Pad_Template entry with a missing or mistyped field, a Pad_Direction outside the set {sink, src}, or a Pad_Presence outside the set {always, sometimes, request} — THEN THE Node_Designer API SHALL reject the entire report without returning any partial Pad_Template data and SHALL respond with the existing "introspection failed" unavailability reason instead of an internal error. +5. WHEN an Introspection_Report is requested through the existing gst-properties API route, THE Node_Designer SHALL return, for each element factory, the derived Port_Suggestions and Unmapped_Pads alongside the existing Parameter_Suggestions. +6. WHEN the gst-properties API route serves any stored Introspection_Report, THE Node_Designer SHALL return the existing Parameter_Suggestion and skipped-property response fields with unchanged names, structure, and values compared to the response produced before this feature for the same stored report. +7. IF the stored Introspection_Report is a version-1 report containing no Pad_Template data, THEN THE Node_Designer API SHALL return an empty Port_Suggestion list and an empty Unmapped_Pad list for every element factory, each accompanied by a machine-readable reason indicating the report predates pad capture. +8. IF the stored Introspection_Report contains Pad_Template data and the requested element factory's Pad_Template list is empty, THEN THE Node_Designer API SHALL return an empty Port_Suggestion list and an empty Unmapped_Pad list for that element factory, each accompanied by a machine-readable reason indicating the element declares no pad templates. + +### Requirement 5: Derive Port Suggestions from Pad Templates + +**User Story:** As a computer vision engineer, I want the declared pads of my element converted into sensible port suggestions, so that the declared ports match the element's real connection points. + +#### Acceptance Criteria + +1. WHEN deriving Port_Suggestions from an element's Pad_Templates, THE Node_Designer SHALL map each Pad_Template with Pad_Presence always and Pad_Direction sink to an input Port_Suggestion, and each Pad_Template with Pad_Presence always and Pad_Direction src to an output Port_Suggestion, using the Pad_Template's name template as the suggested Port name and preserving the order in which the Pad_Templates appear in the Introspection_Report. +2. WHEN a mapped Pad_Template's Caps string, including a Caps string marked as truncated, begins with the exact case-sensitive characters `video/x-raw`, THE Node_Designer SHALL produce a Confident_Suggestion with Port_Type VideoFrames. +3. WHEN a Pad_Template maps to a Port_Suggestion per criterion 5.1 and its Caps string does not begin with the exact case-sensitive characters `video/x-raw`, THE Node_Designer SHALL produce an Unconfirmed_Suggestion carrying the Caps string and Port_Type defaulted to VideoFrames, and the derivation reason SHALL state that InferenceMeta and EventSignal are DDA semantic concepts the Caps cannot express. +4. WHEN an element declares a Pad_Template with Pad_Presence sometimes or request, THE Node_Designer SHALL report the Pad_Template as an Unmapped_Pad with its name template, Pad_Direction, Pad_Presence, and a caveat explaining that such pads do not correspond to fixed declared Ports. +5. THE Node_Designer SHALL derive only Port_Suggestions that satisfy the existing Ports_Step validation rules (non-empty Port name, Port_Type from the Node_Type_Catalog). +6. IF using a Pad_Template's name template as the Port name would violate the existing Ports_Step validation rules, THEN THE Node_Designer SHALL exclude that Pad_Template from the Port_Suggestions and SHALL report it as an Unmapped_Pad with a caveat stating that its name template is not a valid Port name. +7. WHEN the same Introspection_Report Pad_Template data is derived more than once, THE Node_Designer SHALL produce identical Port_Suggestions and Unmapped_Pads on every derivation. + +### Requirement 6: Port Scan in the Registration Wizard + +**User Story:** As a computer vision engineer, I want the Ports step of the Registration wizard pre-populated from the built plugin's pads, so that I start from the element's real connection points instead of the generic defaults. + +#### Acceptance Criteria + +1. WHEN a user reaches the Ports_Step of the Registration_Wizard, the wizard's Plugin_Record version has an available Introspection_Report containing Pad_Template data, and the port lists are the Untouched_Defaults, THE Ports_Step SHALL automatically run the Port_Scan without any user action and, on scan completion with at least one Port_Suggestion, SHALL replace the Untouched_Defaults with the derived Port_Suggestions. +2. WHEN a Port_Scan completes and the user has edited the port lists, THE Ports_Step SHALL keep every user-edited Port declaration unchanged and SHALL report each Port_Suggestion whose name exactly matches (case-sensitive comparison) the name of an existing Port as already declared, without modifying that Port. +3. THE Ports_Step SHALL provide a manual scan control that runs the Port_Scan on demand. +4. WHEN a Port_Scan completes, THE Ports_Step SHALL display the scan outcome: the Port_Suggestions applied, each Unconfirmed_Suggestion with its Caps string and confirmation guidance, and each Unmapped_Pad with its caveat. +5. WHILE an Unconfirmed_Suggestion's Port_Type has not been confirmed or edited by the user, THE Ports_Step SHALL display a visible indicator on that Port marking it as needing Port_Type confirmation, distinct from the presentation of confirmed Ports. +6. WHERE a Plugin_Artifact registers more than one element factory, THE Ports_Step SHALL derive Port_Suggestions from the same element factory the Parameter_Scan selects, selecting the element factory whose name exactly matches the wizard's declared element factory when one matches. +7. WHILE a Port_Scan is in progress, THE Ports_Step SHALL keep the manual port controls (add, edit, remove) usable, SHALL disable the manual scan control so that no second Port_Scan starts concurrently, and SHALL never block step navigation on the scan. +8. WHEN a Port_Scan applies Port_Suggestions, THE Ports_Step SHALL leave every applied Port editable and removable by the user exactly like a manually added Port. +9. IF removing a scan-applied Port would make the declaration invalid under the existing port validation rules, or would remove a Port that the existing registered declaration depends on when the Registration_Wizard is updating an already-registered Custom_Node_Type, THEN THE Ports_Step SHALL block the removal and SHALL display the reason the Port is required. +10. IF a Port_Scan completes with zero derived Port_Suggestions, THEN THE Ports_Step SHALL leave the existing port lists unchanged and SHALL display the scan outcome indicating that no Port_Suggestions were derived, including any Unmapped_Pads with their caveats. +11. WHEN a Port_Scan completes and the user has edited the port lists, THE Ports_Step SHALL add each Port_Suggestion whose name matches no existing Port to the port lists as an applied Port, alongside the user-edited Ports. + +### Requirement 7: Degraded and Failure Behavior + +**User Story:** As a computer vision engineer, I want the Ports step to work exactly as before when pad data is unavailable, so that port pre-population never becomes a gate on declaring ports. + +#### Acceptance Criteria + +1. IF the wizard's Plugin_Record version has no successful x86_64 Plugin_Artifact, THEN THE Ports_Step SHALL display an informational notice stating that port pre-population requires a successful x86_64 build, SHALL still display the Port_Guidance, SHALL keep the current port lists unchanged, and SHALL keep the manual port flow (adding, editing, and removing port rows) usable. +2. IF the stored Introspection_Report predates pad capture (contains no Pad_Template data), THEN THE Ports_Step SHALL display an informational notice stating that pad data is unavailable for this build, SHALL still display the Port_Guidance, SHALL keep the current port lists unchanged, and SHALL keep the manual port flow (adding, editing, and removing port rows) usable. +3. IF the stored Introspection_Report has a failure status or the Port_Scan API request fails, THEN THE Ports_Step SHALL display the failure with its diagnostic message, SHALL still display the Port_Guidance, SHALL keep the current port lists unchanged, SHALL keep the manual port flow (adding, editing, and removing port rows) usable, and SHALL keep the manual scan control available so the user can retry the Port_Scan. +4. WHEN a user reaches the Ports_Step of the Create_Wizard, THE Ports_Step SHALL display the Port_Guidance and the category guidance, SHALL state that port pre-population requires a built plugin, and SHALL NOT run the Port_Scan or display the manual scan control, since no Plugin_Artifact exists during creation. +5. IF a Port_Scan completes against an Introspection_Report that contains Pad_Template data but derives zero Port_Suggestions (the element declares no Pad_Templates with Pad_Presence always), THEN THE Ports_Step SHALL display an informational notice that the element declares no always-present pads, SHALL keep the current port lists unchanged, and SHALL keep the manual port flow (adding, editing, and removing port rows) usable. +6. WHILE pad data is unavailable for any reason described in criteria 7.1 through 7.3, THE Ports_Step SHALL accept any port declaration that passes the existing port validation rules and SHALL NOT block step navigation or declaration submission on pad-data availability. diff --git a/.kiro/specs/port-guidance-and-pad-prepopulation/tasks.md b/.kiro/specs/port-guidance-and-pad-prepopulation/tasks.md new file mode 100644 index 00000000..82c18f40 --- /dev/null +++ b/.kiro/specs/port-guidance-and-pad-prepopulation/tasks.md @@ -0,0 +1,205 @@ +# Implementation Plan: Port Guidance and Pad Pre-population + +## Overview + +Implementation proceeds bottom-up along the data flow, mirroring the gst-parameter-prepopulation spec: first the backend pure module (`gst_properties.py` pad parsing/serialization and the `ports_for_element` derivation — the core everything consumes), then the serving route extension, then the build-image capture side (`dda-gst-introspect`), then the frontend in two layers (static guidance module/panel, then port-scan helpers/panel/wizard wiring). Property tests sit directly beside the code they validate: 9 backend Hypothesis properties and 3 frontend fast-check properties, one file per property, each tagged `Feature: port-guidance-and-pad-prepopulation, Property {n}: {title}` with ≥100 iterations. + +Backend: Python — pytest + Hypothesis in `edge-cv-portal/backend/tests/`, venv at `/home/ubuntu/backend-test-venv`. Frontend: TypeScript — vitest + fast-check in `edge-cv-portal/frontend/src/pages/node-designer/`, always run with `--run`. + +## Tasks + +- [x] 1. Extend the backend pure module `gst_properties.py` + - [x] 1.1 Add `PadTemplate` and extend report parsing/serialization + - In `edge-cv-portal/backend/functions/gst_properties.py`: add the pad constants (`VALID_PAD_DIRECTIONS`, `VALID_PAD_PRESENCES`, `MAX_CAPS_LEN = 4096`) and the frozen `PadTemplate` dataclass (`name`, `direction`, `presence`, `caps`, `caps_truncated`) + - Extend `ReportElement` with `pads: Optional[List[PadTemplate]] = None` and `pads_error: Optional[str] = None` (legacy-compatible defaults; invariant: `pads_error` non-None only when `pads == []`) + - `_parse_element`: absent `pads` key → `pads=None, pads_error=None` (stray `padsError` ignored); present → strictly validate each entry (all five fields present and correctly typed, `direction`/`presence` in the valid sets, `caps` length ≤ 4096, `capsTruncated` bool), any violation raises `ReportError` + - `_serialize_element`: omit both keys when `pads is None` (byte-identical legacy output); otherwise emit `pads` (wire keys `name`, `direction`, `presence`, `caps`, `capsTruncated`) and `padsError` + - _Requirements: 4.1, 4.2, 4.3, 4.4, 3.4_ + + - [x] 1.2 Write property test for the extended report round-trip + - **Property 1: Extended report round-trip** + - `edge-cv-portal/backend/tests/test_property_pad_report_roundtrip.py`: extend the report generators of `test_property_gst_report_roundtrip.py` with pad strategies — valid directions/presences, caps up to and at the 4096 boundary with matching `capsTruncated`, elements with `pads=None`, `pads=[]` (± `pads_error`), and populated lists; assert `parse_report(serialize_report(r)) == r` through a real `json.dumps`/`json.loads` cycle; ≥100 iterations + - **Validates: Requirements 4.1, 4.3** + + - [x] 1.3 Write property test for legacy report compatibility + - **Property 2: Legacy reports parse compatibly** + - `edge-cv-portal/backend/tests/test_property_pad_legacy_compat.py`: serialize a valid report, delete the `pads`/`padsError` keys from every element (the exact pre-feature shape), re-parse, and assert every element has `pads=None, pads_error=None` while all other report/element/property fields equal the original + - **Validates: Requirements 4.2** + + - [x] 1.4 Write property test for malformed pad rejection + - **Property 3: Malformed pad data is rejected, not crashed on** + - `edge-cv-portal/backend/tests/test_property_pad_report_rejection.py`: mirror the targeted-mutation approach of `test_property_gst_report_rejection.py` with pad-directed single mutations — non-list `pads`, non-object entry, dropped/mistyped field, `direction` outside {sink, src}, `presence` outside {always, sometimes, request}, `caps` longer than 4096 — and assert `parse_report` raises `ReportError` and nothing else + - **Validates: Requirements 4.4** + + - [x] 1.5 Write property test for parameter-suggestion isolation + - **Property 4: Pad data never changes parameter suggestions** + - `edge-cv-portal/backend/tests/test_property_pad_suggestions_unchanged.py`: for any generated element, `suggestions_for_element` returns identical suggestions and skipped lists whether the element carries pad data or has it stripped (`pads=None, pads_error=None`) + - **Validates: Requirements 4.6** + + - [x] 1.6 Implement `ports_for_element` derivation + - In `gst_properties.py`: add `PORT_TYPE_VIDEO_FRAMES`, `CONFIDENT_CAPS_PREFIX = 'video/x-raw'`, and the reason constants `PADS_REASON_NOT_CAPTURED` / `PADS_REASON_NO_TEMPLATES` / `PADS_REASON_READ_FAILED` + - Pure `ports_for_element(element)` returning `{'portSuggestions', 'unmappedPads', 'padsReason', 'padsMessage'}` with the mutually exclusive reason classification (`pads is None` → `pads_not_captured`; `pads == []` with error → `pads_read_failed` + diagnostic; `pads == []` without → `no_pad_templates`; non-empty → `None`) + - Walk pads in report order, each landing in exactly one list: non-`always` presence → Unmapped_Pad with the runtime-pads caveat; empty/whitespace name template → Unmapped_Pad with the invalid-name caveat; otherwise Port_Suggestion with `direction` sink→`input`/src→`output`, name verbatim, `portType: 'VideoFrames'`, `confident = caps.startswith('video/x-raw')`, the confident/unconfirmed reason text, and `caps`/`capsTruncated` carried through + - _Requirements: 4.7, 4.8, 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ + + - [x] 1.7 Write property test for reason classification + - **Property 5: Pads-reason classification is total and exclusive** + - `edge-cv-portal/backend/tests/test_property_pad_reason_classification.py`: assert the iff mapping of the four element states to `padsReason`/`padsMessage`, and that whenever a reason is set both `portSuggestions` and `unmappedPads` are empty + - **Validates: Requirements 4.7, 4.8** + + - [x] 1.8 Write property test for the derivation partition + - **Property 6: Derivation partitions the pads** + - `edge-cv-portal/backend/tests/test_property_pad_derivation_partition.py`: generators include whitespace-only name templates and all presences; assert every pad appears in exactly one output list with the correct direction mapping, verbatim names, the correct caveat per unmapped case, and report-order preservation in both lists + - **Validates: Requirements 5.1, 5.4, 5.6** + + - [x] 1.9 Write property test for caps confidence + - **Property 7: Caps prefix decides confidence** + - `edge-cv-portal/backend/tests/test_property_pad_caps_confidence.py`: caps generated with/without the `video/x-raw` prefix, case variants, truncated variants; assert `portType` is always `VideoFrames`, `confident` iff the exact case-sensitive prefix, and every non-confident suggestion carries the caps string plus the DDA-semantic-concepts reason + - **Validates: Requirements 5.2, 5.3** + + - [x] 1.10 Write property test for suggestion validity and determinism + - **Property 8: Derived suggestions are valid and derivation is deterministic** + - `edge-cv-portal/backend/tests/test_property_pad_suggestion_validity.py`: every derived Port_Suggestion satisfies the Ports_Step validation rules (non-empty trimmed name, portType in the Node_Type_Catalog), and two calls on the same element yield deeply equal results + - **Validates: Requirements 5.5, 5.7** + +- [x] 2. Checkpoint - Ensure all tests pass + - Ensure all backend pure-module tests pass (run pytest from the venv at `/home/ubuntu/backend-test-venv`), ask the user if questions arise. + +- [x] 3. Extend the serving API route + - [x] 3.1 Extend `get_version_gst_properties` in `plugin_records.py` + - In the available path of the gst-properties route in `edge-cv-portal/backend/functions/plugin_records.py`: call `ports_for_element(element)` next to the existing `suggestions_for_element` and add `portSuggestions`, `unmappedPads`, `padsReason`, `padsMessage` to each element entry, leaving `factory`/`suggestions`/`skipped` and the top-level envelope (`available`, `reason`, `message`, `gstVersion`, `capturedAt`, `elements`) untouched + - No other route logic changes: malformed pad data surfaces through the existing `parse_report` → `ReportError` → `introspection_failed` mapping + - _Requirements: 4.5, 4.6, 4.7, 4.8, 3.2_ + + - [x] 3.2 Extend the route unit tests + - In `edge-cv-portal/backend/tests/test_plugin_gst_properties_route.py`: a stored pads-bearing report returns `portSuggestions`/`unmappedPads`/`padsReason` per element with `suggestions`/`skipped` byte-identical to a pad-free control; a legacy stored report answers `available:true` with `padsReason:'pads_not_captured'` and empty lists; a stored report with malformed pads answers `available:false, reason:'introspection_failed'`; an empty-pad-list element answers `no_pad_templates`; a `pads_read_failed` element carries its `padsMessage` + - _Requirements: 4.4, 4.5, 4.6, 4.7, 4.8_ + +- [x] 4. Extend build-time pad capture + - [x] 4.1 Extend `dda-gst-introspect` with pad-template capture + - In `edge-cv-portal/plugin-build-images/dda-gst-introspect`: add the module-top-level, GI-free pure helpers `MAX_CAPS_LEN = 4096` and `truncate_caps(caps)`; add `describe_pad_templates(factory, Gst)` reading `factory.get_static_pad_templates()` with the direction/presence enum tables and caps via `template.get_caps().to_string()` + - Wire into `describe_factory`: per-element read failure (including unmappable enum values) degrades that element to `pads: []` + `padsError` diagnostic with property data and report status untouched; a factory with no templates records `pads: []` + `padsError: null`; the load-failure and instantiation-failure early returns also emit `pads: []` + `padsError` + - _Requirements: 3.1, 3.2, 3.4, 3.5_ + + - [x] 4.2 Write property test for caps truncation + - **Property 9: Caps truncation is bounded and marked** + - `edge-cv-portal/backend/tests/test_property_pad_caps_truncation.py`: load `plugin-build-images/dda-gst-introspect` via `importlib.util.spec_from_file_location` (top-level imports are GI-free) and test `truncate_caps` directly — result is a prefix of at most 4096 chars, flag true iff input exceeds 4096, and exactly 4096 chars when the flag is true + - **Validates: Requirements 3.4** + + - [x] 4.3 Extend the plugin_builds stanza tests for oversized pads-bearing reports + - Extend the existing stanza tests: an oversized pads-bearing report (over the 256 KiB cap) yields the failed stanza with the size-cap diagnostic while the build status stays succeeded — exercising the unchanged `build_gst_introspection_stanza` against the extended report shape + - _Requirements: 3.3_ + + - [x] 4.4 Rebuild and push the x86_64 build image (optional — deploys to the live ECR repo) + - Run `plugin-build-images/build-and-push.sh x86_64` from `edge-cv-portal/` to publish the updated `dda-gst-introspect`; only plugins rebuilt afterwards carry pad data — earlier builds surface as `pads_not_captured` + - _Requirements: 3.1_ + +- [x] 5. Checkpoint - Ensure all tests pass + - Ensure the full backend suite passes, ask the user if questions arise. + +- [x] 6. Implement the frontend static guidance + - [x] 6.1 Create `portGuidance.ts` static data and divergence function + - New `edge-cv-portal/frontend/src/pages/node-designer/portGuidance.ts` (pure, no imports beyond `types.ts`): `PORT_DEFINITION`, `CONNECTION_RULE`, `INPUT_OUTPUT_DISTINCTION`, `PORT_TYPE_GUIDANCE` (carries + node-role example per Port_Type), and `CATEGORY_ARRANGEMENTS` for all five palette categories (input, preprocessing, inference, post_processing, output with `'at-least-one'` inputs) + - Pure `guidanceDivergence(category, inputs, outputs)`: null iff each side's port count and multiset of port types match the arrangement (`'at-least-one'` diverges only on an empty input side); otherwise flags exactly the diverging side(s) + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.4, 2.5_ + + - [x] 6.2 Write property test for category divergence + - **Property 12: Category divergence flags exactly the diverging sides** + - `edge-cv-portal/frontend/src/pages/node-designer/categoryDivergence.property.test.ts`: fast-check arbitraries over categories and port lists; assert the null-iff-matching characterization and the exact per-side flags; `numRuns: 100` + - **Validates: Requirements 2.4, 2.5** + + - [x] 6.3 Create `PortGuidancePanel.tsx` + - New shared component (`category`, `inputs`, `outputs` props), fully static with no network access: Port definition + connection rule + input/output distinction and the three Port_Type descriptions in a Cloudscape `ExpandableSection`; the selected category's arrangement summary re-rendering on the `category` prop; a dismissable non-blocking `Alert type="info"` naming the diverging side(s) when `guidanceDivergence` is non-null, disappearing when the divergence resolves; never contributes to `portsStepErrors` or step gating + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [x] 6.4 Write component tests for the guidance panel + - `edge-cv-portal/frontend/src/pages/node-designer/PortGuidancePanel.test.tsx`: guidance content present (all three Port_Types with carries + example, connection rule, input/output distinction); all five category arrangements defined and displayed, swapping on category change; divergence advisory appears/disappears and never blocks; no network calls + - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_ + +- [x] 7. Implement the frontend port-scan pure helpers + - [x] 7.1 Create `portScan.ts` and extend the scan wire types + - Extend `ScanElement` in `edge-cv-portal/frontend/src/pages/node-designer/scan.ts` with the optional response fields (`portSuggestions?`, `unmappedPads?`, `padsReason?`, `padsMessage?`) an old backend simply omits + - New `portScan.ts` with the `PadsReason`/`PortSuggestion`/`UnmappedPad` types and the pure functions: `isUntouchedDefaults` (exactly one input `in` + one output `out`, both VideoFrames), `applySuggestions` (untouched + non-empty → replace with the suggestions partitioned by direction in order; otherwise additive merge keeping every existing port unchanged, exact case-sensitive trimmed-name collisions → `alreadyDeclared`, the rest appended in order → `applied`, non-confident applied names → `unconfirmed`; empty suggestions leave the lists unchanged), and `removalBlockReason` (update-mode protection for ports the registered declaration depends on) + - Element selection reuses `pickElement` from `scan.ts` unchanged + - _Requirements: 4.5, 6.1, 6.2, 6.6, 6.9, 6.10, 6.11_ + + - [x] 7.2 Write property test for the untouched-defaults replacement + - **Property 10: Untouched defaults are replaced by the suggestions** + - Create the shared arbitraries module `edge-cv-portal/frontend/src/pages/node-designer/portScanArbitraries.ts` (mirroring `scanArbitraries.ts`: port forms, confident/unconfirmed suggestions in both directions, category/port-list pairs) + - `portReplaceDefaults.property.test.ts`: for any non-empty suggestion list applied over the Untouched_Defaults with `untouched=true`, the lists are exactly the suggestions partitioned by direction in order as `{name, portType}`, `applied` is exactly the suggestion names, `unconfirmed` exactly the non-confident names; `numRuns: 100` + - **Validates: Requirements 6.1** + + - [x] 7.3 Write property test for merge preservation + - **Property 11: Merge preserves edits and appends exactly the new names** + - `portMergePreservation.property.test.ts` (using `portScanArbitraries.ts`): the additive merge keeps every existing port unchanged and in place, reports exact case-sensitive trimmed-name matches in `alreadyDeclared` without modification, appends every other suggestion to its side in order reported in `applied`, and returns identical lists for an empty suggestion list; `numRuns: 100` + - **Validates: Requirements 6.2, 6.10, 6.11** + + - [x] 7.4 Write unit tests for the helper boundaries + - `edge-cv-portal/frontend/src/pages/node-designer/portScan.test.ts`: concrete cases for `isUntouchedDefaults` (defaults, renamed, retyped, added/removed rows) and `removalBlockReason` (declaration-dependent port blocked with reason, unrelated port allowed, null declaration), plus boundary examples complementing the properties + - _Requirements: 6.1, 6.9_ + +- [x] 8. Implement the scan panel and wire both wizards + - [x] 8.1 Create `PortScanPanel.tsx` + - New `edge-cv-portal/frontend/src/pages/node-designer/PortScanPanel.tsx` mirroring `ParameterScanPanel`: fetch on mount via `nodeDesignerApi.getGstProperties`; element picked with `pickElement` + `preferredFactory`; auto-apply at most once per mount only when available, `padsReason == null`, at least one suggestion, and `isUntouchedDefaults` holds at apply time against the wizard's latest lists read through refs; "Scan plugin pads" manual button disabled while loading (doubles as the retry control); outcome summary (applied names per side, alreadyDeclared, each Unconfirmed_Suggestion with caps + confirmation guidance, each Unmapped_Pad with name/direction/presence/caveat, zero-suggestion outcome); degraded states (`no_x86_64_build` info, `pads_not_captured` info, `introspection_failed`/fetch error alert with diagnostic + retry, `pads_read_failed` error with `padsMessage`, `no_pad_templates` info) always rendered beside — never instead of — the manual port controls; upward communication through the single `onApply(PortScanApplyResult)` callback + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.6, 6.7, 6.10, 7.1, 7.2, 7.3, 7.5, 7.6_ + + - [x] 8.2 Write component tests for the scan panel + - `edge-cv-portal/frontend/src/pages/node-designer/PortScanPanel.test.tsx`: auto-scan once over untouched defaults and not when edited; manual scan button + disabled-while-loading; outcome rendering with unconfirmed caps/guidance and unmapped caveats; factory selection with `preferredFactory`; each degraded state (`no_x86_64_build`, `pads_not_captured`, `introspection_failed`, fetch error with retry, `no_pad_templates`/zero suggestions) rendering beside a usable manual flow + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.6, 6.7, 6.10, 7.1, 7.2, 7.3, 7.5, 7.6_ + + - [x] 8.3 Wire the Ports step of `RegistrationWizard.tsx` + - Render `PortGuidancePanel` (with `form.category`, `form.inputs`, `form.outputs`) above the port containers, then `PortScanPanel` (plugin id/version, `preferredFactory` from the wizard's declared element factory, latest lists, `onApply`) + - New `unconfirmedPortNames: Set` state (mirroring `scannedNames`) populated from `onApply`'s `unconfirmed`; unconfirmed port rows show a warning `Badge` ("confirm type") with caps/reason inline; editing the port's name or type (including re-selecting the same type as the confirmation gesture) drops the name from the set + - `onApply` flows through the ordinary `patch({inputs, outputs})` path so applied ports stay indistinguishable from manual ones for editing, removal, validation, and step gating; the remove-port handler consults `removalBlockReason(side, port.name, existing?.declaration ?? null)` and a non-null reason disables the remove button with the reason as the row's `FormField` warning text + - _Requirements: 1.5, 6.1, 6.5, 6.7, 6.8, 6.9_ + + - [x] 8.4 Wire the Ports step of `CreateWizard.tsx` + - Render `PortGuidancePanel` only, plus a static info line that port pre-population requires a built plugin (available later in the Registration wizard); no `PortScanPanel`, no scan control, no network request + - _Requirements: 1.5, 7.4_ + + - [x] 8.5 Extend the RegistrationWizard component tests + - Extend `edge-cv-portal/frontend/src/pages/node-designer/RegistrationWizard.test.tsx`: guidance panel present on the Ports step; unconfirmed badge shown after apply and cleared on edit/confirm; applied ports editable/removable via the ordinary controls; removal blocked with the displayed reason in update mode; step navigation unblocked during and after scans + - _Requirements: 1.5, 6.5, 6.7, 6.8, 6.9, 7.6_ + + - [x] 8.6 Extend the CreateWizard component tests + - Extend `edge-cv-portal/frontend/src/pages/node-designer/CreateWizard.test.tsx`: guidance + category guidance rendered on the Ports step, the "requires a built plugin" note present, no scan control, and no gst-properties request issued + - _Requirements: 1.5, 7.4_ + +- [x] 9. Final checkpoint - Ensure all tests pass + - Run the full backend suite (pytest from `/home/ubuntu/backend-test-venv`) and the full frontend suite (`vitest --run`); ensure all tests pass, ask the user if questions arise. + +## Notes + +- Task 4.4 is marked with `*` (optional): it pushes the rebuilt x86_64 build image to the live ECR repository and can be deferred; until it runs, new builds surface as `pads_not_captured` and the degraded flow of Requirement 7.2 applies +- Each task references specific requirements for traceability +- Property tests run ≥100 iterations, one property per test, each tagged `Feature: port-guidance-and-pad-prepopulation, Property {n}: {title}` +- Pad-template capture itself (3.1 and the capture side of 3.2) is integration-verified in the x86_64 build image against a real sample plugin; the pytest suite validates everything downstream of the report document +- Frontend tests must always run with `--run` (single execution, no watch mode) +- Checkpoints ensure incremental validation; every degraded state falls back to the existing manual port flow, so nothing here gates port declaration + +## Task Dependency Graph + +```mermaid +graph TD + T1[1. Backend pure module: PadTemplate + ports_for_element] --> T2[2. Checkpoint] + T2 --> T3[3. Route extension] + T4[4. Build-image capture] --> T2 + T3 --> T5[5. Checkpoint] + T5 --> T6[6. Static guidance module + panel] + T5 --> T7[7. Port-scan pure helpers] + T6 --> T8[8. Scan panel + wizard wiring] + T7 --> T8 + T8 --> T9[9. Final checkpoint] +``` + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "4.1", "6.1", "7.1"] }, + { "id": 1, "tasks": ["1.2", "1.3", "1.4", "1.5", "1.6", "4.2", "4.3", "4.4", "6.2", "6.3", "7.2", "7.4"] }, + { "id": 2, "tasks": ["1.7", "1.8", "1.9", "1.10", "3.1", "6.4", "7.3", "8.1", "8.4"] }, + { "id": 3, "tasks": ["3.2", "8.2", "8.3", "8.6"] }, + { "id": 4, "tasks": ["8.5"] } + ] +} +``` diff --git a/.kiro/specs/portal-user-manager/.config.kiro b/.kiro/specs/portal-user-manager/.config.kiro new file mode 100644 index 00000000..16b7aba7 --- /dev/null +++ b/.kiro/specs/portal-user-manager/.config.kiro @@ -0,0 +1 @@ +{"specId": "a870cc3c-f727-4681-91a4-a3a16c750526", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/portal-user-manager/design.md b/.kiro/specs/portal-user-manager/design.md new file mode 100644 index 00000000..fef99ce5 --- /dev/null +++ b/.kiro/specs/portal-user-manager/design.md @@ -0,0 +1,559 @@ +# Design Document: Portal User Manager + +## Overview + +This feature adds an admin-only User Manager tool to the Edge CV Portal and an optional local cached login capability to the LocalServer edge component. It has four cooperating parts: + +1. **User_Manager (portal frontend)** — a new PortalAdmin-only page reached from the settings dropdown in the top navigation. It lists all Cognito accounts with client-side filtering, and offers account creation (invitation with temporary password), password change (permanent/temporary), forgot-password (temporary password by email), role change, disable/enable, and delete actions, plus a per-device account-sync panel. +2. **User admin API (portal backend)** — a new Lambda module exposing PortalAdmin-only endpoints that perform Cognito admin operations (`list_users`, `admin_create_user`, `admin_set_user_password`, `admin_update_user_attributes`, `admin_enable_user`/`admin_disable_user`, `admin_delete_user`) with a strict audit-before-effect protocol, a last-PortalAdmin guard, and credential-verifier capture at password-set time. +3. **Account_Sync_Service** — portal-to-edge account delivery over a new per-thing named IoT shadow `dda-user-accounts`, following the transport pattern established by the camera-registry-sync feature (portal writes `desired`, edge applies and writes a `reported` ack, an IoT topic rule → SQS → Lambda ingests acks). A DynamoDB sync-state table tracks pending changes and last sync status per device; an EventBridge 5-minute schedule drives retries and 60-second ack-timeout failure marking. +4. **Local_Login on the edge** — a new `local_auth` subsystem in the LocalServer FastAPI backend: a credential cache file holding salted one-way verifiers, a login endpoint issuing HMAC-signed 12-hour session tokens, per-account lockout, and a per-request authorization dependency implementing the (Local_Login_Configuration × Existing_Token_Auth) decision matrix. The local React web UI gains a login screen shown only when local login is enabled. Everything is gated by a Greengrass component-configuration flag that defaults to disabled, preserving today's open-access behavior exactly. + +### Key research findings + +- **Roles are a Cognito custom attribute.** `jwt_authorizer.py` reads `custom:role` from validated JWT claims; the AuthStack defines `custom:role` and `custom:groups` as mutable string attributes. A role change is therefore `admin_update_user_attributes` on `custom:role`, and any token issued afterwards carries the new role automatically (Requirement 5.8 falls out of token issuance semantics). +- **Password policy** (AuthStack): minimum length 12, requires lowercase, uppercase, digits, and symbols. Cognito enforces it on `admin_set_user_password` (`InvalidPasswordException`); the portal's temporary-password generator must conform to it. +- **Cognito never exposes password material.** There is no API or trigger that yields a hash suitable for offline verification. The only place the Portal_Backend ever holds a plaintext password is the User Manager password-set flow, so that is where credential verifiers are computed (design decision D3 below). +- **Cognito cannot email a temporary password to an existing CONFIRMED user.** `admin_create_user` invitations only apply to new users; `admin_reset_user_password` sends a confirmation *code*, not a temporary password. The forgot-password flow therefore generates a policy-conformant temporary password in the Lambda, applies it with `admin_set_user_password(Permanent=False)` (which forces a password change at next sign-in, Requirement 4.2), and delivers it by email through Amazon SES. +- **For *new* users, Cognito's native invitation flow does everything Requirement 12 asks for.** `admin_create_user` with an `email` attribute generates a pool-policy-conformant temporary password and emails the invitation itself (12.3 — no SES involvement needed), places the account in `FORCE_CHANGE_PASSWORD` so the first sign-in must set a new password (12.4), fails atomically with `UsernameExistsException` on duplicates (12.5), and never leaves a partial record on failure (12.9 — the API call is atomic). +- **Disabled-user token refusal is native Cognito behavior.** A user disabled via `admin_disable_user` receives `NotAuthorizedException` on every authentication flow, including refresh-token grants — Cognito rejects sign-in (13.4) and all new JWT issuance including refresh (13.5) with no portal code involved. Requirement 13.5 is therefore documented behavior, not a coding task. +- **Sync transport precedent** (camera-registry-sync): per-thing named shadow, `desired` written by the portal, edge agent (`src/backend/camera_sync/agent.py`) using the existing `IoTShadowAccessor` (IPC) and MQTT `SubscriptionHandler` for delta notifications, plus an IoT topic rule on the shadow's `update/documents` topic routed to SQS → Lambda for the portal-bound direction. The LocalServer recipe already grants shadow IPC/MQTT access for `$aws/things/*/shadow/name/*`, so no new device permissions are needed. +- **Edge auth wiring today**: `utils/auth.py:validate_token` validates bearer tokens remotely against an OAuth introspect endpoint; `endpoints/route/access_log_router.py:get_api_router()` decides *at import time* whether routers carry `Depends(validate_token)`, keyed on the presence of `authorization_settings.json` (`utils.is_authorization_enabled_on_station()`). The decision matrix requires a per-request dependency instead. +- **Component configuration via IPC**: the flask-app already calls Greengrass IPC `GetConfiguration` (`defect_detection_config.py`, `feature_configs_utils.py`), so the LocalServer can read `LocalLoginEnabled` from its own component configuration and re-poll it periodically. +- **Audit logging**: `shared_utils.log_audit_event` writes to the portal audit DynamoDB table but swallows errors. Requirement 6.4 (reject the action when the audit entry cannot be recorded) needs a strict, raising variant with a two-phase (pending → final) write. + +### Design decisions + +| # | Decision | Choice | Rationale | +|---|----------|--------|-----------| +| D1 | New backend module vs. extending `user_management.py` | New Lambda module `user_admin.py` (same shared layer, new API Gateway routes under `/api/v1/admin/...`) | `user_management.py` manages per-use-case role *assignments* in DynamoDB; this feature manages Cognito accounts. Separate IAM permissions (`cognito-idp:Admin*`, SES) stay scoped to one function | +| D2 | Sync transport | Named IoT shadow `dda-user-accounts` per thing; full desired account set + `syncId`; edge acks via `reported.ackSyncId`; topic rule → SQS → ack-ingest Lambda | Matches the proven camera-registry-sync pattern; shadow persistence gives "retain pending changes until delivered" (7.6) for free; device recipe permissions already cover it | +| D3 | Credential verifier source | Computed in `user_admin.py` whenever the backend holds a plaintext password (User Manager permanent/temporary password set, forgot-password temp generation), stored in a portal DynamoDB table | Cognito never exposes password material. Operationally: an account becomes edge-login-capable when an administrator sets its password through the User Manager. Accounts without a captured verifier sync without one and cannot log in locally; the UI shows which accounts are edge-capable | +| D4 | Verifier algorithm | PBKDF2-HMAC-SHA256, 210,000 iterations, 16-byte random salt per set, 32-byte hash | Python stdlib (`hashlib.pbkdf2_hmac`) on both portal Lambda and edge — no new dependencies; OWASP-recommended iteration count; verification cost (~100 ms on Jetson) acceptable for interactive login | +| D5 | Local_Session_Token format | Compact HMAC-SHA256 signed token: `base64url(payload JSON) + "." + base64url(HMAC)`; secret is 32 random bytes generated on first use, file `0600` | No JWT library in the flask-app today; stdlib `hmac`/`hashlib` suffice for a symmetric single-issuer/single-verifier token. Payload: `sub`, `role`, `iat`, `exp`, `jti` | +| D6 | Disabled-account token revocation (8.6) | Token validation re-checks the account's `enabled` flag in the Local_Credential_Cache on every request | No revocation list needed; a disabled flag arriving by sync instantly invalidates all outstanding tokens for that account | +| D7 | Local_Login_Configuration | Greengrass component configuration key `LocalLoginEnabled` (default `false`), read via IPC `GetConfiguration` at startup and re-polled every 30 s | Satisfies 11.1/11.2 (apply within 60 s without reinstall) even when the config is changed by `UpdateConfiguration` without a component restart; absent/unreadable ⇒ disabled (11.4) | +| D8 | Web UI gating (8.1, 8.8) | The react-webapp SPA queries unauthenticated `GET /local-auth/status`; when local login is enabled and no valid session token is held, it renders the login screen and blocks all other views. All API routes are server-enforced | Static SPA assets cannot be meaningfully gated server-side without breaking the login screen itself; data access is enforced at the API, the login screen presentation is client routing | +| D9 | Lockout state (8.10) | In-memory per-username counter in the LocalServer process (5 consecutive failures → 15-minute lockout) | Single-process server; requirement does not demand persistence across restarts, and file-persisting failure counters adds write-amplification for no security gain at this threat level | +| D10 | Audit-before-effect (6.4) | Two-phase audit write: `put_item` a `pending` entry (raising variant) *before* the Cognito call; abort the action if it fails; update to `success`/`failure` after | The only ordering that guarantees "audit unrecordable ⇒ action not applied" without distributed transactions | +| D11 | Temporary password delivery | SES `SendEmail` from a deployment-configured verified sender identity | Cognito cannot deliver a temp password to an existing user (research finding); SES is the standard portal-account mail path. The sender address becomes a ComputeStack parameter | +| D12 | Account creation invitation | Cognito-native `admin_create_user` invitation (no SES, no portal-generated password) | For new users Cognito itself generates the policy-conformant temporary password, emails the invitation, and enforces `FORCE_CHANGE_PASSWORD` at first sign-in — 12.3/12.4 for free, and the portal never holds the invitation password (consistent with D3: a new account becomes edge-login-capable only once an admin sets its password through the User Manager) | +| D13 | Deletion ordering vs. verifier cleanup | Cognito `admin_delete_user` first, then delete the `dda-portal-edge-credentials` record; a verifier-delete failure after a successful Cognito delete reports a partial-cleanup error and retains the record for a later attempt | This ordering makes 14.6 structural (a Cognito failure aborts before the verifier is touched) and makes the 14.10 partial-failure branch explicit and recoverable rather than silent | +| D14 | Last-PortalAdmin guard scope | The existing guard (paginate + count enabled PortalAdmins) is applied unchanged to disable and delete actions, alongside role changes | One shared predicate keeps 5.3, 13.9, and 14.3 behaviorally identical; disable and delete both reduce the enabled-PortalAdmin count exactly like a role change away from PortalAdmin | + +## Architecture + +```mermaid +graph TB + subgraph Portal frontend + DD[Settings dropdown
PortalAdmin-only item] --> UM[UserManager page
/admin/user-manager] + UM --> LIST[Account table + filter] + UM --> ACT[Create / Password / Forgot / Role /
Disable-Enable / Delete modals] + UM --> SYNC[Edge sync panel
per-device status] + end + + subgraph Portal backend + API[API Gateway + jwt_authorizer] --> UA[user_admin.py Lambda
PortalAdmin gate] + UA --> COG[(Cognito User_Pool
custom:role)] + UA --> AUD[(Audit log table
two-phase entries)] + UA --> CRED[(dda-portal-edge-credentials
PBKDF2 verifiers)] + UA --> SES[SES: temp password email] + UA --> SS[(dda-portal-account-sync
per-device sync state)] + SCHED[EventBridge rate 5 min] --> SYNCFN[account_sync.py Lambda] + SYNCFN --> SS + SYNCFN --> SHADOW + ACKQ[SQS ack queue] --> ACKFN[account_sync.py ack ingest] + ACKFN --> SS + end + + subgraph AWS IoT + SHADOW[(Named shadow
dda-user-accounts)] + RULE[Topic rule on
shadow update/documents] --> ACKQ + SHADOW --> RULE + end + + subgraph Edge device / LocalServer + AGENT[user_accounts_sync agent
IoTShadowAccessor + MQTT delta] --> CACHE[(Local_Credential_Cache
/aws_dda/local_credential_cache.json)] + AGENT -->|reported ack| SHADOW + SHADOW -->|desired delta| AGENT + CFG[Greengrass config
LocalLoginEnabled] --> AUTHDEP[authorize_request dependency
decision matrix] + LOGIN[POST /local-auth/login] --> CACHE + LOGIN --> TOK[Local_Session_Token
HMAC, 12 h] + AUTHDEP --> TOK + AUTHDEP --> ETA[Existing validate_token
authorization_settings.json] + WEBUI[react-webapp
login screen] --> LOGIN + end +``` + +### Portal → edge sync sequence + +```mermaid +sequenceDiagram + participant A as Portal_Administrator + participant UA as user_admin.py + participant SS as Sync state table + participant SF as account_sync.py + participant SH as Shadow dda-user-accounts + participant AG as Edge sync agent + participant CC as Local_Credential_Cache + + A->>UA: POST /admin/edge-sync/devices/{id} (selected accounts) + UA->>SS: stage pending account set + new syncId + UA->>SF: invoke sync attempt (also runs every 5 min) + SF->>SH: update desired {syncId, accounts} + SF->>SS: status=in_progress, attemptAt=now + SH-->>AG: delta notification (MQTT) + AG->>CC: atomic replace of cache file + AG->>SH: update reported {ackSyncId, appliedAt, accountCount} + SH-->>SF: topic rule -> SQS -> ack ingest + SF->>SS: status=success, lastSyncAt (ack matches syncId) + Note over SF,SS: No ack within 60 s of attempt:
status=failed "device unreachable",
pending retained, retried ≤ every 5 min (7.6, 7.7, 7.9) +``` + +### Edge request authorization decision matrix + +Evaluated per request by the new `authorize_request` FastAPI dependency (replacing the startup-frozen `Depends(validate_token)` wiring in `get_api_router()`): + +| Local_Login_Configuration | Existing_Token_Auth (settings file present) | Decision | +|---|---|---| +| disabled | not configured | **Allow** — open access, exactly today's behavior (9.1, 9.3) | +| disabled | configured | Require valid bearer token via existing `validate_token`; else 401 (9.4, 10.4) | +| enabled | not configured | Require valid Local_Session_Token; else 401 (8.5, 8.9) | +| enabled | configured | Allow if **either** a valid bearer token **or** a valid Local_Session_Token is presented; else 401 (10.1, 10.2, 10.5) | + +Exemptions in every configuration: `POST /local-auth/login`, `GET /local-auth/status`, and static SPA assets. When local login is disabled, the login endpoint rejects with a "local login disabled" error and never issues a token (9.5). The dependency never reads or writes `authorization_settings.json` beyond the existing presence check (10.3). + +## Components and Interfaces + +### 1. Portal frontend + +**Settings dropdown (`Layout.tsx`)** — add a `user-manager` item to the existing top-nav settings `ButtonDropdown`. Unlike the current `settings` item (disabled for non-admins), the User Manager item is *omitted entirely* for non-PortalAdmin users (1.1, 1.2): the items array is built conditionally on `user?.role === 'PortalAdmin'`. Selecting it navigates to `/admin/user-manager` (1.3). + +**Route guard (`App.tsx` + `UserManager.tsx`)** — the route is registered inside the existing authenticated layout (unauthenticated visitors hit the existing redirect to `/login`, 1.6). The page component itself renders an access-denied `Alert` and nothing else when `user?.role !== 'PortalAdmin'` (1.4), mirroring the `BedrockConfigurationSettings` "Portal Admin access required" pattern. + +**`UserManager.tsx` page** — Cloudscape `Table` + `TextFilter` + action modals: + +- Loads accounts via `GET /api/v1/admin/users` (paginated passthrough of Cognito `list_users`). Columns: username, email, Portal_Role, Cognito status (`CONFIRMED`, `FORCE_CHANGE_PASSWORD`, …), enabled/disabled, edge-login-capable (verifier present) (2.1). +- Filtering is a pure exported function `filterAccounts(accounts, term)`: case-insensitive substring match on username or email; empty result renders the table's empty state without an error (2.2, 2.3); clearing the term restores the full list (2.4). +- Load failure replaces the table body with an error `Alert` — never a partial/stale list (2.5): the accounts state is cleared before each fetch and only set on success. +- **Password modal**: password + confirm fields, a required `RadioGroup` (permanent / temporary — no default selection, submit disabled until chosen, 3.2), client-side policy pre-check mirroring the pool policy, server errors surfaced verbatim including the violated policy rule (3.3). Success flashbar names the account (3.4). +- **Forgot-password action**: confirmation modal; on success shows "temporary password sent to the account's registered email" without ever receiving the password value from the API (4.3); surfaces no-verified-email and delivery errors (4.4, 4.5). +- **Role modal**: `Select` restricted to the five defined roles with the current role preselected (5.2); on success shows confirmation and re-fetches the list (5.7); rejection reasons (including the last-PortalAdmin guard, 5.3) shown in the modal. +- **Create User modal** (`UserManagerModals.tsx`): username, email, and role fields, the role a `Select` restricted to the five defined roles (12.2); client-side pre-checks mirror the server validation (non-empty fields, email shape per 12.6) with submit disabled until valid; server rejection reasons (duplicate username 12.5, invalid email 12.6, missing field 12.7) surfaced verbatim in the modal. Success flashbar states that an invitation with a temporary password was sent to the account's email — the password value is never present in the API response — and the list re-fetches to include the new account (12.10). +- **Disable/Enable confirm modal**: explicit confirmation naming the affected account by username before submission (13.1); on success shows a confirmation identifying the account and re-fetches the list (13.8); an already-in-requested-state response simply re-fetches to show the current state (13.6); rejection reasons (last-PortalAdmin guard for disable, 5.3/13.9) and failures (13.7) shown in the modal. +- **Delete confirm modal**: explicit confirmation naming the affected account by username (14.1); cancel/dismiss submits nothing (14.9); on success shows a confirmation identifying the deleted account and re-fetches the list without it (14.7); rejection reasons (last-PortalAdmin guard, 14.3), not-found (14.11), and partial verifier-cleanup errors (14.10) surfaced in the modal or flashbar. +- **Edge sync panel**: device list from `GET /api/v1/admin/edge-sync/devices` showing last sync status + timestamp per device (7.4); a sync action with account multi-select posts to `POST /api/v1/admin/edge-sync/devices/{deviceId}` (7.1). + +### 2. Portal backend — `user_admin.py` Lambda + +New function `edge-cv-portal/backend/functions/user_admin.py`, shared layer reused, routed under `/api/v1/admin/*` behind the existing `jwt_authorizer` (requests without a valid JWT are rejected by the authorizer before the Lambda runs, 1.7). Every handler first asserts `get_user_from_event(event)['role'] == 'PortalAdmin'` and returns 403 otherwise (1.5). + +| Endpoint | Operation | +|---|---| +| `GET /api/v1/admin/users` | Cognito `list_users` (paginate all), join with the edge-credentials table for the `edgeCapable` flag. Returns username, email, `email_verified`, `custom:role` (default `Viewer`), `UserStatus`, `Enabled` | +| `POST /api/v1/admin/users` | Body `{username, email, role}`. Validate (all fields present and non-empty 12.7, email shape 12.6, role in the five defined values 12.8) → audit-pending → `admin_create_user` with `custom:role`, `email`, `email_verified=true`, default email invitation (D12) → audit-final. `UsernameExistsException` → 409 "username already exists" (12.5) | +| `POST /api/v1/admin/users/{username}/password` | Body `{password, permanent: bool}`. Audit-pending → `admin_set_user_password(Permanent=permanent)` → verifier capture → audit-final | +| `POST /api/v1/admin/users/{username}/forgot-password` | Verified-email check → generate temp password → audit-pending → `admin_set_user_password(Permanent=False)` → SES email → verifier capture → audit-final | +| `PUT /api/v1/admin/users/{username}/role` | Body `{role}`. Validate against the five roles → last-admin guard → audit-pending → `admin_update_user_attributes(custom:role)` → audit-final (records previous and new role, 5.4) | +| `POST /api/v1/admin/users/{username}/disable` | `admin_get_user` → already disabled: no-op 200 with the current state, no mutation (13.6) → last-admin guard (D14, 13.9) → audit-pending → `admin_disable_user` (13.2) → mark sync staging pending with `enabled: false` (7.2, 7.8) → audit-final | +| `POST /api/v1/admin/users/{username}/enable` | `admin_get_user` → already enabled: no-op 200, no mutation (13.6) → audit-pending → `admin_enable_user` (13.3) → mark sync staging pending with `enabled: true` (7.2) → audit-final | +| `DELETE /api/v1/admin/users/{username}` | `admin_get_user` (captures username/email/role for the audit entry, 14.8; `UserNotFoundException` → 404, 14.11) → last-admin guard (D14, 14.3/14.4) → audit-pending → `admin_delete_user` (14.2) → delete the `dda-portal-edge-credentials` record (14.5, D13) → mark sync staging pending with `enabled: false, deleted: true` (7.8) → audit-final | +| `GET /api/v1/admin/edge-sync/devices` | Devices table join with sync-state table: per-device `lastSyncStatus`, `lastSyncAt`, `pendingChanges` | +| `POST /api/v1/admin/edge-sync/devices/{deviceId}` | Body `{usernames: [...]}`. Stages the selected account records + fresh `syncId` in the sync-state table and invokes the sync Lambda for an immediate attempt | + +**Audit protocol (strict, D10)** — a new shared-layer helper `record_audit_event_strict(...)` that `put_item`s and **raises** on failure, plus `finalize_audit_event(event_id, result, details)`. Flow per mutating action: + +1. `record_audit_event_strict(acting_user, action, affected_account, result='pending')` — on failure: return 500 "action not applied", Cognito untouched (6.4, 6.5). +2. Perform the Cognito operation (and SES send for forgot-password). +3. `finalize_audit_event(..., result='success' | 'failure', details)` — details carry previous/new role for role changes (5.4) and rejection reasons for rejected attempts (5.5). Details **never** include passwords, hashes, or temporary password values (6.3); the helper drops keys matching a denylist (`password`, `verifier`, `hash`, `temp*`) defensively. +4. Self-service actions record the affected account as the acting user (6.2) — acting identity always comes from the validated JWT claims, which for self-service *is* the account holder. + +**Last-PortalAdmin guard (5.3, 13.9, 14.3)** — Cognito `list_users` cannot filter on custom attributes, so the guard paginates the pool and counts users with `custom:role == 'PortalAdmin'` and `Enabled == true`. If the action would reduce that count to zero (role change away from PortalAdmin, disable, or **delete**, targeting the last such account), reject with 409 and an explanatory reason, and record the rejected attempt in the audit log (5.5, 13.9, 14.4). The same shared predicate serves all three action types (D14). + +**Account creation validation (12.6–12.8)** — a pure function `validate_create_request(body)` gates the create endpoint: all three fields present and non-empty (rejections name the missing field, 12.7), email consisting of a non-empty local part, `@`, and a non-empty domain containing at least one dot (12.6), and role in the five defined Portal_Role values (12.8). Only a payload passing all checks reaches `admin_create_user` — a rejection performs no User_Pool call, so no account or partial record can exist (12.5 duplicate rejection is likewise atomic on the Cognito side, 12.9). Creation records an `account_create` audit entry carrying the created account's username, email, and role (12.11). No verifier is captured at creation — the invitation password is never held by the portal (D12); the account becomes edge-login-capable when an administrator later sets its password (D3). + +**Disable/enable state transitions (13.2, 13.3, 13.6)** — both endpoints read the account's current `Enabled` state first: when the account is already in the requested state, the handler returns success with the current state and performs no Cognito mutation, no audit-pending write, and no sync staging (13.6). Otherwise the standard audit-before-effect flow runs, and the enabled/disabled flag — a synchronized account attribute — triggers `_mark_account_change_pending` so every configured device's staged set carries the new state (7.2; disable additionally satisfies 7.8's mark-as-disabled-on-next-sync). + +**Deletion flow (D13)** — `admin_get_user` first captures the username, email, and role for the audit entry (14.8) and maps a missing account to 404 without any mutation (14.11); then the guard, audit-pending, `admin_delete_user`, verifier-record delete, and sync staging (`enabled: false, deleted: true`, 7.8) run in that order. A Cognito failure aborts before the verifier record is touched (14.6); a verifier-delete failure after the Cognito delete finalizes the audit entry with a partial-cleanup detail, retains the verifier record for a subsequent attempt, and returns an error stating the account was deleted but its verifier record was not removed (14.10). + +**Temporary password generator** — pure function `generate_temp_password(length=16)`: guarantees ≥1 character from each required class (lower, upper, digit, symbol) and length ≥ the pool minimum (12), assembled with `secrets.choice` and shuffled with `secrets.SystemRandom().shuffle` (4.1). + +**Verifier capture (D3, D4)** — pure function `make_verifier(password)` → `{algorithm: 'pbkdf2-sha256', iterations: 210000, salt: b64, hash: b64}`; stored in `dda-portal-edge-credentials` keyed by normalized username with `updatedAt`. Written after every successful password set (permanent or temporary). A verifier update marks every configured device's sync state as having pending changes (7.2). + +**Cognito error mapping** — `InvalidPasswordException` → 400 with the policy message passed through (3.3); other exceptions → 502 "password change failed" (3.5); the account is untouched in both cases because `admin_set_user_password` is atomic. Forgot-password on an account whose `email_verified != 'true'` → 400 before anything is generated (4.4); SES failure after the temp password was applied is compensated by restoring nothing (the account is now in FORCE_CHANGE_PASSWORD with an undelivered password) — to honor 4.5 ("preserve existing credentials unchanged"), the SES send is performed **before** `admin_set_user_password`: generate → email → set-password. If the email send fails, no credential was modified; if the set-password fails after a successful send, the emailed password is inert (it never became valid) and the action reports failure. Requirement 4.6 (consumed temporary password rejected) is native Cognito `FORCE_CHANGE_PASSWORD` semantics. + +### 3. Account_Sync_Service — portal side (`account_sync.py` Lambda) + +One Lambda with three entry paths (mirroring `camera_sync.py`'s style): + +- **Sync attempt** (invoked directly by `user_admin.py` and by the EventBridge `rate(5 minutes)` schedule for every device with pending changes, 7.7): builds the device's full desired account document from the staged account set, writes the `dda-user-accounts` shadow `desired` state via `iot_data_client(...).update_thing_shadow`, and stamps the sync-state row `in_progress` with `attemptAt`. A shadow-write failure marks the attempt failed with the error reason; pending changes are retained (7.6). +- **Ack ingest** (SQS from the topic rule on `$aws/things/+/shadow/name/dda-user-accounts/update/documents`, same partial-batch-failure pattern as camera sync): a reported `ackSyncId` equal to the device's current `syncId` marks the row `success` with `lastSyncAt = appliedAt` and clears `pendingChanges` (7.4); a reported `error` marks `failed` with the device's reason. Stale acks (unknown `syncId`) are discarded. +- **Timeout sweep** (piggybacked on the 5-minute schedule): any `in_progress` row whose `attemptAt` is older than 60 s without a matching ack is marked `failed` with reason `device unreachable` (7.9) — pending changes retained, so the next scheduled attempt retries (7.6, 7.7). + +The desired document always carries the **complete selected account set** (not a diff), so duplicate or out-of-order delivery is idempotent, and a sync with zero changes is simply a desired write whose content equals the previous one — acked and reported successful (7.5). Account disable/delete in the portal marks the account `enabled: false` in the staged set (delete also flags `deleted: true` for eventual cache pruning), never removing the record silently (7.8). Payloads contain only `{username, email, role, enabled, verifier?}` — never plaintext passwords (7.3, guaranteed structurally: the sync path has no access to plaintext). The builder validates the rendered document against the 8 KB shadow size limit and fails the sync with an explicit reason if exceeded. + +**New infrastructure (ComputeStack additions)**: `dda-portal-edge-credentials` and `dda-portal-account-sync` DynamoDB tables, the ack SQS queue + DLQ, the IoT topic rule, the EventBridge schedule, SES send permission + sender-address parameter, and `cognito-idp:AdminSetUserPassword/AdminUpdateUserAttributes/ListUsers/AdminGetUser` grants scoped to the pool for `user_admin.py` only. The account life-cycle actions (Requirements 12–14) extend the same `user_admin.py`-scoped policy with `cognito-idp:AdminCreateUser/AdminEnableUser/AdminDisableUser/AdminDeleteUser` — no new resources are required. + +### 4. Edge sync agent — `src/backend/user_accounts_sync/agent.py` + +Mirrors `camera_sync/agent.py`: started from `server_setup.py` on a daemon thread; on start it `GetThingShadow`s `dda-user-accounts` (thing name from `AWS_IOT_THING_NAME`) to catch up on any desired state that arrived while offline, then subscribes to `$aws/things/{thing}/shadow/name/dda-user-accounts/update/delta` through the existing MQTT `SubscriptionHandler` pattern. On receiving a desired document it: + +1. Validates the document shape (pure function `parse_sync_document`). +2. Atomically replaces the Local_Credential_Cache file (write temp file `0600`, `os.replace`) — full-set replacement makes application idempotent. +3. Writes `reported: {ackSyncId, appliedAt, accountCount}`; on validation failure writes `reported: {ackSyncId, error: reason}` so the portal records the failure rather than timing out. + +Offline behavior costs nothing extra: the shadow retains `desired`, and the startup `GetThingShadow` is the catch-up path. Agent crashes are logged and never take down the rest of LocalServer. + +### 5. Edge local auth — `src/backend/local_auth/` + +**`credential_cache.py`** — load/parse the cache file with `verify_credentials(username, password) -> Account | None`: constant-shape verification that computes PBKDF2 against the stored (or, for unknown usernames, a dummy) verifier so the 401 response and timing are identical whether or not the username exists (8.3); returns the account only when the verifier matches **and** `enabled` is true. Purely local — no network (8.4). Missing/empty cache ⇒ every login fails with the same 401 plus a logged "no synchronized accounts available" diagnostic (11.3). + +**`session_tokens.py`** — pure token functions over an injected clock: +- `issue_token(secret, username, role, now) -> str`: payload `{sub, role, iat: now, exp: now + 12*3600, jti}`, signature `HMAC-SHA256(secret, payload_b64)` (8.2). +- `validate_token(secret, token, now, cache) -> Account | AuthError`: reject on malformed structure, bad signature (`hmac.compare_digest`), `exp <= now` (8.7); then re-check the cache — account absent or disabled ⇒ reject (8.5, 8.6, D6). +- Secret management: `get_or_create_secret()` reads `/aws_dda/local_session_secret`, creating 32 random bytes (`secrets.token_bytes`) with mode `0600` on first use. + +**`lockout.py`** — `LockoutTracker` (in-memory, injected clock): `record_failure(username)`, `record_success(username)` (resets the counter), `is_locked(username, now)`. 5 consecutive failures ⇒ locked for 15 minutes; while locked, every attempt is rejected regardless of credentials and does not extend the window (8.10). + +**`config.py`** — `LocalLoginConfig` singleton: reads `LocalLoginEnabled` via IPC `GetConfiguration` at startup (11.1) and re-polls on a 30 s background timer (11.2); any read error, missing key, or non-boolean value ⇒ disabled (11.4), logged once per transition. + +**`endpoints/local_auth.py`** — on the unauthenticated router: +- `GET /local-auth/status` → `{localLoginEnabled: bool}` (drives the SPA, D8). +- `POST /local-auth/login` `{username, password}` → when disabled: 403 `{error: "local login is disabled"}`, never issues a token (9.5). When enabled: lockout check → `verify_credentials` → on success `record_success` + `issue_token` → `{token, expiresAt, role, username}`; on failure `record_failure` + uniform 401 (8.2, 8.3, 8.10). + +**`utils/auth.py` extension — `authorize_request`** dependency implementing the decision matrix table above. It extracts the bearer credential once; a well-formed Local_Session_Token (two-segment base64url) is validated locally first, otherwise/on failure the existing remote `validate_token` path runs when Existing_Token_Auth is configured. `get_api_router()` switches from the frozen `Depends(validate_token)` to `Depends(authorize_request)` unconditionally — the dependency itself decides per request, which is what makes runtime configuration changes (11.2) effective without process restart. The download-file query-param token path gains the same either/or acceptance. + +### 6. Edge web UI — `src/react-webapp` + +A `LoginGate` component wraps the app: it fetches `/local-auth/status`; when enabled and no unexpired token is in `sessionStorage`, it renders the login screen (username/password form posting to `/local-auth/login`) instead of the app (8.1, 8.8). On success the token is stored and attached as `Authorization: Bearer` on all API calls. When disabled, the gate renders the app directly — no login screen, no prompt (9.2). API 401 responses clear the stored token and return to the login screen (token expiry mid-session). + +## Data Models + +### Cognito account (existing, read/written via admin APIs) + +`username`, `email`, `email_verified`, `custom:role` (one of the five Portal_Role values, default `Viewer`), `UserStatus` (`CONFIRMED` | `FORCE_CHANGE_PASSWORD` | ...), `Enabled`. + +### `dda-portal-edge-credentials` (DynamoDB, new) + +| Attribute | Type | Notes | +|---|---|---| +| `username` (PK) | S | normalized (lowercase) | +| `verifier` | M | `{algorithm: 'pbkdf2-sha256', iterations: N, salt: b64, hash: b64}` | +| `updatedAt` | N | epoch ms | + +Never contains plaintext; written only by `user_admin.py` password flows. + +### `dda-portal-account-sync` (DynamoDB, new) + +| Attribute | Type | Notes | +|---|---|---| +| `device_id` (PK) | S | IoT thing name | +| `syncId` | S | UUID of the latest staged sync | +| `accounts` | M | staged full account set `{username: {email, role, enabled, deleted?, verifier?}}` | +| `status` | S | `pending` \| `in_progress` \| `success` \| `failed` | +| `failureReason` | S | e.g. `device unreachable` | +| `attemptAt` / `lastSyncAt` | N | epoch ms | +| `pendingChanges` | BOOL | true from staging until a matching ack | + +### Shadow `dda-user-accounts` document + +```jsonc +{ + "state": { + "desired": { + "syncId": "3f2a...", + "version": 1, // document schema version + "accounts": { + "operator1": { + "email": "op1@example.com", + "role": "Operator", + "enabled": true, + "verifier": { "algorithm": "pbkdf2-sha256", "iterations": 210000, + "salt": "b64...", "hash": "b64..." } + }, + "olduser": { "email": "x@y.z", "role": "Viewer", "enabled": false } + } + }, + "reported": { + "ackSyncId": "3f2a...", "appliedAt": 1700000000000, "accountCount": 2 + // or: "ackSyncId": "3f2a...", "error": "reason" + } + } +} +``` + +### Local_Credential_Cache — `/aws_dda/local_credential_cache.json` (mode 0600) + +```jsonc +{ + "version": 1, + "syncId": "3f2a...", + "appliedAt": 1700000000000, + "accounts": { + "operator1": { "email": "...", "role": "Operator", "enabled": true, + "verifier": { "algorithm": "pbkdf2-sha256", "iterations": 210000, + "salt": "b64...", "hash": "b64..." } } + } +} +``` + +### Local_Session_Token + +`base64url(json({sub, role, iat, exp, jti})) + "." + base64url(hmac_sha256(secret, payload_b64))` — `exp = iat + 43200` (12 h). Secret: `/aws_dda/local_session_secret`, 32 bytes, mode 0600. + +### Component configuration (recipe `DefaultConfiguration` addition) + +```yaml +LocalLoginEnabled: "false" # per-device override via deployment configuration merge +``` + +### Audit log entry (existing table, new action types) + +`action ∈ {password_change, forgot_password, role_change, account_create, account_disable, account_enable, account_delete}`, `resource_type = 'user_account'`, `resource_id = `, `result ∈ {pending, success, failure, rejected}`, `details` (role changes: `{previousRole, newRole}`; account creation and deletion: `{username, email, role}` — for deletion, the values at the time of deletion, 14.8; rejections: `{reason}`) — details sanitized by denylist (6.3). + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The prework consolidated overlapping acceptance criteria: the nine decision-matrix criteria (8.1, 8.9, 9.1, 9.3, 9.4, 10.1, 10.2, 10.4, 10.5) collapse into one quantified matrix property; token issuance/validation/corruption (8.2, 8.5, 8.7) collapse into one life-cycle property; audit criteria (5.4, 5.5, 6.1, 6.2) collapse into one completeness property; sync-state criteria (7.4, 7.5, 7.6, 7.9) collapse into one reducer property; and the filter criteria (2.2, 2.3, 2.4) collapse into one filter property. + +The Requirements 12–14 additions consolidate the same way: the creation-validation criteria (12.1 pass-through, 12.6, 12.7, 12.8) collapse into one validation-gate property (24); the disable/enable transition criteria (13.2, 13.3, 13.6) collapse into one transition property (26); the deletion criteria (14.2, 14.5, 14.6, 14.10 plus the 7.8 staging effect) collapse into one deletion-effect property (27); the last-PortalAdmin criteria for disable and delete (13.9's guard aspect, 14.3) extend the existing Property 9 rather than adding a new one; and the new audit criteria (12.11, 13.9, 14.4, 14.8) extend the existing Property 10's action set. Cognito-native behavior (invitation delivery and forced password change, 12.3/12.4; sign-in and token-issuance refusal for disabled accounts including refresh, 13.4/13.5) is documented behavior verified once during rollout, not a property. + +### Property 1: PortalAdmin role gate in the portal UI + +*For any* Portal_Role, the settings dropdown contains the User Manager item, and the User_Manager page renders account-management content instead of an access-denied notice, if and only if the role is PortalAdmin. + +**Validates: Requirements 1.1, 1.2, 1.4** + +### Property 2: PortalAdmin gate on admin API endpoints + +*For any* User_Manager API endpoint and any validated JWT claim set whose role is not PortalAdmin, the handler returns HTTP 403 and performs no Cognito, credential-table, or sync-state mutation. + +**Validates: Requirements 1.5** + +### Property 3: Account listing completeness + +*For any* list of accounts returned by the backend, the User_Manager table model contains exactly one row per account, and every row carries the account's username, email, Portal_Role, User_Pool status, and enabled/disabled state. + +**Validates: Requirements 2.1** + +### Property 4: Account filtering + +*For any* account list and filter term, `filterAccounts` returns exactly the accounts whose username or email contains the term as a case-insensitive substring; an empty or whitespace term returns the full list unchanged; a term matching nothing returns an empty list (never an error). + +**Validates: Requirements 2.2, 2.3, 2.4** + +### Property 5: Admin operations pass through faithfully + +*For any* valid password with any permanence selection, and any defined Portal_Role, the corresponding handler invokes the User_Pool operation with exactly the submitted password and permanence flag (`admin_set_user_password`) or exactly the selected role on `custom:role` (`admin_update_user_attributes`). + +**Validates: Requirements 3.1, 5.1** + +### Property 6: Policy-violation rejection preserves state + +*For any* password rejected by the User_Pool with a policy violation, the handler returns HTTP 400 whose body includes the violated policy rule, and no credential verifier is stored or updated for the account. + +**Validates: Requirements 3.3** + +### Property 7: Temporary password policy conformance + +*For any* invocation of the temporary password generator, the produced password satisfies every Password_Policy rule: length ≥ 12 and at least one lowercase letter, one uppercase letter, one digit, and one symbol. + +**Validates: Requirements 4.1** + +### Property 8: Secret material never leaves the backend + +*For any* user management flow carrying a password or generated Temporary_Password, the serialized HTTP response bodies and every recorded audit log entry contain neither the plaintext value nor its verifier hash. + +**Validates: Requirements 4.3, 6.3** + +### Property 9: Last-PortalAdmin guard + +*For any* population of accounts with roles and enabled flags, and any role-change, disable, or delete action, the guard rejects the action if and only if applying it would leave zero enabled PortalAdmin accounts. + +**Validates: Requirements 5.3, 13.9, 14.3** + +### Property 10: Audit completeness + +*For any* user management action (password change, forgot-password, role change, account creation, account disable, account enable, account deletion) and any acting/affected identities, completing the action successfully records exactly one finalized audit entry carrying the acting user, the affected account, the action type, and the completion timestamp; role changes additionally carry the previous and new role; account creations and deletions additionally carry the account's username, email, and role (for deletions, the values at the time of deletion); rejected attempts record an entry carrying the acting administrator, the affected account, and the rejection reason; and when the acting identity equals the affected account (self-service), the entry's acting user is the affected account. + +**Validates: Requirements 5.4, 5.5, 6.1, 6.2, 12.11, 13.9, 14.4, 14.8** + +### Property 11: Audit failure blocks the action + +*For any* user management action, if recording the pending audit entry fails, the handler performs zero User_Pool mutations and returns an error response indicating the action was not applied. + +**Validates: Requirements 6.4, 6.5** + +### Property 12: Sync round trip preserves account records + +*For any* selected set of account records (including disabled and deleted accounts, and accounts with or without verifiers), building the sync document on the portal and applying it on the edge yields a Local_Credential_Cache containing exactly those records with username, email, Portal_Role, enabled/disabled state, and verifier preserved — disabled or deleted accounts appear marked disabled, never silently dropped. + +**Validates: Requirements 7.1, 7.8** + +### Property 13: Attribute changes propagate to staged syncs + +*For any* change to a synchronized account attribute (username, email, credential verifier, Portal_Role, or enabled state), every configured device's staged account set contains the updated record and is marked as having pending changes. + +**Validates: Requirements 7.2** + +### Property 14: Plaintext never appears in credential material + +*For any* password, the computed verifier is salted and one-way — the serialized verifier, sync documents, and cache files built from it never contain the plaintext password, and two verifiers computed from the same password use different salts and produce different hashes. + +**Validates: Requirements 7.3** + +### Property 15: Sync-state reducer + +*For any* sequence of sync events (attempts, matching acks, error acks, stale acks, and timeout sweeps at arbitrary times) applied to a device's sync state: a matching ack — and only a matching ack — marks the sync successful with its completion timestamp and clears pending changes (including for zero-change syncs); error acks mark failure with the device's reason; a timeout sweep marks an unacknowledged attempt failed with a device-unreachable reason exactly when more than 60 seconds have elapsed since the attempt; and no failure or timeout event ever discards the staged pending account set. + +**Validates: Requirements 7.4, 7.5, 7.6, 7.9** + +### Property 16: Session token life cycle + +*For any* account in the Local_Credential_Cache and any issuance time, a token issued after successful login has an expiry exactly 12 hours after issuance and validates successfully while unexpired and the account is enabled; and *for any* corruption of that token (byte modification, truncation, segment removal, signature from a different secret) or any validation time at or after expiry, validation rejects with an authentication error. + +**Validates: Requirements 8.2, 8.5, 8.7** + +### Property 17: Failed login indistinguishability + +*For any* Local_Credential_Cache (including empty and all-disabled caches) and any two failing login attempts — one with a username present in the cache and a wrong password, one with a username absent from the cache — the two HTTP 401 responses are identical in status and body. + +**Validates: Requirements 8.3, 11.3** + +### Property 18: Disable revokes access + +*For any* account in the Local_Credential_Cache, after the account is marked disabled, login attempts with its correct credentials are rejected and every Local_Session_Token previously issued to it fails validation. + +**Validates: Requirements 8.6** + +### Property 19: Authorization decision matrix + +*For any* combination of Local_Login_Configuration state (enabled/disabled), Existing_Token_Auth state (configured/not configured), and request credential (none, invalid, valid Local_Session_Token, valid Existing_Token_Auth bearer token), `authorize_request` on a non-exempt route decides exactly per the matrix: open access only when both mechanisms are off; a valid existing bearer token always authorizes when Existing_Token_Auth is configured, regardless of Local_Login state; a valid Local_Session_Token authorizes only when Local_Login is enabled; and every other combination yields HTTP 401. + +**Validates: Requirements 8.1, 8.9, 9.1, 9.3, 9.4, 10.1, 10.2, 10.4, 10.5** + +### Property 20: Login lockout + +*For any* sequence of login attempts against an account with arbitrary timing, the account is locked exactly after 5 consecutive failures; while locked (within 15 minutes of lockout) every attempt is rejected regardless of credential correctness; a successful login resets the consecutive-failure count; and after the 15-minute window elapses, a correct-credential attempt succeeds again. + +**Validates: Requirements 8.10** + +### Property 21: Disabled local login never issues tokens + +*For any* submitted credentials and any Local_Credential_Cache content (including credentials that would succeed when enabled), a login request while Local_Login_Configuration is disabled returns an error indicating local login is disabled and the response contains no Local_Session_Token. + +**Validates: Requirements 9.5** + +### Property 22: Local login configuration never touches Existing_Token_Auth + +*For any* sequence of Local_Login_Configuration state changes, the authorization settings file's bytes are unchanged, and the `authorize_request` treatment of bearer tokens depends only on that file's presence. + +**Validates: Requirements 10.3** + +### Property 23: Configuration parsing defaults to disabled + +*For any* component-configuration value (missing key, arbitrary strings, arbitrary JSON values, or an IPC read error), the parsed Local_Login_Configuration state is enabled only for the explicit true representations (`true`, `"true"`); every other input yields disabled. + +**Validates: Requirements 11.4** + +### Property 24: Account creation validation gate + +*For any* create-account payload (arbitrary combinations of missing, empty, and populated username/email/role values, arbitrary email strings, and arbitrary role strings), the handler invokes `admin_create_user` if and only if the payload has a non-empty username, an email consisting of a non-empty local part followed by `@` followed by a non-empty domain containing at least one dot, and a role among the five defined Portal_Role values — and when it does, the call carries exactly the submitted username, email, and role; every rejection performs no User_Pool call and identifies the offending field. + +**Validates: Requirements 12.1, 12.6, 12.7, 12.8** + +### Property 25: Duplicate usernames never create or modify accounts + +*For any* pool population and any valid create-account payload, creation succeeds if and only if the submitted username does not match an existing account; when the username exists, the handler reports the duplicate and performs no account creation or modification of any account. + +**Validates: Requirements 12.5** + +### Property 26: Disable/enable transitions are exact + +*For any* account with any current enabled/disabled state and any confirmed disable or enable action, the handler invokes the corresponding User_Pool state change (`admin_disable_user`/`admin_enable_user`) and marks every configured device's staged sync set pending with the new state if and only if the requested state differs from the current state; when the account is already in the requested state, no User_Pool mutation and no sync staging occurs. + +**Validates: Requirements 13.2, 13.3, 13.6** + +### Property 27: Deletion removes the account, its verifier, and stages the removal + +*For any* account (with or without a stored Edge_Credential_Verifier record) and any injected fault pattern (none, User_Pool delete failure, verifier-record delete failure): a fault-free confirmed deletion deletes the account from the User_Pool, deletes the account's verifier record, and marks every configured device's staged sync set pending with the account disabled and flagged deleted; a User_Pool failure leaves both the account and the verifier record unchanged; and a verifier-record failure after a successful User_Pool delete retains the verifier record and reports that the account was deleted but its verifier record was not removed. + +**Validates: Requirements 14.2, 14.5, 14.6, 14.10, 7.8** + +## Error Handling + +### Portal backend + +| Failure | Handling | +|---|---| +| Non-PortalAdmin JWT on admin route | 403, no operation (Property 2); missing/invalid JWT never reaches the Lambda (jwt_authorizer, 1.7) | +| Cognito `InvalidPasswordException` | 400 with the policy message passed through to the UI (3.3); no verifier write | +| Cognito `UserNotFoundException` | 404 with a generic "account not found" message | +| Other Cognito errors | 502 "operation failed"; account state untouched (Cognito ops are atomic); audit finalized `failure` (3.5, 5.6) | +| Forgot-password: `email_verified != true` | 400 before any generation or delivery (4.4) | +| SES send failure | Error returned; `admin_set_user_password` is only called *after* a successful send, so credentials are preserved (4.5) | +| Strict audit `put_item` failure | 500 "action not applied"; Cognito never called (6.4, 6.5) | +| Last-PortalAdmin violation | 409 with the reason; rejected attempt audited (5.3, 5.5; also disable 13.9 and delete 14.3/14.4 per D14) | +| Create: invalid/missing field or role | 400 identifying the offending field before any Cognito call (12.6, 12.7, 12.8) | +| Create: `UsernameExistsException` | 409 "username already exists"; no account created or modified — the Cognito call is atomic (12.5) | +| Create: other Cognito errors | 502 "account was not created"; no account or partial record remains (12.9); audit finalized `failure` | +| Disable/enable: already in requested state | 200 no-op with the current state; no mutation, no sync staging (13.6) | +| Disable/enable: Cognito failure | 502 "action failed"; state unchanged (13.7); audit finalized `failure` | +| Delete: `UserNotFoundException` | 404 "account not found"; nothing modified; UI refreshes the list (14.11) | +| Delete: Cognito failure | 502 "deletion failed"; account and verifier record untouched — verifier delete only runs after a successful Cognito delete (14.6, D13) | +| Delete: verifier-record delete failure after Cognito delete | Error reporting the account deleted but its verifier record not removed; record retained for a subsequent attempt (14.10) | +| Sync document exceeds shadow size limit | Sync marked `failed` with an explicit size reason; pending changes retained | +| Shadow write failure | Sync marked `failed` with the error; retried on the 5-minute schedule (7.6, 7.7) | +| No ack within 60 s | `failed` / `device unreachable`; pending retained and retried (7.9) | + +### Edge + +| Failure | Handling | +|---|---| +| Malformed sync document | Rejected by `parse_sync_document`; `reported.error` ack written so the portal records a reasoned failure instead of a timeout; existing cache untouched | +| Cache file missing/corrupt at login | Treated as an empty cache: uniform 401 + "no synchronized accounts available" diagnostic log (11.3); never a 500 | +| Config IPC read error | Local login treated as disabled (11.4); transition logged once | +| Token validation failures (expired/malformed/bad signature/disabled account) | Uniform 401 with `WWW-Authenticate: Bearer` (8.6, 8.7) | +| Secret file unreadable | Regenerated (invalidating outstanding sessions — users re-login); logged | +| Sync agent crash | Logged; daemon thread isolated from the rest of LocalServer (camera-sync precedent) | +| Existing_Token_Auth introspection failures | Unchanged from today's `validate_token` behavior (500 on transport error, 401 on inactive token) | + +Failed logins and lockout events are logged with username but never with the submitted password. + +## Testing Strategy + +Both test suites already use property-based testing: **Hypothesis** in `edge-cv-portal/backend/tests` and `test/backend-test`, and **fast-check** (with Vitest) in `edge-cv-portal/frontend`. No new test tooling is required. + +### Property-based tests + +Each correctness property above is implemented as a **single property-based test**, configured for a **minimum of 100 iterations**, and tagged with a comment in the form: + +``` +Feature: portal-user-manager, Property {number}: {property_text} +``` + +Placement: + +- **Portal frontend (fast-check + Vitest)**: Properties 1, 3, 4 — exercised against the exported pure helpers (`filterAccounts`, table row-model builder, dropdown item builder) and component renders parameterized by role. +- **Portal backend (Hypothesis + pytest, moto/mocked Cognito per the existing `conftest.py` session-stack pattern)**: Properties 2, 5–15, 24–27 — handlers exercised through synthesized API Gateway events with a faked `cognito-idp` client (recording calls, injecting `InvalidPasswordException`, `UsernameExistsException`, `UserNotFoundException`, and generic faults), moto DynamoDB for audit/credential/sync tables, and the pure functions (`generate_temp_password`, `make_verifier`, `validate_create_request`, `build_sync_document`, sync-state reducer) tested directly. +- **Edge (Hypothesis + pytest in `test/backend-test/local_auth/`)**: Properties 12 (edge apply side), 16–23 — pure modules (`session_tokens`, `credential_cache`, `lockout`, config parsing) with injected clocks and tmp-path cache files; Property 19 drives `authorize_request` through FastAPI's dependency system with a stubbed remote introspection. + +Mocks keep all property tests pure and fast: no real Cognito, SES, IoT, or network calls occur in any property test (the PBKDF2 iteration count is parameterized down in generators to keep 100+ iterations quick, with one example test at the production count). + +### Example-based unit tests + +Cover the concrete scenarios classified as examples in the prework: dropdown navigation (1.3), unauthenticated redirect (1.6), list-load failure UI (2.5), password modal permanence selection required (3.2), success confirmation (3.4), generic-failure paths (3.5, 5.6), forgot-password guards (4.4, 4.5), role modal options/preselection (5.2), confirmation + refresh (5.7), offline login verification under a no-network guard (8.4), the SPA `LoginGate` in both states (8.8, 9.2), runtime config flip between requests (11.2), and the 11.3 diagnostic log message. For the account life-cycle additions: the Create User modal's role options and success confirmation + refresh (12.2, 12.10), creation Cognito-failure path (12.9), disable/enable/delete confirmation modals naming the account and firing no call before confirmation (13.1, 14.1), cancel/dismiss submits nothing (14.9), success confirmation + refresh (13.8, 14.7), disable/enable failure message (13.7), and the not-found delete path with list refresh (14.11). + +### Integration and smoke tests + +- jwt_authorizer attachment on the new admin routes (1.7) — infrastructure snapshot assertion in the CDK tests. +- EventBridge `rate(5 minutes)` schedule and IoT topic rule → SQS wiring (7.7) — CDK snapshot assertions. +- Cognito `FORCE_CHANGE_PASSWORD` semantics (4.2, 4.6) and role-in-fresh-JWT (5.8) — documented Cognito behavior; verified once manually against a deployed pool during rollout. +- Cognito-native account life-cycle behavior — `admin_create_user` invitation email with a policy-conformant temporary password (12.3), forced password change at the new account's first sign-in (12.4), and refusal of sign-in and all new JWT issuance (including refresh) for disabled accounts (13.4, 13.5) — documented Cognito behavior; verified once manually against a deployed pool during rollout. +- The extended `cognito-idp:AdminCreateUser/AdminEnableUser/AdminDisableUser/AdminDeleteUser` grants on the `user_admin.py`-scoped IAM policy — CDK snapshot assertion in the ComputeStack tests. +- Component-configuration read at startup (11.1) — single smoke test with mocked Greengrass IPC. + +### Regression safety + +The decision-matrix property (19) doubles as the regression guard for Requirements 9 and 10: the (disabled, not-configured) row asserts byte-for-byte today's open behavior, and the (disabled, configured) row pins the existing bearer-token path. Existing `utils/auth.py` tests continue to pass unchanged since `validate_token` itself is not modified — it is composed by the new `authorize_request` dependency. diff --git a/.kiro/specs/portal-user-manager/requirements.md b/.kiro/specs/portal-user-manager/requirements.md new file mode 100644 index 00000000..90a69933 --- /dev/null +++ b/.kiro/specs/portal-user-manager/requirements.md @@ -0,0 +1,226 @@ +# Requirements Document + +## Introduction + +This feature adds a User Manager tool to the Edge CV Portal, reachable from the settings dropdown in the top navigation and visible only to Portal Administrators. The tool lets administrators manage portal user accounts: create new accounts, change passwords, trigger a forgot-password flow that issues a temporary password, change user roles, and disable, re-enable, or delete accounts. Accounts managed in the portal can be synchronized to the LocalServer edge component so that, when a configuration option is enabled, operators can log in locally on the edge device using cached credentials even while the internet connection is down. When the local-login configuration is disabled, edge access remains open (unauthenticated) exactly as it works today, and the existing bearer/JWT token support on the edge remains functional in all cases. + +The portal already authenticates users through an Amazon Cognito user pool with role-based access control (roles include PortalAdmin, UseCaseAdmin, DataScientist, Operator, Viewer). The LocalServer edge component already supports optional token-based authorization: when an authorization settings file is present, API requests require a valid bearer token; when absent, access is open. + +## Glossary + +- **Portal**: The Edge CV Portal web application (React frontend and Lambda backend behind API Gateway) used to manage the defect detection system. +- **User_Manager**: The new administrator-facing tool in the Portal for managing user accounts, passwords, and roles. +- **Portal_Backend**: The Portal's Lambda-based API layer that performs user management operations against the User_Pool. +- **User_Pool**: The Amazon Cognito user pool that stores portal user accounts and issues JWT tokens. +- **Portal_Administrator**: A portal user whose role is PortalAdmin. +- **Portal_Role**: One of the defined portal roles: PortalAdmin, UseCaseAdmin, DataScientist, Operator, Viewer. +- **Temporary_Password**: A system-generated, single-use password delivered to a user that must be changed at next sign-in. +- **LocalServer**: The Greengrass edge component (FastAPI backend and local React web UI) that runs on the edge device. +- **Account_Sync_Service**: The mechanism that transfers user account records (including credential verifiers and roles) from the Portal to the LocalServer. +- **Local_Credential_Cache**: The store on the edge device that holds synchronized account records used for local login. +- **Edge_Credential_Verifier**: The salted, one-way credential verifier record that the Portal stores for an account and includes in sync payloads so that the LocalServer can verify Local_Login passwords. +- **Local_Login**: Authentication performed by the LocalServer against the Local_Credential_Cache without requiring internet connectivity. +- **Local_Login_Configuration**: The edge-side configuration setting that enables or disables Local_Login on a given edge device. +- **Existing_Token_Auth**: The LocalServer's current bearer-token authorization mechanism, enabled by the presence of the authorization settings file. +- **Local_Session_Token**: A signed token issued by the LocalServer after a successful Local_Login, used to authorize subsequent LocalServer API requests. +- **Password_Policy**: The set of rules governing password composition (minimum length, character classes) enforced by the User_Pool. + +## Requirements + +### Requirement 1: User Manager Access and Visibility + +**User Story:** As a Portal_Administrator, I want a User Manager tool available from the settings dropdown in the upper-right navigation, so that I can manage user accounts without leaving the portal. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator opens the settings dropdown in the top navigation, THE Portal SHALL display a User Manager menu item. +2. WHEN a user whose Portal_Role is not PortalAdmin opens the settings dropdown, THE Portal SHALL NOT display the User Manager menu item. +3. WHEN a Portal_Administrator selects the User Manager menu item, THE Portal SHALL navigate to the User_Manager page. +4. IF a signed-in user whose Portal_Role is not PortalAdmin navigates directly to the User_Manager page URL, THEN THE Portal SHALL display an access-denied notice and SHALL NOT render User_Manager content. +5. IF a request whose validated User_Pool JWT token does not include the PortalAdmin Portal_Role reaches a User_Manager API endpoint, THEN THE Portal_Backend SHALL reject the request with an authorization error (HTTP 403) and SHALL NOT perform the requested operation. +6. IF an unauthenticated user navigates directly to the User_Manager page URL, THEN THE Portal SHALL redirect the user to the Portal sign-in page. +7. IF a request without a valid User_Pool JWT token reaches a User_Manager API endpoint, THEN THE Portal_Backend SHALL reject the request and SHALL NOT perform the requested operation. + +### Requirement 2: User Account Listing + +**User Story:** As a Portal_Administrator, I want to see all portal user accounts with their status and roles, so that I can find and manage accounts. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator opens the User_Manager page, THE User_Manager SHALL display all accounts in the User_Pool, showing for each account the username, email, Portal_Role, the account status as reported by the User_Pool, and enabled/disabled state. +2. WHEN a Portal_Administrator enters a filter term, THE User_Manager SHALL restrict the displayed accounts to those whose username or email contains the filter term as a case-insensitive substring. +3. WHEN a filter term matches no accounts, THE User_Manager SHALL display an empty list without an error message. +4. WHEN a Portal_Administrator clears the filter term, THE User_Manager SHALL restore the display of all accounts in the User_Pool. +5. IF the Portal_Backend fails to retrieve the account list, THEN THE User_Manager SHALL display an error message describing the failure and SHALL NOT display a partial or stale account list. + +### Requirement 3: Password Change + +**User Story:** As a Portal_Administrator, I want to set a new password on a user account, so that I can help users who need their password changed. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator submits a new password for a selected account, THE Portal_Backend SHALL set the new password on that account in the User_Pool using the permanence setting selected by the Portal_Administrator. +2. WHEN a Portal_Administrator initiates a password change for a selected account, THE User_Manager SHALL require the Portal_Administrator to select exactly one of two options before submission: a permanent password, or a temporary password that must be changed at the user's next sign-in. +3. IF the submitted password violates the Password_Policy, THEN THE Portal_Backend SHALL reject the change without modifying the account's existing password and THE User_Manager SHALL display the specific policy rule that was violated to the Portal_Administrator. +4. WHEN a password change succeeds, THE User_Manager SHALL display a confirmation message identifying the affected account. +5. IF the User_Pool operation fails for a reason other than a Password_Policy violation, THEN THE User_Manager SHALL display an error message indicating the password change failed and the account's existing password SHALL remain unchanged. + +### Requirement 4: Forgot Password with Temporary Password + +**User Story:** As a Portal_Administrator, I want to trigger a forgot-password action that sends the user a Temporary_Password, so that locked-out users can regain access. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator triggers the forgot-password action for an account that has a verified email address, THE Portal_Backend SHALL generate a Temporary_Password that conforms to the Password_Policy and deliver it to the account's registered email address. +2. WHEN an account signs in with a valid Temporary_Password, THE User_Pool SHALL require that account to set a new password conforming to the Password_Policy before granting access. +3. WHEN THE Portal_Backend confirms delivery of the Temporary_Password to the account's registered email address, THE User_Manager SHALL display a confirmation that the Temporary_Password was sent, without displaying the Temporary_Password value. +4. IF the account has no verified email address, THEN THE Portal_Backend SHALL reject the forgot-password action without generating or delivering a Temporary_Password, and THE User_Manager SHALL display an error message indicating that the account has no verified email address. +5. IF Temporary_Password generation or delivery fails, THEN THE Portal_Backend SHALL preserve the account's existing credentials unchanged and THE User_Manager SHALL display an error message indicating that the Temporary_Password was not sent. +6. IF a sign-in is attempted with a Temporary_Password that has already been consumed by a prior successful sign-in, THEN THE User_Pool SHALL reject the sign-in attempt with an authentication error. + +### Requirement 5: Role Change + +**User Story:** As a Portal_Administrator, I want to change the Portal_Role of a user account, so that I can grant or reduce a user's access as responsibilities change. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator selects a new Portal_Role for an account and confirms, THE Portal_Backend SHALL update the account's Portal_Role in the User_Pool. +2. THE User_Manager SHALL offer only the defined Portal_Role values (PortalAdmin, UseCaseAdmin, DataScientist, Operator, Viewer) as role choices, with the account's current Portal_Role indicated as the current selection. +3. IF a user management action would remove the PortalAdmin role from, or disable, the last remaining enabled PortalAdmin account, THEN THE Portal_Backend SHALL reject the action and THE User_Manager SHALL display the reason. +4. WHEN an account's Portal_Role changes, THE Portal_Backend SHALL record the change in the portal audit log with the acting administrator, the affected account, the previous role, and the new role. +5. WHEN a role change is rejected, THE Portal_Backend SHALL record an audit log entry for the rejected attempt with the acting administrator, the affected account, and the rejection reason. +6. IF the User_Pool update fails during a role change, THEN THE Portal_Backend SHALL leave the account's Portal_Role unchanged and THE User_Manager SHALL display an error message indicating the role change failed. +7. WHEN a role change completes successfully, THE User_Manager SHALL display a confirmation of the change and refresh the displayed account list to show the account's new Portal_Role. +8. WHEN a JWT token is issued for an account after its Portal_Role has changed, THE Portal_Backend SHALL include the account's new Portal_Role in the token. + +### Requirement 6: Audit Logging of User Management Actions + +**User Story:** As a Portal_Administrator, I want user management actions recorded in the audit log, so that account changes are traceable. + +#### Acceptance Criteria + +1. WHEN a password change, forgot-password action, role change, account creation, account disable action, account enable action, or account deletion completes successfully, THE Portal_Backend SHALL record exactly one audit log entry containing the identity of the acting user, the identity of the affected account, the action type, and the date and time at which the action completed. +2. IF a user management action is initiated by the account holder rather than an administrator (such as a self-service forgot-password action), THEN THE Portal_Backend SHALL record the affected account's identity as the acting user in the audit log entry. +3. THE Portal_Backend SHALL exclude password values, password hashes, and Temporary_Password values from audit log entries. +4. IF the audit log entry for a user management action cannot be recorded, THEN THE Portal_Backend SHALL reject the user management action and leave the affected account in its state prior to the action. +5. IF the audit log entry for a user management action cannot be recorded, THEN THE Portal_Backend SHALL display to the User_Manager an error message indicating that the action was not applied. + +### Requirement 7: Account Sync to the Edge + +**User Story:** As a Portal_Administrator, I want portal accounts synchronized to the LocalServer edge component, so that operators can authenticate locally on the edge device. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator initiates an account sync for an edge device, THE Account_Sync_Service SHALL transfer the selected account records (username, email, Portal_Role, enabled/disabled state, and a credential verifier) to that device's Local_Credential_Cache. +2. WHEN any synchronized account attribute (username, email, credential verifier, Portal_Role, or enabled/disabled state) changes in the Portal, THE Account_Sync_Service SHALL include the updated account record in the next sync to each configured edge device. +3. THE Account_Sync_Service SHALL transfer credential material only in the form of a salted, one-way credential verifier, and SHALL exclude plaintext passwords from sync payloads, transport, and storage. +4. WHEN a sync completes, THE Account_Sync_Service SHALL report the sync result (success, or failure with a reason) and the sync completion timestamp to the Portal, and THE User_Manager SHALL display the last sync status and timestamp per edge device. +5. WHEN a sync completes with zero account changes to transfer, THE Account_Sync_Service SHALL report the sync as successful. +6. IF a sync attempt fails due to an unreachable edge device, THEN THE Account_Sync_Service SHALL retain the pending account changes until they are successfully delivered to that device. +7. WHILE an edge device has undelivered pending account changes, THE Account_Sync_Service SHALL attempt delivery of all pending account changes to that device at intervals not exceeding 5 minutes until delivery succeeds. +8. WHEN an account is disabled or deleted in the Portal, THE Account_Sync_Service SHALL mark that account as disabled in the Local_Credential_Cache on the next sync. +9. IF an edge device does not acknowledge a sync transfer within 60 seconds of transfer initiation, THEN THE Account_Sync_Service SHALL record the sync as failed with a reason indicating the device was unreachable. + +### Requirement 8: Local Cached Login on the Edge + +**User Story:** As an operator at a site with an unreliable internet connection, I want to log in to the LocalServer using my synchronized portal account, so that I can use the edge component when the internet is down. + +#### Acceptance Criteria + +1. WHERE the Local_Login_Configuration is enabled, THE LocalServer SHALL require authentication for all LocalServer web UI access and API requests, except for the login screen and the login endpoint. +2. WHERE the Local_Login_Configuration is enabled, WHEN a user submits a username and password that match an enabled account in the Local_Credential_Cache, THE LocalServer SHALL grant access and issue a Local_Session_Token valid for 12 hours from the time of issuance. +3. WHERE the Local_Login_Configuration is enabled, WHEN a user submits credentials that do not match an enabled account in the Local_Credential_Cache, THE LocalServer SHALL reject the login attempt with an authentication error (HTTP 401) whose response is identical whether or not the submitted username exists in the Local_Credential_Cache. +4. THE LocalServer SHALL validate Local_Login credentials against the Local_Credential_Cache without requiring internet connectivity. +5. WHEN the LocalServer receives an API request bearing a valid Local_Session_Token, defined as a token whose signature verifies successfully, that has not expired, and that was issued to an account currently marked enabled in the Local_Credential_Cache, THE LocalServer SHALL authorize the request. +6. IF an account in the Local_Credential_Cache is marked disabled, THEN THE LocalServer SHALL reject login attempts for that account and SHALL reject requests bearing any Local_Session_Token previously issued to that account. +7. WHEN the LocalServer receives an API request bearing a Local_Session_Token that is expired, malformed, or fails signature verification, THE LocalServer SHALL reject the request with an authentication error (HTTP 401). +8. WHERE the Local_Login_Configuration is enabled, WHEN an unauthenticated user requests a LocalServer web UI page other than the login screen, THE LocalServer SHALL present the login screen. +9. WHERE the Local_Login_Configuration is enabled, WHEN the LocalServer receives an API request that bears neither a valid Local_Session_Token nor valid Existing_Token_Auth credentials (per Requirement 10), THE LocalServer SHALL reject the request with an authentication error (HTTP 401). +10. IF an account accumulates 5 consecutive failed Local_Login attempts, THEN THE LocalServer SHALL reject all subsequent login attempts for that account for 15 minutes, regardless of the credentials submitted. + +### Requirement 9: Open Access When Local Login Is Disabled + +**User Story:** As a site administrator who has not enabled local login, I want edge access to keep working exactly as it does today, so that enabling this feature is fully optional. + +#### Acceptance Criteria + +1. WHERE the Local_Login_Configuration is disabled and Existing_Token_Auth is not configured, THE LocalServer SHALL serve web UI requests without requiring authentication. +2. WHERE the Local_Login_Configuration is disabled, THE LocalServer SHALL present the web UI without a local login screen and without prompting for credentials. +3. WHERE the Local_Login_Configuration is disabled and Existing_Token_Auth is not configured, THE LocalServer SHALL serve API requests without requiring authentication. +4. WHERE the Local_Login_Configuration is disabled and Existing_Token_Auth is configured, THE LocalServer SHALL reject API requests that do not carry a valid bearer token under Existing_Token_Auth with an authentication error (HTTP 401). +5. WHERE the Local_Login_Configuration is disabled, IF the LocalServer receives a Local_Login request, THEN THE LocalServer SHALL reject the request with an error indicating that Local_Login is disabled and SHALL NOT issue a Local_Session_Token. + +### Requirement 10: Retention of Existing Token Support + +**User Story:** As an integrator using the existing bearer-token authorization on the edge, I want the current token support retained, so that existing integrations keep working regardless of this feature. + +#### Acceptance Criteria + +1. WHERE Existing_Token_Auth is configured on an edge device, WHEN an API request carries a bearer token that the existing validation mechanism confirms as valid, THE LocalServer SHALL authorize the request regardless of the Local_Login_Configuration state. +2. WHERE both Existing_Token_Auth is configured and the Local_Login_Configuration is enabled, THE LocalServer SHALL authorize an API request when the request carries either a valid bearer token under Existing_Token_Auth or a valid Local_Session_Token. +3. WHEN the Local_Login_Configuration changes state (enabled to disabled or disabled to enabled), THE LocalServer SHALL continue to determine Existing_Token_Auth solely from the presence of the authorization settings file and SHALL NOT modify that file's contents. +4. WHERE Existing_Token_Auth is configured and the Local_Login_Configuration is disabled, IF an API request does not carry a bearer token that the existing validation mechanism confirms as valid, THEN THE LocalServer SHALL reject the request with an authentication error (HTTP 401). +5. WHERE both Existing_Token_Auth is configured and the Local_Login_Configuration is enabled, IF an API request carries neither a valid bearer token under Existing_Token_Auth nor a valid Local_Session_Token, THEN THE LocalServer SHALL reject the request with an authentication error (HTTP 401). + +### Requirement 11: Local Login Configuration + +**User Story:** As a Portal_Administrator, I want to enable or disable local cached login per edge device through configuration, so that each site can choose whether local authentication applies. + +#### Acceptance Criteria + +1. WHEN the LocalServer component starts, THE LocalServer SHALL read the Local_Login_Configuration state (enabled or disabled) from its component configuration. +2. WHEN the Local_Login_Configuration is changed on a running edge device, THE LocalServer SHALL apply the new state to all subsequent web UI and API requests within 60 seconds of the change, without requiring reinstallation of the LocalServer component. +3. IF the Local_Login_Configuration is enabled and the Local_Credential_Cache contains no enabled accounts, THEN THE LocalServer SHALL reject each login attempt with an authentication error and SHALL log a diagnostic message indicating that no synchronized accounts are available. +4. IF the Local_Login_Configuration value is absent from or unreadable in the component configuration, THEN THE LocalServer SHALL treat the Local_Login_Configuration as disabled. + +### Requirement 12: Account Creation + +**User Story:** As a Portal_Administrator, I want to create new user accounts from the User_Manager, so that new users can be given portal access without leaving the portal. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator submits a new account with a username, an email address, and a Portal_Role, THE Portal_Backend SHALL create the account in the User_Pool with the submitted username, email address, and Portal_Role. +2. THE User_Manager SHALL offer only the defined Portal_Role values (PortalAdmin, UseCaseAdmin, DataScientist, Operator, Viewer) as the role choice for a new account. +3. WHEN THE Portal_Backend creates an account in the User_Pool, THE User_Pool SHALL deliver an invitation containing a Temporary_Password that conforms to the Password_Policy to the account's email address. +4. WHEN a newly created account signs in with its Temporary_Password, THE User_Pool SHALL require the account to set a new password conforming to the Password_Policy before granting access. +5. IF the submitted username matches an existing account in the User_Pool, THEN THE Portal_Backend SHALL reject the creation without creating or modifying any account, and THE User_Manager SHALL display an error message indicating that the username already exists. +6. IF the submitted email address does not consist of a non-empty local part, followed by an '@' separator, followed by a non-empty domain containing at least one dot, THEN THE Portal_Backend SHALL reject the creation without creating an account, and THE User_Manager SHALL display an error message identifying the email address as invalid. +7. IF the submitted account is missing a username, email address, or Portal_Role, or any of these values is empty, THEN THE Portal_Backend SHALL reject the creation without creating an account, and THE User_Manager SHALL display an error message identifying the missing field. +8. IF the submitted Portal_Role is not one of the five defined Portal_Role values (PortalAdmin, UseCaseAdmin, DataScientist, Operator, Viewer), THEN THE Portal_Backend SHALL reject the creation without creating an account. +9. IF the User_Pool operation fails during account creation, THEN THE Portal_Backend SHALL report the failure with no account or partial account record remaining in the User_Pool, and THE User_Manager SHALL display an error message indicating that the account was not created. +10. WHEN an account creation completes successfully, THE User_Manager SHALL display a confirmation indicating that an invitation with a Temporary_Password was sent to the account's email address, without displaying the Temporary_Password value, and SHALL refresh the displayed account list to include the new account. +11. WHEN an account creation completes successfully, THE Portal_Backend SHALL record the creation in the portal audit log with the acting administrator and the created account's username, email address, and Portal_Role. + +### Requirement 13: Account Disable and Enable + +**User Story:** As a Portal_Administrator, I want to disable and re-enable user accounts, so that I can suspend a user's access without deleting the account. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator initiates a disable or enable action for an account, THE User_Manager SHALL require explicit confirmation identifying the affected account by username before submitting the action. +2. WHEN a Portal_Administrator confirms a disable action for an enabled account, THE Portal_Backend SHALL mark the account as disabled in the User_Pool. +3. WHEN a Portal_Administrator confirms an enable action for a disabled account, THE Portal_Backend SHALL mark the account as enabled in the User_Pool. +4. WHILE an account is marked disabled in the User_Pool, THE User_Pool SHALL reject sign-in attempts for that account with an authentication error. +5. WHILE an account is marked disabled in the User_Pool, THE User_Pool SHALL reject requests to issue new JWT tokens for that account, including requests made through token refresh. +6. IF a confirmed disable or enable action targets an account that is already in the requested enabled/disabled state, THEN THE Portal_Backend SHALL make no change to the account and THE User_Manager SHALL refresh the displayed account list to show the account's current state. +7. IF the User_Pool operation fails during a disable or enable action, THEN THE Portal_Backend SHALL leave the account's enabled/disabled state unchanged and THE User_Manager SHALL display an error message indicating that the action failed. +8. WHEN a disable or enable action completes successfully, THE User_Manager SHALL display a confirmation identifying the affected account and refresh the displayed account list to show the account's new enabled/disabled state. +9. WHEN a disable action is rejected under Requirement 5.3 (last remaining enabled PortalAdmin account), THE Portal_Backend SHALL record an audit log entry for the rejected attempt with the acting administrator, the affected account, and the rejection reason. + +### Requirement 14: Account Deletion + +**User Story:** As a Portal_Administrator, I want to delete user accounts, so that accounts that are no longer needed are removed from the portal. + +#### Acceptance Criteria + +1. WHEN a Portal_Administrator initiates a delete action for an account, THE User_Manager SHALL require explicit confirmation identifying the affected account by username before submitting the deletion. +2. WHEN a Portal_Administrator confirms deletion of an account, THE Portal_Backend SHALL delete the account from the User_Pool. +3. IF a delete action targets the last remaining enabled PortalAdmin account, THEN THE Portal_Backend SHALL reject the deletion without modifying the account and THE User_Manager SHALL display the reason. +4. WHEN a delete action is rejected because it targets the last remaining enabled PortalAdmin account, THE Portal_Backend SHALL record an audit log entry for the rejected attempt with the acting administrator, the affected account, and the rejection reason. +5. WHEN an account is deleted from the User_Pool, THE Portal_Backend SHALL delete the account's Edge_Credential_Verifier record from portal storage. +6. IF the User_Pool operation fails during deletion, THEN THE Portal_Backend SHALL leave the account and the account's Edge_Credential_Verifier record unchanged and THE User_Manager SHALL display an error message indicating that the deletion failed. +7. WHEN an account deletion completes successfully, THE User_Manager SHALL display a confirmation identifying the deleted account and refresh the displayed account list without the deleted account. +8. WHEN an account deletion completes successfully, THE Portal_Backend SHALL record the deletion in the portal audit log with the acting administrator and the deleted account's username, email address, and Portal_Role at the time of deletion. +9. IF the Portal_Administrator cancels or dismisses the confirmation without confirming, THEN THE User_Manager SHALL NOT submit the deletion and the account SHALL remain unchanged in the User_Pool. +10. IF the Edge_Credential_Verifier record deletion fails after the account has been deleted from the User_Pool, THEN THE Portal_Backend SHALL retain the Edge_Credential_Verifier record for removal on a subsequent attempt and THE User_Manager SHALL display an error message indicating that the account was deleted but its Edge_Credential_Verifier record was not removed. +11. IF a confirmed deletion targets an account that no longer exists in the User_Pool, THEN THE Portal_Backend SHALL report the failure without modifying any account, and THE User_Manager SHALL display an error message indicating that the account was not found and refresh the displayed account list. diff --git a/.kiro/specs/portal-user-manager/tasks.md b/.kiro/specs/portal-user-manager/tasks.md new file mode 100644 index 00000000..7e73f3eb --- /dev/null +++ b/.kiro/specs/portal-user-manager/tasks.md @@ -0,0 +1,443 @@ +# Implementation Plan: Portal User Manager + +## Overview + +Implementation proceeds in four parallel streams that share no files: portal backend (strict audit helpers, `user_admin.py`, `account_sync.py`), portal frontend (Layout dropdown, UserManager page, modals, sync panel), edge local auth (`local_auth` pure modules, `authorize_request`, endpoints), and edge sync/web UI (`user_accounts_sync` agent, `LoginGate`). Infrastructure (ComputeStack) lands after both Lambdas exist. Property-based tests use Hypothesis (backend/edge) and fast-check (frontend), each tagged `Feature: portal-user-manager, Property N` with a minimum of 100 iterations; each property test lives in its own test file so tests can be written in parallel. + +## Tasks + +- [x] 1. Portal backend foundations + - [x] 1.1 Implement strict audit helpers in the shared layer + - Add `record_audit_event_strict(...)` (two-phase: `put_item` a `pending` entry and raise on failure) and `finalize_audit_event(event_id, result, details)` to the portal shared layer alongside `log_audit_event` + - Sanitize `details` with a denylist (`password`, `verifier`, `hash`, `temp*`) before writing + - Support the new action types `password_change`, `forgot_password`, `role_change` with `resource_type='user_account'` and results `pending | success | failure | rejected` + - _Requirements: 6.1, 6.3, 6.4, 6.5_ + + - [x] 1.2 Create `user_admin.py` Lambda scaffold with PortalAdmin gate and pure credential functions + - Create `edge-cv-portal/backend/functions/user_admin.py` reusing the shared layer, with a router that asserts `get_user_from_event(event)['role'] == 'PortalAdmin'` on every handler and returns 403 otherwise + - Implement pure function `generate_temp_password(length=16)`: length ≥ 12, at least one lowercase, uppercase, digit, and symbol, assembled with `secrets.choice` and shuffled with `secrets.SystemRandom().shuffle` + - Implement pure function `make_verifier(password)` → `{algorithm: 'pbkdf2-sha256', iterations: 210000, salt: b64, hash: b64}` using `hashlib.pbkdf2_hmac` with a 16-byte random salt (iteration count parameterizable for tests) + - _Requirements: 1.5, 4.1, 7.3_ + + - [ ]* 1.3 Write property test for the temporary password generator + - **Property 7: Temporary password policy conformance** + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 7` + - **Validates: Requirements 4.1** + + - [ ]* 1.4 Write property test for verifier one-wayness + - **Property 14: Plaintext never appears in credential material** + - Serialized verifier, sync documents, and cache files built from it never contain the plaintext; same password twice yields different salts and hashes + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 14` + - **Validates: Requirements 7.3** + +- [x] 2. Implement `user_admin.py` account-management handlers + - [x] 2.1 Implement `GET /api/v1/admin/users` account listing + - Paginate Cognito `list_users` fully, join with the `dda-portal-edge-credentials` table for the `edgeCapable` flag + - Return username, email, `email_verified`, `custom:role` (default `Viewer`), `UserStatus`, `Enabled` + - _Requirements: 1.5, 1.7, 2.1_ + + - [x] 2.2 Implement `POST /api/v1/admin/users/{username}/password` + - Flow: audit-pending → `admin_set_user_password(Permanent=permanent)` → verifier capture into `dda-portal-edge-credentials` → audit-final + - Map `InvalidPasswordException` → 400 with the policy message passed through and no verifier write; other Cognito errors → 502 "password change failed"; `UserNotFoundException` → 404 + - _Requirements: 3.1, 3.3, 3.5, 6.1, 6.4_ + + - [x] 2.3 Implement `POST /api/v1/admin/users/{username}/forgot-password` + - Flow: verified-email check (400 before any generation if `email_verified != 'true'`) → `generate_temp_password` → audit-pending → SES `SendEmail` from the configured sender → `admin_set_user_password(Permanent=False)` → verifier capture → audit-final + - SES send happens before the password set so a delivery failure leaves credentials untouched; response never contains the temporary password value + - _Requirements: 4.1, 4.3, 4.4, 4.5, 6.1, 6.3_ + + - [x] 2.4 Implement `PUT /api/v1/admin/users/{username}/role` with the last-PortalAdmin guard + - Validate against the five defined Portal_Role values; guard: paginate the pool counting enabled `custom:role == 'PortalAdmin'` users, reject with 409 + reason if the change would leave zero, and audit the rejected attempt + - Flow: audit-pending → `admin_update_user_attributes` on `custom:role` → audit-final recording previous and new role; Cognito failure → audit-final `failure`, role unchanged, error surfaced + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + + - [ ]* 2.5 Write property test for the PortalAdmin API gate + - **Property 2: PortalAdmin gate on admin API endpoints** + - Synthesized API Gateway events with non-PortalAdmin claims against every handler: 403 and zero Cognito/credential/sync mutations (recording fake clients) + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 2` + - **Validates: Requirements 1.5** + + - [ ]* 2.6 Write property test for faithful admin operation pass-through + - **Property 5: Admin operations pass through faithfully** + - Recording fake `cognito-idp` client asserts exact password/permanence and exact role on `custom:role` + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 5` + - **Validates: Requirements 3.1, 5.1** + + - [ ]* 2.7 Write property test for policy-violation handling + - **Property 6: Policy-violation rejection preserves state** + - Inject `InvalidPasswordException`: 400 with the violated rule in the body, no verifier stored or updated + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 6` + - **Validates: Requirements 3.3** + + - [ ]* 2.8 Write property test for secret confinement + - **Property 8: Secret material never leaves the backend** + - For password and forgot-password flows: serialized HTTP responses and all recorded audit entries contain neither the plaintext nor the verifier hash + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 8` + - **Validates: Requirements 4.3, 6.3** + + - [ ]* 2.9 Write property test for the last-PortalAdmin guard + - **Property 9: Last-PortalAdmin guard** + - Arbitrary account populations (roles × enabled flags) and role-change/disable actions: rejected iff zero enabled PortalAdmins would remain + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 9` + - **Validates: Requirements 5.3** + + - [ ]* 2.10 Write property test for audit completeness + - **Property 10: Audit completeness** + - Every successful action → exactly one finalized entry (acting user, affected account, action type, completion timestamp); role changes carry previous/new role; rejections carry the reason; self-service records the affected account as actor + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 10` + - **Validates: Requirements 5.4, 5.5, 6.1, 6.2** + + - [ ]* 2.11 Write property test for audit-failure blocking + - **Property 11: Audit failure blocks the action** + - Failing `put_item` on the pending entry → zero Cognito mutations and an error response stating the action was not applied + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 11` + - **Validates: Requirements 6.4, 6.5** + + - [ ]* 2.12 Write unit tests for backend error paths + - Generic Cognito failure → 502 with account untouched (3.5, 5.6); unverified email → 400 before generation (4.4); SES failure → credentials preserved (4.5); `UserNotFoundException` → 404 + - _Requirements: 3.5, 4.4, 4.5, 5.6_ + +- [x] 3. Implement Account_Sync_Service portal side + - [x] 3.1 Implement sync staging, `build_sync_document`, and edge-sync endpoints in `user_admin.py` + - `GET /api/v1/admin/edge-sync/devices`: devices table joined with `dda-portal-account-sync` for `lastSyncStatus`, `lastSyncAt`, `pendingChanges` + - `POST /api/v1/admin/edge-sync/devices/{deviceId}`: stage the selected full account set + fresh `syncId`, set `pendingChanges=true`, invoke the sync Lambda + - Pure `build_sync_document(accounts, syncId)`: complete account set `{username, email, role, enabled, deleted?, verifier?}` (never plaintext), disabled/deleted accounts marked `enabled: false` and never dropped; validate against the 8 KB shadow limit + - Hook attribute changes (verifier capture, role change, enable/disable) to mark every configured device's staged set updated and pending + - _Requirements: 7.1, 7.2, 7.3, 7.8_ + + - [ ]* 3.2 Write property test for attribute-change propagation + - **Property 13: Attribute changes propagate to staged syncs** + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 13` + - **Validates: Requirements 7.2** + + - [ ]* 3.3 Write property test for sync document building + - **Property 12: Sync round trip preserves account records** (portal build side) + - For any selected account set (disabled/deleted, with/without verifiers), the built document contains exactly those records with all attributes preserved and no silent drops + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 12` + - **Validates: Requirements 7.1, 7.8** + + - [x] 3.4 Implement `account_sync.py` sync-attempt entry + - Create `edge-cv-portal/backend/functions/account_sync.py` (mirroring `camera_sync.py` style): build the desired document from the staged set, `update_thing_shadow` on the `dda-user-accounts` named shadow, stamp the row `in_progress` with `attemptAt` + - Shadow-write failure or size-limit violation → `failed` with the reason, pending changes retained; runs on direct invoke and on the 5-minute schedule for every device with pending changes + - _Requirements: 7.6, 7.7_ + + - [x] 3.5 Implement ack ingest and timeout sweep in `account_sync.py` + - SQS handler (partial-batch-failure pattern): reported `ackSyncId` matching the current `syncId` → `success`, `lastSyncAt = appliedAt`, clear `pendingChanges`; reported `error` → `failed` with the device's reason; stale acks discarded + - Timeout sweep on the 5-minute schedule: `in_progress` rows with `attemptAt` older than 60 s and no ack → `failed` / `device unreachable`, pending retained + - _Requirements: 7.4, 7.5, 7.6, 7.9_ + + - [ ]* 3.6 Write property test for the sync-state reducer + - **Property 15: Sync-state reducer** + - Arbitrary event sequences (attempts, matching/error/stale acks, timeout sweeps at arbitrary times): only a matching ack marks success and clears pending (including zero-change syncs); timeout fires exactly when > 60 s elapsed; no failure discards the staged set + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 15` + - **Validates: Requirements 7.4, 7.5, 7.6, 7.9** + +- [x] 4. Checkpoint - portal backend + - Ensure all tests pass, ask the user if questions arise. + +- [x] 5. Infrastructure (ComputeStack additions) + - [x] 5.1 Add the new infrastructure to the ComputeStack + - DynamoDB tables `dda-portal-edge-credentials` and `dda-portal-account-sync` + - `user_admin.py` and `account_sync.py` Lambda functions; API Gateway routes under `/api/v1/admin/*` behind the existing `jwt_authorizer` + - Ack SQS queue + DLQ; IoT topic rule on `$aws/things/+/shadow/name/dda-user-accounts/update/documents` → SQS; EventBridge `rate(5 minutes)` schedule → `account_sync.py` + - SES send permission + verified-sender-address stack parameter; `cognito-idp:AdminSetUserPassword/AdminUpdateUserAttributes/ListUsers/AdminGetUser` grants scoped to the pool for `user_admin.py` only; table/queue/shadow IAM grants + - _Requirements: 1.7, 4.1, 7.7_ + + - [ ]* 5.2 Write CDK snapshot/integration assertions + - jwt_authorizer attached to every new admin route (1.7); EventBridge `rate(5 minutes)` schedule and IoT topic rule → SQS wiring (7.7); Lambda IAM scoping + - _Requirements: 1.7, 7.7_ + +- [x] 6. Portal frontend + - [x] 6.1 Add the User Manager entry point and route + - `Layout.tsx`: build the settings `ButtonDropdown` items conditionally — the `user-manager` item is present only when `user?.role === 'PortalAdmin'` (exported pure item-builder function); selection navigates to `/admin/user-manager` + - `App.tsx`: register the route inside the authenticated layout (unauthenticated visitors hit the existing `/login` redirect) + - _Requirements: 1.1, 1.2, 1.3, 1.6_ + + - [x] 6.2 Implement the `UserManager` page with account table and filtering + - New `UserManager.tsx`: access-denied `Alert` and nothing else for non-PortalAdmin users; Cloudscape `Table` + `TextFilter` over `GET /api/v1/admin/users` with columns username, email, Portal_Role, Cognito status, enabled/disabled, edge-login-capable + - Exported pure `filterAccounts(accounts, term)`: case-insensitive substring on username or email; empty/whitespace term → full list; no match → empty list, table empty state + - Clear accounts state before each fetch; load failure renders an error `Alert`, never a partial or stale list; exported pure table row-model builder + - _Requirements: 1.4, 2.1, 2.2, 2.3, 2.4, 2.5_ + + - [x] 6.3 Implement the password, forgot-password, and role modals + - Password modal: password + confirm, required permanent/temporary `RadioGroup` with no default and submit disabled until chosen, client-side policy pre-check, server policy errors shown verbatim, success flashbar naming the account + - Forgot-password confirmation modal: success shows "temporary password sent to the account's registered email" without the value; surfaces no-verified-email and delivery errors + - Role modal: `Select` limited to the five roles with the current role preselected; success confirmation + list re-fetch; rejection reasons (incl. last-PortalAdmin guard) shown in the modal + - _Requirements: 3.2, 3.3, 3.4, 3.5, 4.3, 4.4, 4.5, 5.2, 5.3, 5.7_ + + - [x] 6.4 Implement the edge sync panel + - Device list from `GET /api/v1/admin/edge-sync/devices` showing last sync status + timestamp per device; account multi-select sync action posting to `POST /api/v1/admin/edge-sync/devices/{deviceId}` + - _Requirements: 7.1, 7.4_ + + - [ ]* 6.5 Write property test for the UI role gate + - **Property 1: PortalAdmin role gate in the portal UI** + - For any Portal_Role: dropdown item present and page renders management content (vs. access denied) iff PortalAdmin — via the item-builder and role-parameterized renders + - fast-check + Vitest, min 100 iterations, tag `Feature: portal-user-manager, Property 1` + - **Validates: Requirements 1.1, 1.2, 1.4** + + - [ ]* 6.6 Write property test for listing completeness + - **Property 3: Account listing completeness** + - Row-model builder: exactly one row per account carrying username, email, role, status, enabled state + - fast-check + Vitest, min 100 iterations, tag `Feature: portal-user-manager, Property 3` + - **Validates: Requirements 2.1** + + - [ ]* 6.7 Write property test for account filtering + - **Property 4: Account filtering** + - fast-check + Vitest, min 100 iterations, tag `Feature: portal-user-manager, Property 4` + - **Validates: Requirements 2.2, 2.3, 2.4** + + - [ ]* 6.8 Write unit tests for frontend example scenarios + - Dropdown navigation (1.3), unauthenticated redirect (1.6), list-load failure UI (2.5), permanence selection required before submit (3.2), success confirmation (3.4), forgot-password confirmation without value (4.3), role modal options/preselection (5.2), confirmation + refresh (5.7) + - _Requirements: 1.3, 1.6, 2.5, 3.2, 3.4, 4.3, 5.2, 5.7_ + +- [x] 7. Checkpoint - portal complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 8. Edge local auth core modules (`src/backend/local_auth/`) + - [x] 8.1 Implement `credential_cache.py` + - Load/parse `/aws_dda/local_credential_cache.json`; `verify_credentials(username, password) -> Account | None` with constant-shape verification (PBKDF2 against stored or dummy verifier), returning the account only when the verifier matches and `enabled` is true + - Missing/empty/corrupt cache treated as empty: every login fails uniformly with a logged "no synchronized accounts available" diagnostic; purely local, no network + - _Requirements: 8.3, 8.4, 8.6, 11.3_ + + - [x] 8.2 Implement `session_tokens.py` + - `issue_token(secret, username, role, now)`: payload `{sub, role, iat, exp: iat + 43200, jti}`, `base64url(payload).base64url(HMAC-SHA256)`; injected clock + - `validate_token(secret, token, now, cache)`: reject malformed structure, bad signature (`hmac.compare_digest`), `exp <= now`; then re-check the cache — account absent or disabled ⇒ reject + - `get_or_create_secret()`: `/aws_dda/local_session_secret`, 32 random bytes, mode `0600`, regenerate if unreadable + - _Requirements: 8.2, 8.5, 8.6, 8.7_ + + - [x] 8.3 Implement `lockout.py` + - In-memory `LockoutTracker` with injected clock: `record_failure`, `record_success` (resets), `is_locked`; 5 consecutive failures → 15-minute lock rejecting all attempts without extending the window + - _Requirements: 8.10_ + + - [x] 8.4 Implement `config.py` + - `LocalLoginConfig` singleton reading `LocalLoginEnabled` via Greengrass IPC `GetConfiguration` at startup, re-polled on a 30 s background timer; enabled only for explicit `true`/`"true"`; missing key, read error, or any other value ⇒ disabled, transitions logged once + - _Requirements: 11.1, 11.2, 11.4_ + + - [ ]* 8.5 Write property test for the session token life cycle + - **Property 16: Session token life cycle** + - Expiry exactly 12 h after issuance; validates while unexpired + enabled; any corruption (byte edit, truncation, segment removal, foreign-secret signature) or time at/after expiry rejects + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 16` + - **Validates: Requirements 8.2, 8.5, 8.7** + + - [ ]* 8.6 Write property test for disable-revokes-access + - **Property 18: Disable revokes access** + - After marking an account disabled: correct-credential logins rejected and every previously issued token fails validation + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 18` + - **Validates: Requirements 8.6** + + - [ ]* 8.7 Write property test for login lockout + - **Property 20: Login lockout** + - Arbitrary attempt sequences/timings: locked exactly after 5 consecutive failures; all attempts rejected within the 15-minute window; success resets the count; correct credentials succeed after the window + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 20` + - **Validates: Requirements 8.10** + + - [ ]* 8.8 Write property test for configuration parsing + - **Property 23: Configuration parsing defaults to disabled** + - Arbitrary values (missing key, strings, JSON values, IPC errors): enabled only for explicit true representations + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 23` + - **Validates: Requirements 11.4** + + - [ ]* 8.9 Write unit tests for offline verification and config startup + - Credential verification under a no-network guard (8.4); component-configuration read at startup with mocked Greengrass IPC (11.1) + - _Requirements: 8.4, 11.1_ + +- [x] 9. Edge auth endpoints and authorization dependency + - [x] 9.1 Implement `endpoints/local_auth.py` + - Unauthenticated `GET /local-auth/status` → `{localLoginEnabled}`; `POST /local-auth/login`: when disabled → 403 "local login is disabled", never issues a token; when enabled → lockout check → `verify_credentials` → success: `record_success` + `issue_token` → `{token, expiresAt, role, username}`; failure: `record_failure` + uniform 401 + - Log failed logins and lockouts with username, never the submitted password + - _Requirements: 8.2, 8.3, 8.10, 9.5, 11.3_ + + - [ ]* 9.2 Write property test for failed-login indistinguishability + - **Property 17: Failed login indistinguishability** + - Any cache (incl. empty/all-disabled): 401 responses identical in status and body for known-username-wrong-password vs. unknown-username attempts; "no synchronized accounts available" diagnostic logged for empty caches + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 17` + - **Validates: Requirements 8.3, 11.3** + + - [ ]* 9.3 Write property test for disabled-login token refusal + - **Property 21: Disabled local login never issues tokens** + - Any credentials and cache content while disabled: error indicates local login disabled, response contains no token + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 21` + - **Validates: Requirements 9.5** + + - [x] 9.4 Implement the `authorize_request` dependency and rewire routers + - Extend `utils/auth.py` with `authorize_request` implementing the decision matrix per request: open access only when both mechanisms off; valid existing bearer always authorizes when Existing_Token_Auth configured; valid Local_Session_Token authorizes only when local login enabled; otherwise 401 with `WWW-Authenticate: Bearer` + - Two-segment base64url credentials validated locally first, falling back to the existing remote `validate_token`; never reads or writes `authorization_settings.json` beyond the presence check + - Switch `get_api_router()` from the frozen `Depends(validate_token)` to `Depends(authorize_request)` unconditionally; exempt `/local-auth/login`, `/local-auth/status`, and static SPA assets; give the download-file query-param token path the same either/or acceptance + - _Requirements: 8.1, 8.5, 8.9, 9.1, 9.3, 9.4, 10.1, 10.2, 10.3, 10.4, 10.5, 11.2_ + + - [ ]* 9.5 Write property test for the authorization decision matrix + - **Property 19: Authorization decision matrix** + - Drive `authorize_request` through FastAPI's dependency system with stubbed remote introspection across all (local-login state × settings-file presence × credential kind) combinations; the (disabled, not-configured) row pins today's open behavior + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 19` + - **Validates: Requirements 8.1, 8.9, 9.1, 9.3, 9.4, 10.1, 10.2, 10.4, 10.5** + + - [ ]* 9.6 Write property test for settings-file isolation + - **Property 22: Local login configuration never touches Existing_Token_Auth** + - Any sequence of local-login state changes: settings file bytes unchanged; bearer treatment depends only on file presence + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 22` + - **Validates: Requirements 10.3** + + - [ ]* 9.7 Write unit test for runtime configuration changes + - Flip `LocalLoginEnabled` between requests without restarting the app; the next request follows the new state + - _Requirements: 11.2_ + +- [x] 10. Edge sync agent and component configuration + - [x] 10.1 Implement `src/backend/user_accounts_sync/agent.py` + - Pure `parse_sync_document` validating document shape; atomic cache replacement (temp file mode `0600`, `os.replace`) + - Agent mirrors `camera_sync/agent.py`: startup `GetThingShadow` catch-up on `dda-user-accounts`, MQTT `SubscriptionHandler` on the shadow delta topic, apply → write `reported {ackSyncId, appliedAt, accountCount}`; validation failure → `reported {ackSyncId, error}` with the existing cache untouched; crashes logged, never fatal to LocalServer + - _Requirements: 7.1, 7.4, 7.8, 7.9_ + + - [ ]* 10.2 Write property test for edge sync application + - **Property 12: Sync round trip preserves account records** (edge apply side) + - For any valid sync document (disabled/deleted accounts, with/without verifiers), applying it yields a cache containing exactly those records with all attributes preserved; disabled/deleted marked disabled, never dropped + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 12` + - **Validates: Requirements 7.1, 7.8** + + - [x] 10.3 Wire the agent and configuration into the component + - Start the sync agent on a daemon thread from `server_setup.py`; start the `LocalLoginConfig` poller + - Add `LocalLoginEnabled: "false"` to the LocalServer recipe `DefaultConfiguration` + - _Requirements: 11.1_ + +- [x] 11. Edge web UI login gate + - [x] 11.1 Implement the `LoginGate` component in `src/react-webapp` + - Fetch `/local-auth/status`; when enabled and no unexpired token in `sessionStorage`, render the login screen (posting to `/local-auth/login`) and block all other views; when disabled, render the app directly with no login screen or prompt + - On success store the token and attach `Authorization: Bearer` to all API calls; API 401 clears the token and returns to the login screen + - _Requirements: 8.1, 8.8, 9.2_ + + - [ ]* 11.2 Write unit tests for the login gate + - `LoginGate` in both states: enabled-without-token renders the login screen and blocks the app (8.8); disabled renders the app with no prompt (9.2) + - _Requirements: 8.8, 9.2_ + +- [x] 12. Final checkpoint + - Ensure all tests pass, ask the user if questions arise. + +- [x] 13. Backend account life-cycle handlers (`user_admin.py`) + - [x] 13.1 Extend the shared-layer audit action types + - Add `account_create`, `account_disable`, `account_enable`, and `account_delete` to the shared-layer `USER_ACCOUNT_AUDIT_ACTIONS` so the strict audit helpers accept the new action types with `resource_type='user_account'` and the existing denylist sanitization + - _Requirements: 6.1, 12.11, 14.8_ + + - [x] 13.2 Implement `validate_create_request` and `POST /api/v1/admin/users` + - Pure function `validate_create_request(body)`: all three fields present and non-empty (rejections name the missing field), email consisting of a non-empty local part, `@`, and a non-empty domain containing at least one dot, role in the five defined Portal_Role values; a rejection performs no User_Pool call + - Flow: validate → audit-pending (`account_create`) → `admin_create_user` with `custom:role`, `email`, `email_verified=true`, and the Cognito-native email invitation (D12 — no SES, no portal-generated password, no verifier capture) → audit-final carrying the created account's `{username, email, role}` + - `UsernameExistsException` → 409 "username already exists" with no account created or modified; other Cognito errors → 502 "account was not created" with no partial record (creation is atomic), audit-final `failure` + - _Requirements: 12.1, 12.3, 12.5, 12.6, 12.7, 12.8, 12.9, 12.11_ + + - [x] 13.3 Implement `POST /api/v1/admin/users/{username}/disable` and `/enable` + - Both endpoints read the current `Enabled` state via `admin_get_user` first; already-in-requested-state → 200 no-op returning the current state with no Cognito mutation, no audit-pending write, and no sync staging (13.6) + - Disable: last-PortalAdmin guard (shared predicate, D14) → 409 + reason with the rejected attempt audited before any mutation (13.9); then audit-pending (`account_disable`) → `admin_disable_user` → mark sync staging pending with `enabled: false` → audit-final + - Enable: audit-pending (`account_enable`) → `admin_enable_user` → mark sync staging pending with `enabled: true` → audit-final + - Cognito failure → 502 "action failed" with the state unchanged and audit-final `failure` (13.7) + - _Requirements: 13.2, 13.3, 13.6, 13.7, 13.9, 7.2, 7.8_ + + - [x] 13.4 Implement `DELETE /api/v1/admin/users/{username}` + - Flow (D13 ordering): `admin_get_user` captures username/email/role for the audit entry (14.8) and maps `UserNotFoundException` → 404 with nothing modified (14.11) → last-PortalAdmin guard → 409 + reason with the rejected attempt audited (14.3, 14.4) → audit-pending (`account_delete`) → `admin_delete_user` → delete the `dda-portal-edge-credentials` record → mark sync staging pending with `enabled: false, deleted: true` → audit-final + - A Cognito failure aborts before the verifier record is touched (14.6); a verifier-delete failure after a successful Cognito delete retains the record for a subsequent attempt, finalizes the audit entry with a partial-cleanup detail, and returns an error stating the account was deleted but its verifier record was not removed (14.10) + - _Requirements: 14.2, 14.3, 14.4, 14.5, 14.6, 14.8, 14.10, 14.11, 7.8_ + + - [ ]* 13.5 Write property test for the account-creation validation gate + - **Property 24: Account creation validation gate** + - Arbitrary payloads (missing/empty/populated fields, arbitrary email and role strings): `admin_create_user` invoked iff the payload is valid, carrying exactly the submitted username, email, and role; every rejection performs no User_Pool call and identifies the offending field + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 24` + - **Validates: Requirements 12.1, 12.6, 12.7, 12.8** + + - [ ]* 13.6 Write property test for duplicate-username rejection + - **Property 25: Duplicate usernames never create or modify accounts** + - Arbitrary pool populations and valid payloads (recording fake `cognito-idp` client raising `UsernameExistsException` for existing usernames): creation succeeds iff the username is new; duplicates → 409 with zero account creations or modifications + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 25` + - **Validates: Requirements 12.5** + + - [ ]* 13.7 Write property test for disable/enable state transitions + - **Property 26: Disable/enable transitions are exact** + - Arbitrary current states × confirmed disable/enable actions: `admin_disable_user`/`admin_enable_user` invoked and every configured device's staged sync set marked pending with the new state iff the requested state differs; already-in-requested-state → no User_Pool mutation and no sync staging + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 26` + - **Validates: Requirements 13.2, 13.3, 13.6** + + - [ ]* 13.8 Write property test for deletion effects + - **Property 27: Deletion removes the account, its verifier, and stages the removal** + - Accounts with/without verifier records × injected fault patterns (none, Cognito delete failure, verifier-record delete failure): fault-free deletion removes the account and its verifier record and stages `enabled: false, deleted: true` on every configured device; a Cognito failure leaves both untouched; a verifier-delete failure retains the record and reports the partial cleanup + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 27` + - **Validates: Requirements 14.2, 14.5, 14.6, 14.10, 7.8** + + - [ ]* 13.9 Extend the Property 9 test to cover disable and delete actions + - **Property 9: Last-PortalAdmin guard** (extended scope) + - Arbitrary account populations (roles × enabled flags) with the action space extended to role-change, disable, and delete: rejected iff zero enabled PortalAdmins would remain + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 9` + - **Validates: Requirements 5.3, 13.9, 14.3** + + - [ ]* 13.10 Extend the Property 10 test to cover the new action types + - **Property 10: Audit completeness** (extended scope) + - Action space extended to account creation, disable, enable, and deletion: exactly one finalized entry per successful action; creations and deletions carry the account's username, email, and role (for deletions, the values at the time of deletion); rejected disable/delete attempts carry the acting administrator, affected account, and rejection reason + - Hypothesis, min 100 iterations, tag `Feature: portal-user-manager, Property 10` + - **Validates: Requirements 5.4, 5.5, 6.1, 6.2, 12.11, 13.9, 14.4, 14.8** + + - [ ]* 13.11 Write unit tests for the new backend error paths + - Creation Cognito failure → 502 with no account or partial record remaining (12.9); disable/enable Cognito failure → 502 with the state unchanged (13.7); delete of a nonexistent account → 404 with nothing modified (14.11) + - _Requirements: 12.9, 13.7, 14.11_ + +- [x] 14. Checkpoint - backend account life-cycle + - Ensure all tests pass, ask the user if questions arise. + +- [x] 15. Infrastructure (ComputeStack IAM extension) + - [x] 15.1 Extend the `user_admin.py` Cognito IAM grants + - Add `cognito-idp:AdminCreateUser`, `AdminEnableUser`, `AdminDisableUser`, and `AdminDeleteUser` to the existing pool-scoped `user_admin` policy statement in `edge-cv-portal/infrastructure/lib/compute-stack.ts`; no new resources required + - _Requirements: 12.1, 13.2, 13.3, 14.2_ + + - [ ]* 15.2 Write CDK snapshot assertion for the extended grants + - Assert the extended `cognito-idp:AdminCreateUser/AdminEnableUser/AdminDisableUser/AdminDeleteUser` actions on the `user_admin.py`-scoped policy in the ComputeStack tests + - _Requirements: 12.1, 13.2, 13.3, 14.2_ + +- [x] 16. Frontend account life-cycle modals + - [x] 16.1 Implement the Create User modal + - In `UserManagerModals.tsx` (or a new modal file), wired into `UserManager.tsx`: username, email, and role fields with the role a `Select` restricted to the five defined roles (12.2); client-side pre-checks mirroring the server validation (non-empty fields, email shape) with submit disabled until valid; server rejection reasons (duplicate username, invalid email, missing field) surfaced verbatim in the modal + - Success flashbar states an invitation with a temporary password was sent to the account's email — never the value — and the list re-fetches to include the new account (12.10) + - _Requirements: 12.2, 12.5, 12.6, 12.7, 12.10_ + + - [x] 16.2 Implement the disable/enable confirm modal + - Explicit confirmation naming the affected account by username before submission (13.1); on success a confirmation identifying the account + list re-fetch (13.8); an already-in-requested-state response simply re-fetches to show the current state (13.6); rejection reasons (last-PortalAdmin guard, 5.3) and failures (13.7) shown in the modal + - _Requirements: 13.1, 13.6, 13.7, 13.8, 5.3_ + + - [x] 16.3 Implement the delete confirm modal + - Explicit confirmation naming the affected account by username (14.1); cancel/dismiss submits nothing (14.9); on success a confirmation identifying the deleted account + list re-fetch without it (14.7); rejection reasons (last-PortalAdmin guard, 14.3), not-found with list refresh (14.11), and partial verifier-cleanup errors (14.10) surfaced in the modal or flashbar + - _Requirements: 14.1, 14.3, 14.7, 14.9, 14.10, 14.11_ + + - [ ]* 16.4 Write unit tests for the new modal behaviors + - Create User role options and success confirmation + refresh (12.2, 12.10); disable/enable/delete confirmations naming the account and firing no call before confirmation (13.1, 14.1); cancel/dismiss submits nothing (14.9); success confirmation + refresh (13.8, 14.7); disable/enable failure message (13.7); not-found delete path with list refresh (14.11) + - _Requirements: 12.2, 12.10, 13.1, 13.7, 13.8, 14.1, 14.7, 14.9, 14.11_ + +- [x] 17. Final checkpoint - account life-cycle + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each property-based test is a single test tagged `Feature: portal-user-manager, Property {number}`, configured for a minimum of 100 iterations; PBKDF2 iteration counts are parameterized down in generators (one example test at the production count) +- Each property test lives in its own test file so parallel waves never write the same file +- Property 12 has a portal build-side test (3.3) and an edge apply-side test (10.2) since the two codebases cannot import each other +- Cognito `FORCE_CHANGE_PASSWORD` semantics (4.2, 4.6) and role-in-fresh-JWT (5.8) are documented Cognito behaviors verified manually during rollout, not coding tasks +- Cognito-native account life-cycle behavior — invitation delivery with a policy-conformant temporary password (12.3), forced password change at first sign-in (12.4), and sign-in/token-issuance refusal for disabled accounts including refresh (13.4, 13.5) — is likewise documented behavior verified manually during rollout, not a coding task +- Tasks 13–17 extend already-implemented-and-deployed code: 13.9 and 13.10 extend the existing Property 9 and Property 10 test scopes (same tags, extended action generators) rather than adding new properties +- No new test tooling is required: Hypothesis exists in `edge-cv-portal/backend/tests` and `test/backend-test`; fast-check + Vitest exist in `edge-cv-portal/frontend` + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1", "1.2", "6.1", "8.1", "8.2", "8.3", "8.4", "10.1"] }, + { "id": 1, "tasks": ["1.3", "1.4", "2.1", "6.2", "8.5", "8.6", "8.7", "8.8", "8.9", "9.1", "9.4", "10.2", "10.3", "11.1"] }, + { "id": 2, "tasks": ["2.2", "2.5", "6.3", "6.5", "6.6", "6.7", "9.2", "9.3", "9.5", "9.6", "9.7", "11.2"] }, + { "id": 3, "tasks": ["2.3", "2.7", "6.4"] }, + { "id": 4, "tasks": ["2.4", "2.8", "6.8"] }, + { "id": 5, "tasks": ["3.1", "2.6", "2.9", "2.10", "2.11", "2.12"] }, + { "id": 6, "tasks": ["3.2", "3.3", "3.4"] }, + { "id": 7, "tasks": ["3.5"] }, + { "id": 8, "tasks": ["3.6", "5.1"] }, + { "id": 9, "tasks": ["5.2"] }, + { "id": 10, "tasks": ["13.1", "15.1", "16.1"] }, + { "id": 11, "tasks": ["13.2", "15.2", "16.2"] }, + { "id": 12, "tasks": ["13.3", "13.5", "13.6", "16.3"] }, + { "id": 13, "tasks": ["13.4", "13.7", "16.4"] }, + { "id": 14, "tasks": ["13.8", "13.9", "13.10", "13.11"] } + ] +} +``` diff --git a/.kiro/specs/triton-inference-runtimes-missing-fix/tasks.md b/.kiro/specs/triton-inference-runtimes-missing-fix/tasks.md index f5aec0af..e916c9d8 100644 --- a/.kiro/specs/triton-inference-runtimes-missing-fix/tasks.md +++ b/.kiro/specs/triton-inference-runtimes-missing-fix/tasks.md @@ -105,7 +105,7 @@ This plan fixes the missing `inference_runtimes.py` on the subsequent-setup path - Confirm all tests still pass after the fix (no regressions) - _Requirements: 3.1, 3.2, 3.3, 3.4_ -- [-] 4. Add drift-proofing and downstream staging tests +- [x] 4. Add drift-proofing and downstream staging tests - **Property 1: Fix Checking (PBT)** - Generate varied source resource file sets (add arbitrary new resource files alongside `inference_runtimes.py`) and varied pre-existing destination states → assert the fixed re-sync delivers every source file to `/aws_dda/resources_for_copy`, proving the drift class of bug cannot recur - Verify downstream staging: with `inference_runtimes.py` now present in `/aws_dda/resources_for_copy`, run the `model_convertor.py` staging path and assert `inference_runtimes.py` is copied next to `lfv_model_template.py` into the model version directory (Requirement 2.2) - Run all tests diff --git a/.kiro/specs/workflow-designer-bugfixes/.config.kiro b/.kiro/specs/workflow-designer-bugfixes/.config.kiro new file mode 100644 index 00000000..1de9bbdf --- /dev/null +++ b/.kiro/specs/workflow-designer-bugfixes/.config.kiro @@ -0,0 +1 @@ +{"specId": "274a7fd2-6800-4369-b820-3d849778d9f4", "workflowType": "requirements-first", "specType": "bugfix"} diff --git a/.kiro/specs/workflow-designer-bugfixes/bugfix.md b/.kiro/specs/workflow-designer-bugfixes/bugfix.md new file mode 100644 index 00000000..eb9ef4e0 --- /dev/null +++ b/.kiro/specs/workflow-designer-bugfixes/bugfix.md @@ -0,0 +1,93 @@ +# Bugfix Requirements Document + +## Introduction + +This spec covers three distinct bugs reported in the edge-cv-portal workflow/node designer: + +1. **Generate-workflow temperature handling**: The Bedrock invocations behind workflow generation (`workflow_generator.py`) and node generation (`node_generator.py`, which reuses `get_bedrock_configuration()`) always send a sampling temperature because `DEFAULT_BEDROCK_CONFIG` bakes in `temperature: 0.2`. Newer Anthropic models (e.g. Opus 4.x-class models) reject requests that set temperature at all, failing generation with a "deprecated parameter" style `BEDROCK_INVOCATION_FAILED` error. When no explicit temperature value has been configured by an admin or provided per-request, the temperature parameter must be omitted from the invocation entirely. The PortalAdmin Bedrock settings form (`BedrockConfigurationSettings.tsx`) and its backend validation (`data_accounts.py`) currently refuse a blank temperature, so an admin cannot even unset it. + +2. **Input-type custom nodes carry a default input port**: The node-designer create and registration wizards (`CreateWizard.tsx`, `RegistrationWizard.tsx`) seed the port declaration with one input port ("in") and one output port ("out") regardless of the selected palette category. When the category is `input` (a source node), the wizard still presents an input port — contradicting the wizard's own Port_Guidance ("Input nodes typically declare no inputs and one VideoFrames output") and leaving users unable to tell what is actually required for inputs vs outputs per node kind. + +3. **Node import selects every plugin by default**: In the import flow (`ImportView.tsx`), when an official module's individual plugin list loads, every plugin is checked by default (`setSelectedModulePlugins(allPluginNames(plugins))`). The user must uncheck everything they do not want instead of opting in to what to import. + +## Bug Analysis + +### Current Behavior (Defect) + +**Bug 1 — Temperature always sent to Bedrock** + +1.1 WHEN a workflow generation is invoked and no temperature has been explicitly configured in the portal Bedrock settings and no per-request temperature override is given THEN the system sends the default temperature 0.2 in the Bedrock inference configuration, and models that reject the temperature parameter fail the generation with a deprecated-parameter invocation error + +1.2 WHEN a node (plugin scaffold) generation is invoked and no temperature has been explicitly configured and no override is given THEN the system sends the default temperature 0.2 in the Bedrock inference configuration, failing on models that reject the temperature parameter + +1.3 WHEN a PortalAdmin clears the Temperature field in the Bedrock configuration settings form THEN the system rejects the save with "Temperature must be between 0 and 1", so the configured temperature cannot be unset + +**Bug 2 — Input-category nodes presented with an input port** + +1.4 WHEN a user creates or registers a custom node and selects the `input` palette category THEN the system keeps presenting the default input port row ("in"), implying an input port is expected for a source node + +1.5 WHEN a user selects a palette category in either wizard THEN the system leaves the untouched default port rows (one "in" input, one "out" output) unchanged, so the presented ports contradict the category's typical arrangement and the user cannot tell what is required for inputs and outputs per node kind + +**Bug 3 — Module import checks all plugins by default** + +1.6 WHEN an official module's plugin list loads during node import THEN the system checks every enumerated plugin by default, requiring the user to uncheck unwanted plugins instead of opting in + +### Expected Behavior (Correct) + +**Bug 1 — Omit temperature when no value is given** + +2.1 WHEN a workflow generation is invoked and no temperature has been explicitly configured in the portal Bedrock settings and no per-request temperature override is given THEN the system SHALL omit the temperature parameter entirely from the Bedrock inference configuration + +2.2 WHEN a node (plugin scaffold) generation is invoked and no temperature has been explicitly configured and no override is given THEN the system SHALL omit the temperature parameter entirely from the Bedrock inference configuration + +2.3 WHEN a PortalAdmin clears the Temperature field in the Bedrock configuration settings form and saves THEN the system SHALL accept the save and store the temperature as unset, meaning no temperature is sent at invocation + +**Bug 2 — Port defaults and requirements follow the node kind** + +2.4 WHEN a user selects the `input` palette category in either wizard and the port rows are still the untouched defaults THEN the system SHALL present no input port rows (and one VideoFrames output), matching the category's typical arrangement + +2.5 WHEN a user selects any palette category in either wizard and the port rows are still the untouched defaults THEN the system SHALL adjust the default port rows to that category's typical arrangement, so the presented inputs and outputs reflect what the node kind requires + +2.6 WHEN a user views the ports step for a selected category THEN the system SHALL clearly state what is required for inputs and outputs for that node kind (e.g. input nodes: no inputs, one output; output nodes: at least one input, no outputs) + +**Bug 3 — Import selection is opt-in** + +2.7 WHEN an official module's plugin list loads during node import THEN the system SHALL check no plugins by default, so the user explicitly opts in to what to import + +2.8 WHEN the module plugin list is available and the user has selected no plugins THEN the system SHALL require an explicit selection (individual checks or "Select all") before the import proceeds, instead of silently importing the whole module + +### Unchanged Behavior (Regression Prevention) + +**Bug 1** + +3.1 WHEN a temperature has been explicitly configured in the portal Bedrock settings THEN the system SHALL CONTINUE TO send that temperature in the Bedrock inference configuration + +3.2 WHEN a valid per-request temperature override (a number in [0, 1]) is supplied to POST /workflows/generate THEN the system SHALL CONTINUE TO send that temperature for that invocation, suppressing top_p + +3.3 WHEN an out-of-range or non-numeric per-request temperature is supplied THEN the system SHALL CONTINUE TO reject the request with 400 INVALID_TEMPERATURE + +3.4 WHEN both a temperature and a top_p would apply to an invocation THEN the system SHALL CONTINUE TO never send temperature and top_p together (temperature wins when set; top_p is sent only when temperature is explicitly configured as null with a configured top_p) + +3.5 WHEN the GenerateChatPanel temperature field is left blank THEN the system SHALL CONTINUE TO omit the temperature key from the generate request payload + +**Bug 2** + +3.6 WHEN a user has edited the port rows (renamed, retyped, added, or removed any port) and then changes the palette category THEN the system SHALL CONTINUE TO preserve the user-edited port rows without rewriting them + +3.7 WHEN a non-input palette category (preprocessing, inference, post_processing, output) is selected THEN the system SHALL CONTINUE TO present that category's expected input and output ports per its typical arrangement + +3.8 WHEN a user declares any valid port arrangement, including one diverging from the category's typical arrangement THEN the system SHALL CONTINUE TO accept the declaration (guidance stays advisory, non-blocking) + +3.9 WHEN the declared ports diverge from the selected category's typical arrangement THEN the system SHALL CONTINUE TO show the dismissable Port_Guidance divergence advisory + +3.10 WHEN a Port_Scan is applied over untouched default port rows THEN the system SHALL CONTINUE TO replace the defaults with the pad-derived suggestions, and over user-edited rows SHALL CONTINUE TO merge additively + +**Bug 3** + +3.11 WHEN the user checks individual plugins from the module plugin list THEN the system SHALL CONTINUE TO import exactly the selected subset (recorded as selected_plugins) + +3.12 WHEN the user selects every plugin (e.g. via "Select all") THEN the system SHALL CONTINUE TO import the whole module (a full selection serializes to no selected_plugins parameter, today's whole-module behavior) + +3.13 WHEN the module plugin list fails to load THEN the system SHALL CONTINUE TO proceed with the whole-module import as a non-blocking fallback + +3.14 WHEN a plugin-set import lands in pending_selection THEN the system SHALL CONTINUE TO default that selection dialog to no plugins selected and require at least one plugin before submission diff --git a/.kiro/specs/workflow-designer-bugfixes/design.md b/.kiro/specs/workflow-designer-bugfixes/design.md new file mode 100644 index 00000000..fe2445d8 --- /dev/null +++ b/.kiro/specs/workflow-designer-bugfixes/design.md @@ -0,0 +1,343 @@ +# Workflow Designer Bugfixes Design + +## Overview + +This design covers three independent bugs in the edge-cv-portal, fixed together under one spec because they were reported together and each is small and well-isolated: + +1. **Bug 1 — Temperature always sent to Bedrock**: `DEFAULT_BEDROCK_CONFIG` in `workflow_generator.py` (mirrored in `data_accounts.py`) bakes in `temperature: 0.2` and `top_p: 0.9`. When an admin has never configured a temperature and no per-request override is given, the fallback default is sent to the Bedrock Converse API, and newer Anthropic models that reject the temperature parameter fail generation with `BEDROCK_INVOCATION_FAILED`. Additionally the settings form (`BedrockConfigurationSettings.tsx`) and backend validation (`validate_bedrock_configuration` in `data_accounts.py`) refuse a blank temperature, so the parameter cannot even be unset. The fix makes "unset" the default (no sampling parameter sent unless explicitly configured or overridden) and makes blank/unset a valid stored state end to end. + +2. **Bug 2 — Default ports ignore the palette category**: `CreateWizard.tsx` and `RegistrationWizard.tsx` seed the port declaration with one input ("in") and one output ("out") regardless of the selected palette category, so an `input` (source) node is presented with an input port, contradicting the wizard's own Port_Guidance. The fix derives the default port rows from the selected category's typical arrangement (`CATEGORY_ARRANGEMENTS` in `portGuidance.ts`), rewrites untouched default rows when the category changes, and states each kind's input/output requirements on the ports step — while preserving user-edited rows and keeping the guidance advisory. + +3. **Bug 3 — Module import selects every plugin by default**: `ImportView.tsx` seeds the module plugin selection with `allPluginNames(plugins)`, so the user must opt out instead of opting in. The fix seeds the selection empty and relies on the already-existing selection gate (`moduleSelectionIncomplete` blocks `formComplete`) so an explicit selection (individual checks or "Select all") is required before the import proceeds. Serialization semantics are untouched: a full selection still serializes to no `selected_plugins` parameter (whole module). + +All backend invocation-path logic (`invoke_generation` in both generators) already omits `None` sampling parameters, so Bug 1 is primarily a defaults/validation/settings-round-trip fix, not an invocation-logic fix. + +## Glossary + +- **Bug_Condition (C)**: The condition under which a bug manifests. Each of the three bugs has its own condition, formalized below (`isBugCondition1/2/3`). +- **Property (P)**: The desired behavior for inputs where the bug condition holds, formalized in the Correctness Properties section. +- **Preservation**: Behavior for all inputs where the bug condition does NOT hold, which must be byte-for-byte identical before and after the fix (F(X) = F'(X)). +- **Bedrock_Configuration**: The settings-table item `{setting_key: 'bedrock_configuration', value: {model_id, region, max_tokens, temperature, top_p, timeout_seconds}}`, written by `data_accounts.update_bedrock_configuration_setting()` and read by `workflow_generator.get_bedrock_configuration()` (reused by `node_generator.py`). +- **DEFAULT_BEDROCK_CONFIG**: The fallback configuration constant, duplicated (deliberately mirrored) in `edge-cv-portal/backend/functions/workflow_generator.py` and `edge-cv-portal/backend/functions/data_accounts.py`. +- **inference_config**: The `inferenceConfig` dict passed to `bedrock-runtime.converse()` by `invoke_generation()` in `workflow_generator.py` and `node_generator.py`: `{maxTokens}` plus at most one of `temperature` / `topP`. +- **Untouched_Defaults**: Port rows exactly equal to the wizard-seeded defaults, never renamed, retyped, added to, or removed from. Today detected by `portScan.isUntouchedDefaults()` as exactly one "in" input + one "out" output, both VideoFrames; generalized by this fix to "the default arrangement of any palette category". +- **CATEGORY_ARRANGEMENTS**: The per-category typical port arrangement data in `edge-cv-portal/frontend/src/pages/node-designer/portGuidance.ts` (input: no inputs + one VideoFrames output; preprocessing: VideoFrames→VideoFrames; inference: VideoFrames→InferenceMeta; post_processing: InferenceMeta→EventSignal; output: at-least-one input + no outputs). +- **Port_Scan**: The pad-derived port pre-population in the Registration wizard (`portScan.ts`): suggestions replace Untouched_Defaults wholesale, otherwise merge additively. +- **Module plugin selection**: The checkbox list in `ImportView.tsx` over `GET /plugin-modules?module=` results; serialized by `selectedPluginsParam()` in `importFlow.ts` (full/empty selection → `undefined` → whole-module import). + +## Bug Details + +### Bug 1 — Temperature omitted when unset + +#### Bug Condition + +The bug manifests on any workflow or node generation where no temperature was explicitly stored in the Bedrock settings and no per-request override is supplied: `get_bedrock_configuration()` falls back to `DEFAULT_BEDROCK_CONFIG['temperature'] = 0.2`, so `invoke_generation()` always puts `temperature` into the inference config. It also manifests in the settings path: a PortalAdmin clearing the Temperature field is rejected client-side (`validate()` in `BedrockConfigurationSettings.tsx`: `form.temperature.trim() === ''` → error) and server-side (`validate_bedrock_configuration()`: `not _is_number(temperature)` → error). + +**Formal Specification:** +``` +FUNCTION isBugCondition1(input) + INPUT: input of type GenerationRequest | SettingsSave + OUTPUT: boolean + + IF input is GenerationRequest THEN + RETURN storedBedrockConfig.temperature is ABSENT // never configured + AND input.temperature_override is ABSENT + // buggy result: inference_config contains temperature = 0.2 + ELSE // SettingsSave + RETURN input.temperature_field is BLANK + // buggy result: save rejected with "Temperature must be between 0 and 1" + END IF +END FUNCTION +``` + +#### Examples + +- No Bedrock configuration was ever stored; a user prompts the workflow generator against an Opus 4.x-class model. Expected: invocation succeeds with no `temperature` in `inferenceConfig`. Actual: `inferenceConfig = {maxTokens: 4096, temperature: 0.2}` and the model rejects the request (`BEDROCK_INVOCATION_FAILED`, deprecated-parameter style error). +- Same store state, node scaffold generation (`node_generator.py` calls `get_bedrock_configuration()` and its own identical `invoke_generation` inference-config logic). Same failure. +- A PortalAdmin opens the Bedrock settings, clears the Temperature input, saves. Expected: save accepted, temperature stored unset. Actual: client validation error "Temperature must be between 0 and 1"; even bypassing the client, the backend returns 400 "temperature must be a number between 0 and 1". +- Edge case: existing test `test_default_configuration_sends_temperature_without_top_p` in `edge-cv-portal/backend/tests/test_workflow_generation.py` codifies the buggy behavior (`assert inference_config["temperature"] == 0.2`) and must be inverted by the fix. + +### Bug 2 — Input-kind nodes and port defaults + +#### Bug Condition + +The bug manifests whenever a wizard's port rows are still the seeded defaults (`inputs: [{name: 'in', portType: 'VideoFrames'}]`, `outputs: [{name: 'out', portType: 'VideoFrames'}]` from `initialForm()` in `CreateWizard.tsx` and the detail-load seeding in `RegistrationWizard.tsx`) and the selected palette category's typical arrangement differs from those defaults. Selecting a category only patches `form.category`; the port rows never change. + +**Formal Specification:** +``` +FUNCTION isBugCondition2(input) + INPUT: input of type WizardState // {category, inputs, outputs, portRowsUntouched} + OUTPUT: boolean + + RETURN input.portRowsUntouched // defaults never edited + AND arrangementOf(input.category) != multiset(input.inputs, input.outputs) + // buggy result: presented rows stay one "in" input + one "out" output + // instead of the category's typical arrangement +END FUNCTION +``` + +#### Examples + +- User selects category `input` in the Create wizard without touching the ports step. Expected: no input port rows, one VideoFrames output. Actual: an "in" input row is presented, contradicting the guidance text "Input nodes typically declare no inputs and one VideoFrames output" rendered by `PortGuidancePanel` directly above it. +- User selects `inference`. Expected default: one VideoFrames input, one InferenceMeta output. Actual: both ports VideoFrames. +- User selects `output`. Expected default: at least one input (default one VideoFrames input), no outputs. Actual: an "out" output row is presented. +- Edge case: user selects `preprocessing` (the initial category). The current defaults happen to equal the preprocessing arrangement, so no visible defect — the fixed defaults must be identical to today's for this category. +- Edge case: user renames "in" to "video", then changes category. Rows are user-edited; they must be preserved exactly (this already works today and must continue). + +### Bug 3 — Import default selection + +#### Bug Condition + +The bug manifests whenever an official module's plugin list loads in `ImportView.tsx`: the load effect runs `setSelectedModulePlugins(allPluginNames(plugins))`, so every plugin is checked and `formComplete` is immediately true — the import can proceed with zero explicit user selection. Note the serialization subtlety: a full selection and an empty selection both serialize to `selected_plugins = undefined` (whole module) via `selectedPluginsParam()`, so "default all" and "default none without a gate" are behaviorally identical on the wire; the required behavior is default none **plus** the explicit selection gate. + +**Formal Specification:** +``` +FUNCTION isBugCondition3(input) + INPUT: input of type ModulePluginListLoad // non-empty plugin list for the chosen module + OUTPUT: boolean + + RETURN input.plugins.length > 0 + // buggy result: selectedModulePlugins = allPluginNames(input.plugins) + // (every plugin checked; import proceeds with no explicit opt-in) +END FUNCTION +``` + +#### Examples + +- User picks `gst-plugins-good` (74 plugins enumerated). Expected: 0 of 74 selected, import blocked until the user checks plugins or clicks "Select all". Actual: 74 of 74 selected, import proceeds immediately as a whole-module import. +- Edge case: the module plugin list fails to load (`isModuleListingUnavailable`) or is empty. `modulePluginNames.length > 0` is false, so no selection gate applies and the whole-module import proceeds — this fallback must be preserved. +- Edge case: the `pending_selection` dialog (post-fetch plugin-set selection) already defaults to no selection and requires at least one plugin (`pluginSelectionError`); it is unaffected and must stay that way. + +## Expected Behavior + +### Preservation Requirements + +**Unchanged Behaviors — Bug 1:** +- An explicitly stored temperature keeps being sent (`inferenceConfig.temperature`), with `topP` suppressed (Req 3.1, 3.4). +- A valid per-request override (number in [0, 1], booleans excluded) keeps replacing the configured temperature for that invocation only and suppresses `topP` (Req 3.2). +- Invalid overrides keep rejecting with 400 `INVALID_TEMPERATURE` before any Bedrock call (Req 3.3). +- `temperature` and `topP` are never sent together; `topP` is sent only when no temperature applies and a top_p is explicitly configured (Req 3.4). Note: after the fix, "never configured" and "explicitly configured as null" both resolve to `temperature = None` in the effective config — both mean "omit temperature", and `topP` flows exactly when a top_p was explicitly stored. This is intentional: the fixed defaults make the two states indistinguishable, and the pre-fix distinction only existed because the buggy 0.2 default masked the unset state. +- `GenerateChatPanel.tsx` blank-field behavior: a blank temperature input keeps omitting the `temperature` key from the request payload entirely (Req 3.5) — this code is already correct and is not modified. +- All other Bedrock_Configuration validation rules (model_id, region, max_tokens, timeout clamp ≤ 60 s) and the stored item shape are unchanged. + +**Unchanged Behaviors — Bug 2:** +- User-edited port rows (any rename, retype, addition, removal) survive category changes untouched (Req 3.6). +- Non-input categories keep presenting their typical arrangements (Req 3.7) — for `preprocessing` the fixed defaults are byte-identical to today's seeds. +- Any valid port arrangement remains accepted; guidance stays advisory and never gates the wizard (`portsStepErrors` unchanged) (Req 3.8). +- The dismissable divergence advisory (`guidanceDivergence` + `PortGuidancePanel` alert) keeps firing exactly as before (Req 3.9). +- Port_Scan semantics: suggestions replace Untouched_Defaults wholesale and merge additively over user-edited rows (Req 3.10). `isUntouchedDefaults` is generalized (see Fix Implementation) so the new category-shaped defaults still count as untouched. + +**Unchanged Behaviors — Bug 3:** +- A checked subset imports exactly that subset (`selected_plugins` recorded) (Req 3.11). +- Selecting every plugin ("Select all") serializes to no `selected_plugins` parameter — whole-module import, today's wire behavior exactly (Req 3.12); `selectedPluginsParam()` is not modified. +- Listing-unavailable / empty-list whole-module fallback is non-blocking (Req 3.13); the gate only applies when `modulePluginNames.length > 0`. +- The `pending_selection` dialog keeps defaulting to no selection with `pluginSelectionError` requiring at least one plugin (Req 3.14); untouched by this fix. + +**Scope:** +All inputs outside the three bug conditions are completely unaffected: +- Bug 1: any invocation where a temperature applies (stored or overridden), any settings save with a numeric temperature, all non-temperature settings fields, GenerateChatPanel behavior. +- Bug 2: any wizard interaction after the user edits port rows; every step other than Ports; the divergence alert and Port_Scan behavior over edited rows. +- Bug 3: manual repository URL imports, the pending_selection dialog, classification/acknowledgment/architecture logic, all `importFlow.ts` helpers. + +## Hypothesized Root Cause + +These are confirmed root causes — all three were located by reading the code: + +1. **Bug 1 — a fallback default that should be "unset" is a concrete value**: + - `workflow_generator.DEFAULT_BEDROCK_CONFIG` (line ~125) and its mirror `data_accounts.DEFAULT_BEDROCK_CONFIG` (line ~91) carry `'temperature': 0.2, 'top_p': 0.9`. `get_bedrock_configuration()` starts from `dict(DEFAULT_BEDROCK_CONFIG)`, so with nothing stored the effective temperature is 0.2 and `invoke_generation()` (both generators, logic `if temperature is not None: send it`) sends it. + - Downstream, the invocation logic is already correct for `None` — the null-carrying merge in `workflow_generator.get_bedrock_configuration()` (`if key in stored` for temperature/top_p) proves unset was anticipated but the defaults never allowed it to occur without an explicit stored null. + - `data_accounts.validate_bedrock_configuration()` requires temperature (and top_p) to be numbers, and `read_stored_bedrock_configuration()` uses `if stored.get(key) is not None` for every key, so a stored null would be hidden behind the 0.2 default on read — the settings API can neither store nor faithfully report "unset". + - `BedrockConfigurationSettings.tsx` `validate()` rejects a blank temperature/top_p field, and the loader does `String(config.temperature)` which would render a null as the literal string "null". + +2. **Bug 2 — static seeds with no category linkage**: `initialForm()` in `CreateWizard.tsx` and the detail-load form seed in `RegistrationWizard.tsx` hard-code `inputs: [{...emptyPort(), name: 'in'}], outputs: [{...emptyPort(), name: 'out'}]`. The category `Select.onChange` handlers patch only `category`. `CATEGORY_ARRANGEMENTS` already encodes the correct per-category arrangements but is used solely for display/divergence, never for seeding. + +3. **Bug 3 — deliberate default-all seeding**: the module plugin load effect in `ImportView.tsx` (line ~258, comment "Default: ALL plugins selected (whole-module import)") seeds the full list. The selection gate infrastructure (`moduleSelectionIncomplete` → `formComplete`, plus the `errorText` on the checkbox FormField) already exists but never triggers because the selection is never empty on load. + +## Correctness Properties + +Property 1: Bug Condition — Temperature omitted when unset + +_For any_ Bedrock configuration store state in which no temperature value was explicitly stored (nothing stored at all, a stored item without the temperature key, or a stored null) and any generation request without a temperature override, the fixed `get_bedrock_configuration()` + `invoke_generation()` pipeline (workflow and node generators alike) SHALL produce an `inferenceConfig` containing no `temperature` key — and no `topP` key unless a top_p was explicitly stored; and _for any_ settings save with a blank/null temperature (or top_p), the fixed validation SHALL accept the save and store the parameter as unset, round-tripping as unset through GET and the settings form. + +**Validates: Requirements 2.1, 2.2, 2.3** + +Property 2: Preservation — Explicit temperature behavior unchanged + +_For any_ input where the bug condition does NOT hold — a temperature explicitly stored as a number in [0, 1], or a per-request override supplied — the fixed pipeline SHALL produce exactly the same result as the original: the applicable temperature sent as `inferenceConfig.temperature` with `topP` suppressed; invalid overrides (out of range, non-numeric, boolean) rejected with 400 `INVALID_TEMPERATURE` before any Bedrock call; `temperature` and `topP` never sent together; a blank GenerateChatPanel field omitting the `temperature` key from the request payload; and all non-temperature settings validation rules unchanged. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** + +Property 3: Bug Condition — Default ports follow the selected category + +_For any_ palette category in `CATEGORY_ARRANGEMENTS` selected in either wizard while the port rows are Untouched_Defaults (equal to the default arrangement of any category), the fixed wizards SHALL present default port rows whose port-type multisets exactly match that category's typical arrangement (in particular: category `input` → zero input rows and one VideoFrames output; `output` → one input row and zero outputs), every seeded row SHALL carry a non-empty name (so `portsStepErrors` stays clean), `guidanceDivergence(category, inputs, outputs)` SHALL be null for the seeded rows, and the ports step SHALL state that category's input/output requirements. + +**Validates: Requirements 2.4, 2.5, 2.6** + +Property 4: Preservation — Edited rows, advisory guidance, and Port_Scan unchanged + +_For any_ port rows that are NOT Untouched_Defaults (any rename, retype, addition, or removal), the fixed wizards SHALL leave the rows exactly unchanged across any sequence of category changes; `guidanceDivergence` and `portsStepErrors` SHALL answer identically to the original for all inputs; guidance SHALL remain non-blocking; and `applySuggestions` SHALL preserve its replace-over-untouched-defaults / merge-over-edited semantics, with the generalized untouched detection answering true for exactly the per-category default arrangements (including today's in/out pair, which equals the preprocessing defaults). + +**Validates: Requirements 3.6, 3.7, 3.8, 3.9, 3.10** + +Property 5: Bug Condition — Import selection defaults to none with an explicit gate + +_For any_ non-empty module plugin list loading in the import view (including after switching modules), the fixed view SHALL seed the selection empty (0 of N selected) and SHALL keep the import blocked (`formComplete` false, gate message shown) until the user explicitly selects at least one plugin individually or via "Select all". + +**Validates: Requirements 2.7, 2.8** + +Property 6: Preservation — Import serialization and fallbacks unchanged + +_For any_ selection state, the fixed view SHALL serialize exactly as the original: a proper subset serializes to that subset as `selected_plugins`; a full selection (or, when no list is available, no selection) serializes to no `selected_plugins` parameter (whole-module import); an unavailable or empty plugin list SHALL never block the import; and the post-fetch `pending_selection` dialog SHALL keep its default-none, at-least-one-required behavior. + +**Validates: Requirements 3.11, 3.12, 3.13, 3.14** + +## Fix Implementation + +### Changes Required + +#### Bug 1 — Temperature omitted when unset + +**File**: `edge-cv-portal/backend/functions/workflow_generator.py` + +1. **`DEFAULT_BEDROCK_CONFIG`**: change `'temperature': 0.2` → `'temperature': None` and `'top_p': 0.9` → `'top_p': None`, updating the comment: unset by default; sampling parameters are sent only when explicitly configured (or overridden per-request). Both defaults must change together — leaving `top_p: 0.9` would start sending `topP` on every unconfigured invocation, violating Req 3.4 ("top_p only with an explicitly configured top_p") and re-introducing the same rejection risk. +2. **No other changes**: `get_bedrock_configuration()` already carries stored nulls for temperature/top_p (`if key in stored`), and `invoke_generation()` already omits `None` values. The temperature-override path in `generate_workflow()` is untouched. + +**File**: `edge-cv-portal/backend/functions/node_generator.py` + +3. **No changes**: it imports `get_bedrock_configuration` from `workflow_generator` and its `invoke_generation` already omits `None`. Fixed transitively; covered by tests. + +**File**: `edge-cv-portal/backend/functions/data_accounts.py` + +4. **`DEFAULT_BEDROCK_CONFIG` mirror**: same change as (1) — the two constants must stay identical ("Must mirror workflow_generator.DEFAULT_BEDROCK_CONFIG"). +5. **`read_stored_bedrock_configuration()`**: special-case `temperature` / `top_p` exactly like `workflow_generator.get_bedrock_configuration()` does (`if key in stored: config[key] = stored[key]` instead of `if stored.get(key) is not None`), so a stored null reads back as unset rather than being replaced by the default. +6. **`validate_bedrock_configuration()`**: accept `None` for `temperature` and `top_p` (`if temperature is not None and (not _is_number(temperature) or not (0 <= temperature <= 1))`). Numbers outside [0, 1] and non-numeric values keep rejecting. All other field validations unchanged. +7. **`update_bedrock_configuration_setting()`**: no logic change needed — `if key in body: config[key] = body[key]` already carries an explicit JSON `null` through the merge, validation now accepts it, and the written `value` dict stores it as a DynamoDB null. Verify `_native_to_dynamo` passes `None` through (it converts floats to Decimal; `None` must survive). + +**File**: `edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.tsx` + +8. **`validate()`**: blank temperature and blank top_p become valid (meaning "unset — omitted at invocation"); non-blank values keep requiring a number in [0, 1]. +9. **Load effect**: map a null/undefined `config.temperature` / `config.top_p` to `''` instead of `String(null)`. +10. **`handleSave()`**: send `temperature: form.temperature.trim() === '' ? null : Number(form.temperature)` (same for `top_p`) — the explicit `null` is required because the backend merges provided keys over the current configuration, so omitting the key would keep the old value instead of unsetting it. +11. **Constraint text**: Temperature / Top P fields note "0 to 1 — leave blank to let the model use its default (parameter is omitted)". + +**Existing tests to update**: `test_workflow_generation.py::test_default_configuration_sends_temperature_without_top_p` asserts the buggy `temperature == 0.2` default and becomes "default configuration sends no sampling parameters"; `test_bedrock_configuration.py` gains blank/null acceptance cases (its invalid-value rejections for 1.5 / -0.1 / non-numbers stay). + +#### Bug 2 — Category-driven default ports + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/declaration.ts` (new pure helpers; alternatively `portGuidance.ts`, but `declaration.ts` owns `PortForm`) + +1. **`defaultPortsForCategory(category: string): { inputs: PortForm[]; outputs: PortForm[] }`**: derived from `CATEGORY_ARRANGEMENTS`: + - `input`: `inputs: []`, `outputs: [{name: 'out', portType: 'VideoFrames'}]` + - `preprocessing`: `[{name: 'in', portType: 'VideoFrames'}]` / `[{name: 'out', portType: 'VideoFrames'}]` (byte-identical to today's seeds) + - `inference`: `[{name: 'in', portType: 'VideoFrames'}]` / `[{name: 'out', portType: 'InferenceMeta'}]` + - `post_processing`: `[{name: 'in', portType: 'InferenceMeta'}]` / `[{name: 'out', portType: 'EventSignal'}]` + - `output` (`'at-least-one'`): `[{name: 'in', portType: 'VideoFrames'}]` / `[]` — one concrete VideoFrames input as the seeded representative of "at least one input of any type" + - unknown category: fall back to the preprocessing shape (today's seeds). + Every seeded row has a non-empty name so `portsStepErrors` never fires on defaults, and each concrete arrangement yields `guidanceDivergence === null` (the `output` seed satisfies `'at-least-one'`). +2. **`isDefaultPortArrangement(inputs: PortForm[], outputs: PortForm[]): boolean`**: true exactly when the rows deep-equal `defaultPortsForCategory(c)` for some category `c`. This is the generalized Untouched_Defaults notion — any rename, retype, addition, or removal makes it false. + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/portScan.ts` + +3. **`isUntouchedDefaults()`**: delegate to `isDefaultPortArrangement()` (import from `declaration.ts`; `portScan.ts` already imports `PortForm` from there). Today's in/out pair equals the preprocessing defaults, so all current true-cases stay true; the new category defaults additionally count as untouched, keeping Port_Scan's replace-over-defaults semantics coherent with the new seeding (Req 3.10). `applySuggestions()` and `removalBlockReason()` are untouched. + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/CreateWizard.tsx` + +4. **`initialForm()`**: seed `...defaultPortsForCategory('preprocessing')` instead of the hard-coded rows (no behavioral change for the initial state). +5. **Palette category `Select.onChange`**: when `isDefaultPortArrangement(form.inputs, form.outputs)` is true, patch `{category, ...defaultPortsForCategory(newCategory)}`; otherwise patch `{category}` only (Req 2.5, 3.6). + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/RegistrationWizard.tsx` + +6. **Detail-load form seed** (line ~138): seed `...defaultPortsForCategory('preprocessing')`. When the wizard prefills from an existing registered declaration, those rows come from the declaration, not the seeds, and will not match a default arrangement unless coincidentally identical — accepted edge case, consistent with today's `isUntouchedDefaults` semantics. +7. **Palette category `Select.onChange`**: same untouched-defaults rewrite as (5). + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/portGuidance.ts` + `PortGuidancePanel.tsx` + +8. **Per-kind requirements statement (Req 2.6)**: add a pure `arrangementRequirements(category)` helper deriving explicit lines from `CATEGORY_ARRANGEMENTS` (e.g. input → "Inputs: none · Outputs: 1 × VideoFrames"; output → "Inputs: at least one (any port type) · Outputs: none") and render it in `PortGuidancePanel` under the existing arrangement summary, clearly labeled as the typical requirement per node kind. The panel stays purely advisory — no contribution to step gating (Req 3.8). + +#### Bug 3 — Opt-in import selection + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/ImportView.tsx` + +1. **Module plugin load effect** (line ~258): `setSelectedModulePlugins([])` instead of `allPluginNames(plugins)`; update the comment ("Default: none selected — the user opts in explicitly"). The effect already resets the selection to `[]` on module change, so switching modules also lands on none-selected. +2. **Checkbox FormField copy** (line ~859): description becomes opt-in wording, e.g. "Choose which of the module's plugins to import and build. No plugins are selected by default — select individual plugins or use Select all to import the whole module." The existing `errorText` ("Select at least one plugin to import") now shows on the pristine empty state and serves as the visible gate message (Req 2.8). +3. **No other changes**: the gate (`moduleSelectionIncomplete` → `formComplete`), `selectedPluginsParam()`, `moduleSelectionSummary()`, "Select all"/"Clear" buttons, the listing-unavailable fallback, and the `pending_selection` dialog are all already correct and untouched. + +**File**: `edge-cv-portal/frontend/src/pages/node-designer/importFlow.ts` + +4. **Optional pure extraction for testability**: `moduleSelectionIncomplete(source, availableNames, selectedNames): boolean` mirroring the inline gate expression, used by the component and property-tested directly. (Small, safe refactor; the inline expression may alternatively stay and be covered by component tests.) + +## Testing Strategy + +### Validation Approach + +Two phases per bug: first run exploratory tests asserting the *correct* behavior against the UNFIXED code to surface counterexamples and confirm the root causes; then implement the fixes and verify fix checking (bug inputs now behave correctly) and preservation checking (non-bug inputs unchanged, F(X) = F'(X)). Backend property tests use **hypothesis** (established convention: `edge-cv-portal/backend/tests/test_property_*.py`, fixtures in `tests/conftest.py` with moto/mocked Bedrock clients as in `test_workflow_generation.py`). Frontend property tests use **fast-check + vitest** (already a devDependency; component behavior via the existing React Testing Library patterns, e.g. `GenerateChatPanel.test.tsx`). + +### Exploratory Bug Condition Checking + +**Goal**: Surface counterexamples demonstrating each bug BEFORE fixing, confirming the root cause analysis (all three are already confirmed by code reading; the exploratory runs document the failures). + +**Test Plan**: Write the fix-checking tests below and run them on the unfixed code. + +**Test Cases**: +1. **Unset temperature omitted (backend)**: with an empty settings table, POST /workflows/generate and assert `converse` received no `temperature` in `inferenceConfig` (will fail on unfixed code: receives 0.2). +2. **Node generator unset temperature**: same assertion through `node_generator`'s generation turn (will fail on unfixed code). +3. **Blank temperature save**: PUT bedrock-configuration with `{"temperature": null}` expecting 200 (will fail on unfixed code: 400 validation error). +4. **Input-category defaults (frontend)**: render CreateWizard, select category `input`, assert zero input rows and one VideoFrames output row (will fail on unfixed code: "in" row present). +5. **Import default selection (frontend)**: render ImportView with a mocked module plugin list, assert 0 selected and import blocked (will fail on unfixed code: all selected, import enabled). + +**Expected Counterexamples**: +- `inferenceConfig == {maxTokens: 4096, temperature: 0.2}` with nothing stored +- 400 "temperature must be a number between 0 and 1" for a null temperature save +- Untouched port rows `in/out` after selecting category `input`; `selectedModulePlugins.length === plugins.length` after list load + +### Fix Checking + +**Goal**: Verify that for all inputs where a bug condition holds, the fixed code produces the expected behavior. + +**Pseudocode:** +``` +FOR ALL input WHERE isBugCondition1(input) OR isBugCondition2(input) OR isBugCondition3(input) DO + result := fixedSystem(input) + ASSERT expectedBehavior(result) // Properties 1, 3, 5 +END FOR +``` + +### Preservation Checking + +**Goal**: Verify that for all inputs where no bug condition holds, the fixed code produces the same result as the original. + +**Pseudocode:** +``` +FOR ALL input WHERE NOT (isBugCondition1(input) OR isBugCondition2(input) OR isBugCondition3(input)) DO + ASSERT originalSystem(input) = fixedSystem(input) // Properties 2, 4, 6 +END FOR +``` + +**Testing Approach**: Property-based testing is recommended for preservation checking because: +- It generates many test cases automatically across the input domain (arbitrary stored configs, arbitrary port-row edits, arbitrary plugin lists/selections) +- It catches edge cases manual unit tests miss (temperature 0 vs None vs absent key; port rows coincidentally equal to another category's defaults; full-selection vs empty-selection serialization) +- It provides strong guarantees that behavior is unchanged for all non-buggy inputs + +**Test Plan**: The original behavior for non-bug inputs is already pinned by existing suites — `test_workflow_generation.py` (explicit/override/null-temperature/top_p rules, INVALID_TEMPERATURE), `test_bedrock_configuration.py` (validation and item shape), `GenerateChatPanel.test.tsx` (blank-field omission), the portScan/portGuidance frontend tests, and `test_plugin_import_selection.py` / importFlow tests. Keeping those suites green (with the one inverted default-behavior test noted above) plus the new property tests constitutes preservation checking. + +**Test Cases**: +1. **Explicit temperature preservation (hypothesis)**: for any stored temperature in [0, 1] and optional override, the fixed pipeline sends exactly the temperature the original rules dictate, `topP` suppressed. +2. **Edited port rows preservation (fast-check)**: for any port rows not equal to any category's defaults and any category-change sequence, rows are returned unchanged. +3. **Selection serialization preservation (fast-check)**: for any plugin list and selection, `selectedPluginsParam` answers as today (subset → subset; full/empty/unavailable → undefined). + +### Unit Tests + +- Backend (`edge-cv-portal/backend/tests`): default config omits both sampling parameters; stored-null temperature with stored top_p sends `topP` only; PUT accepts `{"temperature": null}` / `{"top_p": null}` and the value round-trips as null through GET; `read_stored_bedrock_configuration` carries stored nulls; existing invalid-value rejections unchanged; node-generator turn with unset temperature sends no sampling parameters. +- Frontend (vitest + RTL): `defaultPortsForCategory` per-category shapes; wizard category change rewrites untouched defaults and preserves edited rows (both wizards); `PortGuidancePanel` renders per-kind requirement lines; Bedrock settings form accepts blank temperature/top_p, loads null as blank, saves blank as null; ImportView seeds empty selection, blocks import until selection, "Select all" then import serializes to no `selected_plugins`. + +### Property-Based Tests + +- **hypothesis** (backend, `edge-cv-portal/backend/tests/test_property_*.py`): generate arbitrary stored-config variants (absent item, missing keys, explicit nulls, numeric values, Decimal-encoded values) × optional request overrides; assert Property 1 (no `temperature`/unexpected `topP` when unset) and Property 2 (exact original inference config and 400 behavior otherwise; `temperature` and `topP` never both present in any generated case). +- **fast-check** (frontend): generate categories × port-row edit sequences; assert Property 3 (untouched defaults match `CATEGORY_ARRANGEMENTS` multisets, names non-empty, `guidanceDivergence` null) and Property 4 (edited rows invariant under category changes; `isDefaultPortArrangement` true exactly for per-category defaults; `applySuggestions` replace/merge semantics unchanged over both old and new default shapes). +- **fast-check** (frontend): generate plugin lists × selection actions; assert Property 5 (initial selection empty, gate blocks until non-empty) and Property 6 (`selectedPluginsParam` / `moduleSelectionSummary` unchanged for all selections; empty-or-unavailable list never blocks). + +### Integration Tests + +- Full generate flow (mock Bedrock, empty settings table): prompt → 200 with a definition, `converse` called with `inferenceConfig = {maxTokens: 4096}` only; then store a temperature via the settings PUT and confirm the next generation sends it; then PUT `{"temperature": null}` and confirm it is omitted again. +- Create wizard end-to-end: select `input`, keep the seeded ports, submit — declaration posts with `inputs: []` and one VideoFrames output; Registration wizard: edit a port, change category twice, run a Port_Scan — edited rows merge additively. +- Import flow end-to-end (mocked API): choose a module, verify import blocked at 0 selected; select a subset → request carries `selected_plugins`; "Select all" → request carries no `selected_plugins`; simulate listing failure → import proceeds whole-module. diff --git a/.kiro/specs/workflow-designer-bugfixes/tasks.md b/.kiro/specs/workflow-designer-bugfixes/tasks.md new file mode 100644 index 00000000..09bec5f6 --- /dev/null +++ b/.kiro/specs/workflow-designer-bugfixes/tasks.md @@ -0,0 +1,257 @@ +# Implementation Plan + +## Overview + +The three bugs are independent, so the plan is organized as three parallel streams plus a final checkpoint. Within each stream the order is strict (exploration test → preservation tests → fix → verification), but Stream A (tasks 1–3), Stream B (tasks 4–6), and Stream C (tasks 7–9) have no dependencies on each other and can proceed in parallel. + +## Task Dependency Graph + +```mermaid +graph TD + T1[Task 1: Bug 1 exploration test] --> T3[Task 3: Bug 1 fix + verify] + T2[Task 2: Bug 1 preservation tests] --> T3 + T4[Task 4: Bug 2 exploration test] --> T6[Task 6: Bug 2 fix + verify] + T5[Task 5: Bug 2 preservation tests] --> T6 + T7[Task 7: Bug 3 exploration test] --> T9[Task 9: Bug 3 fix + verify] + T8[Task 8: Bug 3 preservation tests] --> T9 + T3 --> T10[Task 10: Final checkpoint] + T6 --> T10 + T9 --> T10 +``` + +Streams A (1→2→3), B (4→5→6), and C (7→8→9) are mutually independent and may proceed in parallel; only task 10 requires all three. + +```json +{ + "waves": [ + { + "wave": 1, + "tasks": ["1", "2", "4", "5", "7", "8"], + "description": "Exploration and preservation tests for all three bugs, written and run against the UNFIXED code. All six tasks are mutually independent." + }, + { + "wave": 2, + "tasks": ["3", "6", "9"], + "description": "Implement and verify each fix. Task 3 depends on tasks 1 and 2; task 6 depends on tasks 4 and 5; task 9 depends on tasks 7 and 8. The three fix tasks are mutually independent." + }, + { + "wave": 3, + "tasks": ["10"], + "description": "Final checkpoint: full backend suite, full frontend suite, and TypeScript build. Depends on tasks 3, 6, and 9." + } + ] +} +``` + +## Tasks + +### Stream A — Bug 1: Temperature omitted when unset (backend + settings form) + +- [x] 1. Write Bug 1 bug condition exploration test + - **Property 1: Bug Condition** - Temperature omitted when unset + - **CRITICAL**: This test MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior - it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples demonstrating the bug (expected: `inferenceConfig == {maxTokens: 4096, temperature: 0.2}` with nothing stored; 400 "temperature must be a number between 0 and 1" for a null-temperature save) + - Create `edge-cv-portal/backend/tests/test_property_bedrock_sampling_unset.py` using hypothesis with existing conftest fixtures (moto/mocked Bedrock `converse` as in `test_workflow_generation.py`) + - Property over unset-temperature store states from `isBugCondition1` (no stored item at all, stored item without the `temperature` key, stored explicit null) × generation requests without a temperature override: assert the captured `inferenceConfig` contains no `temperature` key, and no `topP` key unless a top_p was explicitly stored (from Property 1 in design) + - Cover both pipelines: workflow generation (`workflow_generator.py`) and node scaffold generation (`node_generator.py`, which reuses `get_bedrock_configuration()`) + - Settings path: PUT bedrock-configuration with `{"temperature": null}` (and `{"top_p": null}`) expecting 200, with the value round-tripping as unset through GET (`read_stored_bedrock_configuration`) + - Run: `python3 -m pytest tests/test_property_bedrock_sampling_unset.py` from `edge-cv-portal/backend` on UNFIXED code + - **EXPECTED OUTCOME**: Test FAILS (this is correct - it proves the bug exists) + - Document counterexamples found to understand root cause + - Mark task complete when test is written, run, and failure is documented + - _Requirements: 1.1, 1.2, 1.3, 2.1, 2.2, 2.3_ + +- [x] 2. Write Bug 1 preservation property tests (BEFORE implementing fix) + - **Property 2: Preservation** - Explicit temperature behavior unchanged + - **IMPORTANT**: Follow observation-first methodology - observe behavior on UNFIXED code for non-buggy inputs (explicitly stored temperatures, per-request overrides), then encode it + - Create `edge-cv-portal/backend/tests/test_property_bedrock_sampling_preservation.py` (hypothesis) + - Property over stored temperatures in [0, 1] (numeric and Decimal-encoded) × optional valid overrides: the applicable temperature is sent as `inferenceConfig.temperature` with `topP` suppressed; an override replaces the configured value for that invocation only (from Preservation Requirements in design) + - Property over invalid overrides (out of range, non-numeric, boolean): rejected with 400 `INVALID_TEMPERATURE` before any Bedrock call + - Invariant across all generated cases: `temperature` and `topP` never both present in an `inferenceConfig` + - Non-temperature settings validation unchanged: invalid-value rejections (e.g. 1.5, -0.1, non-numbers) and model_id/region/max_tokens/timeout rules keep rejecting/accepting as today + - Run: `python3 -m pytest tests/test_property_bedrock_sampling_preservation.py` from `edge-cv-portal/backend` on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + +- [x] 3. Fix Bug 1 — unset sampling defaults and blank-temperature settings round-trip + + - [x] 3.1 Implement the backend fix + - `edge-cv-portal/backend/functions/workflow_generator.py`: change `DEFAULT_BEDROCK_CONFIG` to `'temperature': None` and `'top_p': None` (both together — leaving `top_p: 0.9` would start sending `topP` on every unconfigured invocation, violating Req 3.4); update the comment (unset by default; sampling parameters sent only when explicitly configured or overridden). No changes to `get_bedrock_configuration()` or `invoke_generation()` — they already carry stored nulls and omit `None` + - `edge-cv-portal/backend/functions/node_generator.py`: no changes (fixed transitively via `get_bedrock_configuration`) + - `edge-cv-portal/backend/functions/data_accounts.py`: mirror the `DEFAULT_BEDROCK_CONFIG` change; `read_stored_bedrock_configuration()` special-cases `temperature`/`top_p` with `if key in stored` so a stored null reads back as unset; `validate_bedrock_configuration()` accepts `None` for `temperature`/`top_p` (non-None values keep requiring a number in [0, 1]); verify `_native_to_dynamo` passes `None` through + - Update existing test `test_workflow_generation.py::test_default_configuration_sends_temperature_without_top_p` (it asserts the buggy `temperature == 0.2`) to "default configuration sends no sampling parameters" + - _Bug_Condition: isBugCondition1(input) from design — GenerationRequest with no stored temperature and no override, or SettingsSave with blank temperature_ + - _Expected_Behavior: Property 1 from design — no temperature/topP in inferenceConfig unless explicitly configured; blank/null temperature saves accepted and round-trip as unset_ + - _Preservation: Property 2 from design — explicit temperature, override, and INVALID_TEMPERATURE behavior unchanged_ + - _Requirements: 2.1, 2.2, 2.3, 3.1, 3.2, 3.3, 3.4_ + + - [x] 3.2 Implement the settings form fix + - `edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.tsx`: `validate()` treats blank temperature/top_p as valid (unset); load effect maps null/undefined `config.temperature`/`config.top_p` to `''` (not `String(null)`); `handleSave()` sends explicit `null` for blank fields (`form.temperature.trim() === '' ? null : Number(form.temperature)`) since the backend merges provided keys; constraint text notes "0 to 1 — leave blank to let the model use its default (parameter is omitted)" + - `GenerateChatPanel.tsx` is NOT modified — its blank-field omission behavior is already correct (Req 3.5) + - Update/extend `BedrockConfigurationSettings` component tests: blank temperature/top_p accepted, null loads as blank, blank saves as null + - _Bug_Condition: isBugCondition1(SettingsSave) — blank temperature field rejected client-side_ + - _Expected_Behavior: Property 1 from design — blank field accepted, stored unset_ + - _Preservation: non-blank values keep requiring a number in [0, 1]; all other field validations unchanged_ + - _Requirements: 2.3, 3.5_ + + - [x] 3.3 Verify Bug 1 bug condition exploration test now passes + - **Property 1: Expected Behavior** - Temperature omitted when unset + - **IMPORTANT**: Re-run the SAME test from task 1 - do NOT write a new test + - The test from task 1 encodes the expected behavior; when it passes, it confirms the fix + - Run: `python3 -m pytest tests/test_property_bedrock_sampling_unset.py` from `edge-cv-portal/backend` + - **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed) + - _Requirements: 2.1, 2.2, 2.3_ + + - [x] 3.4 Verify Bug 1 preservation tests still pass + - **Property 2: Preservation** - Explicit temperature behavior unchanged + - **IMPORTANT**: Re-run the SAME tests from task 2 - do NOT write new tests + - Run: `python3 -m pytest tests/test_property_bedrock_sampling_preservation.py` from `edge-cv-portal/backend` + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + + - [x] 3.5 Run affected existing suites for Bug 1 + - Backend: `python3 -m pytest tests/test_workflow_generation.py tests/test_bedrock_configuration.py tests/test_node_generator.py tests/test_node_generator_integration.py` from `edge-cv-portal/backend` (with the one inverted default-behavior test from 3.1) + - Frontend: `npx vitest run src/components/BedrockConfigurationSettings.test.tsx src/components/GenerateChatPanel.test.tsx` from `edge-cv-portal/frontend` (adjust paths to actual test file locations) + - Fix any regressions before proceeding + - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_ + +### Stream B — Bug 2: Category-driven default ports (node-designer wizards) + +- [x] 4. Write Bug 2 bug condition exploration test + - **Property 3: Bug Condition** - Default ports follow the selected category + - **CRITICAL**: This test MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior - it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples (expected: untouched port rows stay `in`/`out` after selecting category `input`) + - Create `edge-cv-portal/frontend/src/pages/node-designer/categoryDefaultPorts.property.test.ts` (fast-check over categories in `CATEGORY_ARRANGEMENTS`) plus RTL component assertions in/alongside `CreateWizard.test.tsx` and `RegistrationWizard.test.tsx` + - From `isBugCondition2`: for every palette category selected while port rows are Untouched_Defaults, the presented default rows' port-type multisets match that category's typical arrangement — in particular `input` → zero input rows and one VideoFrames output; `output` → one input row and zero outputs (from Property 3 in design) + - Assert every seeded row has a non-empty name (`portsStepErrors` stays clean) and `guidanceDivergence(category, inputs, outputs)` is null for the seeded rows + - Assert the ports step states the category's input/output requirements (Req 2.6) + - Run: `npx vitest run src/pages/node-designer/categoryDefaultPorts.property.test.ts src/pages/node-designer/CreateWizard.test.tsx src/pages/node-designer/RegistrationWizard.test.tsx` from `edge-cv-portal/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Test FAILS (this is correct - it proves the bug exists) + - Document counterexamples found to understand root cause + - Mark task complete when test is written, run, and failure is documented + - _Requirements: 1.4, 1.5, 2.4, 2.5, 2.6_ + +- [x] 5. Write Bug 2 preservation property tests (BEFORE implementing fix) + - **Property 4: Preservation** - Edited rows, advisory guidance, and Port_Scan unchanged + - **IMPORTANT**: Follow observation-first methodology - observe UNFIXED wizard behavior for user-edited rows and Port_Scan, then encode it + - Create `edge-cv-portal/frontend/src/pages/node-designer/portDefaultsPreservation.property.test.ts` (fast-check) + - Property: for any port rows that are NOT untouched defaults (any rename, retype, addition, removal) and any sequence of category changes, the rows are returned exactly unchanged (Req 3.6) + - Property: `guidanceDivergence` and `portsStepErrors` answer identically to today for generated inputs; guidance stays advisory/non-blocking; the dismissable divergence advisory keeps firing (Req 3.8, 3.9) + - Property: `applySuggestions` replace-over-untouched-defaults / merge-over-edited semantics unchanged, including over today's in/out default pair (Req 3.10); non-input categories present their typical arrangements (Req 3.7) + - Run: `npx vitest run src/pages/node-designer/portDefaultsPreservation.property.test.ts` from `edge-cv-portal/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.6, 3.7, 3.8, 3.9, 3.10_ + +- [x] 6. Fix Bug 2 — category-driven default ports in both wizards + + - [x] 6.1 Implement the fix + - `edge-cv-portal/frontend/src/pages/node-designer/declaration.ts`: add pure helpers `defaultPortsForCategory(category)` (derived from `CATEGORY_ARRANGEMENTS`: input → no inputs + one VideoFrames "out"; preprocessing → byte-identical to today's in/out seeds; inference → VideoFrames "in" / InferenceMeta "out"; post_processing → InferenceMeta "in" / EventSignal "out"; output → one VideoFrames "in" / no outputs; unknown → preprocessing shape) and `isDefaultPortArrangement(inputs, outputs)` (true exactly when rows deep-equal some category's defaults) + - `edge-cv-portal/frontend/src/pages/node-designer/portScan.ts`: `isUntouchedDefaults()` delegates to `isDefaultPortArrangement()`; `applySuggestions()` and `removalBlockReason()` untouched + - `CreateWizard.tsx`: `initialForm()` seeds from `defaultPortsForCategory('preprocessing')`; category `Select.onChange` rewrites port rows to the new category's defaults only when `isDefaultPortArrangement(form.inputs, form.outputs)` is true, otherwise patches `category` only + - `RegistrationWizard.tsx`: same seeding for the detail-load form seed (~line 138) and same category-change rewrite + - `portGuidance.ts` + `PortGuidancePanel.tsx`: add pure `arrangementRequirements(category)` and render per-kind requirement lines (e.g. input → "Inputs: none · Outputs: 1 × VideoFrames"); panel stays purely advisory, no step gating + - Add/extend unit tests: `defaultPortsForCategory` per-category shapes; `PortGuidancePanel` requirement lines; wizard category change rewrites untouched defaults and preserves edited rows + - _Bug_Condition: isBugCondition2(input) from design — untouched default rows while the selected category's arrangement differs_ + - _Expected_Behavior: Property 3 from design — seeded rows match the category's typical arrangement, names non-empty, guidanceDivergence null, requirements stated_ + - _Preservation: Property 4 from design — edited rows invariant, guidance advisory, Port_Scan replace/merge semantics unchanged_ + - _Requirements: 2.4, 2.5, 2.6, 3.6, 3.7, 3.8, 3.9, 3.10_ + + - [x] 6.2 Verify Bug 2 bug condition exploration test now passes + - **Property 3: Expected Behavior** - Default ports follow the selected category + - **IMPORTANT**: Re-run the SAME test from task 4 - do NOT write a new test + - Run: `npx vitest run src/pages/node-designer/categoryDefaultPorts.property.test.ts src/pages/node-designer/CreateWizard.test.tsx src/pages/node-designer/RegistrationWizard.test.tsx` from `edge-cv-portal/frontend` + - **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed) + - _Requirements: 2.4, 2.5, 2.6_ + + - [x] 6.3 Verify Bug 2 preservation tests still pass + - **Property 4: Preservation** - Edited rows, advisory guidance, and Port_Scan unchanged + - **IMPORTANT**: Re-run the SAME tests from task 5 - do NOT write new tests + - Run: `npx vitest run src/pages/node-designer/portDefaultsPreservation.property.test.ts` from `edge-cv-portal/frontend` + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - _Requirements: 3.6, 3.7, 3.8, 3.9, 3.10_ + + - [x] 6.4 Run affected existing suites for Bug 2 + - `npx vitest run src/pages/node-designer/portScan.test.ts src/pages/node-designer/declaration.test.ts src/pages/node-designer/PortGuidancePanel.test.tsx src/pages/node-designer/PortScanPanel.test.tsx src/pages/node-designer/portReplaceDefaults.property.test.ts src/pages/node-designer/portMergePreservation.property.test.ts src/pages/node-designer/categoryDivergence.property.test.ts` from `edge-cv-portal/frontend` + - Fix any regressions before proceeding + - _Requirements: 3.7, 3.8, 3.9, 3.10_ + +### Stream C — Bug 3: Opt-in module import selection + +- [x] 7. Write Bug 3 bug condition exploration test + - **Property 5: Bug Condition** - Import selection defaults to none with an explicit gate + - **CRITICAL**: This test MUST FAIL on unfixed code - failure confirms the bug exists + - **DO NOT attempt to fix the test or the code when it fails** + - **NOTE**: This test encodes the expected behavior - it will validate the fix when it passes after implementation + - **GOAL**: Surface counterexamples (expected: `selectedModulePlugins.length === plugins.length` immediately after list load, import enabled with no explicit opt-in) + - Create `edge-cv-portal/frontend/src/pages/node-designer/importSelectionDefault.property.test.ts` (fast-check over non-empty plugin lists, including after switching modules) plus RTL assertions in/alongside `ImportView.test.tsx` with a mocked module plugin list + - From `isBugCondition3`: for any non-empty module plugin list loading in the import view, the selection is seeded empty (0 of N selected) and the import stays blocked (`formComplete` false, gate message shown) until the user selects at least one plugin individually or via "Select all" (from Property 5 in design) + - Run: `npx vitest run src/pages/node-designer/importSelectionDefault.property.test.ts src/pages/node-designer/ImportView.test.tsx` from `edge-cv-portal/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Test FAILS (this is correct - it proves the bug exists) + - Document counterexamples found to understand root cause + - Mark task complete when test is written, run, and failure is documented + - _Requirements: 1.6, 2.7, 2.8_ + +- [x] 8. Write Bug 3 preservation property tests (BEFORE implementing fix) + - **Property 6: Preservation** - Import serialization and fallbacks unchanged + - **IMPORTANT**: Follow observation-first methodology - observe UNFIXED serialization/fallback behavior, then encode it + - Create `edge-cv-portal/frontend/src/pages/node-designer/importSelectionPreservation.property.test.ts` (fast-check) + - Property: for any plugin list and selection, `selectedPluginsParam` answers as today — a proper subset serializes to that subset as `selected_plugins` (Req 3.11); a full selection or no available list serializes to no `selected_plugins` parameter, i.e. whole-module import (Req 3.12); `moduleSelectionSummary` unchanged + - Property: an unavailable or empty plugin list never blocks the import (non-blocking whole-module fallback, Req 3.13) + - The post-fetch `pending_selection` dialog keeps its default-none, at-least-one-required (`pluginSelectionError`) behavior (Req 3.14) + - Run: `npx vitest run src/pages/node-designer/importSelectionPreservation.property.test.ts` from `edge-cv-portal/frontend` on UNFIXED code + - **EXPECTED OUTCOME**: Tests PASS (this confirms baseline behavior to preserve) + - Mark task complete when tests are written, run, and passing on unfixed code + - _Requirements: 3.11, 3.12, 3.13, 3.14_ + +- [x] 9. Fix Bug 3 — opt-in module plugin selection + + - [x] 9.1 Implement the fix + - `edge-cv-portal/frontend/src/pages/node-designer/ImportView.tsx`: module plugin load effect (~line 258) seeds `setSelectedModulePlugins([])` instead of `allPluginNames(plugins)`; update the comment ("Default: none selected — the user opts in explicitly"); checkbox FormField description (~line 859) becomes opt-in wording, with the existing `errorText` ("Select at least one plugin to import") serving as the visible gate on the pristine empty state + - `edge-cv-portal/frontend/src/pages/node-designer/importFlow.ts` (optional, for testability): extract pure `moduleSelectionIncomplete(source, availableNames, selectedNames)` mirroring the inline gate expression + - No other changes: `selectedPluginsParam()`, `moduleSelectionSummary()`, "Select all"/"Clear" buttons, listing-unavailable fallback, and the `pending_selection` dialog stay untouched + - _Bug_Condition: isBugCondition3(input) from design — non-empty module plugin list load seeds a full selection_ + - _Expected_Behavior: Property 5 from design — selection seeds empty, import blocked until explicit opt-in_ + - _Preservation: Property 6 from design — serialization semantics and whole-module fallbacks unchanged_ + - _Requirements: 2.7, 2.8, 3.11, 3.12, 3.13, 3.14_ + + - [x] 9.2 Verify Bug 3 bug condition exploration test now passes + - **Property 5: Expected Behavior** - Import selection defaults to none with an explicit gate + - **IMPORTANT**: Re-run the SAME test from task 7 - do NOT write a new test + - Run: `npx vitest run src/pages/node-designer/importSelectionDefault.property.test.ts src/pages/node-designer/ImportView.test.tsx` from `edge-cv-portal/frontend` + - **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed) + - _Requirements: 2.7, 2.8_ + + - [x] 9.3 Verify Bug 3 preservation tests still pass + - **Property 6: Preservation** - Import serialization and fallbacks unchanged + - **IMPORTANT**: Re-run the SAME tests from task 8 - do NOT write new tests + - Run: `npx vitest run src/pages/node-designer/importSelectionPreservation.property.test.ts` from `edge-cv-portal/frontend` + - **EXPECTED OUTCOME**: Tests PASS (confirms no regressions) + - _Requirements: 3.11, 3.12, 3.13, 3.14_ + + - [x] 9.4 Run affected existing suites for Bug 3 + - Frontend: `npx vitest run src/pages/node-designer/importFlow.test.ts src/pages/node-designer/ImportView.test.tsx` from `edge-cv-portal/frontend` + - Backend: `python3 -m pytest tests/test_plugin_import_selection.py` from `edge-cv-portal/backend` (pending_selection / selected_plugins wire behavior) + - Fix any regressions before proceeding + - _Requirements: 3.11, 3.12, 3.13, 3.14_ + +### Final checkpoint + +- [x] 10. Checkpoint - Ensure all tests pass + - Run the full portal backend suite: `python3 -m pytest` from `edge-cv-portal/backend` + - Run the full frontend suite: `npx vitest run` from `edge-cv-portal/frontend` + - Run the TypeScript build: `npx tsc -b` from `edge-cv-portal/frontend` + - Ensure all tests pass, ask the user if questions arise + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, 3.13, 3.14_ + +## Notes + +- Backend property tests: hypothesis, in `edge-cv-portal/backend/tests/test_property_*.py`, run with `python3 -m pytest` from `edge-cv-portal/backend`. Hypothesis profiles already cap examples (fast profile, 25 examples, default locally) — do NOT hardcode `max_examples` in new tests. +- Frontend tests: vitest + React Testing Library + fast-check, under `edge-cv-portal/frontend/src`, run with `npx vitest run` from `edge-cv-portal/frontend`; typecheck with `npx tsc -b`. +- Exploration tests (tasks 1, 4, 7) are expected to FAIL on unfixed code — that failure is the confirmation of each bug, not a problem to fix at that stage. +- Requirement numbers reference `bugfix.md`: 1.x = current defective behavior, 2.x = expected behavior, 3.x = unchanged behavior (regression prevention). Properties 1–6 reference the Correctness Properties in `design.md`. diff --git a/.kiro/specs/workflow-manager/.config.kiro b/.kiro/specs/workflow-manager/.config.kiro new file mode 100644 index 00000000..d5e3a236 --- /dev/null +++ b/.kiro/specs/workflow-manager/.config.kiro @@ -0,0 +1 @@ +{"specId": "a10a3030-7ba6-4a2d-9246-924ccfdd6ccd", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/workflow-manager/design.md b/.kiro/specs/workflow-manager/design.md new file mode 100644 index 00000000..e7cc0369 --- /dev/null +++ b/.kiro/specs/workflow-manager/design.md @@ -0,0 +1,453 @@ +# Design Document: Workflow Manager + +## Overview + +The Workflow Manager adds a graphical video-pipeline builder to the edge-cv-portal. Users compose video analytics pipelines on a drag-and-drop canvas (Workflow_Builder), which produces a serializable graph document (Workflow_Definition). The portal validates the graph (Workflow_Validator), compiles it into a GStreamer pipeline configuration (Workflow_Compiler), packages it with plugin dependencies as a Greengrass component (Component_Packager), and deploys it to edge devices where LocalServer executes it through its existing GStreamer/Triton path. Two supporting capabilities round out the feature: prompt-based workflow generation via configurable Amazon Bedrock models (Workflow_Generator), and cloud-side pre-deployment testing against canned data with hardware nodes stubbed (Workflow_Test_Runner). + +The design deliberately layers new capability alongside the existing system rather than replacing it: + +- **Portal side**: New Lambda functions, DynamoDB tables, and S3 prefixes follow the existing patterns in `edge-cv-portal/backend/functions`, `edge-cv-portal/infrastructure/lib`, and the RBAC middleware. No existing API or table is modified in a breaking way. +- **Edge side**: LocalServer gains a `workflow/` subsystem that consumes compiled pipeline configurations delivered by Workflow_Components. The existing `src/backend/gstreamer/` path (`GstPipelineBuilder` → pipeline string → `GstPipelineManager.run_pipeline`) driven by `PipelineConfiguration` remains untouched; the workflow executor reuses `GstPipelineManager` for actual pipeline execution but never alters `GstPipelineBuilder` or `Pipeline_Configuration` handling (Requirement 13). + +### Key Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Canvas library | React Flow (`@xyflow/react`) | Established, MIT-licensed React node-graph library; typed ports, custom nodes, pan/zoom, minimap out of the box; fits existing React 18 + TypeScript + Vite stack | +| Validation/compilation location | Shared Python package in a Lambda layer, plus a TypeScript validator subset in the frontend for inline markers | Single source of truth for authoritative validation server-side; fast feedback client-side | +| Compiled artifact format | JSON "Compiled Pipeline Document" containing an ordered element list per branch segment, from which LocalServer renders a `gst-launch`-style string | Matches the existing executor, which runs `Gst.parse_launch` on a pipeline string; keeps the compiler output inspectable and testable | +| Workflow delivery to edge | Generic `aws.edgeml.dda.WorkflowRunner` recipe pattern: one Greengrass component per workflow version whose artifacts are the compiled document + plugins + Python node code; LocalServer discovers it via a well-known directory | Avoids modifying LocalServer's own component recipe; deploy/remove of a workflow never restarts LocalServer (Requirement 13.3) | +| Test execution environment | Portal-account Fargate task (x86_64 container with GStreamer + CPU Triton) orchestrated by Step Functions | Lambda cannot host GStreamer/Triton within limits; Fargate gives an isolated, time-boxed (10 min) sandbox with no device access (Requirement 12.9, 12.13) | +| Bedrock generation | Converse API with tool-use (structured output) against a configurable model; node catalog supplied in the system prompt | Structured output dramatically improves parse success; configuration lives in the existing portal settings table (Requirement 10.6) | + +## Architecture + +### System Context + +```mermaid +graph TB + subgraph Portal Account + FE[React Frontend
Workflow_Builder canvas] + APIGW[API Gateway] + WFAPI[workflows.py Lambda
Workflow_Store API] + VAL[Workflow_Validator +
Workflow_Compiler
Lambda layer] + GEN[workflow_generator.py Lambda
Bedrock invocation] + PKG[workflow_packaging.py Lambda
Component_Packager] + TEST[Step Functions +
Fargate test sandbox
Workflow_Test_Runner] + DDB[(DynamoDB
Workflows, WorkflowVersions,
TestDatasets, TestRuns)] + S3P[(S3
definitions, compiled docs,
test datasets, results)] + end + subgraph UseCase Account + GGREG[Greengrass Component Registry] + GGDEP[Greengrass Deployments] + S3U[(UseCase S3
component artifacts)] + end + subgraph Edge Device + GG[Greengrass Nucleus] + WFC[Workflow_Component
artifacts on disk] + LS[LocalServer
workflow executor +
existing pipeline path] + TRITON[Embedded Triton] + end + BR[Amazon Bedrock] + + FE --> APIGW --> WFAPI + WFAPI --> VAL + WFAPI --> DDB + WFAPI --> S3P + APIGW --> GEN --> BR + APIGW --> PKG + PKG -->|STS AssumeRole| GGREG + PKG --> S3U + WFAPI -->|existing Deployment_Service| GGDEP + WFAPI --> TEST + TEST --> S3P + GGDEP --> GG --> WFC + LS -->|discovers| WFC + LS --> TRITON +``` + +### Portal Backend Flow + +New Lambda functions follow the established pattern (`rbac_middleware` decorator, per-use-case STS AssumeRole, DynamoDB + S3): + +- `workflows.py` — CRUD, versioning, duplicate, list, delete-with-deployment-check (Workflow_Store) +- `workflow_validation.py` — validate endpoint; imports the shared `workflow_core` layer +- `workflow_packaging.py` — compile + package + register Greengrass component (Component_Packager) +- `workflow_generator.py` — Bedrock chat sessions (Workflow_Generator) +- `workflow_testing.py` — test dataset upload/list, test run start/status/results (fronts the Workflow_Test_Runner state machine) + +A shared Python package `workflow_core` (deployed as a Lambda layer under `edge-cv-portal/backend/layers/`, also vendored into LocalServer and the test sandbox image) contains the node catalog, serializer, validator, and compiler. This guarantees the portal, the test runner, and the edge agree on schema and semantics. + +### Edge Execution Flow + +```mermaid +sequenceDiagram + participant GG as Greengrass Nucleus + participant WC as Workflow_Component + participant WD as WorkflowWatcher (LocalServer) + participant EX as WorkflowExecutor (LocalServer) + participant GM as GstPipelineManager (existing) + participant TR as Triton (existing) + + GG->>WC: deploy artifacts to /aws_dda/workflows/{workflowId}/{version}/ + WD->>WD: detect manifest.json (filesystem watch + startup scan) + WD->>EX: register workflow as runnable + Note over EX: trigger fires (interval / folder / MQTT / API) + EX->>EX: render pipeline string from compiled document
prepend component plugin dir to GST_PLUGIN_PATH + EX->>GM: run_pipeline(pipeline_string) + GM->>TR: emltriton inference call + GM-->>EX: tags / error + EX->>EX: post-pipeline outputs (MQTT, OPC UA)
status via existing reporting path +``` + +LocalServer never restarts when a Workflow_Component is deployed or removed: the component's lifecycle is "install artifacts only" (no long-running process), and LocalServer's watcher picks up changes at runtime. Existing `Pipeline_Configuration` execution is a separate code path that is not touched (Requirement 13). + +### Research Summary + +- **React Flow** ([reactflow.dev](https://reactflow.dev)) supports custom node types with multiple typed handles, `isValidConnection` callbacks for port-type checks at drag time, and JSON-serializable state (`nodes`, `edges`, `viewport`) — a direct fit for Requirements 1 and 3. It is the de facto standard for React node editors (used by Langflow, n8n-style tools). +- **Lumeo's node reference** ([docs.lumeo.com/docs/node-reference](https://docs.lumeo.com/docs/node-reference)) informed the node taxonomy: sources, transforms (dewarp/rotate/crop), model inference, logic/filter, and outputs (MQTT, digital I/O, OPC UA, capture). The catalog below mirrors this shape while mapping onto DDA's existing GStreamer elements (`emltriton`, `emoutputevent`, `emlcapture`, `videocrop`, `videoflip`, `videoconvert`). +- **Existing executor contract** (`src/backend/gstreamer/gst_pipeline.py`): pipelines are executed via `Gst.parse_launch(pipeline_str)` with a GLib main loop, a 120 s watchdog, and tag parsing for `is_anomalous`/`confidence`. The compiler therefore targets a launch-string-renderable document. Branching uses named `tee` elements (`tee name=t ... t. ! queue ! ...`), which `parse_launch` supports and the existing builder already uses for outputs. +- **Bedrock Converse API** supports tool-use (function calling) for structured JSON output across Anthropic, Amazon Nova, and Meta models, making the model identifier configurable without per-model prompt surgery (Requirement 10.6). +- **Greengrass component model**: components with only `Artifacts` and no `Lifecycle/Run` step install files and stay `FINISHED`; deploying/removing them does not affect other components — the mechanism relied on for Requirement 13.3. + +## Components and Interfaces + +### 1. Workflow_Builder (Frontend) + +Location: `edge-cv-portal/frontend/src/pages/workflows/`, `edge-cv-portal/frontend/src/components/workflow-builder/` + +- **Canvas**: React Flow instance with custom node component rendering category color, title, ports (handles) labeled with port types, and inline validation badges. +- **Node_Palette**: left sidebar listing node types grouped by category (input, preprocessing, model inference, post-processing, output), sourced from the node catalog endpoint (`GET /api/v1/workflows/node-catalog`), draggable via HTML5 drag-and-drop onto the canvas (Requirement 1.1, 1.2). +- **Connection rules**: React Flow `isValidConnection` calls `arePortsCompatible(sourcePort, targetPort)`; incompatible attempts are rejected and a toast/tooltip shows the reason, e.g. "Cannot connect VideoFrames output to EventSignal input" (Requirement 1.4). +- **Config panel**: right sidebar showing the selected node's parameter schema rendered as CloudScape form controls; edits validated against parameter type/constraints with inline errors (Requirement 1.7, 1.8). Model inference node's `modelName` parameter is a select populated from the existing model registry API filtered by Use_Case (Requirement 2.6). Custom_Python_Node exposes a code editor field plus declared input/output port type pickers (Requirement 2.7). +- **Inline validation**: a lightweight TypeScript mirror of validator checks V4 (missing required parameters) and V5 (unreachable nodes) runs on every graph mutation; affected nodes get warning badges that clear when resolved (Requirement 1.9, 1.10). Full validation (all checks) is invoked explicitly via a Validate button calling the backend (Requirement 4.8, 4.9). +- **Actions toolbar**: Save (versioned), Validate, Test, Generate (chat panel), Package, Deploy — each gated by the user's role from the existing auth context (Requirement 11). +- **Chat panel**: collapsible panel for prompt-based generation; maintains a session id, renders generated workflows onto the canvas only after client-side parse + backend validation, and never clobbers the canvas on failure (Requirement 10.3, 10.4). +- **Test panel**: dataset picker/uploader and per-node test result display, marking stubbed nodes with a "simulated" badge and limitation text (Requirement 12.2, 12.8). + +### 2. Node Catalog (`workflow_core.catalog`) + +The catalog is data, not code: a list of `NodeTypeDescriptor` records (see Data Models) declaring ports, parameters, per-architecture GStreamer mappings, and hardware-dependence flags (Requirement 2.8). Initial catalog: + +| Category | Node type | Ports (in → out) | GStreamer mapping (sketch) | Hardware-dependent | +|---|---|---|---|---| +| input | `camera_source` | → VideoFrames | `appsrc`/`v4l2src`/CSI file source chains reused from existing builder logic | yes | +| input | `folder_source` | → VideoFrames | `filesrc ! (jpegparse ! jpegdec | pngdec) ! videoconvert` per-arch variants (JP6 PNG staging path) | no | +| input | `digital_input` | → EventSignal | GPIO poll adapter (executor-level, not a GStreamer element) | yes | +| preprocessing | `dewarp` | VideoFrames → VideoFrames | `opencv`-based `dewarp` plugin (packaged dependency) | no | +| preprocessing | `rotate` | VideoFrames → VideoFrames | `videoflip method=` | no | +| preprocessing | `crop` | VideoFrames → VideoFrames | `videocrop top=.. bottom=.. left=.. right=..` | no | +| preprocessing | `format_convert` | VideoFrames → VideoFrames | `videoconvert ! capsfilter caps=video/x-raw,format=` | no | +| inference | `model_inference` | VideoFrames → InferenceMeta | `emltriton model-repo= server-path= model= ...` (mirrors existing `_add_inference_plugins`) | no | +| post-processing | `custom_python` | declared → declared | `emlpython` bridge element invoking user code via appsink/appsrc pair | no | +| post-processing | `inference_filter` | InferenceMeta → InferenceMeta | executor-evaluated condition over inference metadata (confidence/anomaly rules), compiled to `emoutputevent`-style rule strings where applicable | no | +| output | `digital_output` | InferenceMeta → | `emoutputevent script-path= config=` (existing element) | yes | +| output | `mqtt_publish` | InferenceMeta → | executor-level MQTT client publish on pipeline completion | yes | +| output | `opcua_write` | InferenceMeta → | executor-level OPC UA client write (`opcua` Python lib packaged as dependency) | yes | +| output | `capture` | VideoFrames/InferenceMeta → | `jpegenc ! emlcapture ...` (existing element chain) | no | + +Port types: `VideoFrames`, `InferenceMeta`, `EventSignal`. Compatibility is exact-match plus declared coercions (e.g., `InferenceMeta` flows over the same GStreamer buffer stream as `VideoFrames` with attached metadata, so `capture` accepts both). + +### 3. Workflow_Serializer (`workflow_core.serializer`) + +- `serialize(graph: WorkflowGraph) -> str`: emits canonical JSON — keys sorted, stable node/connection ordering by id — containing `schemaVersion`, all nodes (id, type, position, parameter values), and connections (Requirement 3.1). Canonicalization is what makes the round-trip property "identical JSON structure" achievable (Requirement 3.4). +- `parse(doc: str) -> ParseResult`: JSON-Schema validation first (returns the first violation with a JSON-pointer path, Requirement 3.3), then graph construction, then migration: if `schemaVersion` is older but supported, registered migration functions upgrade the document stepwise and the result carries `migrations: [from, to]` (Requirement 3.5). +- A TypeScript twin of the schema (generated from the JSON Schema) is used by the frontend; the JSON document itself is the interchange format, so only the Python implementation is authoritative. + +### 4. Workflow_Validator (`workflow_core.validator`) + +Pure function `validate(graph, catalog) -> list[ValidationFinding]`, always running all checks and returning the complete list (Requirement 4.6): + +| Check | Rule | Requirement | +|---|---|---| +| V1 | ≥1 input node and ≥1 output node | 4.1 | +| V2 | every connection joins output port → input port with compatible types | 4.2 | +| V3 | no cycles; on failure, report the node ids in each cycle (Tarjan SCC) | 4.3 | +| V4 | every required parameter has a value satisfying its constraints | 4.4 | +| V5 | every node reachable from some input node (forward BFS from inputs) | 4.5 | +| W1 (warning) | output node with no incoming connection, unused output ports, etc. | 4.6 | + +Each `ValidationFinding` carries `severity`, `code`, `message`, and `nodeId`/`connectionId`. Packaging, publishing, and deployment endpoints call `validate` and reject on any error-severity finding; they additionally verify the stored version's recorded validation status (Requirement 4.7, 4.10). + +### 5. Workflow_Compiler (`workflow_core.compiler`) + +`compile(graph, target_arch, context) -> CompiledPipelineDocument | list[CompileError]` + +Algorithm: +1. Re-run the validator; refuse to compile with errors. +2. Topologically sort nodes (graph is a DAG after V3). +3. For each node, look up the `GstMapping` for `target_arch` in the catalog; missing mapping → `CompileError{nodeId, arch}` (Requirement 6.5). +4. Emit one `ElementChain` per node (1..n GStreamer elements with args), tagged with the originating `nodeId` — every node appears exactly once (Requirement 6.6). Model inference nodes emit the `emltriton` chain with model name and the Triton repo/server paths LocalServer uses (Requirement 6.2). Executor-level nodes (digital input, MQTT, OPC UA, inference filter conditions) emit `ExecutorBinding` entries instead of GStreamer elements; they still appear exactly once in the document. +5. Linearize connections: a node whose output feeds N>1 downstream nodes gets `tee name=t` and each branch starts with `queue` (Requirement 6.3), producing named segments renderable as a single `parse_launch` string. +6. Compute `pluginDependencies`: the set of GStreamer plugins/Python packages referenced by mappings minus the LocalServer-bundled set (per-arch bundled manifest in the catalog) (Requirement 6.4). + +Output `CompiledPipelineDocument` (JSON): `{schemaVersion, workflowId, workflowVersion, targetArch, segments: [{name, elements: [{nodeId, factory, args}]}], executorBindings: [...], pluginDependencies: [...]}`. LocalServer renders segments to a launch string with `" ! "` joins and `t. !` branch references — the same string dialect `GstPipelineManager.run_pipeline` already executes. + +### 6. Component_Packager (portal backend, `workflow_packaging.py`) + +1. Compile the workflow for each user-selected architecture (x86_64, arm64 JP4/JP5/JP6) (Requirement 7.4). +2. Assemble artifacts per arch: `manifest.json` (component metadata + arch), `workflow.json` (Workflow_Definition), `compiled_pipeline.json`, `plugins//*.so` (resolved from a curated plugin artifact library in portal S3), and for Custom_Python_Nodes `python/{nodeId}/handler.py` + `requirements.txt` (Requirement 7.1, 7.3). +3. Upload artifacts to the Use_Case account S3 bucket (via assumed role), then `CreateComponentVersion` with name `dda.workflow.{workflowId}` and version `{workflowVersion}.0.0` in the Use_Case account's Greengrass registry (Requirement 7.2). Recipe has per-arch platform manifests and **no Run lifecycle** — install-only, so deployment never disturbs LocalServer or other components (Requirement 13.3). +4. All-or-nothing: artifacts staged under a temporary S3 prefix; component registration happens only after all artifacts for all selected architectures upload successfully. On any failure, the temp prefix is deleted and the failing artifact reported; no partial component version exists (Requirement 7.5). + +### 7. Deployment_Service extension (`deployments.py`) + +Reuses the existing deployment Lambda: adds Workflow_Component as a deployable component type, device/thing-group targeting within the Use_Case (Requirement 8.1), records `workflowId + version → deployment → devices` association in the Deployments table (Requirement 8.2), and surfaces per-device Greengrass deployment status on the workflow page (Requirement 8.3). Pre-submit compatibility check: each target device's reported LocalServer component version is compared against the Workflow_Component's `minLocalServerVersion` (from the compiled document schema version); incompatible devices are reported before submission (Requirement 8.4). Greengrass semantics already replace an older component version with a newer one in a revised deployment (Requirement 8.5). + +### 8. LocalServer Workflow Subsystem (edge, `src/backend/workflow_engine/` — new package) + +- **WorkflowWatcher**: scans `/aws_dda/workflows/` at startup and watches for changes (inotify with poll fallback); on discovery of a `manifest.json` + `compiled_pipeline.json`, registers the workflow in a new `workflow_registrations` table (SQLAlchemy + alembic migration, additive only) and exposes it via new Flask endpoints (`/workflows` list/trigger/status) (Requirement 9.1). +- **WorkflowExecutor**: on trigger, renders the launch string from the compiled document, prepends the component's `plugins//` directory to `GST_PLUGIN_PATH` for that run (Requirement 9.2), and executes via the existing `GstPipelineManager.run_pipeline` — inheriting the watchdog, error capture, and tag parsing. Model inference flows through `emltriton` → embedded Triton exactly as today (Requirement 9.3). After pipeline completion it processes `executorBindings`: digital output actuation via the existing `dio_utils` (Requirement 9.4), MQTT publish via the existing `mqtt/` client (Requirement 9.5), OPC UA writes via the packaged client (Requirement 9.6). +- **Custom Python execution**: the `emlpython` bridge runs user code in a subprocess with a defined stdin/stdout frame+metadata protocol; the pipeline element is an `appsink`/`appsrc` pair managed by the executor (Requirement 9.8). Subprocess isolation bounds the blast radius of user code. +- **Failure handling**: pipeline errors from `GstPipelineManager` already identify the failing element; the executor maps element → `nodeId` via the compiled document tags and reports workflow status as failed through the existing status reporting path (Requirement 9.7). A workflow failure never touches `Pipeline_Configuration` execution — separate threads, separate registries (Requirement 13.4, 13.7). +- **Isolation for backward compatibility**: the workflow subsystem is additive — no changes to `gstreamer/pipeline_builder.py`, `model/PipelineConfiguration.py`, shadow handling, or existing endpoints. Devices without Workflow_Components have an empty registry and identical behavior (Requirement 13.1, 13.5, 13.6). Triton serves models to both paths concurrently; model repository contents are not modified by workflow execution (Requirement 13.8). + +### 9. Workflow_Generator (portal backend, `workflow_generator.py`) + +- Chat sessions stored in DynamoDB (`WorkflowChatSessions`, TTL'd) holding message history and the current canvas Workflow_Definition snapshot sent by the frontend. +- Invocation: Bedrock Converse API with a `create_workflow` tool whose input schema **is** the Workflow_Definition JSON Schema; system prompt includes the serialized node catalog (types, ports, parameters, constraints) (Requirement 10.2). Follow-up prompts include the current canvas definition and instruct modification rather than regeneration (Requirement 10.5). +- Response handling: tool-use output parsed by Workflow_Serializer, then run through Workflow_Validator; the definition plus findings are returned to the frontend for canvas rendering and review — never auto-saved or deployed (Requirement 10.3). Parse failure → error response, canvas untouched, prompt preserved client-side (Requirement 10.4, 10.7). +- `Bedrock_Configuration` (model id, region, inference params, timeout ≤ 60 s) lives in the existing portal settings storage, editable only by PortalAdmin via the settings UI (Requirement 10.6). Lambda invokes with a client-side timeout equal to the configured value. + +### 10. Workflow_Test_Runner (portal backend + sandbox) + +- **API** (`workflow_testing.py`): `POST /test-datasets` (S3 presigned multipart upload; server-side verification of total size ≤ 500 MB and supported formats — JPEG/PNG image sets — before the dataset record is committed; violations reject with reason and persist nothing) (Requirement 12.3, 12.11); `POST /workflows/{id}/test-runs` starts a Step Functions execution; `GET /test-runs/{id}` returns status and per-node results. +- **State machine**: Validate → Compile (target `x86_64`, `simulation=true`) → RunSandbox (Fargate) → CollectResults. Validation or compilation errors short-circuit: each error recorded with node/connection id, run marked failed, pipeline never executed (Requirement 12.4, 12.12). +- **Sandbox container**: x86_64 image with GStreamer, the DDA plugin set, CPU Triton, and a slim test harness that (a) renders the compiled document exactly as LocalServer does, (b) substitutes stubs when `simulation=true`: hardware-dependent nodes (per the catalog flag) are replaced by recorder equivalents — camera/digital-input sources are fed from the Test_Dataset via `multifilesrc`/`appsrc`, and digital output/MQTT/OPC UA bindings write their would-be actuations to a recording log instead of any endpoint (Requirement 12.5, 12.6). The task runs in an isolated subnet with no route to device networks and creates no Greengrass resources (Requirement 12.9). +- **Results**: per-node records `{nodeId, status, outputs (S3 refs for frames/metadata), stubActivity, error}` written to S3 + a `TestRuns` DynamoDB item (Requirement 12.7). On mid-run failure, results produced so far are retained and the failing node identified (Requirement 12.10). Step Functions enforces a 10-minute execution timeout; on timeout the task is stopped, status = failed (timeout), partial results retained (Requirement 12.13). + +### 11. Access Control and Audit + +RBAC enforced in `rbac_middleware` with new permission actions mapped to existing roles (Requirement 11.1–11.4): + +| Action | DataScientist | Operator | UseCaseAdmin | Viewer | +|---|---|---|---|---| +| workflow:read | ✓ | ✓ | ✓ | ✓ | +| workflow:create/edit/save/delete | ✓ | – | ✓ | – | +| workflow:test | ✓ | – | ✓ | – | +| workflow:package/deploy | – | ✓ | ✓ | – | +| bedrock-config:write | PortalAdmin only | | | | + +Create/modify/delete/package/deploy operations write to the existing AuditLog table with user, action, workflow id/version, timestamp (Requirement 11.5). + +## Data Models + +### Workflow_Definition JSON (schema version `1`) + +```json +{ + "schemaVersion": 1, + "nodes": [ + { + "id": "n1", + "type": "camera_source", + "position": {"x": 100, "y": 200}, + "parameters": {"device": "/dev/video0", "gain": 4} + }, + { + "id": "n2", + "type": "model_inference", + "position": {"x": 400, "y": 200}, + "parameters": {"modelName": "widget-anomaly-v3"} + } + ], + "connections": [ + {"id": "c1", "from": {"node": "n1", "port": "out"}, "to": {"node": "n2", "port": "in"}} + ] +} +``` + +Serialization is canonical: object keys sorted, `nodes` sorted by `id`, `connections` sorted by `id`, no insignificant whitespace variation. + +### Node catalog descriptor (Python dataclasses in `workflow_core.catalog`) + +```python +@dataclass(frozen=True) +class PortDescriptor: + name: str # "in", "out" + port_type: str # "VideoFrames" | "InferenceMeta" | "EventSignal" + +@dataclass(frozen=True) +class ParameterDescriptor: + name: str + param_type: str # "string" | "int" | "float" | "bool" | "enum" | "code" | "model_ref" + required: bool + default: Any | None + constraints: dict # min/max, enum values, regex, etc. + +@dataclass(frozen=True) +class GstMapping: + arch: str # "x86_64" | "arm64_jp4" | "arm64_jp5" | "arm64_jp6" | "sim" + element_chain: list[dict] # [{factory, args_template}] or [] for executor-level nodes + executor_binding: str | None + plugin_dependencies: list[str] + +@dataclass(frozen=True) +class NodeTypeDescriptor: + type_id: str + category: str # input | preprocessing | inference | post_processing | output + display_name: str + inputs: list[PortDescriptor] + outputs: list[PortDescriptor] + parameters: list[ParameterDescriptor] + mappings: list[GstMapping] + hardware_dependent: bool # drives test-runner stubbing +``` + +### DynamoDB tables (new, additive) + +| Table | Key | Attributes | +|---|---|---| +| Workflows | `workflow_id` | usecase_id, account_id, name, description, created_at, updated_at, latest_version, GSI: usecase_id | +| WorkflowVersions | `workflow_id` + `version` | s3_definition_key, validation_status {passed/failed/none, findings_key, validated_at}, compiled_arch_keys, component_arn, created_by | +| TestDatasets | `dataset_id` | usecase_id, name, s3_prefix, total_bytes, format, created_by, GSI: usecase_id | +| TestRuns | `test_run_id` | workflow_id, version, dataset_id, status, started_at, finished_at, results_s3_key, failure {nodeId, message, timeout} | +| WorkflowChatSessions | `session_id` | usecase_id, messages, current_definition_key, ttl | + +Workflow_Definition documents, compiled documents, test datasets, and test results live in portal S3 under `workflows/{usecase_id}/...` prefixes. Deployment associations reuse the existing Deployments table with a `component_type: workflow` attribute. + +### Edge-side additions (SQLAlchemy, alembic migration — additive only) + +``` +workflow_registrations(id, workflow_id, version, arch, artifact_path, status, registered_at) +workflow_executions(id, registration_id, started_at, finished_at, status, failing_node_id, error) +``` + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +The workflow-manager's pure core (`workflow_core`: catalog, serializer, validator, compiler) is highly amenable to property-based testing. Generators produce random valid workflow graphs (and controlled corruptions/defect seedings of them) from the node catalog. + +### Property 1: Serialization round trip + +For all valid Workflow_Definition graphs, `parse(serialize(g))` produces a graph equivalent to `g`, and `serialize(parse(serialize(g)))` produces JSON byte-identical to `serialize(g)`. + +**Validates: Requirements 3.1, 3.2, 3.4** + +### Property 2: Parser rejects invalid documents descriptively + +For all invalid JSON documents (random junk, or valid documents corrupted by random schema-violating mutations), `parse` returns a descriptive error identifying a violation location, never a graph and never an unhandled exception. + +**Validates: Requirements 3.3** + +### Property 3: Validator finding-set exactness + +For all graphs constructed by seeding a random valid graph with a random set of known defects (missing input/output nodes, incompatible-port connections, injected cycles, cleared required parameters, detached unreachable nodes), the validator returns findings that exactly match the seeded defect set — every seeded defect is reported with the correct node/connection identifier (and cycle findings name nodes actually in the cycle), and no findings are reported for defect classes that were not seeded. + +**Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6** + +### Property 4: Compiler references every node exactly once + +For all valid Workflow_Definitions, the compiled pipeline document references every node in the definition exactly once — the multiset of `nodeId` tags across all segment elements and executor bindings equals the definition's node set with multiplicity one. + +**Validates: Requirements 6.6, 6.1** + +### Property 5: Compiled order respects the graph, branches get tee/queue + +For all valid Workflow_Definitions, for every connection the elements of the source node precede the elements of the target node in the rendered pipeline order, and every node whose output feeds more than one downstream node is followed by a `tee` element with a `queue` element at the head of each branch — and nodes without fan-out get no `tee`. + +**Validates: Requirements 6.1, 6.3** + +### Property 6: Inference nodes compile to correctly configured emltriton elements + +For all valid Workflow_Definitions containing model inference nodes, each such node compiles to exactly one `emltriton` element whose args include the node's configured model name and the Triton model-repository and server paths used by LocalServer. + +**Validates: Requirements 6.2** + +### Property 7: Plugin dependency set correctness + +For all valid Workflow_Definitions and target architectures, the compiler's `pluginDependencies` output equals the union of the catalog-declared plugin dependencies of the definition's nodes for that architecture minus the LocalServer-bundled plugin set for that architecture. + +**Validates: Requirements 6.4** + +### Property 8: Unmapped architecture yields identifying compile errors + +For all Workflow_Definitions containing at least one node type with no GStreamer mapping for the chosen target architecture, compilation fails with errors that identify exactly those nodes and the unsupported architecture, and compilation succeeds when all node types have mappings. + +**Validates: Requirements 6.5** + +### Property 9: Parameter constraint predicate correctness + +For all parameter descriptors in the catalog and all generated values (valid and invalid against the descriptor's type and constraints), the shared parameter-validation predicate accepts the value if and only if the value satisfies the descriptor's declared type and constraints. + +**Validates: Requirements 1.8** + +### Property 10: Connection acceptance equals port compatibility + +For all pairs of ports drawn from catalog node types, attempting to create a connection succeeds if and only if the source is an output port, the target is an input port, and their declared types are compatible; every rejection carries a non-empty reason. + +**Validates: Requirements 1.3, 1.4** + +### Property 11: Node deletion leaves no dangling connections + +For all workflow graphs and any node in them, deleting that node removes the node and results in a graph containing no connection that references the deleted node, while all connections between remaining nodes are preserved. + +**Validates: Requirements 1.5** + +### Property 12: Inline markers are exactly the offending nodes + +For all workflow graphs (including graphs reached by any sequence of canvas mutations), the set of nodes carrying inline validation markers equals exactly the set of nodes with missing required parameter values plus the set of nodes unreachable from any input node — so resolving a condition removes its marker. + +**Validates: Requirements 1.9, 1.10** + +### Property 13: Catalog well-formedness + +For all node type descriptors in the catalog, the descriptor declares its input ports, output ports, and parameters completely: every port has a type from the known port-type set, every parameter has a valid type and satisfiable constraints, every declared default value satisfies its own parameter's constraints, and the category is one of the five palette sections. + +**Validates: Requirements 2.8** + +### Property 14: Simulation stubs exactly the hardware-dependent nodes + +For all valid Workflow_Definitions compiled with `simulation=true`, every hardware-dependent node (per the catalog flag) is mapped to a recording stub, no hardware executor binding or hardware element remains in the output, and every non-hardware-dependent node compiles identically to the non-simulation output. + +**Validates: Requirements 12.6** + +### Property 15: Test report covers every node + +For all valid Workflow_Definitions executed by the test harness (with the pipeline layer mocked), the per-node results report contains exactly one entry per node in the definition, each keyed by its node identifier. + +**Validates: Requirements 12.7** + +## Error Handling + +### Portal backend + +- **API errors**: all new Lambdas follow the existing error envelope (`{error: {code, message, details}}`) with appropriate HTTP statuses — 400 for validation/parse failures (including the full findings list, Requirement 4.7), 403 for RBAC denials (Requirement 5.8, 11.4), 404 for missing workflows scoped correctly to avoid cross-tenant existence leaks, 409 for delete-with-active-deployments (with referencing deployment ids, Requirement 5.6). +- **Serializer**: parse errors carry a JSON-pointer path and the first violation encountered; migration failures for unsupported versions return a distinct `UNSUPPORTED_SCHEMA_VERSION` code. +- **Packaging atomicity**: staged S3 prefix + register-last ordering; any artifact failure aborts, cleans the stage, and reports the failing artifact — no partial component versions (Requirement 7.5). +- **Bedrock invocation**: configured timeout (≤ 60 s) enforced client-side; throttling/model errors surface as a user-visible failure with the prompt preserved in the chat panel state for retry; malformed model output is caught by the serializer and never mutates the canvas (Requirement 10.4, 10.7). +- **Test runs**: validator/compiler errors fail the run before any sandbox task starts (Requirement 12.12); Step Functions timeout (10 min) stops the Fargate task and marks the run failed-with-timeout, retaining partial per-node results already flushed to S3 (Requirement 12.10, 12.13); dataset upload violations reject before any dataset record is written (Requirement 12.11). + +### Edge (LocalServer) + +- **Workflow discovery**: malformed or incompatible-schema artifacts are registered with status `invalid` and reported through the existing status path; they never enter the runnable set. +- **Execution failures**: `GstPipelineManager` already converts bus errors to `PipelineExecutionException` naming the failing element and enforces a 120 s watchdog; the workflow executor maps the element name back to the `nodeId` via compiled-document tags and reports failure through the existing status reporting path (Requirement 9.7). Failures are contained to the workflow's execution thread — `Pipeline_Configuration` execution is unaffected (Requirement 13.7). +- **Custom Python nodes**: user code runs in a subprocess with a wall-clock limit and bounded memory; non-zero exit, protocol violation, or timeout fails only that workflow run with the node identified. +- **Plugin loading**: `GST_PLUGIN_PATH` is extended per-run, not globally, so a bad delivered plugin cannot poison the existing pipeline path. + +## Testing Strategy + +The feature uses a dual approach: property-based tests for the pure `workflow_core` logic and the frontend graph-state logic, and example/integration tests for wiring, UI affordances, external services, and device behavior. + +### Property-based tests + +- **Library**: `hypothesis` for Python (`workflow_core` — Properties 1–9, 13, 14, 15), `fast-check` for TypeScript (frontend graph state and the TS validator mirror — Properties 10, 11, 12). +- **Configuration**: minimum 100 iterations per property test. +- **Traceability**: each property is implemented by a single property-based test tagged with a comment in the format `**Feature: workflow-manager, Property {number}: {property_text}**`. +- **Generators**: a shared `graph_strategy` builds random valid Workflow_Definitions from the catalog (random node subsets, valid parameter values, type-compatible DAG wiring, optional fan-out); defect-seeding combinators produce controlled invalid graphs for Properties 2, 3, and 8. Generators cover edge cases: empty parameter strings, whitespace, unicode in names/descriptions, single-node graphs, and maximal fan-out. + +### Unit and example-based tests + +- Catalog content checks (Requirements 2.1–2.5), model-picker population (2.6), Custom_Python_Node parameter acceptance (2.7). +- Schema-migration fixtures per registered migration (3.5). +- API guard examples: packaging/deployment rejection on validation errors and on missing passed-validation records (4.7, 4.10), delete-with-deployments (5.6), RBAC role×action matrix as a parameterized table covering all roles and operations (11.1–11.4), Bedrock config admin-only access (10.6). +- Frontend component tests (Vitest + Testing Library): palette rendering (1.1), validate button and findings display (4.8, 4.9), chat panel presence and failure handling (10.1, 10.3, 10.4, 10.5, 10.7), test action and dataset picker (12.1, 12.2), stub identification in reports (12.8). +- Error-path examples: packaging artifact failure atomicity (7.5), test-run mid-execution failure (12.10), validator/compiler failure short-circuit (12.12), dataset upload boundary cases at/over 500 MB and unsupported formats (12.3, 12.11). + +### Integration tests + +- **Portal**: Workflow_Store CRUD/versioning/duplication against local DynamoDB (moto) (5.1–5.5, 5.7), packaging against mocked S3/Greengrass asserting artifact sets and registration calls (7.1–7.4), deployment creation and association records (8.1–8.5), audit log writes per operation (11.5), Bedrock invocation with mocked Converse API (10.2), Step Functions test-run ordering and absence of any Greengrass interaction in the test path (12.4, 12.9). +- **Test sandbox**: containerized end-to-end run of a sample workflow against a small Test_Dataset with CPU Triton (12.5), timeout behavior with a shortened limit (12.13). +- **Edge (device/CI images per arch)**: workflow discovery and registration (9.1), execution through `GstPipelineManager` with delivered plugins (9.2), Triton inference (9.3), digital output/MQTT/OPC UA against local endpoints or mocks (9.4–9.6), failure reporting (9.7), custom Python bridge end-to-end (9.8). +- **Backward compatibility (Requirement 13)**: the existing LocalServer and portal test suites run unchanged against the new build (13.1, 13.2, 13.6); device regression tests deploy/remove a Workflow_Component while a Pipeline_Configuration runs and assert no restarts, unchanged results, and fault isolation (13.3, 13.4, 13.7); concurrent Triton usage compared against baseline inference results (13.8); alembic migration applied to a copy of a production-shaped DB asserting additive-only changes (13.5). diff --git a/.kiro/specs/workflow-manager/requirements.md b/.kiro/specs/workflow-manager/requirements.md new file mode 100644 index 00000000..126e7396 --- /dev/null +++ b/.kiro/specs/workflow-manager/requirements.md @@ -0,0 +1,221 @@ +# Requirements Document + +## Introduction + +The Workflow Manager adds a graphical video-pipeline builder to the edge-cv-portal cloud portal. Users compose video analytics pipelines on a drag-and-drop canvas by placing nodes (inputs, preprocessing, model inference, post-processing, and outputs) and drawing connections between them. Completed workflows are stored per account in the portal, validated, compiled into GStreamer/NVIDIA DeepStream pipeline definitions, packaged as Greengrass components (including any GStreamer plugin dependencies), and deployed to one or more edge devices where the LocalServer component executes them in its GStreamer path. All existing model types served via the embedded NVIDIA Triton Inference Server remain usable as inference nodes, and existing edge functions such as digital input and digital output become first-class nodes. A secondary capability allows users to generate workflows from a natural-language prompt using configurable Amazon Bedrock models through a chat interface. + +## Glossary + +- **Portal**: The edge-cv-portal cloud web application (React frontend, Lambda backend, DynamoDB storage) used to manage DDA use cases, models, components, deployments, and devices. +- **LocalServer**: The Greengrass component (aws.edgeml.dda.LocalServer.<arch>) running on an edge device. It embeds the NVIDIA Triton Inference Server and executes GStreamer pipelines, calling into Triton via the emltriton plugin. +- **Pipeline_Configuration**: The pre-existing pipeline definition format that LocalServer executes through its current GStreamer path (src/backend/gstreamer/), predating and distinct from a Workflow_Definition. +- **Workflow_Builder**: The graphical canvas UI within the Portal where users place Nodes and draw Connections to compose a Workflow_Definition. +- **Node**: A single processing stage in a workflow (for example a camera source, dewarp filter, model inference stage, or MQTT output) represented as a box on the canvas. +- **Node_Palette**: The categorized list of available Node types shown in the Workflow_Builder, organized into input, preprocessing, model inference, post-processing, and output sections. +- **Connection**: A directed edge drawn between an output port of one Node and an input port of another Node, defining data flow. +- **Port**: A typed attachment point on a Node where a Connection begins or ends. Each Port declares a media or data type (for example video frames, inference metadata, or event signals). +- **Workflow_Definition**: The serializable graph document (Nodes, Node configurations, and Connections) that fully describes a workflow, stored as JSON. +- **Workflow_Serializer**: The Portal component that serializes a Workflow_Definition graph to its JSON document form and parses JSON documents back into Workflow_Definition graphs. +- **Workflow_Validator**: The component that checks a Workflow_Definition for structural and semantic correctness (connectivity, port type compatibility, cycles, required configuration). +- **Workflow_Compiler**: The component that translates a valid Workflow_Definition into a GStreamer pipeline configuration executable by LocalServer, including element ordering, caps, and plugin arguments. +- **Component_Packager**: The Portal backend component that packages a compiled workflow and its GStreamer plugin dependencies into a versioned Greengrass component. +- **Workflow_Component**: The Greengrass component produced by the Component_Packager for a specific workflow version. +- **Deployment_Service**: The existing Portal capability, extended by this feature, that creates Greengrass deployments targeting edge devices or thing groups. +- **Workflow_Store**: The Portal backend persistence layer (API plus DynamoDB/S3 storage) that stores Workflow_Definitions per account and use case. +- **Workflow_Generator**: The Portal backend component that invokes a configured Amazon Bedrock model to produce a Workflow_Definition from a natural-language prompt. +- **Bedrock_Configuration**: Portal settings identifying which Amazon Bedrock model, region, and inference parameters the Workflow_Generator uses. +- **Custom_Python_Node**: A Node type whose behavior is defined by user-supplied Python code executed within the pipeline. +- **Use_Case**: The existing Portal tenancy unit (per-account production line) to which workflows, models, and devices are scoped. +- **Workflow_Test_Runner**: The Portal backend component that executes a validated and compiled Workflow_Definition against a Test_Dataset in a simulated, cloud-side environment, substituting stubs for hardware-dependent Nodes, without deploying artifacts to any edge device. +- **Test_Dataset**: A named collection of canned sample inputs (for example sample images or video frames) scoped to a Use_Case, selected or uploaded by a user, that the Workflow_Test_Runner feeds into a workflow test run as source data. + +## Requirements + +### Requirement 1: Graphical Workflow Canvas + +**User Story:** As a computer vision engineer, I want to build video pipelines by dragging nodes onto a canvas and drawing connections between them, so that I can compose edge pipelines visually without writing GStreamer syntax. + +#### Acceptance Criteria + +1. WHEN a user opens the Workflow_Builder, THE Workflow_Builder SHALL display an empty canvas and the Node_Palette organized into input, preprocessing, model inference, post-processing, and output sections. +2. WHEN a user drags a Node type from the Node_Palette onto the canvas, THE Workflow_Builder SHALL add a new Node instance at the drop position with that Node type's default configuration. +3. WHEN a user drags from an output Port of one Node to an input Port of another Node, THE Workflow_Builder SHALL create a Connection between the two Ports. +4. WHEN a user attempts to connect two Ports with incompatible declared types, THE Workflow_Builder SHALL reject the Connection and display the reason for the rejection. +5. WHEN a user selects a Node or Connection and issues a delete action, THE Workflow_Builder SHALL remove the selected element and all Connections attached to a removed Node. +6. THE Workflow_Builder SHALL support canvas panning, zooming, and repositioning of Nodes by dragging. +7. WHEN a user selects a Node, THE Workflow_Builder SHALL display a configuration panel showing that Node's configurable parameters with their current values. +8. WHEN a user edits a Node parameter in the configuration panel, THE Workflow_Builder SHALL validate the entered value against the parameter's declared type and constraints and display a validation error for invalid values. +9. WHILE a user edits a workflow on the canvas, THE Workflow_Builder SHALL display inline validation markers on Nodes that have required parameters without values and on Nodes that are not reachable from any input Node. +10. WHEN a canvas edit resolves a condition indicated by an inline validation marker, THE Workflow_Builder SHALL remove that marker from the canvas. + +### Requirement 2: Node Type Catalog + +**User Story:** As a computer vision engineer, I want a catalog of input, preprocessing, inference, post-processing, and output node types, so that I can build complete pipelines covering existing DDA functions and new integrations. + +#### Acceptance Criteria + +1. THE Node_Palette SHALL provide input Node types including camera source, folder/file source, and digital input. +2. THE Node_Palette SHALL provide preprocessing Node types including dewarp, rotate, crop, and video format conversion. +3. THE Node_Palette SHALL provide a model inference Node type that runs any model registered in the Portal model registry for the selected Use_Case via the LocalServer Triton path. +4. THE Node_Palette SHALL provide post-processing Node types including a Custom_Python_Node and inference-result filtering based on configurable conditions over inference metadata. +5. THE Node_Palette SHALL provide output Node types including digital output, MQTT publish, OPC UA write, and inference-result capture to the device file system. +6. WHEN a user places a model inference Node, THE Workflow_Builder SHALL present the models available to the selected Use_Case for selection as a Node parameter. +7. WHEN a user places a Custom_Python_Node, THE Workflow_Builder SHALL accept user-supplied Python code and declared input and output Port types as Node parameters. +8. THE Node_Palette SHALL declare, for every Node type, its input Ports, output Ports, Port types, and configurable parameters with types, defaults, and constraints. + +### Requirement 3: Workflow Definition Serialization + +**User Story:** As a Portal developer, I want workflow graphs serialized to and from a stable JSON format, so that workflows can be stored, versioned, transferred to devices, and reloaded into the canvas without loss. + +#### Acceptance Criteria + +1. WHEN a Workflow_Definition graph is serialized, THE Workflow_Serializer SHALL produce a JSON document containing all Nodes, Node configurations, Node positions, Connections, and a schema version identifier. +2. WHEN a valid Workflow_Definition JSON document is parsed, THE Workflow_Serializer SHALL produce a Workflow_Definition graph equivalent to the document contents. +3. WHEN a malformed or schema-violating JSON document is parsed, THE Workflow_Serializer SHALL return a descriptive error identifying the first violation encountered. +4. FOR ALL valid Workflow_Definition graphs, serializing then parsing then serializing SHALL produce an equivalent Workflow_Definition graph and identical JSON structure (round-trip property). +5. WHEN a JSON document with an older supported schema version is parsed, THE Workflow_Serializer SHALL migrate the document to the current schema version and report the migration in the parse result. + +### Requirement 4: Workflow Validation + +**User Story:** As a computer vision engineer, I want my workflow checked for errors before deployment, so that I do not deploy pipelines that cannot run on the edge device. + +#### Acceptance Criteria + +1. WHEN validation is requested for a Workflow_Definition, THE Workflow_Validator SHALL verify that the graph contains at least one input Node and at least one output Node. +2. WHEN validation is requested for a Workflow_Definition, THE Workflow_Validator SHALL verify that every Connection joins an output Port to an input Port with compatible types. +3. IF a Workflow_Definition contains a cycle, THEN THE Workflow_Validator SHALL report a validation error identifying the Nodes participating in the cycle. +4. IF a Workflow_Definition contains a Node with a required parameter that has no value, THEN THE Workflow_Validator SHALL report a validation error identifying the Node and parameter. +5. IF a Workflow_Definition contains a Node that is not reachable from any input Node, THEN THE Workflow_Validator SHALL report a validation error identifying the unreachable Node. +6. WHEN validation completes, THE Workflow_Validator SHALL return the complete list of validation errors and warnings found, each with the associated Node or Connection identifier. +7. WHEN a user requests packaging, publishing, or deployment of a Workflow_Definition that has validation errors, THE Portal SHALL reject the request and display the validation errors. +8. THE Workflow_Builder SHALL provide a user-invocable validate action for the current Workflow_Definition. +9. WHEN a user invokes the validate action, THE Workflow_Builder SHALL run all Workflow_Validator checks on the current Workflow_Definition and display the complete list of resulting validation errors and warnings. +10. WHEN a user requests packaging, publishing, or deployment of a workflow version, THE Portal SHALL verify that the workflow version passed a Workflow_Validator run with zero validation errors before starting the requested operation. + +### Requirement 5: Workflow Persistence Per Account + +**User Story:** As a computer vision engineer, I want my workflows saved in the cloud portal under my account, so that I can manage a library of pipelines and deploy them to devices over time. + +#### Acceptance Criteria + +1. WHEN a user saves a workflow, THE Workflow_Store SHALL persist the Workflow_Definition scoped to the user's account and selected Use_Case, together with a name, description, creation timestamp, and last-modified timestamp. +2. WHEN a user saves changes to an existing workflow, THE Workflow_Store SHALL create a new workflow version and retain prior versions. +3. WHEN a user lists workflows, THE Workflow_Store SHALL return the workflows belonging to Use_Cases that the user is authorized to access. +4. WHEN a user opens a saved workflow, THE Workflow_Builder SHALL load the Workflow_Definition and render the Nodes, positions, configurations, and Connections as they were saved. +5. WHEN a user deletes a workflow that has no active deployments, THE Workflow_Store SHALL remove the workflow and its versions. +6. IF a user requests deletion of a workflow that has active deployments, THEN THE Workflow_Store SHALL reject the deletion and identify the deployments that reference the workflow. +7. WHEN a user duplicates a workflow, THE Workflow_Store SHALL create a new workflow with a copy of the source Workflow_Definition under a new name. +8. IF a user attempts to access a workflow belonging to a Use_Case the user is not authorized for, THEN THE Portal SHALL deny the request and return an authorization error. + +### Requirement 6: Workflow Compilation to GStreamer Pipeline + +**User Story:** As a Portal developer, I want validated workflow graphs compiled into GStreamer pipeline configurations, so that LocalServer can execute them in its existing GStreamer path. + +#### Acceptance Criteria + +1. WHEN a valid Workflow_Definition is compiled, THE Workflow_Compiler SHALL produce a GStreamer pipeline configuration in which each Node maps to its corresponding GStreamer element chain and each Connection maps to element linkage in topological order. +2. WHEN a Workflow_Definition contains a model inference Node, THE Workflow_Compiler SHALL emit an emltriton element configured with the selected model name and the Triton model repository and server paths used by LocalServer. +3. WHEN a Workflow_Definition contains a branch where one Node output connects to multiple downstream Nodes, THE Workflow_Compiler SHALL emit tee and queue elements to realize the branch. +4. WHEN compilation completes, THE Workflow_Compiler SHALL include in the output the list of GStreamer plugin dependencies required by the compiled pipeline beyond those bundled with LocalServer. +5. IF a Workflow_Definition contains a Node type with no available GStreamer element mapping for the target device architecture, THEN THE Workflow_Compiler SHALL return a compilation error identifying the Node and the unsupported architecture. +6. FOR ALL valid Workflow_Definitions, THE Workflow_Compiler SHALL produce a pipeline configuration that references every Node in the Workflow_Definition exactly once. + +### Requirement 7: Greengrass Component Packaging + +**User Story:** As an operator, I want workflows packaged as Greengrass components including their GStreamer plugin dependencies, so that a single deployment delivers everything the edge device needs to run the pipeline. + +#### Acceptance Criteria + +1. WHEN a user requests packaging of a validated workflow version, THE Component_Packager SHALL create a Workflow_Component containing the Workflow_Definition, the compiled pipeline configuration, and the GStreamer plugin dependency artifacts identified by the Workflow_Compiler. +2. WHEN the Component_Packager creates a Workflow_Component, THE Component_Packager SHALL assign a component version derived from the workflow version and register the component in the Greengrass component registry of the Use_Case account. +3. WHEN a workflow contains a Custom_Python_Node, THE Component_Packager SHALL include the user-supplied Python code and its declared Python package dependencies in the Workflow_Component artifacts. +4. WHEN a workflow targets multiple device architectures, THE Component_Packager SHALL produce architecture-specific artifacts for each supported LocalServer architecture (x86_64, arm64 JetPack 4, arm64 JetPack 5, arm64 JetPack 6) selected by the user. +5. IF packaging fails for any artifact, THEN THE Component_Packager SHALL report the failure with the failing artifact identified and register no partial component version. + +### Requirement 8: Deployment to Edge Devices + +**User Story:** As an operator, I want to deploy a workflow to one or more edge devices from the portal, so that the same pipeline can run across my fleet. + +#### Acceptance Criteria + +1. WHEN a user initiates deployment of a Workflow_Component, THE Deployment_Service SHALL allow selection of one or more target edge devices or thing groups within the Use_Case. +2. WHEN a deployment is created, THE Deployment_Service SHALL create a Greengrass deployment that includes the Workflow_Component and records the deployment association between the workflow version and the target devices. +3. WHEN a user views a workflow, THE Portal SHALL display the deployment status (in progress, succeeded, failed, cancelled) of that workflow per target device. +4. IF a target device does not have a LocalServer component version compatible with the Workflow_Component, THEN THE Deployment_Service SHALL report the incompatibility for that device before the deployment is submitted. +5. WHEN a user deploys a newer version of a workflow to a device that runs an older version, THE Deployment_Service SHALL replace the older Workflow_Component version with the newer version on that device. + +### Requirement 9: LocalServer Workflow Execution + +**User Story:** As an operator, I want deployed workflows executed by LocalServer on the edge device, so that pipelines run with the existing Triton inference and GStreamer infrastructure. + +#### Acceptance Criteria + +1. WHEN a Workflow_Component is deployed to a device, THE LocalServer SHALL discover the workflow's compiled pipeline configuration and register the workflow as runnable. +2. WHEN a registered workflow is triggered, THE LocalServer SHALL execute the compiled GStreamer pipeline, including loading any GStreamer plugin dependencies delivered by the Workflow_Component. +3. WHEN a workflow containing a model inference Node executes, THE LocalServer SHALL perform inference through its embedded Triton Inference Server using the model configured on the Node. +4. WHEN a workflow containing a digital output Node executes and the Node's condition evaluates true, THE LocalServer SHALL actuate the configured digital output pin with the configured signal type and pulse width. +5. WHEN a workflow containing an MQTT output Node executes, THE LocalServer SHALL publish the Node's configured payload to the configured MQTT broker and topic. +6. WHEN a workflow containing an OPC UA output Node executes, THE LocalServer SHALL write the Node's configured value to the configured OPC UA server node. +7. IF pipeline execution fails, THEN THE LocalServer SHALL record the failure with the failing element identified and report the workflow execution status as failed through its existing status reporting path. +8. WHEN a workflow containing a Custom_Python_Node executes, THE LocalServer SHALL execute the user-supplied Python code within the pipeline with access to the Node's input data and publish the code's output to the Node's output Port. + +### Requirement 10: Prompt-Based Workflow Generation + +**User Story:** As a computer vision engineer, I want to describe a pipeline in natural language and have it generated for me, so that I can create workflows faster than building them node by node. + +#### Acceptance Criteria + +1. WHERE prompt-based generation is enabled, THE Portal SHALL provide a chat interface within the Workflow_Builder that accepts natural-language workflow requests. +2. WHEN a user submits a prompt, THE Workflow_Generator SHALL invoke the Amazon Bedrock model specified in the Bedrock_Configuration with the prompt and the Node type catalog, and SHALL return a Workflow_Definition. +3. WHEN the Workflow_Generator returns a Workflow_Definition, THE Portal SHALL validate the generated Workflow_Definition with the Workflow_Validator and render the workflow on the canvas for user review before any save or deployment. +4. IF the Workflow_Generator output cannot be parsed into a valid Workflow_Definition, THEN THE Portal SHALL display an error describing the failure and SHALL leave the current canvas contents unchanged. +5. WHEN a user submits a follow-up prompt in the same chat session, THE Workflow_Generator SHALL apply the requested modification to the current canvas Workflow_Definition rather than generating a new workflow from scratch. +6. THE Portal SHALL allow a PortalAdmin to configure the Bedrock_Configuration, including model identifier, region, and inference parameters. +7. IF the Bedrock model invocation fails or exceeds a configurable timeout of at most 60 seconds, THEN THE Portal SHALL display an error message identifying the failure and preserve the user's prompt for retry. + +### Requirement 11: Access Control + +**User Story:** As a portal administrator, I want workflow capabilities governed by the existing portal roles, so that only authorized users can build, modify, or deploy pipelines. + +#### Acceptance Criteria + +1. THE Portal SHALL permit users with the DataScientist or UseCaseAdmin role in a Use_Case to create, edit, and save workflows within that Use_Case. +2. THE Portal SHALL permit users with the Operator or UseCaseAdmin role in a Use_Case to package and deploy workflows within that Use_Case. +3. THE Portal SHALL permit users with the Viewer role in a Use_Case to view workflows and deployment status within that Use_Case in read-only form. +4. IF a user without a permitted role attempts a workflow create, edit, package, deploy, or delete operation, THEN THE Portal SHALL deny the operation and return an authorization error. +5. WHEN a workflow is created, modified, deleted, packaged, or deployed, THE Portal SHALL record the action, the acting user, and a timestamp in the existing audit log. + +### Requirement 12: Pre-Deployment Workflow Testing with Canned Data + +**User Story:** As a computer vision engineer, I want to test my workflow with canned sample data before deploying it, so that I can confirm the pipeline behaves as expected without needing an edge device. + +#### Acceptance Criteria + +1. THE Workflow_Builder SHALL provide a user-invocable test action for the current Workflow_Definition. +2. WHEN a user invokes the test action, THE Workflow_Builder SHALL prompt the user to select an existing Test_Dataset scoped to the selected Use_Case or upload new sample inputs scoped to the selected Use_Case. +3. WHEN a user uploads sample inputs in a supported format with a total size of at most 500 MB, THE Workflow_Store SHALL persist the inputs as a Test_Dataset scoped to the user's account and selected Use_Case and make the Test_Dataset selectable in subsequent test runs. +4. WHEN a test run is started, THE Workflow_Test_Runner SHALL run the Workflow_Validator checks and the Workflow_Compiler on the current Workflow_Definition before executing the pipeline. +5. WHEN validation and compilation succeed for a test run, THE Workflow_Test_Runner SHALL execute the compiled pipeline in a cloud-side simulated environment using the selected Test_Dataset as source data. +6. WHEN a test run executes a workflow containing hardware-dependent Nodes (for example camera source, digital input, digital output, MQTT publish, or OPC UA write), THE Workflow_Test_Runner SHALL substitute a stub for each hardware-dependent Node that records the data the Node would have consumed or emitted without actuating any physical or device-local endpoint. +7. WHEN a test run completes, THE Workflow_Test_Runner SHALL report per-Node results including produced outputs, recorded stub activity, and any errors, each associated with the Node identifier. +8. WHEN a test run report is displayed, THE Workflow_Builder SHALL identify which Nodes were executed with stubs and describe the limitation that stubbed Nodes were simulated rather than actuated. +9. THE Workflow_Test_Runner SHALL execute test runs without requiring a connected edge device and without creating any Greengrass deployment or delivering any artifact to an edge device. +10. IF pipeline execution fails during a test run, THEN THE Workflow_Test_Runner SHALL report the failure with the failing Node identified and an error description, mark the test run status as failed, and retain the per-Node results produced before the failure. +11. IF a user uploads sample inputs that exceed 500 MB in total size or are in an unsupported format, THEN THE Workflow_Store SHALL reject the upload, display an error message identifying the reason, and persist no Test_Dataset. +12. IF the Workflow_Validator or the Workflow_Compiler reports errors during a test run, THEN THE Workflow_Test_Runner SHALL report each error with the associated Node or Connection identifier, mark the test run status as failed, and not execute the pipeline. +13. IF a test run's pipeline execution exceeds 10 minutes, THEN THE Workflow_Test_Runner SHALL terminate the execution, mark the test run status as failed with a timeout indication, and report the partial per-Node results produced before termination. + +### Requirement 13: Backward Compatibility with Existing DDA Pipelines and Portal Functionality + +**User Story:** As an operator, I want the Workflow Manager introduced without changing my existing pipelines, models, components, or deployments, so that production lines running today continue to operate exactly as before. + +#### Acceptance Criteria + +1. WHEN a LocalServer version containing workflow support is deployed to a device that runs an existing Pipeline_Configuration, THE LocalServer SHALL continue to execute that Pipeline_Configuration through its existing GStreamer path, producing the same execution results and the same status reporting as before the deployment. +2. THE Portal SHALL provide all pre-existing capabilities (model registry, model deployment, existing Greengrass component management, deployments, and Use_Case management) with the same operation outcomes and no new mandatory configuration after the Workflow Manager feature is introduced. +3. WHEN a Workflow_Component is deployed to, started on, stopped on, or removed from a device, THE LocalServer SHALL preserve the configuration and execution state of every non-workflow pipeline and component on that device without stopping, restarting, or reconfiguring them. +4. WHILE a Workflow_Component executes on a device, THE LocalServer SHALL continue executing every Pipeline_Configuration that was running when the Workflow_Component started. +5. THE Portal SHALL introduce the Workflow Manager feature without requiring migration or modification of existing Pipeline_Configurations, registered models, or Greengrass components. +6. IF a device receives no Workflow_Component, THEN THE LocalServer on that device SHALL execute Pipeline_Configurations with the same execution results and the same status reporting as before the Workflow Manager feature was introduced. +7. IF a Workflow_Component fails during deployment, startup, or execution on a device, THEN THE LocalServer SHALL continue executing every non-workflow pipeline and component on that device and SHALL report the failure through its existing status reporting path. +8. WHILE a Workflow_Component and an existing Pipeline_Configuration concurrently use the same Triton-served model on a device, THE LocalServer SHALL serve inference requests from both without altering the inference results returned to the Pipeline_Configuration. diff --git a/.kiro/specs/workflow-manager/tasks.md b/.kiro/specs/workflow-manager/tasks.md new file mode 100644 index 00000000..0db1a9b3 --- /dev/null +++ b/.kiro/specs/workflow-manager/tasks.md @@ -0,0 +1,338 @@ +# Implementation Plan: Workflow Manager + +## Overview + +Implementation proceeds on the `workflow_manager` git branch (already created). The shared `workflow_core` Python package (catalog, serializer, validator, compiler) is built first because the portal Lambdas, the test sandbox, and the LocalServer workflow engine all depend on it. Portal backend and frontend work follow, then generation, cloud testing, edge execution, and finally backward-compatibility verification. Python code uses `hypothesis` and TypeScript code uses `fast-check` for property-based tests, each configured for a minimum of 100 iterations and tagged `**Feature: workflow-manager, Property {number}: {property_text}**`. + +## Tasks + +- [x] 1. Set up the shared workflow_core package and node catalog + - [x] 1.1 Create the workflow_core package skeleton and test infrastructure + - Create `edge-cv-portal/backend/layers/workflow_core/` with package layout (`catalog`, `serializer`, `validator`, `compiler` modules), `pyproject.toml`, and pytest + hypothesis setup configured for 100+ iterations per property + - Add layer build script consistent with existing `edge-cv-portal/backend/layers/` conventions + - _Requirements: 3.1, 4.6, 6.1_ + + - [x] 1.2 Implement node catalog data models and initial catalog + - Write `PortDescriptor`, `ParameterDescriptor`, `GstMapping`, and `NodeTypeDescriptor` dataclasses in `workflow_core.catalog` + - Populate the initial catalog: camera_source, folder_source, digital_input, dewarp, rotate, crop, format_convert, model_inference, custom_python, inference_filter, digital_output, mqtt_publish, opcua_write, capture — each with ports, port types (VideoFrames, InferenceMeta, EventSignal), parameters (types, defaults, constraints), per-architecture GstMappings (x86_64, arm64_jp4, arm64_jp5, arm64_jp6, sim), executor bindings, plugin dependencies, and hardware_dependent flags + - Implement port-type compatibility rules (exact match plus declared coercions) and the per-arch LocalServer-bundled plugin manifest + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.8_ + + - [x] 1.3 Write property test for catalog well-formedness + - **Property 13: Catalog well-formedness** + - **Validates: Requirements 2.8** + + - [x] 1.4 Write unit tests for catalog content + - Assert presence and parameterization of all required input, preprocessing, inference, post-processing, and output node types + - _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_ + +- [x] 2. Implement Workflow_Serializer + - [x] 2.1 Implement the graph model, JSON Schema, and canonical serialization + - Write the `WorkflowGraph` model (nodes with id/type/position/parameters, connections with typed port endpoints) + - Author the Workflow_Definition JSON Schema (schemaVersion 1) and implement `serialize(graph)` with canonical output: sorted keys, nodes and connections ordered by id + - _Requirements: 3.1_ + + - [x] 2.2 Implement parse with descriptive errors and schema migration + - Implement `parse(doc)` running JSON-Schema validation first (first violation reported with JSON-pointer path), then graph construction + - Implement the stepwise migration registry: older supported schemaVersion documents upgrade to current and the ParseResult reports `migrations: [from, to]`; unsupported versions return `UNSUPPORTED_SCHEMA_VERSION` + - _Requirements: 3.2, 3.3, 3.5_ + + - [x] 2.3 Implement shared hypothesis generators + - Write `graph_strategy` producing random valid Workflow_Definitions from the catalog (random node subsets, valid parameter values, type-compatible DAG wiring, optional fan-out) with edge cases: empty/whitespace/unicode strings, single-node graphs, maximal fan-out + - Write defect-seeding combinators producing controlled invalid graphs (missing input/output nodes, incompatible-port connections, injected cycles, cleared required parameters, detached unreachable nodes) and schema-corrupting document mutators + - _Requirements: 3.4, 4.6, 6.6_ + + - [x] 2.4 Write property test for serialization round trip + - **Property 1: Serialization round trip** + - **Validates: Requirements 3.1, 3.2, 3.4** + + - [x] 2.5 Write property test for parser rejection of invalid documents + - **Property 2: Parser rejects invalid documents descriptively** + - **Validates: Requirements 3.3** + + - [x] 2.6 Write unit tests for schema migration + - Fixture documents per registered migration, asserting migrated output and reported migration path + - _Requirements: 3.5_ + +- [x] 3. Implement Workflow_Validator + - [x] 3.1 Implement the shared parameter constraint predicate + - Write the parameter-validation predicate over `ParameterDescriptor` (type check plus min/max, enum, regex constraints) in `workflow_core`, used by the validator and mirrored by the frontend + - _Requirements: 1.8, 4.4_ + + - [x] 3.2 Implement validate() with all checks + - Implement checks V1 (≥1 input and ≥1 output node), V2 (connection port direction and type compatibility), V3 (cycle detection via Tarjan SCC reporting nodes in each cycle), V4 (required parameters satisfy constraints), V5 (reachability from input nodes via forward BFS), and W1 warnings + - Always run all checks and return the complete `ValidationFinding` list with severity, code, message, and nodeId/connectionId + - _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ + + - [x] 3.3 Write property test for the parameter constraint predicate + - **Property 9: Parameter constraint predicate correctness** + - **Validates: Requirements 1.8** + + - [x] 3.4 Write property test for validator finding-set exactness + - **Property 3: Validator finding-set exactness** + - **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6** + +- [x] 4. Implement Workflow_Compiler + - [x] 4.1 Implement compile() producing the Compiled Pipeline Document + - Re-run validation and refuse to compile on errors; topologically sort the DAG + - Emit one ElementChain per node tagged with nodeId (emltriton chains for model inference with model name and Triton repo/server paths; ExecutorBinding entries for executor-level nodes), each node appearing exactly once + - Linearize connections with `tee name=t` plus `queue` per branch for fan-out; return CompileError{nodeId, arch} for node types lacking a mapping on the target architecture + - Compute pluginDependencies as catalog-declared dependencies minus the per-arch LocalServer-bundled set; output the CompiledPipelineDocument JSON (segments, executorBindings, pluginDependencies) + - _Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_ + + - [x] 4.2 Implement simulation-mode compilation + - Add `simulation=true` support: hardware-dependent nodes (per catalog flag) map to recording stubs (dataset-fed sources via multifilesrc/appsrc, recording bindings for outputs); non-hardware nodes compile identically to non-simulation output + - _Requirements: 12.6_ + + - [x] 4.3 Write property test for node reference exactness + - **Property 4: Compiler references every node exactly once** + - **Validates: Requirements 6.6, 6.1** + + - [x] 4.4 Write property test for compiled order and branching + - **Property 5: Compiled order respects the graph, branches get tee/queue** + - **Validates: Requirements 6.1, 6.3** + + - [x] 4.5 Write property test for emltriton configuration + - **Property 6: Inference nodes compile to correctly configured emltriton elements** + - **Validates: Requirements 6.2** + + - [x] 4.6 Write property test for plugin dependency set + - **Property 7: Plugin dependency set correctness** + - **Validates: Requirements 6.4** + + - [x] 4.7 Write property test for unmapped architecture errors + - **Property 8: Unmapped architecture yields identifying compile errors** + - **Validates: Requirements 6.5** + + - [x] 4.8 Write property test for simulation stubbing + - **Property 14: Simulation stubs exactly the hardware-dependent nodes** + - **Validates: Requirements 12.6** + +- [x] 5. Checkpoint - workflow_core complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 6. Implement portal backend storage and validation APIs + - [x] 6.1 Add workflow infrastructure to CDK + - Define DynamoDB tables (Workflows, WorkflowVersions, TestDatasets, TestRuns, WorkflowChatSessions with TTL) with GSIs, portal S3 `workflows/{usecase_id}/...` prefixes, the workflow_core Lambda layer, new Lambda functions, and API Gateway routes in `edge-cv-portal/infrastructure/lib` following existing stack patterns + - Register new RBAC permission actions (workflow:read/create/edit/save/delete/test/package/deploy, bedrock-config:write) mapped to existing roles in `rbac_middleware` + - _Requirements: 5.1, 11.1, 11.2, 11.3, 11.4_ + + - [x] 6.2 Implement workflows.py (Workflow_Store API) + - CRUD with Use_Case scoping, save-as-new-version with prior versions retained, list filtered by authorized Use_Cases, open/load returning the stored definition, duplicate under a new name, delete rejected with referencing deployment ids when active deployments exist (409), cross-tenant access denied (403/404 without existence leaks) + - Write audit log entries for create/modify/delete via the existing AuditLog table + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 11.4, 11.5_ + + - [x] 6.3 Implement workflow_validation.py and the node-catalog endpoint + - Validate endpoint importing the workflow_core layer, returning the complete findings list and recording validation status (passed/failed, findings key, validated_at) on the workflow version + - `GET /api/v1/workflows/node-catalog` serving the serialized catalog for the frontend palette + - Shared guard helper used by packaging/publishing/deployment endpoints: reject when error-severity findings exist or the version lacks a passed-validation record + - _Requirements: 2.8, 4.6, 4.7, 4.10_ + + - [x] 6.4 Write integration tests for Workflow_Store + - CRUD, versioning, duplication, delete-with-deployments against local DynamoDB (moto) + - _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ + + - [x] 6.5 Write API guard unit tests + - Packaging/deployment rejection on validation errors and on missing passed-validation records; delete-with-active-deployments identifying deployments + - _Requirements: 4.7, 4.10, 5.6_ + +- [x] 7. Implement Component_Packager and deployment extension + - [x] 7.1 Implement workflow_packaging.py + - Compile per user-selected architecture; assemble per-arch artifacts (manifest.json, workflow.json, compiled_pipeline.json, plugins//*.so from the curated plugin library in portal S3, python/{nodeId}/handler.py + requirements.txt for Custom_Python_Nodes) + - Upload to the Use_Case account S3 via assumed role and register `dda.workflow.{workflowId}` version `{workflowVersion}.0.0` with per-arch platform manifests and an install-only recipe (no Run lifecycle) + - All-or-nothing staging: temp S3 prefix, register only after all artifacts upload; on failure delete the stage, report the failing artifact, register nothing; write audit log entries for packaging + - _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 11.5, 13.3_ + + - [x] 7.2 Write unit tests for packaging atomicity + - Simulated artifact upload failures assert stage cleanup, failing-artifact reporting, and absence of partial component versions + - _Requirements: 7.5_ + + - [x] 7.3 Extend deployments.py for Workflow_Components + - Add workflow component type with device/thing-group targeting within the Use_Case; record workflow version → deployment → devices associations (component_type: workflow) in the Deployments table + - Surface per-device Greengrass deployment status for the workflow page; pre-submit compatibility check comparing device LocalServer version against the component's minLocalServerVersion; rely on Greengrass revision semantics for version replacement; write audit log entries for deploy + - _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 11.5_ + + - [x] 7.4 Write integration tests for packaging and deployment + - Packaging against mocked S3/Greengrass asserting artifact sets and registration calls; deployment creation and association records + - _Requirements: 7.1, 7.2, 7.3, 7.4, 8.1, 8.2, 8.3, 8.4, 8.5_ + + - [x] 7.5 Write RBAC and audit tests + - Parameterized role×action matrix covering all roles against read/create/edit/save/delete/test/package/deploy; audit log write per create/modify/delete/package/deploy operation + - _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ + +- [x] 8. Implement the Workflow_Builder frontend + - [x] 8.1 Implement TypeScript schema types, validator mirror, and port compatibility + - Generate TypeScript types from the Workflow_Definition JSON Schema; implement `arePortsCompatible` mirroring catalog compatibility rules; implement the TS mirror of validator checks V4 (missing required parameters, using the parameter constraint predicate) and V5 (unreachable nodes) + - _Requirements: 1.4, 1.8, 1.9_ + + - [x] 8.2 Implement the canvas page with React Flow + - Add `@xyflow/react`; build the canvas at `edge-cv-portal/frontend/src/pages/workflows/` with custom node components (category color, title, typed port handles, validation badges), pan/zoom/reposition, and delete of nodes/connections removing attached connections + - Node_Palette sidebar grouped by the five categories, sourced from the node-catalog endpoint, with HTML5 drag-and-drop placing nodes with default configuration + - Connection rules via `isValidConnection` calling `arePortsCompatible`; rejected attempts show the reason + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_ + + - [x] 8.3 Implement the node configuration panel + - CloudScape form controls rendered from the parameter schema with inline validation errors from the constraint predicate; model_inference `modelName` select populated from the model registry API filtered by Use_Case; Custom_Python_Node code editor plus input/output port-type pickers + - _Requirements: 1.7, 1.8, 2.6, 2.7_ + + - [x] 8.4 Implement inline validation markers + - Run the TS V4/V5 mirror on every graph mutation; add warning badges to offending nodes and clear them when the condition resolves + - _Requirements: 1.9, 1.10_ + + - [x] 8.5 Implement the actions toolbar and API wiring + - Save (versioned), Validate (backend full validation with complete findings display), Duplicate, Delete — each gated by role from the existing auth context; open/load renders saved nodes, positions, configurations, and connections + - _Requirements: 4.8, 4.9, 5.1, 5.2, 5.4, 5.7, 11.1, 11.3_ + + - [x] 8.6 Write property test for connection acceptance + - **Property 10: Connection acceptance equals port compatibility** + - **Validates: Requirements 1.3, 1.4** + + - [x] 8.7 Write property test for node deletion + - **Property 11: Node deletion leaves no dangling connections** + - **Validates: Requirements 1.5** + + - [x] 8.8 Write property test for inline markers + - **Property 12: Inline markers are exactly the offending nodes** + - **Validates: Requirements 1.9, 1.10** + + - [x] 8.9 Write frontend component tests for the builder + - Palette rendering with five sections; validate button invoking backend and displaying the complete findings list + - _Requirements: 1.1, 4.8, 4.9_ + +- [x] 9. Checkpoint - portal storage, packaging, and builder complete + - Ensure all tests pass, ask the user if questions arise. + +- [x] 10. Implement prompt-based workflow generation + - [x] 10.1 Implement workflow_generator.py + - Chat sessions in WorkflowChatSessions (TTL) holding message history and current canvas definition snapshot; Bedrock Converse API invocation with a `create_workflow` tool whose input schema is the Workflow_Definition JSON Schema and the serialized node catalog in the system prompt + - Follow-up prompts include the current canvas definition and instruct modification rather than regeneration; tool output parsed by Workflow_Serializer then validated by Workflow_Validator, returning definition plus findings (never auto-saved or deployed); client-side timeout equal to the configured value (≤ 60 s) with failures returned as descriptive errors + - _Requirements: 10.2, 10.3, 10.5, 10.7_ + + - [x] 10.2 Implement Bedrock_Configuration settings + - Model identifier, region, inference parameters, and timeout in the existing portal settings storage; settings UI section and API restricted to PortalAdmin via bedrock-config:write + - _Requirements: 10.6_ + + - [x] 10.3 Implement the frontend chat panel + - Collapsible panel maintaining a session id; renders generated workflows onto the canvas only after client-side parse and backend validation succeed; on parse/invocation failure displays the error, leaves the canvas unchanged, and preserves the prompt for retry + - _Requirements: 10.1, 10.3, 10.4, 10.7_ + + - [x] 10.4 Write integration tests for generation + - Mocked Converse API asserting prompt/catalog assembly, tool-use output handling, follow-up modification flow, and timeout behavior + - _Requirements: 10.2, 10.5, 10.7_ + + - [x] 10.5 Write frontend tests for the chat panel + - Panel presence, render-after-validation, failure handling leaving canvas untouched, prompt preservation + - _Requirements: 10.1, 10.3, 10.4, 10.5, 10.7_ + +- [x] 11. Implement the Workflow_Test_Runner + - [x] 11.1 Implement workflow_testing.py + - `POST /test-datasets` with S3 presigned multipart upload and server-side verification (total size ≤ 500 MB, JPEG/PNG formats) before committing the dataset record; violations reject with reason and persist nothing; dataset list scoped to Use_Case + - `POST /workflows/{id}/test-runs` starting the Step Functions execution; `GET /test-runs/{id}` returning status and per-node results + - _Requirements: 12.2, 12.3, 12.11_ + + - [x] 11.2 Implement the test-run Step Functions state machine and CDK infrastructure + - Validate → Compile (x86_64, simulation=true) → RunSandbox (Fargate in isolated subnet, no device network routes, no Greengrass resources) → CollectResults; validation/compilation errors short-circuit with per-node/connection error records and status failed without executing the pipeline + - 10-minute execution timeout stopping the task, marking failed-with-timeout, retaining partial results; TestRuns DynamoDB item and results in S3 + - _Requirements: 12.4, 12.9, 12.12, 12.13_ + + - [x] 11.3 Implement the sandbox container and test harness + - x86_64 image with GStreamer, the DDA plugin set, CPU Triton, and vendored workflow_core; harness renders the compiled document exactly as LocalServer does, feeds sources from the Test_Dataset, records stub activity for hardware bindings instead of actuating endpoints + - Per-node results `{nodeId, status, outputs, stubActivity, error}` flushed incrementally to S3 so mid-run failures retain prior results and identify the failing node + - _Requirements: 12.5, 12.6, 12.7, 12.10_ + + - [x] 11.4 Write property test for test report coverage + - **Property 15: Test report covers every node** + - **Validates: Requirements 12.7** + + - [x] 11.5 Write unit tests for test-run error paths + - Dataset upload boundaries at/over 500 MB and unsupported formats; validator/compiler failure short-circuit; mid-run failure retention + - _Requirements: 12.10, 12.11, 12.12_ + + - [x] 11.6 Implement the frontend test panel + - Test action prompting dataset selection or upload scoped to the Use_Case; per-node result display marking stubbed nodes with a "simulated" badge and limitation text + - _Requirements: 12.1, 12.2, 12.8_ + + - [x] 11.7 Write frontend tests for the test panel + - Test action, dataset picker/uploader, stub identification and limitation description in reports + - _Requirements: 12.1, 12.2, 12.8_ + + - [x] 11.8 Write sandbox integration tests + - Containerized end-to-end run of a sample workflow against a small Test_Dataset with CPU Triton; timeout behavior with a shortened limit; assert no Greengrass interaction in the test path + - _Requirements: 12.5, 12.9, 12.13_ + +- [x] 12. Implement the LocalServer workflow engine + - [x] 12.1 Vendor workflow_core and add additive database migration + - Vendor workflow_core into LocalServer; add alembic migration creating `workflow_registrations` and `workflow_executions` tables (additive only, no changes to existing tables) + - _Requirements: 9.1, 13.5_ + + - [x] 12.2 Implement WorkflowWatcher and registration endpoints + - New `src/backend/workflow_engine/` package: startup scan plus inotify/poll watch of `/aws_dda/workflows/`; register discovered manifest.json + compiled_pipeline.json in workflow_registrations (malformed/incompatible artifacts registered as invalid and reported, never runnable); new Flask endpoints `/workflows` list/trigger/status — no changes to existing endpoints + - _Requirements: 9.1, 13.3, 13.6_ + + - [x] 12.3 Implement WorkflowExecutor pipeline execution + - Render the launch string from the compiled document (segments joined with `!`, tee branch references); prepend the component's `plugins//` directory to GST_PLUGIN_PATH per-run only; execute via existing `GstPipelineManager.run_pipeline` (watchdog, error capture, tag parsing inherited) so inference flows through emltriton → embedded Triton + - Map failing element back to nodeId via compiled-document tags and report workflow status as failed through the existing status reporting path; execute in a separate thread with a separate registry so Pipeline_Configuration execution is never touched + - _Requirements: 9.2, 9.3, 9.7, 13.1, 13.4, 13.7, 13.8_ + + - [x] 12.4 Implement executor output bindings + - Post-pipeline processing of executorBindings: digital output actuation via existing dio_utils (condition, pin, signal type, pulse width), MQTT publish via the existing mqtt client, OPC UA writes via the packaged client + - _Requirements: 9.4, 9.5, 9.6_ + + - [x] 12.5 Implement the Custom_Python_Node bridge + - `emlpython` bridge running user code in a subprocess with a defined stdin/stdout frame+metadata protocol, wall-clock and memory limits; appsink/appsrc pair managed by the executor; failures fail only that workflow run with the node identified + - _Requirements: 9.8_ + + - [x] 12.6 Write edge integration tests for the workflow engine + - Discovery and registration; execution through GstPipelineManager with delivered plugins; Triton inference; digital output/MQTT/OPC UA against local endpoints or mocks; failure reporting with failing node identified; custom Python bridge end-to-end + - _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8_ + +- [x] 13. Verify backward compatibility + - [x] 13.1 Run existing LocalServer and portal test suites unchanged against the new build + - Assert same outcomes with no new mandatory configuration; devices without Workflow_Components behave identically + - _Requirements: 13.1, 13.2, 13.5, 13.6_ + + - [x] 13.2 Write device regression tests for workflow/pipeline isolation + - Deploy and remove a Workflow_Component while a Pipeline_Configuration runs; assert no LocalServer restarts, unchanged Pipeline_Configuration results and status reporting, and workflow failures contained without affecting non-workflow pipelines + - _Requirements: 13.3, 13.4, 13.7_ + + - [x] 13.3 Write concurrent Triton and migration safety tests + - Concurrent workflow and Pipeline_Configuration inference against the same Triton-served model compared against baseline results; alembic migration applied to a copy of a production-shaped DB asserting additive-only changes + - _Requirements: 13.5, 13.8_ + +- [x] 14. Final checkpoint + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Development happens on the `workflow_manager` git branch (already created) +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- Property tests use hypothesis (Python) and fast-check (TypeScript) with a minimum of 100 iterations, tagged `**Feature: workflow-manager, Property {number}: {property_text}**` +- workflow_core is built first as it is a shared dependency of the portal Lambdas, the test sandbox, and the LocalServer workflow engine +- The existing `src/backend/gstreamer/` Pipeline_Configuration path is never modified; all edge changes are additive (Requirement 13) + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["1.1"] }, + { "id": 1, "tasks": ["1.2"] }, + { "id": 2, "tasks": ["1.3", "1.4", "2.1", "3.1"] }, + { "id": 3, "tasks": ["2.2", "3.2", "3.3"] }, + { "id": 4, "tasks": ["2.3", "4.1", "6.1"] }, + { "id": 5, "tasks": ["2.4", "2.5", "2.6", "3.4", "4.2", "8.1"] }, + { "id": 6, "tasks": ["4.3", "4.4", "4.5", "4.6", "4.7", "4.8", "6.2", "8.2"] }, + { "id": 7, "tasks": ["6.3", "6.4", "7.1", "8.3"] }, + { "id": 8, "tasks": ["6.5", "7.2", "7.3", "8.4", "8.6", "8.7"] }, + { "id": 9, "tasks": ["7.5", "7.4", "8.5", "8.8", "10.1", "11.1"] }, + { "id": 10, "tasks": ["8.9", "10.2", "10.3", "11.2", "12.1"] }, + { "id": 11, "tasks": ["10.4", "10.5", "11.3", "11.6", "12.2"] }, + { "id": 12, "tasks": ["11.4", "11.5", "11.7", "12.3"] }, + { "id": 13, "tasks": ["11.8", "12.4"] }, + { "id": 14, "tasks": ["12.5"] }, + { "id": 15, "tasks": ["12.6"] }, + { "id": 16, "tasks": ["13.1", "13.2", "13.3"] } + ] +} +``` diff --git a/edge-cv-portal/.gitignore b/edge-cv-portal/.gitignore index 10552dc9..c83b6565 100644 --- a/edge-cv-portal/.gitignore +++ b/edge-cv-portal/.gitignore @@ -12,6 +12,11 @@ build/ *.egg-info/ cdk.out/ .cdk.staging/ +backend/layers/*/layer.zip + +# Test caches +.pytest_cache/ +.hypothesis/ # IDE .vscode/ diff --git a/edge-cv-portal/backend/functions/account_sync.py b/edge-cv-portal/backend/functions/account_sync.py new file mode 100644 index 00000000..934bef34 --- /dev/null +++ b/edge-cv-portal/backend/functions/account_sync.py @@ -0,0 +1,615 @@ +""" +Account_Sync_Service - portal-side sync Lambda. + +Feature: portal-user-manager (task 3.4: sync-attempt entry). + +One Lambda with three entry paths, routed by event shape (mirroring +camera_sync.py's style): + + - Sync attempt (this task): invoked directly by user_admin.py + ({action: 'sync_attempt', device_id, syncId}) after staging, and by + the EventBridge rate(5 minutes) schedule for every device with + pending changes (Req 7.7). Builds the device's full desired account + document from the staged set in `dda-portal-account-sync`, writes + the `dda-user-accounts` named-shadow desired state via + update_thing_shadow, and stamps the row `in_progress` with + `attemptAt`. A shadow-write failure or size-limit violation marks + the attempt `failed` with the reason; pending changes are retained + (Req 7.6). + + - Ack ingest (task 3.5): SQS records from the IoT topic rule on + $aws/things/+/shadow/name/dda-user-accounts/update/documents + (partial-batch-failure pattern, camera_sync.py conventions). A + reported ackSyncId equal to the row's current syncId marks it + `success` with lastSyncAt = appliedAt and clears pendingChanges + (Reqs 7.4, 7.5); a reported error marks `failed` with the device's + reason, pending retained; stale acks (ackSyncId != current syncId) + are discarded without any state change. Malformed records are + logged and dead-lettered, never endlessly redelivered. + + - Timeout sweep (task 3.5): piggybacked on the 5-minute schedule. + in_progress rows whose attemptAt is older than 60 s without an ack + are marked `failed` / `device unreachable` (Req 7.9); the staged + set and pendingChanges are retained for the next scheduled retry + (Req 7.6). +""" +import json +import logging +import os +import time +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# The per-thing named shadow carrying the desired account set. +USER_ACCOUNTS_SHADOW_NAME = "dda-user-accounts" + +# Sync-state row statuses (dda-portal-account-sync data model). +STATUS_PENDING = "pending" +STATUS_IN_PROGRESS = "in_progress" +STATUS_SUCCESS = "success" +STATUS_FAILED = "failed" + +# An in_progress attempt without an ack within this window is recorded +# failed with REASON_DEVICE_UNREACHABLE (Req 7.9). +ACK_TIMEOUT_MS = 60 * 1000 +REASON_DEVICE_UNREACHABLE = "device unreachable" + +# --- Pure sync-document builder -------------------------------------------- +# +# Own copy of the builder consistent with user_admin.build_sync_document +# (separate Lambda packages cannot import each other's function modules; +# the camera-registry-sync feature duplicates shared pure logic the same +# way). + +# Shadow dda-user-accounts document schema version (design data model). +SYNC_DOCUMENT_VERSION = 1 + +# AWS IoT named-shadow document size limit the rendered desired state +# must fit within (design: validate against the 8 KB shadow limit). +SHADOW_SIZE_LIMIT_BYTES = 8 * 1024 + + +class SyncDocumentTooLarge(ValueError): + """The rendered sync document exceeds the 8 KB shadow size limit.""" + + +def build_sync_document(accounts: Dict[str, Dict[str, Any]], + sync_id: str) -> Dict[str, Any]: + """ + Build the complete desired sync document for one device from a staged + account set (pure function, design data model for the + dda-user-accounts shadow). + + Each record carries only {email, role, enabled, deleted?, verifier?} + - the fields are copied by an explicit whitelist so plaintext + passwords can never appear in a sync payload no matter what the + input carries (Req 7.3). Disabled or deleted accounts are marked + `enabled: false` and are never dropped from the document (Req 7.8). + + Raises SyncDocumentTooLarge when the rendered desired state exceeds + the 8 KB shadow limit, with an explicit reason. + """ + doc_accounts = {} + for username, record in (accounts or {}).items(): + record = record or {} + deleted = bool(record.get("deleted", False)) + enabled = bool(record.get("enabled", False)) and not deleted + + entry: Dict[str, Any] = { + "email": record.get("email", ""), + "role": record.get("role") or "Viewer", + "enabled": enabled, + } + if deleted: + entry["deleted"] = True + + verifier = record.get("verifier") + if verifier: + entry["verifier"] = { + "algorithm": verifier.get("algorithm"), + "iterations": int(verifier.get("iterations", 0)), + "salt": verifier.get("salt"), + "hash": verifier.get("hash"), + } + + doc_accounts[username] = entry + + document = { + "syncId": sync_id, + "version": SYNC_DOCUMENT_VERSION, + "accounts": doc_accounts, + } + + rendered = json.dumps({"state": {"desired": document}}, + separators=(",", ":")) + size = len(rendered.encode("utf-8")) + if size > SHADOW_SIZE_LIMIT_BYTES: + raise SyncDocumentTooLarge( + f"The rendered sync document is {size} bytes, exceeding the " + f"{SHADOW_SIZE_LIMIT_BYTES}-byte (8 KB) IoT shadow limit; " + f"reduce the number of selected accounts" + ) + return document + + +# --- Lazy AWS accessors ----------------------------------------------------- +# Created lazily (camera_sync.py style) so test mocks are honored. + +def _dynamodb(): + import boto3 + + return boto3.resource("dynamodb") + + +def _sync_table(): + table_name = os.environ.get( + "ACCOUNT_SYNC_TABLE", "dda-portal-account-sync") + return _dynamodb().Table(table_name) + + +def _resolve_usecase_id(device_id: str) -> Optional[str]: + """The device's usecase_id from the portal devices table, or None.""" + devices_table = os.environ.get("DEVICES_TABLE") + if not devices_table: + return None + try: + response = _dynamodb().Table(devices_table).get_item( + Key={"device_id": device_id}) + except Exception as exc: # noqa: BLE001 - fall back to the local client + logger.warning( + "Could not resolve usecase for device %s: %s", device_id, exc) + return None + usecase_id = (response.get("Item") or {}).get("usecase_id") + return str(usecase_id) if usecase_id else None + + +def iot_data_client(device_id: str): + """iot-data client for the device's Use_Case account. + + Assumed-role (or single-account) client when the device resolves to + a Use_Case (camera_registry.py pattern); the Lambda's own iot-data + client otherwise. + """ + usecase_id = _resolve_usecase_id(device_id) + if usecase_id: + from shared_utils import (get_usecase, get_usecase_client, + get_usecase_region) + + usecase = get_usecase(usecase_id) + return get_usecase_client("iot-data", usecase, + region=get_usecase_region(usecase)) + import boto3 + + return boto3.client("iot-data") + + +# --- Sync-state row stamping ------------------------------------------------ + +def _stamp_in_progress(device_id: str, now_ms: int) -> None: + """The shadow desired write succeeded: the attempt is in flight, + awaiting the device ack (ingested by task 3.5). pendingChanges is + deliberately untouched - only a matching ack clears it.""" + _sync_table().update_item( + Key={"device_id": device_id}, + UpdateExpression=( + "SET #st = :in_progress, attemptAt = :now " + "REMOVE failureReason"), + ExpressionAttributeNames={"#st": "status"}, + ExpressionAttributeValues={ + ":in_progress": STATUS_IN_PROGRESS, + ":now": now_ms, + }, + ) + + +def _stamp_failed(device_id: str, reason: str, now_ms: int) -> None: + """The attempt failed (shadow write error or size-limit violation): + record the reason. pendingChanges is retained so the staged set is + retried by the 5-minute schedule (Req 7.6).""" + _sync_table().update_item( + Key={"device_id": device_id}, + UpdateExpression=( + "SET #st = :failed, failureReason = :reason, attemptAt = :now"), + ExpressionAttributeNames={"#st": "status"}, + ExpressionAttributeValues={ + ":failed": STATUS_FAILED, + ":reason": reason, + ":now": now_ms, + }, + ) + + +# --- Sync attempt ------------------------------------------------------------ + +def attempt_sync(device_id: str) -> Dict[str, Any]: + """Attempt delivery of a device's staged account set (Reqs 7.6, 7.7). + + Builds the desired document from the row's *current* staged set and + syncId (a direct invoke's syncId may already be superseded by an + attribute-change hook), writes the dda-user-accounts shadow desired + state, and stamps the row in_progress with attemptAt. Any failure + marks the row failed with the reason; pending changes are retained. + """ + row = _sync_table().get_item( + Key={"device_id": device_id}).get("Item") + if not row: + logger.info("No staged account sync for device %s; nothing to " + "attempt", device_id) + return {"device_id": device_id, "status": "skipped", + "reason": "no staged sync"} + if not row.get("pendingChanges"): + logger.info("Device %s has no pending account changes; nothing " + "to attempt", device_id) + return {"device_id": device_id, "status": "skipped", + "reason": "no pending changes"} + + sync_id = row.get("syncId") or "" + now_ms = int(time.time() * 1000) + + try: + document = build_sync_document(row.get("accounts") or {}, sync_id) + except SyncDocumentTooLarge as exc: + logger.error("Account sync for %s failed: %s", device_id, exc) + _stamp_failed(device_id, str(exc), now_ms) + return {"device_id": device_id, "syncId": sync_id, + "status": STATUS_FAILED, "reason": str(exc)} + + try: + client = iot_data_client(device_id) + client.update_thing_shadow( + thingName=device_id, + shadowName=USER_ACCOUNTS_SHADOW_NAME, + payload=json.dumps({"state": {"desired": document}}, + separators=(",", ":")), + ) + except Exception as exc: # noqa: BLE001 - any shadow-path failure + reason = f"shadow write failed: {exc}" + logger.error("Account sync for %s failed: %s", device_id, reason) + _stamp_failed(device_id, reason, now_ms) + return {"device_id": device_id, "syncId": sync_id, + "status": STATUS_FAILED, "reason": reason} + + _stamp_in_progress(device_id, now_ms) + logger.info("Account sync attempt for %s in progress (syncId %s, " + "%d account(s))", device_id, sync_id, + len(document["accounts"])) + return {"device_id": device_id, "syncId": sync_id, + "status": STATUS_IN_PROGRESS} + + +def _pending_device_ids() -> List[str]: + """Every device with undelivered pending account changes (Req 7.7).""" + from boto3.dynamodb.conditions import Attr + + device_ids: List[str] = [] + scan_kwargs: Dict[str, Any] = { + "FilterExpression": Attr("pendingChanges").eq(True), + "ProjectionExpression": "device_id", + } + table = _sync_table() + while True: + page = table.scan(**scan_kwargs) + device_ids.extend( + item["device_id"] for item in page.get("Items", [])) + last_key = page.get("LastEvaluatedKey") + if not last_key: + return device_ids + scan_kwargs["ExclusiveStartKey"] = last_key + + +# --- Entry paths -------------------------------------------------------------- + +def _handle_direct_invoke(event: Dict[str, Any]) -> Dict[str, Any]: + """user_admin.py invoke: {action: 'sync_attempt', device_id, syncId}.""" + device_id = event.get("device_id") + if not device_id: + logger.error("sync_attempt invoke without a device_id: %s", event) + return {"status": "skipped", "reason": "no device_id"} + invoked_sync_id = event.get("syncId") + result = attempt_sync(device_id) + if invoked_sync_id and result.get("syncId") not in (None, + invoked_sync_id): + # The staged set was refreshed between staging and this invoke; + # the row's current syncId was delivered, which supersedes the + # invoke's. + logger.info( + "Invoked syncId %s superseded by %s for device %s", + invoked_sync_id, result.get("syncId"), device_id) + return result + + +def _handle_schedule() -> Dict[str, Any]: + """EventBridge rate(5 minutes): sweep timeouts, then attempt delivery + for every device with pending changes (Req 7.7).""" + _sweep_timeouts() + results = [attempt_sync(device_id) + for device_id in _pending_device_ids()] + logger.info("Scheduled account sync pass attempted %d device(s)", + len(results)) + return {"attempted": len(results), "results": results} + + +def _sweep_timeouts() -> None: + """in_progress rows whose attemptAt is older than 60 s without an + ack are marked failed / device unreachable (Req 7.9). + + An ack would have moved the row off in_progress, so status alone + identifies "no ack". The update is conditional on the row still + being in_progress past the threshold so a racing ack or fresh + attempt is never clobbered. pendingChanges and the staged set are + retained, so the next scheduled pass retries (Reqs 7.6, 7.7). + """ + from boto3.dynamodb.conditions import Attr + from botocore.exceptions import ClientError + + threshold = int(time.time() * 1000) - ACK_TIMEOUT_MS + table = _sync_table() + + timed_out: List[str] = [] + scan_kwargs: Dict[str, Any] = { + "FilterExpression": (Attr("status").eq(STATUS_IN_PROGRESS) + & Attr("attemptAt").lt(threshold)), + "ProjectionExpression": "device_id", + } + while True: + page = table.scan(**scan_kwargs) + timed_out.extend( + item["device_id"] for item in page.get("Items", [])) + last_key = page.get("LastEvaluatedKey") + if not last_key: + break + scan_kwargs["ExclusiveStartKey"] = last_key + + for device_id in timed_out: + try: + table.update_item( + Key={"device_id": device_id}, + UpdateExpression=( + "SET #st = :failed, failureReason = :reason"), + ConditionExpression=( + "#st = :in_progress AND attemptAt < :threshold"), + ExpressionAttributeNames={"#st": "status"}, + ExpressionAttributeValues={ + ":failed": STATUS_FAILED, + ":reason": REASON_DEVICE_UNREACHABLE, + ":in_progress": STATUS_IN_PROGRESS, + ":threshold": threshold, + }, + ) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code") + if code == "ConditionalCheckFailedException": + # An ack or a fresh attempt landed between the scan and + # this update; the newer state wins. + continue + logger.exception( + "Failed to record sync timeout for device %s", device_id) + continue + logger.info( + "Account sync for %s timed out without an ack within %d s; " + "recorded failed (%s), pending changes retained", + device_id, ACK_TIMEOUT_MS // 1000, REASON_DEVICE_UNREACHABLE) + + +# --- Ack ingest --------------------------------------------------------------- +# +# The IoT topic rule +# SELECT *, topic(3) AS thing_name +# FROM '$aws/things/+/shadow/name/dda-user-accounts/update/documents' +# forwards every shadow documents event to the ack queue. Each SQS record +# body is the shadow documents payload plus the rule-added thing_name; the +# device's ack lives in current.state.reported as +# {ackSyncId, appliedAt, accountCount} (success) or {ackSyncId, error} +# (validation failure). +# +# Batch semantics (partial batch responses, camera_sync.py conventions): +# - transient persistence errors -> the record's messageId is returned +# in batchItemFailures so only that record is retried +# - malformed / unparseable records -> logged and dead-lettered via an +# explicit SendMessage to the DLQ, NOT reported as batch failures, so +# a bad record is never endlessly redelivered + + +class MalformedAck(Exception): + """An ack record that can never be processed (dead-letter it).""" + + +def _parse_ack_record( + record: Dict[str, Any]) -> Tuple[str, Optional[Dict[str, Any]]]: + """Extract (thing_name, reported state | None) from one SQS record. + + Returns None for the reported state when the shadow documents event + carries none (e.g. our own desired write on a never-reported + shadow) - nothing to ingest, not an error. + + Raises MalformedAck for anything that can never be processed. + """ + try: + body = json.loads(record.get("body") or "") + except (json.JSONDecodeError, ValueError) as exc: + raise MalformedAck(f"unparseable message body: {exc}") from exc + if not isinstance(body, dict): + raise MalformedAck("message body is not a JSON object") + + thing_name = body.get("thing_name") + if not thing_name or not isinstance(thing_name, str): + raise MalformedAck("missing thing_name (topic rule SELECT)") + + state = ((body.get("current") or {}).get("state")) or {} + if not isinstance(state, dict): + raise MalformedAck("current.state is not an object") + reported = state.get("reported") + if reported is None: + return thing_name, None + if not isinstance(reported, dict): + raise MalformedAck("current.state.reported is not an object") + return thing_name, reported + + +def _ingest_ack(device_id: str, reported: Dict[str, Any]) -> None: + """Reduce one device ack into the sync-state row (Reqs 7.4, 7.5). + + Matching ackSyncId + no error -> success, lastSyncAt = appliedAt, + pendingChanges cleared (a zero-change sync acks the same way, 7.5). + Matching ackSyncId + error -> failed with the device's reason, + pending retained. Stale acks (ackSyncId != current syncId, or no + row) are discarded without any state change. Updates are guarded on + the row still carrying the acked syncId so a concurrent re-stage is + never clobbered. + """ + from botocore.exceptions import ClientError + + ack_sync_id = reported.get("ackSyncId") + if not ack_sync_id: + logger.info( + "Shadow documents event for %s carries no ackSyncId; " + "nothing to ingest", device_id) + return + + table = _sync_table() + row = table.get_item(Key={"device_id": device_id}).get("Item") + if not row or row.get("syncId") != ack_sync_id: + logger.info( + "Discarding stale ack for %s (ackSyncId %s, current syncId " + "%s)", device_id, ack_sync_id, + (row or {}).get("syncId")) + return + + error = reported.get("error") + try: + if error: + # The device rejected the document (validation failure): + # failed with the device's reason, pending retained (7.6). + table.update_item( + Key={"device_id": device_id}, + UpdateExpression=( + "SET #st = :failed, failureReason = :reason"), + ConditionExpression="syncId = :ack", + ExpressionAttributeNames={"#st": "status"}, + ExpressionAttributeValues={ + ":failed": STATUS_FAILED, + ":reason": str(error), + ":ack": ack_sync_id, + }, + ) + logger.info( + "Account sync %s for %s failed on the device: %s", + ack_sync_id, device_id, error) + else: + applied_at = reported.get("appliedAt") + last_sync_at = (int(applied_at) if applied_at is not None + else int(time.time() * 1000)) + table.update_item( + Key={"device_id": device_id}, + UpdateExpression=( + "SET #st = :success, lastSyncAt = :applied, " + "pendingChanges = :false REMOVE failureReason"), + ConditionExpression="syncId = :ack", + ExpressionAttributeNames={"#st": "status"}, + ExpressionAttributeValues={ + ":success": STATUS_SUCCESS, + ":applied": last_sync_at, + ":false": False, + ":ack": ack_sync_id, + }, + ) + logger.info( + "Account sync %s for %s acknowledged (appliedAt %s); " + "pending changes cleared", ack_sync_id, device_id, + applied_at) + except ClientError as exc: + code = exc.response.get("Error", {}).get("Code") + if code == "ConditionalCheckFailedException": + # The row was re-staged with a fresh syncId between the read + # and this update: the ack is now stale; discard it. + logger.info( + "Discarding ack for %s superseded by a newer staged " + "sync (ackSyncId %s)", device_id, ack_sync_id) + return + raise + + +def _dead_letter(record: Dict[str, Any], reason: str) -> bool: + """Send a malformed record to the DLQ; True when the send succeeded.""" + import boto3 + + dlq_url = os.environ.get("ACCOUNT_SYNC_ACK_DLQ_URL") + if not dlq_url: + logger.error("ACCOUNT_SYNC_ACK_DLQ_URL not configured; cannot " + "dead-letter malformed account-sync ack") + return False + try: + boto3.client("sqs").send_message( + QueueUrl=dlq_url, + MessageBody=record.get("body") or "", + MessageAttributes={ + "deadLetterReason": { + "DataType": "String", + "StringValue": reason[:256] or "unknown", + }, + }, + ) + return True + except Exception: # noqa: BLE001 - DLQ send is best-effort + logger.exception("Failed to dead-letter malformed account-sync ack") + return False + + +def _handle_ack_records(records: List[Dict[str, Any]]) -> Dict[str, Any]: + """SQS ack ingest from the shadow update/documents topic rule. + + Returns a partial batch response ({"batchItemFailures": [...]}) so + that transient persistence errors retry only the affected record. + Malformed records are logged and dead-lettered explicitly and are + NOT reported as batch failures (they would never succeed on retry). + """ + batch_item_failures = [] + for record in records: + message_id = record.get("messageId") + try: + thing_name, reported = _parse_ack_record(record) + if reported is None: + logger.info( + "Shadow documents event for '%s' carries no reported " + "state; nothing to ingest", thing_name) + continue + _ingest_ack(thing_name, reported) + except MalformedAck as exc: + logger.error( + "Malformed account-sync ack (message %s): %s", + message_id, exc) + if not _dead_letter(record, str(exc)) and message_id: + # Could not preserve the message; let SQS retry/redrive it. + batch_item_failures.append({"itemIdentifier": message_id}) + except Exception: # noqa: BLE001 - transient failure: retry record + logger.exception( + "Transient failure processing account-sync ack " + "(message %s); reporting batch item failure", message_id) + if message_id: + batch_item_failures.append({"itemIdentifier": message_id}) + return {"batchItemFailures": batch_item_failures} + + +def handler(event, context): + """Route by event shape: SQS records -> ack ingest; direct + {action: 'sync_attempt'} -> single-device attempt; EventBridge + scheduled event -> pending-changes sweep.""" + event = event or {} + + records = event.get("Records") + if isinstance(records, list) and records and ( + records[0].get("eventSource") == "aws:sqs"): + return _handle_ack_records(records) + + if event.get("action") == "sync_attempt": + return _handle_direct_invoke(event) + + if event.get("source") == "aws.events" or ( + event.get("detail-type") == "Scheduled Event"): + return _handle_schedule() + + logger.error("Unrecognized account_sync event shape: %s", + json.dumps(event, default=str)[:512]) + return {"status": "skipped", "reason": "unrecognized event shape"} diff --git a/edge-cv-portal/backend/functions/bedrock_common.py b/edge-cv-portal/backend/functions/bedrock_common.py new file mode 100644 index 00000000..ad79c952 --- /dev/null +++ b/edge-cv-portal/backend/functions/bedrock_common.py @@ -0,0 +1,167 @@ +""" +Shared Bedrock_Configuration resolution and client construction +(custom-node-code-assist, Requirements 4.1-4.7). + +Extracted verbatim from workflow_generator.py so every Bedrock-backed +feature (workflow generation, custom node code assist) reads the same +`bedrock_configuration` settings item with identical semantics: + +- Defaults overridden by stored values; sampling parameters + (temperature / top_p) honor an explicitly stored null (the parameter + stays unset and is omitted from the invocation). +- The invocation timeout is coerced to an int (junk -> 60) and clamped + to [1, 60] seconds. +- bedrock-runtime clients are cached per (region, timeout) with a + client-side read timeout equal to the configured invocation timeout + and retries disabled, so total wall time cannot exceed the timeout. +- build_inference_config emits maxTokens plus at most ONE sampling + parameter: temperature when set, else topP when set (recent Anthropic + models reject requests specifying both). + +This module lives in the same Lambda bundle as its consumers +(backend/functions is one code asset), so they import it directly. +""" +import logging +import os +from decimal import Decimal +from typing import Any, Dict, Tuple + +import boto3 +from botocore.config import Config as BotoConfig +from botocore.exceptions import ClientError + +logger = logging.getLogger() + +# AWS clients +dynamodb = boto3.resource('dynamodb') + +# Environment variables +SETTINGS_TABLE = os.environ.get('SETTINGS_TABLE') + +# Bedrock_Configuration lives in the portal settings table under this key; +# the settings UI/API (workflow-manager task 10.2) is restricted to +# PortalAdmin via bedrock-config:write (workflow-manager Requirement 10.6). +BEDROCK_CONFIG_SETTING_KEY = 'bedrock_configuration' + +# The invocation timeout is configurable up to 60 seconds +# (workflow-manager Requirement 10.7; code-assist Requirement 4.4). +MAX_TIMEOUT_SECONDS = 60 + +DEFAULT_BEDROCK_CONFIG = { + # Cross-region inference profile: current Anthropic models on Bedrock + # are invokable only through inference profiles (the bare + # foundation-model ids are not directly invokable, and older direct + # ids like claude-3-5-sonnet have reached end of life). + 'model_id': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'region': os.environ.get('AWS_REGION', 'us-east-1'), + 'max_tokens': 4096, + # Sampling parameters are unset by default: they are sent to Bedrock + # only when explicitly configured in the settings (or overridden + # per-request). Recent Anthropic models reject requests that set + # temperature at all, and never accept temperature AND top_p together, + # so build_inference_config() omits None values and sends at most one + # of the two. + 'temperature': None, + 'top_p': None, + 'timeout_seconds': MAX_TIMEOUT_SECONDS, +} + +# Cached per (region, timeout) so warm invocations reuse connections. +_bedrock_clients: Dict[Tuple[str, int], Any] = {} + + +def _decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: _decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_decimal_to_native(i) for i in obj] + return obj + + +def get_bedrock_configuration() -> Dict: + """ + Load the Bedrock_Configuration from the portal settings table, falling + back to sensible defaults for any missing value. + + Stored item shape (written by the PortalAdmin settings API): + {setting_key: 'bedrock_configuration', + value: {model_id, region, max_tokens, temperature, top_p, + timeout_seconds}} + A flat item (attributes directly on the item) is also accepted. + + The timeout is clamped to at most 60 seconds. + """ + config = dict(DEFAULT_BEDROCK_CONFIG) + if SETTINGS_TABLE: + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': BEDROCK_CONFIG_SETTING_KEY} + ) + item = response.get('Item') + if item: + stored = item.get('value') if isinstance(item.get('value'), dict) else item + stored = _decimal_to_native(stored) + for key in DEFAULT_BEDROCK_CONFIG: + if key in ('temperature', 'top_p'): + # An explicitly stored null unsets a sampling + # parameter (recent Anthropic models reject + # temperature and top_p together; nulling + # temperature lets top_p be sent instead). + if key in stored: + config[key] = stored[key] + elif stored.get(key) is not None: + config[key] = stored[key] + except ClientError as e: + logger.warning(f"Could not read Bedrock configuration, using defaults: {str(e)}") + + try: + timeout = int(config['timeout_seconds']) + except (TypeError, ValueError): + timeout = MAX_TIMEOUT_SECONDS + config['timeout_seconds'] = max(1, min(timeout, MAX_TIMEOUT_SECONDS)) + return config + + +def get_bedrock_client(region: str, timeout_seconds: int): + """ + bedrock-runtime client with a client-side read timeout equal to the + configured invocation timeout (the Lambda invokes with a client-side + timeout equal to the configured value). Retries are disabled so the + total wall time cannot exceed the configured timeout. + """ + cache_key = (region, timeout_seconds) + client = _bedrock_clients.get(cache_key) + if client is None: + client = boto3.client( + 'bedrock-runtime', + region_name=region, + config=BotoConfig( + connect_timeout=min(timeout_seconds, 10), + read_timeout=timeout_seconds, + retries={'max_attempts': 0} + ) + ) + _bedrock_clients[cache_key] = client + return client + + +def build_inference_config(config: Dict) -> Dict: + """ + Converse inferenceConfig from a resolved Bedrock_Configuration. + + Never send temperature and top_p together: recent Anthropic models + (e.g. claude-sonnet-4-5) reject requests specifying both. Temperature + wins when set; top_p is sent only when temperature is absent/None + (Requirements 4.2, 4.3). + """ + inference_config = {'maxTokens': int(config['max_tokens'])} + temperature = config.get('temperature') + top_p = config.get('top_p') + if temperature is not None: + inference_config['temperature'] = float(temperature) + elif top_p is not None: + inference_config['topP'] = float(top_p) + return inference_config diff --git a/edge-cv-portal/backend/functions/camera_registry.py b/edge-cv-portal/backend/functions/camera_registry.py new file mode 100644 index 00000000..ba879c22 --- /dev/null +++ b/edge-cv-portal/backend/functions/camera_registry.py @@ -0,0 +1,837 @@ +""" +Camera_Registry API Lambda (camera-registry-sync). + +Serves the per-device Camera_Registry over the routes registered by +CameraRegistryApiStack (all under /devices/{id}/cameras): + +Read routes (task 6.1): + GET /devices/{id}/cameras Registry entries + META (Viewer). + Per-entry ``stale`` is computed against the Staleness_Threshold + (Req 4.1), absent entries carry ``absent_since`` (Req 4.4), and the + response attaches the IoT connectivity status from the existing + device-status lookup (Req 4.2). Devices that never completed a + synchronization return ``{"state": "never-synced"}`` rather than a + bare empty list (Req 1.6). + GET /devices/{id}/cameras/conflicts Conflict events, newest first + (Viewer, Req 6.3). + +Mutation, conflict re-apply, and refresh routes (task 6.2): + POST /devices/{id}/cameras Create origin ``portal-created`` + (Operator, Reqs 5.1, 5.7). The shadow ``desired.changes`` entry is + written FIRST; only then is the registry entry marked ``pending`` + with a fresh ``portal_change_id`` — a shadow-client failure returns + 502 with the registry untouched. + PUT /devices/{id}/cameras/{csid} Update (Operator). Rejects + origin ``edge-discovered`` with ``DISCOVERY_MANAGED`` (Req 5.6). + DELETE /devices/{id}/cameras/{csid} Pending delete (Operator); + same discovery-managed rejection. + POST /devices/{id}/cameras/conflicts/{cid}/reapply Re-issue the + overridden portal version as a new pending change (Operator, + Req 6.4). + POST /devices/{id}/cameras/refresh On-demand GetThingShadow pull + through get_usecase_client, run through the same reducer as the + SQS ingest path (Viewer). + +All mutating routes log an audit event carrying the acting user, the +device, the camera source, and the timestamp (Reqs 12.2, 12.3). + +Authorization follows the existing use-case permission pattern +(rbac_manager checks against the device's usecase_id — Reqs 1.5, 12.1): +the Use_Case is resolved from the device's own registry items when they +exist, so a caller cannot re-scope another tenant's device by query +parameter; the query parameter is only the fallback for devices the +registry has never seen. Out-of-scope requests get the standard 403 with +an ``unauthorized_access`` audit event. + +Storage (design "Data Models"): DynamoDB table ``dda-portal-camera-registry`` +with PK ``device_id`` and item-type-prefixed SK — ``CAMERA#{csid}``, +``META``, ``CONFLICT#{ts}#{uuid}`` — written by the Portal_Sync_Service +(camera_sync.py). +""" +import json +import logging +import os +import uuid +from datetime import datetime +from decimal import Decimal +from typing import Any, Dict, List, Optional + +import boto3 +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError + +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, get_usecase_client, get_usecase_region, + assume_cross_account_role, create_boto3_client, + rbac_manager, Permission, +) + +# The refresh route runs the exact same reduction as the SQS ingest path +# (camera_sync.py is bundled into the same Lambda code asset). +import camera_sync + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +dynamodb = boto3.resource('dynamodb') + +CAMERA_REGISTRY_TABLE = os.environ.get('CAMERA_REGISTRY_TABLE') +SETTINGS_TABLE = os.environ.get('SETTINGS_TABLE') + +# Item-type SK prefixes (design: dda-portal-camera-registry layout). +SK_META = 'META' +SK_CAMERA_PREFIX = 'CAMERA#' +SK_CONFLICT_PREFIX = 'CONFLICT#' + +# Staleness_Threshold: settings-table entry, PortalAdmin-editable through +# the existing settings API (task 6.5); read here with the default-24 +# fallback (Reqs 4.1, 4.3). +STALENESS_SETTING_KEY = 'camera_registry.staleness_threshold_hours' +DEFAULT_STALENESS_THRESHOLD_HOURS = 24 + +# Viewer-held permission gating the read (and refresh) routes (Reqs 1.3, +# 12.1); Operator-held permission gating the mutation routes (Reqs 5.7, +# 12.2) — the same permission the existing device-mutation routes use. +VIEW_PERMISSION = Permission.VIEW_DEVICES +MUTATE_PERMISSION = Permission.MANAGE_DEVICES + +# Sync_Channel shadow written by the mutation routes and pulled by the +# refresh route (design: named shadow per thing). +SHADOW_NAME = 'dda-camera-registry' + +# Machine-readable rejection code for mutations of discovery-managed +# sources (Req 5.6). +DISCOVERY_MANAGED = 'DISCOVERY_MANAGED' + +ORIGIN_EDGE_DISCOVERED = 'edge-discovered' +ORIGIN_PORTAL_CREATED = 'portal-created' + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +# --------------------------------------------------------------------------- +# Registry reads +# --------------------------------------------------------------------------- + +def query_device_items(device_id: str) -> List[Dict[str, Any]]: + """All registry items of one device (single-partition read).""" + table = dynamodb.Table(CAMERA_REGISTRY_TABLE) + items: List[Dict[str, Any]] = [] + kwargs: Dict[str, Any] = { + 'KeyConditionExpression': Key('device_id').eq(device_id), + } + while True: + response = table.query(**kwargs) + items.extend(response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + return items + + +def device_usecase_id(items: List[Dict[str, Any]]) -> Optional[str]: + """The device's Use_Case as recorded on its own registry items. + + META is authoritative; any other item's scoping attribute serves when + META has not been written yet (e.g. only portal-created entries). + """ + meta = next((item for item in items if item.get('sk') == SK_META), None) + if meta and meta.get('usecase_id'): + return meta['usecase_id'] + for item in items: + if item.get('usecase_id'): + return item['usecase_id'] + return None + + +def staleness_threshold_hours() -> float: + """The configured Staleness_Threshold, defaulting to 24 hours. + + The settings entry is PortalAdmin-editable through the existing + settings API (data_accounts.py, reserved id + 'camera-registry-configuration'); when unset (or on any read failure) + the default keeps the route functional (Req 4.1). + """ + if not SETTINGS_TABLE: + return DEFAULT_STALENESS_THRESHOLD_HOURS + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': STALENESS_SETTING_KEY} + ) + value = (response.get('Item') or {}).get('value') + if value is not None: + hours = float(value) + if hours > 0: + return hours + except (ClientError, TypeError, ValueError) as e: + logger.warning(f"Could not read staleness threshold setting: {e}") + return DEFAULT_STALENESS_THRESHOLD_HOURS + + +# --------------------------------------------------------------------------- +# Authorization (Reqs 1.5, 12.1) +# --------------------------------------------------------------------------- + +def authorize(user: Dict, event: Dict, device_id: str, + usecase_id: Optional[str], + permission: Permission) -> Optional[Dict]: + """Use-case permission check for a registry route. + + Returns an error response, or None when authorized. Denials log the + standard ``unauthorized_access`` audit event (Req 1.5). + """ + if not usecase_id: + return create_response(400, {'error': 'usecase_id parameter required'}) + if rbac_manager.has_permission(user['user_id'], usecase_id, permission, + user_info=user): + return None + log_audit_event( + user['user_id'], 'unauthorized_access', 'camera_registry', device_id, + 'denied', + { + 'required_permission': permission.value, + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path'), + } + ) + return create_response(403, { + 'error': 'Access denied', + 'required_permission': permission.value, + }) + + +# --------------------------------------------------------------------------- +# Device connectivity (Req 4.2) — the existing device-status lookup +# (devices.py pattern: assumed use-case role + Greengrass core-device status) +# --------------------------------------------------------------------------- + +def device_connectivity_status(usecase_id: str, device_id: str) -> str: + """The device's reported status, 'UNKNOWN' when the lookup fails. + + The camera inventory must stay readable when the status lookup is + unavailable (offline use-case account, missing role), so every failure + degrades to 'UNKNOWN' instead of failing the request. + """ + try: + usecase = get_usecase(usecase_id) + credentials = assume_cross_account_role( + usecase['cross_account_role_arn'], usecase['external_id'] + ) + region = usecase.get('region', os.environ.get('AWS_REGION', 'us-east-1')) + greengrass_client = create_boto3_client('greengrassv2', credentials, region) + response = greengrass_client.get_core_device(coreDeviceThingName=device_id) + return response.get('status', 'UNKNOWN') + except Exception as e: # noqa: BLE001 — availability over precision here + logger.warning(f"Device status lookup failed for {device_id}: {e}") + return 'UNKNOWN' + + +# --------------------------------------------------------------------------- +# Views +# --------------------------------------------------------------------------- + +def camera_view(item: Dict[str, Any], now: int, + threshold_ms: float) -> Dict[str, Any]: + """One registry camera item in API shape, with computed ``stale``.""" + sk = item.get('sk') or '' + last_reported_at = item.get('last_reported_at') + # Older than the Staleness_Threshold — strictly (Req 4.1); entries the + # device has never reported (portal-created, still pending) carry no + # last-reported timestamp and staleness does not apply to them. + stale = (last_reported_at is not None + and (now - int(last_reported_at)) > threshold_ms) + view = { + 'camera_source_id': item.get('camera_source_id') + or sk[len(SK_CAMERA_PREFIX):], + 'name': item.get('name'), + 'type': item.get('type'), + 'params': item.get('params') or {}, + 'capabilities': item.get('capabilities') or {}, + 'origin': item.get('origin'), + 'version': item.get('version'), + 'last_reported_at': last_reported_at, + 'sync_status': item.get('sync_status'), + 'absent': bool(item.get('absent', False)), + 'stale': stale, + } + if item.get('failure_reason') is not None: + view['failure_reason'] = item['failure_reason'] + if view['absent'] and item.get('absent_since') is not None: + view['absent_since'] = item['absent_since'] + return view + + +def conflict_view(item: Dict[str, Any]) -> Dict[str, Any]: + """One conflict-event item in API shape (Req 6.3).""" + sk = item.get('sk') or '' + view = { + # SK is CONFLICT#{ts}#{uuid}; the uuid segment is the event's + # URL-safe identifier (the {cid} of the re-apply route). + 'conflict_id': sk.rsplit('#', 1)[-1], + 'camera_source_id': item.get('camera_source_id'), + 'edge_version': item.get('edge_version'), + 'portal_version': item.get('portal_version'), + 'resolution': item.get('resolution'), + 'created_at': item.get('created_at'), + } + if item.get('reapplied_as') is not None: + view['reapplied_as'] = item['reapplied_as'] + return view + + +# --------------------------------------------------------------------------- +# Read routes (task 6.1) +# --------------------------------------------------------------------------- + +def get_cameras(device_id: str, user: Dict, event: Dict, + query_params: Dict) -> Dict: + """GET /devices/{id}/cameras (Viewer — Reqs 1.3, 1.6, 4.1, 4.2, 4.4).""" + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, VIEW_PERMISSION) + if error: + return error + + meta = next((item for item in items if item.get('sk') == SK_META), None) + never_synced = meta is None or bool(meta.get('never_synced', True)) + + threshold_hours = staleness_threshold_hours() + threshold_ms = threshold_hours * 3600 * 1000 + now = now_ms() + cameras = [ + camera_view(item, now, threshold_ms) + for item in items + if (item.get('sk') or '').startswith(SK_CAMERA_PREFIX) + ] + cameras.sort(key=lambda c: (c.get('name') or '', c['camera_source_id'])) + + return create_response(200, { + 'device_id': device_id, + 'usecase_id': usecase_id, + # Never-completed synchronization is an explicit state, never a + # bare empty list (Req 1.6). Portal-created pending entries (if + # any) are still listed so operators see what they queued. + 'state': 'never-synced' if never_synced else 'synced', + 'last_report_at': (meta or {}).get('last_report_at'), + 'staleness_threshold_hours': threshold_hours, + # IoT connectivity from the existing device-status lookup (Req 4.2) + 'device_status': device_connectivity_status(usecase_id, device_id), + 'cameras': cameras, + 'count': len(cameras), + }) + + +def get_conflicts(device_id: str, user: Dict, event: Dict, + query_params: Dict) -> Dict: + """GET /devices/{id}/cameras/conflicts (Viewer — Req 6.3), newest first.""" + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, VIEW_PERMISSION) + if error: + return error + + conflicts = [ + conflict_view(item) + for item in items + if (item.get('sk') or '').startswith(SK_CONFLICT_PREFIX) + ] + conflicts.sort( + key=lambda c: (int(c['created_at']) if c.get('created_at') is not None else 0, + c['conflict_id']), + reverse=True, + ) + + return create_response(200, { + 'device_id': device_id, + 'usecase_id': usecase_id, + 'conflicts': conflicts, + 'count': len(conflicts), + }) + + +# --------------------------------------------------------------------------- +# Sync_Channel shadow access (task 6.2) +# --------------------------------------------------------------------------- + +def iot_data_client(usecase_id: str): + """Assumed-role (or single-account) iot-data client for the Use_Case.""" + usecase = get_usecase(usecase_id) + return get_usecase_client('iot-data', usecase, + region=get_usecase_region(usecase)) + + +def write_desired_change(usecase_id: str, device_id: str, csid: str, + change: Dict[str, Any]) -> Optional[Dict]: + """Write one desired.changes entry to the device's registry shadow. + + Returns an error response on failure, None on success. Callers write + the shadow FIRST and touch the registry only afterwards, so a shadow + client failure leaves the registry state untouched (task 6.2 / design + portal→edge flow). + """ + try: + client = iot_data_client(usecase_id) + client.update_thing_shadow( + thingName=device_id, + shadowName=SHADOW_NAME, + payload=json.dumps( + {'state': {'desired': {'changes': {csid: change}}}}, + default=lambda o: float(o) if isinstance(o, Decimal) else o, + ), + ) + return None + except Exception as e: # noqa: BLE001 — any shadow-path failure is a 502 + logger.error(f"Shadow desired write failed for {device_id}/{csid}: {e}") + return create_response(502, { + 'error': 'Failed to deliver the change to the device sync channel', + }) + + +# --------------------------------------------------------------------------- +# Mutation routes (task 6.2 — Reqs 5.1, 5.6, 5.7, 12.2, 12.3) +# --------------------------------------------------------------------------- + +def new_change_id() -> str: + return f"pc-{uuid.uuid4()}" + + +def find_camera_item(items: List[Dict[str, Any]], + csid: str) -> Optional[Dict[str, Any]]: + sk = f"{SK_CAMERA_PREFIX}{csid}" + return next((item for item in items if item.get('sk') == sk), None) + + +def discovery_managed_rejection(csid: str) -> Dict: + """Reject mutations of origin edge-discovered sources (Req 5.6).""" + return create_response(409, { + 'error': f"Camera source '{csid}' is discovery-managed and cannot " + "be modified from the Portal", + 'code': DISCOVERY_MANAGED, + 'camera_source_id': csid, + }) + + +def validate_camera_body(body: Any) -> Optional[Dict]: + """Minimal shape validation for create/update bodies.""" + if not isinstance(body, dict): + return create_response(400, {'error': 'JSON object body required'}) + if not body.get('name') or not isinstance(body.get('name'), str): + return create_response(400, {'error': 'name is required'}) + if not body.get('type') or not isinstance(body.get('type'), str): + return create_response(400, {'error': 'type is required'}) + if 'params' in body and not isinstance(body['params'], dict): + return create_response(400, {'error': 'params must be an object'}) + return None + + +def audit_mutation(user: Dict, action: str, device_id: str, csid: str, + usecase_id: str, portal_change_id: str, + extra: Optional[Dict] = None) -> None: + """Audit event for a mutating route (Reqs 12.2, 12.3). + + log_audit_event stamps the acting user, timestamp, and result; the + details carry the affected device and camera source. + """ + details = { + 'device_id': device_id, + 'camera_source_id': csid, + 'usecase_id': usecase_id, + 'portal_change_id': portal_change_id, + } + if extra: + details.update(extra) + log_audit_event(user['user_id'], action, 'camera_registry', device_id, + 'success', details) + + +def mark_pending(device_id: str, usecase_id: str, csid: str, + portal_change_id: str, pending_content: Dict[str, Any], + existing: Optional[Dict[str, Any]], + body: Optional[Dict[str, Any]] = None) -> None: + """Upsert the registry entry into sync_status=pending (Req 5.1). + + Existing entries keep their last-reported edge state as the effective + content (the portal version travels in pending_content until the + device acknowledges); newly created entries carry the portal content + directly so they are visible in the cameras view while pending. + """ + table = dynamodb.Table(CAMERA_REGISTRY_TABLE) + if existing: + item = dict(existing) + else: + item = { + 'name': (body or {}).get('name'), + 'type': (body or {}).get('type'), + 'params': (body or {}).get('params') or {}, + 'capabilities': {}, + 'origin': ORIGIN_PORTAL_CREATED, + 'version': 0, + 'absent': False, + } + item.update({ + 'device_id': device_id, + 'sk': f"{SK_CAMERA_PREFIX}{csid}", + 'camera_source_id': csid, + 'usecase_id': usecase_id, + 'sync_status': 'pending', + 'portal_change_id': portal_change_id, + 'pending_content': pending_content, + }) + item.pop('failure_reason', None) # a fresh change supersedes old failures + table.put_item(Item={k: v for k, v in item.items() if v is not None}) + + +def create_camera(device_id: str, user: Dict, event: Dict, + query_params: Dict, body: Any) -> Dict: + """POST /devices/{id}/cameras (Operator — Reqs 5.1, 5.7).""" + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) + if not usecase_id and isinstance(body, dict): + usecase_id = body.get('usecase_id') + if not usecase_id: + usecase_id = query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, MUTATE_PERMISSION) + if error: + return error + error = validate_camera_body(body) + if error: + return error + + csid = body.get('camera_source_id') or f"portal-{uuid.uuid4().hex[:12]}" + if find_camera_item(items, csid) is not None: + return create_response(409, { + 'error': f"Camera source '{csid}' already exists", + }) + + portal_change_id = new_change_id() + change = { + 'op': 'create', + 'portalChangeId': portal_change_id, + 'name': body['name'], + 'type': body['type'], + 'params': body.get('params') or {}, + } + # Shadow FIRST; a failure returns 502 with the registry untouched. + error = write_desired_change(usecase_id, device_id, csid, change) + if error: + return error + + pending_content = {'op': 'create', 'name': body['name'], + 'type': body['type'], + 'params': body.get('params') or {}} + mark_pending(device_id, usecase_id, csid, portal_change_id, + pending_content, existing=None, body=body) + audit_mutation(user, 'create_camera_source', device_id, csid, + usecase_id, portal_change_id) + return create_response(201, { + 'device_id': device_id, + 'camera_source_id': csid, + 'origin': ORIGIN_PORTAL_CREATED, + 'sync_status': 'pending', + 'portal_change_id': portal_change_id, + }) + + +def update_camera(device_id: str, csid: str, user: Dict, event: Dict, + query_params: Dict, body: Any) -> Dict: + """PUT /devices/{id}/cameras/{csid} (Operator — Reqs 5.1, 5.6, 5.7).""" + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, MUTATE_PERMISSION) + if error: + return error + entry = find_camera_item(items, csid) + if entry is None: + return create_response(404, { + 'error': f"Camera source '{csid}' not found"}) + if entry.get('origin') == ORIGIN_EDGE_DISCOVERED: + return discovery_managed_rejection(csid) + error = validate_camera_body(body) + if error: + return error + + portal_change_id = new_change_id() + change = { + 'op': 'update', + 'portalChangeId': portal_change_id, + 'baseVersion': entry.get('version'), + 'name': body['name'], + 'type': body['type'], + 'params': body.get('params') or {}, + } + error = write_desired_change(usecase_id, device_id, csid, change) + if error: + return error + + pending_content = {'op': 'update', 'name': body['name'], + 'type': body['type'], + 'params': body.get('params') or {}} + mark_pending(device_id, usecase_id, csid, portal_change_id, + pending_content, existing=entry) + audit_mutation(user, 'update_camera_source', device_id, csid, + usecase_id, portal_change_id) + return create_response(200, { + 'device_id': device_id, + 'camera_source_id': csid, + 'sync_status': 'pending', + 'portal_change_id': portal_change_id, + }) + + +def delete_camera(device_id: str, csid: str, user: Dict, event: Dict, + query_params: Dict) -> Dict: + """DELETE /devices/{id}/cameras/{csid} (Operator) — pending delete.""" + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, MUTATE_PERMISSION) + if error: + return error + entry = find_camera_item(items, csid) + if entry is None: + return create_response(404, { + 'error': f"Camera source '{csid}' not found"}) + if entry.get('origin') == ORIGIN_EDGE_DISCOVERED: + return discovery_managed_rejection(csid) + + portal_change_id = new_change_id() + change = { + 'op': 'delete', + 'portalChangeId': portal_change_id, + 'baseVersion': entry.get('version'), + } + error = write_desired_change(usecase_id, device_id, csid, change) + if error: + return error + + mark_pending(device_id, usecase_id, csid, portal_change_id, + {'op': 'delete'}, existing=entry) + audit_mutation(user, 'delete_camera_source', device_id, csid, + usecase_id, portal_change_id) + return create_response(200, { + 'device_id': device_id, + 'camera_source_id': csid, + 'sync_status': 'pending', + 'portal_change_id': portal_change_id, + }) + + +# --------------------------------------------------------------------------- +# Conflict re-apply (task 6.2 — Req 6.4) +# --------------------------------------------------------------------------- + +def reapply_conflict(device_id: str, cid: str, user: Dict, event: Dict, + query_params: Dict) -> Dict: + """POST /devices/{id}/cameras/conflicts/{cid}/reapply (Operator). + + Re-issues the conflict's overridden portal version as a new pending + change with a fresh portal_change_id and marks the conflict event + ``reapplied_as`` (Req 6.4). + """ + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, MUTATE_PERMISSION) + if error: + return error + + conflict = next( + (item for item in items + if (item.get('sk') or '').startswith(SK_CONFLICT_PREFIX) + and (item['sk'].rsplit('#', 1)[-1] == cid)), + None) + if conflict is None: + return create_response(404, { + 'error': f"Conflict event '{cid}' not found"}) + + portal_version = conflict.get('portal_version') or {} + csid = conflict.get('camera_source_id') + if not portal_version or not csid: + return create_response(400, { + 'error': 'Conflict event carries no portal version to re-apply'}) + + entry = find_camera_item(items, csid) + if entry is not None and entry.get('origin') == ORIGIN_EDGE_DISCOVERED: + return discovery_managed_rejection(csid) + + # The overridden portal version becomes a new pending change: an + # update against the current edge-retained entry, or a re-create + # when the deletion was retained (Req 6.5 aftermath). + op = portal_version.get('op') or ('create' if entry is None else 'update') + if op == 'update' and entry is None: + op = 'create' + if op == 'delete' and entry is None: + return create_response(409, { + 'error': f"Camera source '{csid}' no longer exists; the " + "portal deletion is already effective"}) + + portal_change_id = new_change_id() + change: Dict[str, Any] = {'op': op, 'portalChangeId': portal_change_id} + if op != 'delete': + change.update({ + 'name': portal_version.get('name'), + 'type': portal_version.get('type'), + 'params': portal_version.get('params') or {}, + }) + if entry is not None: + change['baseVersion'] = entry.get('version') + + error = write_desired_change(usecase_id, device_id, csid, change) + if error: + return error + + pending_content = {'op': op} + if op != 'delete': + pending_content.update({ + 'name': portal_version.get('name'), + 'type': portal_version.get('type'), + 'params': portal_version.get('params') or {}, + }) + body_for_create = { + 'name': portal_version.get('name'), + 'type': portal_version.get('type'), + 'params': portal_version.get('params') or {}, + } + mark_pending(device_id, usecase_id, csid, portal_change_id, + pending_content, existing=entry, body=body_for_create) + + dynamodb.Table(CAMERA_REGISTRY_TABLE).update_item( + Key={'device_id': device_id, 'sk': conflict['sk']}, + UpdateExpression='SET reapplied_as = :pc', + ExpressionAttributeValues={':pc': portal_change_id}, + ) + audit_mutation(user, 'reapply_camera_conflict', device_id, csid, + usecase_id, portal_change_id, {'conflict_id': cid}) + return create_response(200, { + 'device_id': device_id, + 'camera_source_id': csid, + 'conflict_id': cid, + 'sync_status': 'pending', + 'portal_change_id': portal_change_id, + }) + + +# --------------------------------------------------------------------------- +# On-demand refresh (task 6.2) — GetThingShadow pull through the same reducer +# --------------------------------------------------------------------------- + +def refresh_cameras(device_id: str, user: Dict, event: Dict, + query_params: Dict) -> Dict: + """POST /devices/{id}/cameras/refresh (Viewer). + + Pulls the device's dda-camera-registry shadow via get_usecase_client + and runs the exact same reduction the SQS ingest path uses + (camera_sync._process_report), then returns the refreshed inventory. + """ + items = query_device_items(device_id) + usecase_id = device_usecase_id(items) or query_params.get('usecase_id') + error = authorize(user, event, device_id, usecase_id, VIEW_PERMISSION) + if error: + return error + + try: + client = iot_data_client(usecase_id) + response = client.get_thing_shadow( + thingName=device_id, shadowName=SHADOW_NAME) + payload = json.loads(response['payload'].read(), + parse_float=Decimal) + except ClientError as e: + if e.response.get('Error', {}).get('Code') == 'ResourceNotFoundException': + return create_response(404, { + 'error': 'Device has no camera registry shadow to refresh from', + }) + logger.error(f"Shadow refresh pull failed for {device_id}: {e}") + return create_response(502, { + 'error': 'Failed to read the device sync channel'}) + except Exception as e: # noqa: BLE001 — assumed-role/parse failures + logger.error(f"Shadow refresh pull failed for {device_id}: {e}") + return create_response(502, { + 'error': 'Failed to read the device sync channel'}) + + reported = ((payload.get('state') or {}).get('reported')) + if isinstance(reported, dict): + camera_sync._process_report(device_id, reported, + usecase_id=usecase_id) + + # Return the refreshed inventory in the same shape as the GET route. + return get_cameras(device_id, user, event, query_params) + + +# --------------------------------------------------------------------------- +# Handler / routing +# --------------------------------------------------------------------------- + +def handler(event, context): + """Route Camera_Registry API requests (CameraRegistryApiStack routes).""" + try: + http_method = event.get('httpMethod') + path = event.get('path', '') or '' + path_parameters = event.get('pathParameters') or {} + query_parameters = event.get('queryStringParameters') or {} + + logger.info(f"Camera_Registry request: {http_method} {path}") + + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400', + }, + 'body': '' + } + + device_id = path_parameters.get('id') + if not device_id: + return create_response(400, {'error': 'device id required'}) + if not CAMERA_REGISTRY_TABLE: + return create_response(500, {'error': 'Camera registry table not configured'}) + + user = get_user_from_event(event) + csid = path_parameters.get('csid') + cid = path_parameters.get('cid') + + body: Any = None + if event.get('body'): + try: + # parse_float=Decimal: camera params may carry non-integral + # numbers and DynamoDB rejects Python floats. + body = json.loads(event['body'], parse_float=Decimal) + except (json.JSONDecodeError, ValueError): + return create_response(400, {'error': 'Invalid JSON body'}) + + # Static segments before path params (conflicts/refresh), reads + # before mutations. + if http_method == 'GET' and path.endswith('/cameras/conflicts'): + return get_conflicts(device_id, user, event, query_parameters) + if http_method == 'GET' and path.endswith('/cameras'): + return get_cameras(device_id, user, event, query_parameters) + + # Mutation / re-apply / refresh routes (task 6.2). + if http_method == 'POST' and path.endswith('/reapply'): + if not cid: + return create_response(400, {'error': 'conflict id required'}) + return reapply_conflict(device_id, cid, user, event, + query_parameters) + if http_method == 'POST' and path.endswith('/cameras/refresh'): + return refresh_cameras(device_id, user, event, query_parameters) + if http_method == 'POST' and path.endswith('/cameras'): + return create_camera(device_id, user, event, query_parameters, + body) + if csid and http_method == 'PUT': + return update_camera(device_id, csid, user, event, + query_parameters, body) + if csid and http_method == 'DELETE': + return delete_camera(device_id, csid, user, event, + query_parameters) + + return create_response(404, {'error': 'Not found'}) + + except Exception as e: + logger.error(f"Error in camera_registry handler: {str(e)}", exc_info=True) + return create_response(500, {'error': 'Internal server error'}) diff --git a/edge-cv-portal/backend/functions/camera_sync.py b/edge-cv-portal/backend/functions/camera_sync.py new file mode 100644 index 00000000..201816dd --- /dev/null +++ b/edge-cv-portal/backend/functions/camera_sync.py @@ -0,0 +1,576 @@ +""" +Portal_Sync_Service - camera registry sync reduction core. + +Feature: camera-registry-sync (task 5.1). + +Pure, side-effect-free reduction of incoming edge camera reports against +the current Camera_Registry state, plus the SQS ingest handler (task 5.4) +that applies `reduce_report` per camera source and `stamp_meta` per +report, persisting the outcomes to the `dda-portal-camera-registry` +table scoped to the device's `usecase_id` (Req 1.4). + +Reduction rules (design "Portal_Sync_Service" section): + - discard_stale: incoming.version < registry_entry.version (Req 3.5) + - ack matching the entry's portal_change_id -> upsert, synced (Req 5.3) + - failure entry for the entry's portal_change_id -> failed + reason (Req 5.4) + - conflict exactly when a pending portal change is unacknowledged and the + edge content differs from the pending content: edge wins, and a + ConflictEvent carries both versions, the resolution, and the + timestamp (Reqs 6.1, 6.2, 6.3) + - a reported deletion while a portal update is pending resolves as + deletion-retained with a ConflictEvent (Req 6.5) + - every processed report stamps the device META item: last_report_at, + never_synced cleared (Req 3.2) + +The reducer is idempotent under duplicate and out-of-order delivery: +re-applying an already-applied report reproduces the same registry entry +(version-guarded), and replaying a report whose conflict was already +resolved reduces to a plain upsert without emitting a second event. +""" +import json +import logging +import os +import time +import uuid +from dataclasses import dataclass +from decimal import Decimal +from typing import Any, Dict, Optional, Tuple + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# SyncOutcome actions +ACTION_UPSERT = "upsert" +ACTION_DISCARD_STALE = "discard_stale" +ACTION_CONFLICT = "conflict" + +# Registry entry sync statuses +SYNC_STATUS_SYNCED = "synced" +SYNC_STATUS_PENDING = "pending" +SYNC_STATUS_FAILED = "failed" + +# Conflict resolutions +RESOLUTION_EDGE_RETAINED = "edge-retained" +RESOLUTION_DELETION_RETAINED = "deletion-retained" + +# Fields that constitute a Camera_Source's comparable "content" for +# conflict classification (Req 6.1). Sync metadata, capability metadata, +# and version counters are deliberately excluded. +_CONTENT_FIELDS = ("name", "type", "params") + +# Portal-originated change operation carried in pending_content. +_OP_DELETE = "delete" + + +@dataclass(frozen=True) +class ConflictEvent: + """Record of a detected Conflict (Req 6.3). + + Carries both conflicting versions, the resolution applied, and the + timestamp. `edge_version` is None when the edge state is a deletion + (deletion-retained, Req 6.5). + """ + + camera_source_id: Optional[str] + edge_version: Optional[Dict[str, Any]] + portal_version: Optional[Dict[str, Any]] + resolution: str + created_at: int + + +@dataclass(frozen=True) +class SyncOutcome: + """Result of reducing one incoming camera-source report. + + action: upsert | discard_stale | conflict + entry: the resulting registry entry to persist; None means the + entry is deleted (or, for discard_stale with no prior + entry, that there is nothing to persist) + conflict_event: present exactly when action == conflict + """ + + action: str + entry: Optional[Dict[str, Any]] + conflict_event: Optional[ConflictEvent] = None + + +def _content_of(source: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Project a camera-source-shaped dict onto its comparable content.""" + if not source: + return {} + return {field: source.get(field) for field in _CONTENT_FIELDS} + + +def _is_failure_entry(incoming: Dict[str, Any]) -> bool: + """Failure entries carry a reason (and no camera content).""" + return incoming.get("status") == "failed" or ( + "reason" in incoming and "version" not in incoming + ) + + +def _is_deletion(incoming: Optional[Dict[str, Any]]) -> bool: + """A reported deletion: the source vanished from a full report. + + The ingest handler represents it as None or {"deleted": True}. + """ + return incoming is None or incoming.get("deleted") is True + + +def _carry_identity( + entry: Dict[str, Any], registry_entry: Optional[Dict[str, Any]] +) -> Dict[str, Any]: + """Preserve identity/scoping attributes from the existing entry (Req 1.4).""" + if registry_entry: + for key in ("usecase_id", "camera_source_id", "device_id"): + if key in registry_entry: + entry.setdefault(key, registry_entry[key]) + return entry + + +def _entry_from_incoming( + registry_entry: Optional[Dict[str, Any]], + incoming: Dict[str, Any], + now_ms: int, +) -> Dict[str, Any]: + """Build the upserted registry entry from an edge camera report (Req 1.1).""" + entry: Dict[str, Any] = { + "name": incoming.get("name"), + "type": incoming.get("type"), + "params": incoming.get("params", {}), + "capabilities": incoming.get("capabilities", {}), + "origin": incoming.get("origin"), + "version": incoming.get("version"), + "last_reported_at": now_ms, + "absent": bool(incoming.get("absent", False)), + "sync_status": SYNC_STATUS_SYNCED, + } + if entry["absent"] and incoming.get("absentSince") is not None: + entry["absent_since"] = incoming["absentSince"] + return _carry_identity(entry, registry_entry) + + +def _reduce_failure( + registry_entry: Optional[Dict[str, Any]], incoming: Dict[str, Any] +) -> SyncOutcome: + """A portal-originated change failed to apply on the device (Req 5.4).""" + if registry_entry is None: + # No entry to mark; a failure for an unknown source is stale noise. + return SyncOutcome(ACTION_DISCARD_STALE, None) + change_id = incoming.get("portalChangeId") + if change_id != registry_entry.get("portal_change_id"): + # Failure report for a superseded change: keep the current entry. + return SyncOutcome(ACTION_DISCARD_STALE, registry_entry) + entry = dict(registry_entry) + entry["sync_status"] = SYNC_STATUS_FAILED + entry["failure_reason"] = incoming.get("reason") + return SyncOutcome(ACTION_UPSERT, entry) + + +def _reduce_deletion( + registry_entry: Optional[Dict[str, Any]], now_ms: int +) -> SyncOutcome: + """The source disappeared from the device's full report.""" + if registry_entry is None: + # Already gone; duplicate delivery is a no-op (idempotent). + return SyncOutcome(ACTION_UPSERT, None) + pending_content = registry_entry.get("pending_content") or {} + if registry_entry.get("sync_status") == SYNC_STATUS_PENDING: + if pending_content.get("op") == _OP_DELETE: + # Portal wanted the deletion too: agreement, not a conflict. + return SyncOutcome(ACTION_UPSERT, None) + # Edge deletion vs pending portal modification: deletion wins (Req 6.5). + return SyncOutcome( + ACTION_CONFLICT, + None, + ConflictEvent( + camera_source_id=registry_entry.get("camera_source_id"), + edge_version=None, + portal_version=pending_content or None, + resolution=RESOLUTION_DELETION_RETAINED, + created_at=now_ms, + ), + ) + return SyncOutcome(ACTION_UPSERT, None) + + +def reduce_report( + registry_entry: Optional[Dict[str, Any]], + incoming: Optional[Dict[str, Any]], + now_ms: int, +) -> SyncOutcome: + """Reduce one incoming camera-source state against the registry entry. + + Args: + registry_entry: the current Camera_Registry entry for this source, + or None when the source is unknown to the registry. + incoming: the source's state from the edge report - a camera map + (design reported-document shape), a failure entry + ({reason, portalChangeId}), or a deletion marker + (None / {"deleted": True}) for a source missing from a + full report. + now_ms: report processing timestamp (epoch milliseconds). + + Returns: + SyncOutcome with the action, the resulting entry (None = deleted), + and a ConflictEvent exactly when a Conflict was classified. + """ + if incoming is None or _is_deletion(incoming): + return _reduce_deletion(registry_entry, now_ms) + if _is_failure_entry(incoming): + return _reduce_failure(registry_entry, incoming) + + # Version-guarded staleness discard (Req 3.5). + if registry_entry is not None and ( + (incoming.get("version") or 0) < (registry_entry.get("version") or 0) + ): + return SyncOutcome(ACTION_DISCARD_STALE, registry_entry) + + entry = _entry_from_incoming(registry_entry, incoming, now_ms) + + if ( + registry_entry is not None + and registry_entry.get("sync_status") == SYNC_STATUS_PENDING + ): + if incoming.get("ack") == registry_entry.get("portal_change_id"): + # Device acknowledged the pending portal change (Req 5.3). + return SyncOutcome(ACTION_UPSERT, entry) + pending_content = registry_entry.get("pending_content") or {} + if _content_of(incoming) != _content_of(pending_content): + # Unacknowledged pending change and diverging edge content: + # Conflict (Req 6.1). Edge wins (Req 6.2); both versions are + # preserved in the event (Req 6.3). + return SyncOutcome( + ACTION_CONFLICT, + entry, + ConflictEvent( + camera_source_id=registry_entry.get("camera_source_id"), + edge_version=_content_of(incoming), + portal_version=pending_content or None, + resolution=RESOLUTION_EDGE_RETAINED, + created_at=now_ms, + ), + ) + # Edge content equals the pending portal content without an ack: + # the states converged, so the change is effectively applied. + return SyncOutcome(ACTION_UPSERT, entry) + + return SyncOutcome(ACTION_UPSERT, entry) + + +def stamp_meta( + meta_entry: Optional[Dict[str, Any]], now_ms: int +) -> Dict[str, Any]: + """Stamp the device META item for a processed report (Reqs 1.6, 3.2). + + Sets last_report_at and clears never_synced; idempotent. + """ + meta = dict(meta_entry) if meta_entry else {} + meta["last_report_at"] = now_ms + meta["never_synced"] = False + return meta + + +# --------------------------------------------------------------------------- +# SQS ingest handler (task 5.4) +# +# The per-use-case IoT topic rule +# SELECT *, topic(3) AS thing_name +# FROM '$aws/things/+/shadow/name/dda-camera-registry/update/documents' +# forwards every shadow documents event to the shadow-report queue. Each SQS +# record body is the shadow documents payload plus the rule-added thing_name. +# +# Batch semantics (partial batch responses, ReportBatchItemFailures): +# - transient persistence errors -> the record's messageId is returned in +# batchItemFailures so only that record is retried +# - malformed / unparseable reports -> logged and dead-lettered via an +# explicit SendMessage to the DLQ, NOT reported as batch failures, so a +# bad report never blocks or poisons the batch +# --------------------------------------------------------------------------- + +# Sort-key conventions of the dda-portal-camera-registry table (design +# "Data Models"): PK device_id, SK item-type-prefixed. +SK_CAMERA_PREFIX = "CAMERA#" +SK_META = "META" +SK_CONFLICT_PREFIX = "CONFLICT#" + + +class MalformedReport(Exception): + """A shadow-report record that can never be processed (dead-letter it).""" + + +def _dynamodb(): + """DynamoDB resource, created lazily so test mocks are honored.""" + import boto3 + + return boto3.resource("dynamodb") + + +def _registry_table(): + table_name = os.environ.get("CAMERA_REGISTRY_TABLE") + if not table_name: + raise RuntimeError("CAMERA_REGISTRY_TABLE not configured") + return _dynamodb().Table(table_name) + + +def _resolve_usecase_id(thing_name: str) -> Optional[str]: + """The device's usecase_id from the portal devices table (Req 1.4).""" + devices_table = os.environ.get("DEVICES_TABLE") + if not devices_table: + return None + response = _dynamodb().Table(devices_table).get_item( + Key={"device_id": thing_name} + ) + usecase_id = (response.get("Item") or {}).get("usecase_id") + return str(usecase_id) if usecase_id else None + + +def _parse_record(record: Dict[str, Any]) -> Tuple[str, Optional[Dict[str, Any]]]: + """Extract (thing_name, reported document | None) from one SQS record. + + Returns None for the report when the shadow document carries no + reported state (e.g. a desired-only update on a never-reported shadow) + — nothing to ingest, not an error. + + Raises MalformedReport for anything that can never be processed. + """ + try: + # parse_float=Decimal: camera params may carry non-integral numbers + # and DynamoDB rejects Python floats. + body = json.loads(record.get("body") or "", parse_float=Decimal) + except (json.JSONDecodeError, ValueError) as exc: + raise MalformedReport(f"unparseable message body: {exc}") from exc + if not isinstance(body, dict): + raise MalformedReport("message body is not a JSON object") + + thing_name = body.get("thing_name") + if not thing_name or not isinstance(thing_name, str): + raise MalformedReport("missing thing_name (topic rule SELECT)") + + state = ((body.get("current") or {}).get("state")) or {} + if not isinstance(state, dict): + raise MalformedReport("current.state is not an object") + reported = state.get("reported") + if reported is None: + return thing_name, None + if not isinstance(reported, dict): + raise MalformedReport("current.state.reported is not an object") + + cameras = reported.get("cameras", {}) + if not isinstance(cameras, dict) or any( + not isinstance(value, dict) for value in cameras.values() + ): + raise MalformedReport("reported.cameras is not a map of objects") + failures = reported.get("failures", {}) + if not isinstance(failures, dict) or any( + not isinstance(value, dict) for value in failures.values() + ): + raise MalformedReport("reported.failures is not a map of objects") + + return thing_name, reported + + +def _load_registry_state( + table, device_id: str +) -> Tuple[Dict[str, Dict[str, Any]], Optional[Dict[str, Any]]]: + """Current camera entries (keyed by camera_source_id) and META item.""" + from boto3.dynamodb.conditions import Key + + entries: Dict[str, Dict[str, Any]] = {} + meta: Optional[Dict[str, Any]] = None + kwargs: Dict[str, Any] = { + "KeyConditionExpression": Key("device_id").eq(device_id) + } + while True: + response = table.query(**kwargs) + for item in response.get("Items", []): + sk = item.get("sk", "") + if sk == SK_META: + meta = item + elif sk.startswith(SK_CAMERA_PREFIX): + entries[sk[len(SK_CAMERA_PREFIX):]] = item + last_key = response.get("LastEvaluatedKey") + if not last_key: + break + kwargs["ExclusiveStartKey"] = last_key + return entries, meta + + +def _strip_empty(value: Any) -> Any: + """Drop None values (DynamoDB stores them as NULL noise) recursively.""" + if isinstance(value, dict): + return {k: _strip_empty(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_strip_empty(v) for v in value] + return value + + +def _persist_outcome( + table, + device_id: str, + usecase_id: str, + csid: str, + outcome: SyncOutcome, +) -> None: + """Write one SyncOutcome: camera item upsert/delete + conflict event.""" + if outcome.conflict_event is not None: + conflict = outcome.conflict_event + table.put_item(Item=_strip_empty({ + "device_id": device_id, + "sk": f"{SK_CONFLICT_PREFIX}{conflict.created_at}#{uuid.uuid4()}", + "usecase_id": usecase_id, + "camera_source_id": csid, + "edge_version": conflict.edge_version, + "portal_version": conflict.portal_version, + "resolution": conflict.resolution, + "created_at": conflict.created_at, + })) + + if outcome.action == ACTION_DISCARD_STALE: + return # Req 3.5: the recorded (newer) entry stays untouched. + + camera_key = {"device_id": device_id, "sk": f"{SK_CAMERA_PREFIX}{csid}"} + if outcome.entry is None: + table.delete_item(Key=camera_key) + return + item = dict(outcome.entry) + item.update(camera_key) + item["camera_source_id"] = csid + item["usecase_id"] = usecase_id # Req 1.4: scoped to the device's use case + table.put_item(Item=_strip_empty(item)) + + +def _deletion_candidates( + entries: Dict[str, Dict[str, Any]], reported: Dict[str, Any] +) -> list: + """Registry sources missing from the full report -> reported deletions. + + Entries whose pending portal change is a `create` are excluded: the + device never had the source, so its absence from a full report is + expected delivery lag, not an edge deletion. + """ + cameras = reported.get("cameras", {}) + failures = reported.get("failures", {}) + candidates = [] + for csid, entry in entries.items(): + if csid in cameras or csid in failures: + continue + pending_content = entry.get("pending_content") or {} + if ( + entry.get("sync_status") == SYNC_STATUS_PENDING + and pending_content.get("op") == "create" + ): + continue + candidates.append(csid) + return candidates + + +def _process_report( + thing_name: str, + reported: Dict[str, Any], + usecase_id: Optional[str] = None, +) -> None: + """Reduce one shadow report into the registry (Reqs 1.4, 3.2). + + `usecase_id` may be supplied by callers that already resolved and + authorized the device's Use_Case (the camera_registry.py refresh + route, which runs this same reduction over an on-demand + GetThingShadow pull); the SQS ingest path resolves it from the + devices table. + """ + if usecase_id is None: + usecase_id = _resolve_usecase_id(thing_name) + if not usecase_id: + raise MalformedReport( + f"device '{thing_name}' has no usecase_id in the devices table" + ) + + table = _registry_table() + entries, meta = _load_registry_state(table, thing_name) + + reported_at = reported.get("reportedAt") + now_ms = int(reported_at) if reported_at is not None else int(time.time() * 1000) + + # Camera state entries from the report. + for csid, incoming in reported.get("cameras", {}).items(): + outcome = reduce_report(entries.get(csid), incoming, now_ms) + _persist_outcome(table, thing_name, usecase_id, csid, outcome) + + # Failure entries: portal-originated changes the device rejected (5.4). + for csid, failure in reported.get("failures", {}).items(): + incoming = {"status": "failed", **failure} + outcome = reduce_report(entries.get(csid), incoming, now_ms) + _persist_outcome(table, thing_name, usecase_id, csid, outcome) + + # Sources missing from the full report: reported deletions. + for csid in _deletion_candidates(entries, reported): + outcome = reduce_report(entries.get(csid), None, now_ms) + _persist_outcome(table, thing_name, usecase_id, csid, outcome) + + # Every processed report stamps the device META item (Reqs 1.6, 3.2). + meta_item = stamp_meta(meta, now_ms) + meta_item["device_id"] = thing_name + meta_item["sk"] = SK_META + meta_item["usecase_id"] = usecase_id + table.put_item(Item=_strip_empty(meta_item)) + + +def _dead_letter(record: Dict[str, Any], reason: str) -> bool: + """Send a malformed record to the DLQ; True when the send succeeded.""" + import boto3 + + dlq_url = os.environ.get("CAMERA_SHADOW_REPORT_DLQ_URL") + if not dlq_url: + logger.error("CAMERA_SHADOW_REPORT_DLQ_URL not configured; " + "cannot dead-letter malformed report") + return False + try: + boto3.client("sqs").send_message( + QueueUrl=dlq_url, + MessageBody=record.get("body") or "", + MessageAttributes={ + "deadLetterReason": { + "DataType": "String", + # SQS message attributes cap at 256 chars comfortably + "StringValue": reason[:256] or "unknown", + }, + }, + ) + return True + except Exception: # noqa: BLE001 - DLQ send is best-effort + logger.exception("Failed to dead-letter malformed shadow report") + return False + + +def handler(event, context): + """SQS ingest: reduce shadow documents events into the Camera_Registry. + + Returns a partial batch response ({"batchItemFailures": [...]}) so that + transient persistence errors retry only the affected record. Malformed + or unparseable reports are logged and dead-lettered explicitly and are + NOT reported as batch failures (they would never succeed on retry). + """ + batch_item_failures = [] + for record in (event or {}).get("Records", []): + message_id = record.get("messageId") + try: + thing_name, reported = _parse_record(record) + if reported is None: + logger.info( + "Shadow documents event for '%s' carries no reported " + "state; nothing to ingest", thing_name) + continue + _process_report(thing_name, reported) + except MalformedReport as exc: + logger.error( + "Malformed camera shadow report (message %s): %s", + message_id, exc) + if not _dead_letter(record, str(exc)) and message_id: + # Could not preserve the message; let SQS retry/redrive it. + batch_item_failures.append({"itemIdentifier": message_id}) + except Exception: # noqa: BLE001 - transient failure: retry the record + logger.exception( + "Transient failure processing camera shadow report " + "(message %s); reporting batch item failure", message_id) + if message_id: + batch_item_failures.append({"itemIdentifier": message_id}) + return {"batchItemFailures": batch_item_failures} diff --git a/edge-cv-portal/backend/functions/code_assist.py b/edge-cv-portal/backend/functions/code_assist.py new file mode 100644 index 00000000..0c837925 --- /dev/null +++ b/edge-cv-portal/backend/functions/code_assist.py @@ -0,0 +1,601 @@ +""" +Code_Assist_Generator API module (Custom Node Code Assist) + +Bedrock-backed code assistance for custom Python node modules +(custom-node-code-assist, Requirements 1.4, 2.1, 2.6, 2.8, 2.10, 6.1-6.4). + +Handles POST /code-assist, dispatched from workflow_generator.handler +(this module lives in the same Lambda bundle; the handler gains one +``resource == '/code-assist'`` branch). Stateless: no chat sessions, no +DynamoDB or S3 writes - each request carries the current editor code, and +authorization is evaluated fresh on every request (Requirement 6.4). + +Request body: + { + "usecase_id": "...", required + "surface": "...", required; workflow-builder | node-designer + "contract": "...", required; process_frame | + process_frame_or_handle | frame_hook + "prompt": "...", required; 1..4000 chars, at least one + non-whitespace character + "current_code": "...", optional string; embedded in the + modify-this-module block iff it contains + a non-whitespace character (2.6, 2.10) + "context": { optional object (frame_hook prompts) + "node_type": "...", + "parameters": [{"name", "param_type", "description"?}] + } + } + +Error envelope: {"error": {"code", "message", "details"}} - identical shape +to every Workflow Manager endpoint. RBAC denial returns the uniform 403 +FORBIDDEN envelope and writes an ``unauthorized_access`` audit entry before +any Bedrock client is constructed (Requirement 6.3). +""" +import ast +import json +import logging +from typing import Any, Dict, List, Optional, Tuple + +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + ReadTimeoutError, +) + +# Shared Bedrock_Configuration resolution and client construction - same +# Lambda bundle (backend/functions is one code asset), same semantics as +# workflow generation (Requirement 4). +from bedrock_common import ( + build_inference_config, + get_bedrock_client, + get_bedrock_configuration, +) + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, log_audit_event, + get_usecase, rbac_manager, Permission +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Server-side twin of the frontend prompt constraint (Requirements 1.4, 2.8). +MAX_PROMPT_CHARS = 4000 + +VALID_SURFACES = frozenset({'workflow-builder', 'node-designer'}) + +TOOL_NAME = 'provide_code' + + +# -------------------------------------------------------------------------- +# Node_Contract table and runtime environment descriptions (Requirement 2.1) +# -------------------------------------------------------------------------- + +# The Python_Bridge custom node runner (src/backend/workflow_engine/ +# python_bridge.py) executes the Workflow_Builder contracts; the +# environment description mirrors it faithfully. +PYTHON_BRIDGE_ENVIRONMENT = ( + 'RUNTIME ENVIRONMENT (Python_Bridge custom node runner):\n' + '- process_frame(frame, metadata): `frame` is a NumPy uint8 array ' + '(H x W x C, or H x W for GRAY8; frame formats are RGB, BGR, RGBA, or ' + 'GRAY8). Return None to pass the frame through unchanged, or an array ' + 'of IDENTICAL shape and dtype (the runtime rejects anything else). ' + 'Attach analysis results by mutating `metadata` in place.\n' + '- handle(frame_bytes, metadata): receives the raw frame bytes; must ' + 'return the tuple (frame_bytes, metadata).\n' + '- cv2, np, and numpy are pre-bound on the handler module - no import ' + 'is needed, but an explicit import is harmless.\n' + '- `import dda_frames` provides to_array(frame_bytes, width, height, ' + "format), to_bytes(array), frame_info() -> {'width', 'height', " + "'format'}, and load_image(path_or_s3_uri) returning a BGR uint8 array " + '(local path or s3:// URI).\n' + '- metadata["frame"] carries {width, height, format} on every ' + 'invocation.\n' + '- Never write to stdout - it belongs to the framed frame protocol; ' + 'use sys.stderr for diagnostics.\n' + '- Extra pip packages may be imported freely; the portal derives the ' + "node's pip requirements from the module's import statements, so emit " + 'a normal import statement for any library the user asks for.' +) + +# The Node_Designer Frame_Processing_Hook (workflow_core/scaffold.py: +# plugin/frame_processing_hook.py) runs in the GStreamer element's +# embedded interpreter with the declared element parameters in `params`. +FRAME_HOOK_ENVIRONMENT = ( + 'RUNTIME ENVIRONMENT (Frame_Processing_Hook, embedded interpreter):\n' + '- process_frame(frame, params): `frame` is the video frame to process ' + 'and `params` is a dict carrying the element\'s declared GObject ' + 'parameters (parameter name -> current value). Return the processed ' + 'frame.\n' + '- The module runs inside the GStreamer element\'s embedded Python ' + 'interpreter; there is no `metadata` argument and no dda_frames helper ' + 'module on this surface.' +) + +# Node_Contract table (design "Prompt assembly"): entry-point rule, +# human-readable signature, and per-contract environment description. +# `entry_points`/`require_exactly_one` drive validate_entry_point (task 2.2). +CONTRACTS: Dict[str, Dict[str, Any]] = { + 'process_frame': { + 'entry_points': frozenset({'process_frame'}), + 'require_exactly_one': False, + 'signature': 'process_frame(frame, metadata)', + 'environment': PYTHON_BRIDGE_ENVIRONMENT, + }, + 'process_frame_or_handle': { + 'entry_points': frozenset({'process_frame', 'handle'}), + 'require_exactly_one': True, + 'signature': 'process_frame(frame, metadata) or ' + 'handle(frame_bytes, metadata)', + 'environment': PYTHON_BRIDGE_ENVIRONMENT, + }, + 'frame_hook': { + 'entry_points': frozenset({'process_frame'}), + 'require_exactly_one': False, + 'signature': 'process_frame(frame, params)', + 'environment': FRAME_HOOK_ENVIRONMENT, + }, +} + + +# -------------------------------------------------------------------------- +# Envelope helpers (same shape as workflow_generator / node_generator) +# -------------------------------------------------------------------------- + +def error_response(status_code: int, code: str, message: str, + details: Optional[Dict] = None) -> Dict: + """Build the error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +# -------------------------------------------------------------------------- +# Request validation (design "400 matrix"; Requirements 1.4, 2.8) +# -------------------------------------------------------------------------- + +def validate_request(body: Dict) -> Optional[Dict]: + """Validate a POST /code-assist body; None when valid, else the 400 + error_response per the design's 400 matrix.""" + missing = [f for f in ('usecase_id', 'surface', 'contract', 'prompt') + if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + + if body['surface'] not in VALID_SURFACES: + return error_response( + 400, 'INVALID_SURFACE', + f"surface must be one of: {', '.join(sorted(VALID_SURFACES))}", + {'surface': body['surface']}) + + if body['contract'] not in CONTRACTS: + return error_response( + 400, 'INVALID_CONTRACT', + f"contract must be one of: {', '.join(sorted(CONTRACTS))}", + {'contract': body['contract']}) + + prompt = body['prompt'] + if not isinstance(prompt, str) or not prompt.strip(): + return error_response( + 400, 'INVALID_PROMPT', + 'prompt must be a string with at least one non-whitespace character') + if len(prompt) > MAX_PROMPT_CHARS: + return error_response( + 400, 'INVALID_PROMPT', + f'prompt must be at most {MAX_PROMPT_CHARS} characters', + {'length': len(prompt), 'max_length': MAX_PROMPT_CHARS}) + + current_code = body.get('current_code') + if current_code is not None and not isinstance(current_code, str): + return error_response(400, 'INVALID_JSON', + 'current_code must be a string when present') + + context = body.get('context') + if context is not None and not isinstance(context, dict): + return error_response(400, 'INVALID_JSON', + 'context must be an object when present') + + return None + + +# -------------------------------------------------------------------------- +# Authorization (Requirements 6.1-6.4) +# -------------------------------------------------------------------------- + +def surface_permissions(surface: str) -> List[Permission]: + """The permissions that authorize Code_Assistant use on a surface.""" + if surface == 'workflow-builder': + return [Permission.WORKFLOW_CREATE, Permission.WORKFLOW_EDIT] + return [Permission.NODE_DESIGNER_GENERATE] + + +def is_authorized(user: Dict, usecase_id: str, surface: str) -> bool: + """Per-surface authorization, evaluated fresh on every request (6.4): + the workflow create or edit permission for the Workflow_Builder surface + (6.1), or the Node_Designer generate permission - UseCaseAdmin within + the Use_Case or PortalAdmin, the same rule as node_generator. + can_generate - for the Node_Designer surface (6.2).""" + return any( + rbac_manager.has_permission(user['user_id'], usecase_id, + permission, user_info=user) + for permission in surface_permissions(surface) + ) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, + surface: str) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry + carrying the acting user, surface, Use_Case, and timestamp (6.3); + written before any Bedrock client is constructed.""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='code_assist', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in surface_permissions(surface)], + 'surface': surface, + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'surface': surface, + 'usecase_id': usecase_id + }) + + +# -------------------------------------------------------------------------- +# Prompt assembly - pure functions (Requirements 2.1, 2.6, 2.10) +# -------------------------------------------------------------------------- + +def build_system_prompt(contract: str, context: Optional[Dict] = None) -> str: + """System prompt carrying the contract's entry-point signature, its + runtime environment description, and the generation rules. For + frame_hook, the declared element parameters from ``context.parameters`` + are embedded so the model addresses `params` correctly.""" + spec = CONTRACTS[contract] + + parts = [ + 'You are the custom node code assistant of the DDA edge computer ' + 'vision portal. Users describe the Python processing code or filter ' + 'they need in natural language; you write the complete Python node ' + 'module that implements it.', + '', + f"TARGET ENTRY POINT: {spec['signature']}", + '', + spec['environment'], + ] + + if contract == 'frame_hook': + parameters = (context or {}).get('parameters') or [] + param_lines = [] + for param in parameters: + if not isinstance(param, dict) or not param.get('name'): + continue + line = f"- {param['name']} ({param.get('param_type', 'unknown')})" + if param.get('description'): + line += f": {param['description']}" + param_lines.append(line) + if param_lines: + parts += ['', + 'DECLARED ELEMENT PARAMETERS (available in `params`):'] + parts += param_lines + + rules = [ + '', + 'Rules:', + f'- Always respond by calling the {TOOL_NAME} tool with the COMPLETE ' + 'Python module source in `code` and one short paragraph for the ' + 'user in `notes`. Do not answer with prose only.', + ] + if spec['require_exactly_one']: + rules.append( + '- Define EXACTLY ONE of the entry points process_frame(frame, ' + 'metadata) or handle(frame_bytes, metadata) - never both ' + '(process_frame for decoded frame processing, handle for raw ' + 'bytes).') + else: + rules.append( + f"- The module must define the entry point {spec['signature']} " + 'at the top level.') + rules.append( + '- Emit a normal `import` statement for every non-builtin library ' + 'the code uses, including any library the user explicitly asks for.') + rules.append( + '- Keep the module complete and self-contained: when a CURRENT ' + 'MODULE CODE block is provided, apply the requested change to that ' + 'code and return the ENTIRE modified module - never a fragment, a ' + 'diff, or code unrelated to the current module.') + + return '\n'.join(parts + rules) + + +def build_user_message(prompt: str, current_code: Optional[str]) -> str: + """The user turn sent to the model: the prompt verbatim, plus the + current editor code in a modify-not-regenerate block if and only if it + contains at least one non-whitespace character (Requirements 2.6, + 2.10). A whitespace-only editor is treated as empty - the prompt is + sent alone and nothing is presented as code to modify.""" + if not current_code or not current_code.strip(): + return prompt + return ( + f'{prompt}\n' + '\n' + 'CURRENT MODULE CODE:\n' + f'{current_code}\n' + '\n' + 'Apply the requested change to this current module rather than ' + 'generating unrelated code from scratch, and return the complete ' + f'modified module via the {TOOL_NAME} tool.' + ) + + +# -------------------------------------------------------------------------- +# Entry-point validation - pure function (Requirements 2.2, 2.3, 5.6) +# -------------------------------------------------------------------------- + +# Defect prefix distinguishing a parse failure (422 GENERATED_CODE_INVALID) +# from an entry-point defect (422 MISSING_ENTRY_POINT). +INVALID_PYTHON_PREFIX = 'generated code is not valid Python' + + +def validate_entry_point(code: str, contract: str) -> Optional[str]: + """None when the generated module is valid for the contract; a defect + description otherwise. + + - ``ast.parse`` failure -> 'generated code is not valid Python: ...' + - The top-level FunctionDef names are intersected with the contract's + entry points; zero matches -> 'missing entry point ...' + - ``require_exactly_one`` contracts (custom_python) must define exactly + one of process_frame/handle -> 'defines both entry points ...' when + both are present (two entry points would silently shadow one another + at runtime: the Python_Bridge prefers process_frame). + """ + spec = CONTRACTS[contract] + try: + module = ast.parse(code) + except (SyntaxError, ValueError) as e: + return f'{INVALID_PYTHON_PREFIX}: {e}' + + top_level = {node.name for node in module.body + if isinstance(node, ast.FunctionDef)} + defined = top_level & spec['entry_points'] + + if not defined: + return (f"missing entry point: the module must define " + f"{spec['signature']} at the top level") + if spec['require_exactly_one'] and len(defined) > 1: + return ('defines both entry points process_frame and handle; ' + 'define exactly one of them') + return None + + +# -------------------------------------------------------------------------- +# Bedrock failure categorization (Requirement 5.1) +# -------------------------------------------------------------------------- + +# botocore error code -> Requirement 5.1 failure category. Total: every +# unlisted code falls through to 'model-error' (design mapping table). +BEDROCK_ERROR_CATEGORIES: Dict[str, str] = { + 'ThrottlingException': 'throttling', + 'TooManyRequestsException': 'throttling', + 'ServiceQuotaExceededException': 'throttling', + 'AccessDeniedException': 'authorization', + 'UnrecognizedClientException': 'authorization', + 'ExpiredTokenException': 'authorization', + 'ResourceNotFoundException': 'model-access', + 'ModelNotReadyException': 'model-access', + 'ValidationException': 'model-access', + 'ModelErrorException': 'model-error', + 'ModelTimeoutException': 'model-error', + 'ServiceUnavailableException': 'model-error', + 'InternalServerException': 'model-error', +} + + +def categorize_bedrock_error(error_code: Any) -> str: + """Map a botocore error code to exactly one of the four Requirement + 5.1 failure categories; anything unrecognized is 'model-error'.""" + return BEDROCK_ERROR_CATEGORIES.get(error_code, 'model-error') + + +# -------------------------------------------------------------------------- +# Bedrock invocation (Requirements 2.1-2.3, 5.1-5.3, 5.6) +# -------------------------------------------------------------------------- + +def build_tool_config() -> Dict: + """Converse toolConfig forcing structured output through provide_code: + extraction is a field read, never markdown-fence scraping. 'No tool + call or empty code' is the well-defined NO_CODE_RETURNED trigger.""" + return { + 'tools': [{ + 'toolSpec': { + 'name': TOOL_NAME, + 'description': ( + 'Return the complete Python node module that fulfils ' + 'the user request. Always call this tool with the ' + 'entire module source in `code` and one short ' + 'paragraph for the user in `notes`.' + ), + 'inputSchema': {'json': { + 'type': 'object', + 'required': ['code'], + 'properties': { + 'code': { + 'type': 'string', + 'description': 'the complete Python module' + }, + 'notes': { + 'type': 'string', + 'description': 'one short paragraph for the user' + } + } + }} + } + }], + 'toolChoice': {'tool': {'name': TOOL_NAME}} + } + + +# -------------------------------------------------------------------------- +# Endpoint +# -------------------------------------------------------------------------- + +def generate_code(contract: str, system_prompt: str, user_message: str) -> Dict: + """Bedrock Converse invocation with the forced provide_code tool, + entry-point validation, and error mapping. + + Nothing before this point constructs a Bedrock client, so every + 400/403/404 settles without Bedrock traffic (Requirement 6.3). The + Bedrock_Configuration is resolved fresh per invocation through the + shared module (Requirement 4.1); the client-side read timeout equals + the clamped configured timeout and retries are disabled, so wall time + cannot exceed it (Requirement 4.4). Success returns + {code, notes, model_id, contract} and persists nothing (2.7, 6.4). + """ + config = get_bedrock_configuration() + client = get_bedrock_client(config['region'], config['timeout_seconds']) + try: + response = client.converse( + modelId=config['model_id'], + system=[{'text': system_prompt}], + messages=[{'role': 'user', 'content': [{'text': user_message}]}], + inferenceConfig=build_inference_config(config), + toolConfig=build_tool_config() + ) + except (ReadTimeoutError, ConnectTimeoutError): + logger.error(f"Code assist invocation exceeded the configured timeout " + f"({config['timeout_seconds']}s, model {config['model_id']})") + return error_response( + 504, 'GENERATION_TIMEOUT', + f"Code generation timed out after {config['timeout_seconds']} seconds. " + 'Your prompt was not lost - please retry.', + {'timeout_seconds': config['timeout_seconds'], + 'model_id': config['model_id']} + ) + except EndpointConnectionError as e: + logger.error(f"Bedrock endpoint unreachable: {str(e)}") + return error_response( + 502, 'BEDROCK_UNREACHABLE', + f"The Bedrock endpoint in region {config['region']} could not be " + 'reached. Check the Bedrock configuration.', + {'region': config['region'], 'category': 'model-access'} + ) + except ClientError as e: + error = e.response.get('Error', {}) + logger.error(f"Bedrock invocation failed: {error.get('Code')}: " + f"{error.get('Message')}") + return error_response( + 502, 'BEDROCK_INVOCATION_FAILED', + f"The Bedrock model invocation failed: " + f"{error.get('Message', 'unknown error')}", + {'category': categorize_bedrock_error(error.get('Code')), + 'bedrock_error_code': error.get('Code'), + 'model_id': config['model_id']} + ) + + content = (response.get('output', {}).get('message', {}) or {}).get('content', []) + tool_input = None + for block in content: + if 'toolUse' in block and block['toolUse'].get('name') == TOOL_NAME: + tool_input = block['toolUse'].get('input') + + code = tool_input.get('code') if isinstance(tool_input, dict) else None + if not isinstance(code, str) or not code.strip(): + logger.error(f"Model returned no {TOOL_NAME} tool call or empty code " + f"(stopReason={response.get('stopReason')})") + return error_response( + 422, 'NO_CODE_RETURNED', + 'The model did not return code. Please retry or rephrase the prompt.', + {'stop_reason': response.get('stopReason')} + ) + + defect = validate_entry_point(code, contract) + if defect is not None: + logger.error(f"Generated code rejected ({contract}): {defect}") + if defect.startswith(INVALID_PYTHON_PREFIX): + return error_response( + 422, 'GENERATED_CODE_INVALID', + 'The generated code is not valid Python. Please retry or ' + 'rephrase the prompt.', + {'defect': defect} + ) + return error_response( + 422, 'MISSING_ENTRY_POINT', + 'The generated code lacks the required entry point ' + f"({CONTRACTS[contract]['signature']}). Please retry or " + 'rephrase the prompt.', + {'defect': defect, 'contract': contract} + ) + + notes = tool_input.get('notes') + return create_response(200, { + 'code': code, + 'notes': notes if isinstance(notes, str) else '', + 'model_id': config['model_id'], + 'contract': contract, + }) + + +def handle_code_assist(event: Dict, user: Dict) -> Dict: + """ + POST /code-assist + Body: {usecase_id, surface, contract, prompt, current_code?, context?} + + Validates the request (400 matrix), authorizes per surface with an + audit entry on denial (403 before any Bedrock traffic), resolves the + Use_Case (404), assembles the contract-specific prompts, and delegates + to the Bedrock invocation. Nothing is persisted anywhere (2.7, 6.4). + """ + body, err = parse_body(event) + if err: + return err + + err = validate_request(body) + if err: + return err + + usecase_id = body['usecase_id'] + surface = body['surface'] + + # Authorization first (fresh per request, 6.4); denial is audited and + # settled before any Bedrock client exists (6.3). + if not is_authorized(user, usecase_id, surface): + return forbidden_response(user, event, usecase_id, surface) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + contract = body['contract'] + return generate_code( + contract=contract, + system_prompt=build_system_prompt(contract, body.get('context')), + user_message=build_user_message(body['prompt'], body.get('current_code')), + ) diff --git a/edge-cv-portal/backend/functions/components.py b/edge-cv-portal/backend/functions/components.py index 5f52d736..ee9199f9 100644 --- a/edge-cv-portal/backend/functions/components.py +++ b/edge-cv-portal/backend/functions/components.py @@ -86,6 +86,101 @@ def _resolve_latest_component_version(greengrass, base_arn): return None return max(versions, key=_version_key) if versions else None + +# --------------------------------------------------------------------------- +# Plugin_Component listing (custom-node-designer, Requirement 16.2) +# +# Node Designer plugins are auto-packaged as Greengrass components named +# `dda.plugin.{pluginId}` and tagged with `dda-portal:plugin-id` / +# `dda-portal:plugin-version` (see plugin_components.py registry_tags in the +# node-designer Lambda bundle). The deployment screen listing joins them with +# their backing Plugin_Record (PLUGIN_RECORDS_TABLE) to show the record's +# Lifecycle_State, and derives the supported Target_Architectures from the +# recipe's platform manifests. + +# Keep in sync with plugin_components.py PLUGIN_COMPONENT_PREFIX. +PLUGIN_COMPONENT_PREFIX = 'dda.plugin.' + +TAG_PLUGIN_ID = 'dda-portal:plugin-id' +TAG_PLUGIN_VERSION = 'dda-portal:plugin-version' + + +def is_plugin_component(component_name: str) -> bool: + """True when a component is a Node Designer Plugin_Component (16.2)""" + return str(component_name).startswith(PLUGIN_COMPONENT_PREFIX) + + +def plugin_version_from_component_version(component_version: Any) -> Optional[int]: + """ + The backing Plugin_Record version of a Plugin_Component version. + + Plugin_Component versions are '{pluginVersion}.0.0' (the inverse of + plugin_components.component_version_for), so the Plugin_Record version is + the major part. Returns None when the version doesn't parse. + """ + try: + return int(str(component_version).split('.')[0]) + except (ValueError, AttributeError): + return None + + +def target_architectures_from_platforms(platforms) -> List[str]: + """ + Derive the DDA Target_Architectures a Plugin_Component supports from its + recipe's platform manifests (16.2). This is the inverse of + plugin_components.platform_for: + + - architecture aarch64 -> the JetPack arch named by the 'variant' + attribute (arm64_jp4 / arm64_jp5 / arm64_jp6) + - architecture amd64 + 'runtime: nvidia' -> x86_64_nvidia + - architecture amd64 (no runtime) -> x86_64 + + Accepts either recipe Manifest 'Platform' blocks (flat dicts) or the + describe_component API shape ({'name': ..., 'attributes': {...}}). + Pure over its input so it is fixture-testable without AWS (task 10.9). + """ + architectures: List[str] = [] + for platform in platforms or []: + if not isinstance(platform, dict): + continue + attributes = platform.get('attributes', platform) + if not isinstance(attributes, dict): + continue + gg_arch = attributes.get('architecture') + if gg_arch == 'aarch64': + derived = attributes.get('variant') + elif gg_arch == 'amd64': + derived = 'x86_64_nvidia' if attributes.get('runtime') == 'nvidia' else 'x86_64' + else: + derived = None + if derived and derived not in architectures: + architectures.append(derived) + return architectures + + +def get_plugin_record_lifecycle_state(plugin_id: Optional[str], + plugin_version: Optional[Any]) -> Optional[str]: + """ + The backing Plugin_Record's Lifecycle_State for one Plugin_Component + version (16.2). Returns None when the record (or the PLUGIN_RECORDS_TABLE + configuration) is unavailable — the listing still shows the component, it + just cannot attribute a lifecycle state. + """ + table_name = os.environ.get('PLUGIN_RECORDS_TABLE') + if not table_name or not plugin_id or plugin_version is None: + return None + try: + table = boto3.resource('dynamodb').Table(table_name) + response = table.get_item( + Key={'plugin_id': plugin_id, 'version': int(plugin_version)}) + item = response.get('Item') + return item.get('lifecycle_state') if item else None + except (ClientError, ValueError, TypeError) as e: + print(f"Warning: could not read Plugin_Record {plugin_id} " + f"v{plugin_version}: {e}") + return None + + def lambda_handler(event, context): """ Handle Greengrass component management requests @@ -427,6 +522,31 @@ def list_private_components(credentials: Dict, region: str, query_params: Dict) 'device_count': 0 } } + + # Plugin_Component listing (custom-node-designer, 16.2): join + # dda.plugin.* components with their backing Plugin_Record via the + # registry tags and expose the record's Lifecycle_State plus the + # supported Target_Architectures derived from the recipe's + # platform manifests for the deployment screen. + if is_plugin_component(component_name): + plugin_id = comp_data['tags'].get(TAG_PLUGIN_ID) + # The listed version is the resolved latest component version; + # its major part IS the backing Plugin_Record version. The + # version tag is only a fallback (tags are merged across + # version-level entries, so it may name an older version). + plugin_version = plugin_version_from_component_version(final_version) + if plugin_version is None: + plugin_version = comp_data['tags'].get(TAG_PLUGIN_VERSION) + enriched_component.update({ + 'is_plugin_component': True, + 'plugin_id': plugin_id, + 'plugin_version': plugin_version, + 'lifecycle_state': get_plugin_record_lifecycle_state( + plugin_id, plugin_version), + 'supported_architectures': + target_architectures_from_platforms(platforms), + }) + components.append(enriched_component) print(f"Returning {len(components)} unique components (latest versions only)") diff --git a/edge-cv-portal/backend/functions/custom_node_types.py b/edge-cv-portal/backend/functions/custom_node_types.py new file mode 100644 index 00000000..e97c0474 --- /dev/null +++ b/edge-cv-portal/backend/functions/custom_node_types.py @@ -0,0 +1,1073 @@ +""" +Custom_Node_Type registration API Lambda function (Custom Node Designer) + +Registration, versioning, deprecation, and reference-checked removal of +Custom_Node_Types over the CustomNodeTypes DynamoDB table +(Requirements 8.1, 8.2, 8.5, 8.6, 14.1, 14.3, 14.4, 14.5). + +Routes (API Gateway REST, node-designer-api-stack.ts): + POST /custom-node-types Register a Custom_Node_Type for a + built Plugin_Record version: display + name, category, Ports with types, + parameters (types, defaults, + constraints, descriptions, examples), + hardware-dependence flag, element/ + property mapping per built + Target_Architecture, Use_Case + scoping (8.1, 8.2) + GET /custom-node-types/{id} Latest version + version history + PUT /custom-node-types/{id} Declaration update -> new version + item, prior versions retained (14.1) + DELETE /custom-node-types/{id} Reference-checked removal (14.4, 14.5) + POST /custom-node-types/{id}/deprecate Flip the deprecated flag (14.3) + +Storage layout (design "Data Models"): + CustomNodeTypes table (CUSTOM_NODE_TYPES_TABLE) + PK node_type_id (S, the declaration's typeId), SK version (N), + GSI usecase-node-types-index (usecase_id + node_type_id). + Attributes: usecase_id (owning Use_Case = the plugin's), usecase_ids + (Use_Case scoping selected at registration), plugin_id + + plugin_version (pinned backing Plugin_Record version), declaration + (NodeTypeDescriptor wire JSON), deprecated flag, created_by/at. + +Declarations are validated through workflow_core.catalog.custom +.descriptor_from_declaration; invalid Port declarations are rejected with +the offending field identified (8.5). The plugin dependency is recorded +as ``custom:{usecase_id}/{plugin_name}`` in every mapping so the +Workflow_Compiler includes the plugin in compiled dependency lists (8.6), +and every declared mapping must target a successfully built +Target_Architecture of the backing Plugin_Record version (8.1). + +Removal (14.4/14.5) scans WorkflowVersions for references to the +Custom_Node_Type. The scan first tries the inverted-index GSI that +workflow save maintains (task 9.2: one ``ref_node_type_id`` keyed entry +per referenced type); until that index exists the scan falls back to a +full WORKFLOW_VERSIONS_TABLE scan, checking the ``custom_node_types`` +reference attribute recorded at save when present and otherwise loading +the stored definition document from S3. Zero references deletes the +catalog items, the plugin's Plugin_Library artifacts +(workflow-plugins/custom/{usecase}/...), and the plugin's +Plugin_Component versions in the Use_Case account registry; otherwise +the removal is rejected listing the referencing workflows. + +Error envelope: {"error": {"code", "message", "details"}} with 400 +parse/validation failures identifying the offending field, 403 RBAC +denial, 404 scoped to avoid cross-tenant existence leaks, and 409 for +type-id conflicts and reference-blocked removal. + +Access control (Requirement 13): node-designer:read for every role in +the Use_Case; node-designer:register for registration and +node-designer:manage for update/deprecate/remove (UseCaseAdmin within +the own Use_Case, PortalAdmin), following plugin_records.py conventions. + +The versioning and reference-counting decision logic (new_node_type_item, +next_node_type_version, inject_plugin_dependency, +unbuilt_mapping_architectures, definition_references_node_type, +item_references_node_type, evaluate_removal) is pure over plain dicts so +tasks 9.3-9.5 can property-test it without AWS. +""" +import json +import os +import logging +from decimal import Decimal +from typing import Any, Callable, Dict, List, Optional, Tuple + +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, get_usecase_client, Permission +) + +from workflow_core.catalog.custom import DeclarationError, descriptor_from_declaration +from workflow_core.catalog.nodes import NODE_CATALOG + +# Reuse the Plugin_Record persistence helpers, error envelope, and RBAC +# helpers from plugin_records.py, the Plugin_Library key conventions from +# plugin_builds.py, the Plugin_Component naming/prefixes from +# plugin_components.py, and the account-bucket cleanup helper from +# workflow_packaging.py (same deployment bundle). +from plugin_records import ( + decimal_to_native, + error_response, + has_node_designer_permission, + now_ms, + parse_body, + get_version_item as get_plugin_version_item, + get_latest_version_item as get_latest_plugin_version_item, + query_versions as query_plugin_versions, + successful_build_archs, +) +from plugin_builds import sanitize_plugin_name, signature_key +from plugin_components import COMPONENT_S3_PREFIX, component_name_for +from workflow_packaging import delete_prefix + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients (portal account) +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables +CUSTOM_NODE_TYPES_TABLE = os.environ.get('CUSTOM_NODE_TYPES_TABLE') +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') + +# Optional inverted-index GSI over WorkflowVersions. Task 9.2 decided +# AGAINST creating it: a DynamoDB GSI indexes one scalar attribute value +# per item, but a workflow version may reference several +# Custom_Node_Types, so a scalar ref_node_type_id cannot represent the +# reference set. Workflow save records the `custom_node_types` map +# attribute ({typeId: typeVersion}) instead, which the scan fallback +# below honors without loading definition documents. The index query is +# kept so a future design (e.g. one ref item per referenced type) can +# swap it in without touching callers. +NODE_TYPE_REFS_INDEX = os.environ.get( + 'WORKFLOW_NODE_TYPE_REFS_INDEX', 'node-type-refs-index') +NODE_TYPE_REF_ATTRIBUTE = 'ref_node_type_id' + +USECASE_NODE_TYPES_INDEX = 'usecase-node-types-index' + +#: Built-in Node_Type_Catalog type ids: a Custom_Node_Type may never +#: collide with a built-in type (resolve_catalog lets built-ins win, so a +#: colliding registration would silently never appear in the palette). +BUILTIN_TYPE_IDS = frozenset(descriptor.type_id for descriptor in NODE_CATALOG) + + +# ------------------------------------------------------------ pure helpers +# +# Everything from here to evaluate_removal is pure over plain dicts, so +# the versioning and reference-counting decision logic is +# property-testable without AWS (tasks 9.3-9.5). + +def plugin_dependency_for(usecase_id: str, plugin_name: str) -> str: + """The recorded plugin dependency of a Custom_Node_Type (8.6): the + ``custom:`` prefix routes the Component_Packager to the plugin's + Plugin_Component instead of the built-in Plugin_Library prefix.""" + return f"custom:{usecase_id}/{plugin_name}" + + +def inject_plugin_dependency(declaration: Dict, dependency: str) -> Dict: + """Return a copy of the wire declaration with the plugin dependency + recorded in every mapping's pluginDependencies (8.6). Idempotent: + mappings already listing the dependency are unchanged.""" + decl = json.loads(json.dumps(declaration)) + mappings = decl.get('mappings') + if isinstance(mappings, list): + for mapping in mappings: + if not isinstance(mapping, dict): + continue + dependencies = mapping.get('pluginDependencies') + if not isinstance(dependencies, list): + dependencies = [] + if dependency not in dependencies: + dependencies = dependencies + [dependency] + mapping['pluginDependencies'] = dependencies + return decl + + +def unbuilt_mapping_architectures(declaration: Dict, + built_archs: List[str]) -> List[Tuple[str, Any]]: + """Mappings declared for Target_Architectures without a successfully + built Plugin_Artifact (8.1: element/property mapping per *built* + Target_Architecture). Returns [(field, arch)] identifying each + offending mapping entry.""" + built = set(built_archs) + offending: List[Tuple[str, Any]] = [] + mappings = declaration.get('mappings') + if not isinstance(mappings, list): + return offending + for index, mapping in enumerate(mappings): + if not isinstance(mapping, dict): + continue + arch = mapping.get('arch') + if arch not in built: + offending.append((f"mappings[{index}].arch", arch)) + return offending + + +def next_node_type_version(latest_version: Optional[int]) -> int: + """Version numbering: strictly increasing, prior versions retained (14.1)""" + return 1 if latest_version is None else int(latest_version) + 1 + + +def new_node_type_item(node_type_id: str, version: int, usecase_id: str, + usecase_ids: List[str], plugin_id: str, + plugin_version: int, declaration: Dict, user_id: str, + timestamp: int, deprecated: bool = False) -> Dict: + """Build a CustomNodeTypes version item (design data model). The + backing Plugin_Record version is pinned so packaging of saved + workflows resolves the recorded version regardless of later updates + (14.2 groundwork).""" + return { + 'node_type_id': node_type_id, + 'version': int(version), + 'usecase_id': usecase_id, + 'usecase_ids': list(usecase_ids), + 'plugin_id': plugin_id, + 'plugin_version': int(plugin_version), + 'declaration': declaration, + 'deprecated': bool(deprecated), + 'created_by': user_id, + 'created_at': timestamp, + 'updated_at': timestamp, + } + + +def definition_references_node_type(definition: Any, node_type_id: str) -> bool: + """Whether a stored Workflow_Definition document places the + Custom_Node_Type on the canvas (a node whose type is the type id).""" + if not isinstance(definition, dict): + return False + nodes = definition.get('nodes') + if not isinstance(nodes, list): + return False + return any(isinstance(node, dict) and node.get('type') == node_type_id + for node in nodes) + + +def item_references_node_type(item: Dict, node_type_id: str, + definition_loader: Callable[[str], Any]) -> bool: + """ + Whether one WorkflowVersions item references the Custom_Node_Type. + + Prefers the ``custom_node_types`` reference attribute recorded at + workflow save (task 9.2 maintains it; a dict of {typeId: typeVersion} + or a list of type ids). Items saved before that wiring carry no + reference attribute, so the stored definition document is loaded and + inspected instead. A definition that cannot be loaded is treated as + non-referencing (logged by the loader). + """ + references = item.get('custom_node_types') + if isinstance(references, dict): + return node_type_id in references + if isinstance(references, (list, tuple, set)): + return node_type_id in list(references) + + s3_key = item.get('s3_definition_key') + if not s3_key: + return False + definition = definition_loader(s3_key) + return definition_references_node_type(definition, node_type_id) + + +def evaluate_removal(node_type_id: str, + references: List[Dict]) -> Optional[Dict]: + """ + Decide a removal request (14.4, 14.5): removal succeeds if and only + if no saved workflow references the Custom_Node_Type. Returns None + when removal is permitted, or {code, message, details} listing + exactly the referencing workflows. + """ + if not references: + return None + return { + 'code': 'CUSTOM_NODE_TYPE_IN_USE', + 'message': f"Custom node type '{node_type_id}' cannot be removed: " + f"{len(references)} saved workflow version(s) reference it", + 'details': { + 'node_type_id': node_type_id, + 'referencing_workflows': references, + }, + } + + +# ------------------------------------------------------------------ views + +def node_type_detail(item: Dict) -> Dict: + """Full Custom_Node_Type version view""" + item = decimal_to_native(item) + return { + 'node_type_id': item['node_type_id'], + 'version': item['version'], + 'usecase_id': item['usecase_id'], + 'usecase_ids': item.get('usecase_ids', []), + 'plugin_id': item.get('plugin_id'), + 'plugin_version': item.get('plugin_version'), + 'declaration': item.get('declaration', {}), + 'deprecated': item.get('deprecated', False), + 'created_by': item.get('created_by'), + 'created_at': item.get('created_at'), + 'updated_at': item.get('updated_at'), + } + + +def node_type_summary(item: Dict) -> Dict: + """Version-history summary of one Custom_Node_Type version""" + item = decimal_to_native(item) + declaration = item.get('declaration') or {} + return { + 'node_type_id': item['node_type_id'], + 'version': item['version'], + 'usecase_id': item['usecase_id'], + 'plugin_id': item.get('plugin_id'), + 'plugin_version': item.get('plugin_version'), + 'display_name': declaration.get('displayName'), + 'category': declaration.get('category'), + 'deprecated': item.get('deprecated', False), + 'updated_at': item.get('updated_at'), + } + + +# ------------------------------------------------------------- persistence + +def node_types_table(): + return dynamodb.Table(CUSTOM_NODE_TYPES_TABLE) + + +def to_dynamo_json(value: Any) -> Any: + """JSON-shaped value with floats as Decimal for DynamoDB storage""" + return json.loads(json.dumps(value), parse_float=Decimal) + + +def query_node_type_versions(node_type_id: str) -> List[Dict]: + """All version items of a Custom_Node_Type, newest first (14.1)""" + from boto3.dynamodb.conditions import Key + items: List[Dict] = [] + kwargs = { + 'KeyConditionExpression': Key('node_type_id').eq(node_type_id), + 'ScanIndexForward': False, + } + while True: + response = node_types_table().query(**kwargs) + items.extend(response.get('Items', [])) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + return [decimal_to_native(item) for item in items] + + +# --------------------------------------------------------- reference scan + +def load_workflow_definition(s3_key: str) -> Optional[Dict]: + """Load one stored Workflow_Definition document; None when unreadable""" + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + except (ClientError, ValueError) as e: + logger.warning(f"Could not load workflow definition {s3_key} during " + f"the reference scan: {str(e)}") + return None + + +def query_reference_index(node_type_id: str) -> List[Dict]: + """ + Query the inverted-index GSI over WorkflowVersions that workflow save + maintains (task 9.2). Raises ClientError when the index does not + exist yet; the caller falls back to the table scan. + """ + from boto3.dynamodb.conditions import Key + table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + references: List[Dict] = [] + kwargs = { + 'IndexName': NODE_TYPE_REFS_INDEX, + 'KeyConditionExpression': Key(NODE_TYPE_REF_ATTRIBUTE).eq(node_type_id), + } + while True: + response = table.query(**kwargs) + for item in response.get('Items', []): + item = decimal_to_native(item) + references.append({'workflow_id': item.get('workflow_id'), + 'version': item.get('version')}) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + return references + + +def scan_workflow_references(node_type_id: str) -> List[Dict]: + """Fallback reference scan over the whole WORKFLOW_VERSIONS_TABLE""" + table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + references: List[Dict] = [] + kwargs: Dict = {} + while True: + response = table.scan(**kwargs) + for item in response.get('Items', []): + item = decimal_to_native(item) + if item_references_node_type(item, node_type_id, + load_workflow_definition): + references.append({'workflow_id': item.get('workflow_id'), + 'version': item.get('version')}) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + return references + + +def find_workflow_references(node_type_id: str) -> List[Dict]: + """ + Saved workflow versions referencing a Custom_Node_Type (14.4/14.5), + as [{workflow_id, version}]. + + Tries the optional inverted-index GSI first; task 9.2 does not create + it (see NODE_TYPE_REFS_INDEX above), so the scan normally falls back + to a full WorkflowVersions scan. Versions saved after task 9.2 carry + the `custom_node_types` map recorded by workflows.py, so the fallback + decides membership from the item alone; only pre-9.2 items require + loading the stored definition document. + """ + try: + return query_reference_index(node_type_id) + except ClientError as e: + logger.info(f"Reference index '{NODE_TYPE_REFS_INDEX}' unavailable " + f"({e.response.get('Error', {}).get('Code', 'error')}); " + f"falling back to a WorkflowVersions scan") + return scan_workflow_references(node_type_id) + + +# --------------------------------------------------------- removal effects + +def delete_plugin_library_artifacts(plugin_id: str) -> List[str]: + """ + Delete every Plugin_Library artifact of the backing plugin (14.4): + each recorded per-arch .so under workflow-plugins/custom/{usecase}/... + plus its detached .sig, across all Plugin_Record versions. + """ + deleted: List[str] = [] + for record in query_plugin_versions(plugin_id): + artifacts = record.get('artifacts') or {} + for entry in artifacts.values(): + if not isinstance(entry, dict): + continue + so_key = entry.get('s3Key') + if not so_key: + continue + for key in (so_key, signature_key(so_key)): + try: + s3.delete_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key) + deleted.append(key) + except ClientError as e: + logger.warning(f"Could not delete Plugin_Library object " + f"{key}: {str(e)}") + return deleted + + +def delete_plugin_component_versions(usecase_id: str, plugin_id: str) -> None: + """ + Delete the plugin's Plugin_Component versions from the Use_Case + account Greengrass registry and its component artifacts from the + account bucket (14.4). Best-effort: registry/account failures are + logged and never abort the catalog removal (the catalog and + Plugin_Library deletions are the authoritative part of 14.4). + """ + try: + usecase = get_usecase(usecase_id) + except ValueError: + logger.warning(f"Use case '{usecase_id}' not found; skipping " + f"Plugin_Component cleanup for plugin {plugin_id}") + return + + session_name = f"node-type-rm-{plugin_id[:24]}-{now_ms() // 1000}"[:64] + component_name = component_name_for(plugin_id) + + try: + greengrass = get_usecase_client('greengrassv2', usecase, + session_name=session_name) + region = getattr(getattr(greengrass, 'meta', None), 'region_name', None) \ + or os.environ.get('AWS_REGION', 'us-east-1') + component_arn = (f"arn:aws:greengrass:{region}:" + f"{usecase.get('account_id')}:components:{component_name}") + kwargs = {'arn': component_arn} + while True: + response = greengrass.list_component_versions(**kwargs) + for version in response.get('componentVersions', []): + version_arn = version.get('arn') + if not version_arn: + continue + try: + greengrass.delete_component(arn=version_arn) + except ClientError as e: + logger.warning(f"Could not delete Plugin_Component " + f"version {version_arn}: {str(e)}") + token = response.get('nextToken') + if not token: + break + kwargs['nextToken'] = token + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code not in ('ResourceNotFoundException', '404'): + logger.warning(f"Could not enumerate Plugin_Component versions of " + f"{component_name}: {str(e)}") + + bucket = usecase.get('s3_bucket') + if bucket: + try: + usecase_s3 = get_usecase_client('s3', usecase, + session_name=session_name) + delete_prefix(usecase_s3, bucket, + f"{COMPONENT_S3_PREFIX}/{plugin_id}/") + except ClientError as e: + logger.warning(f"Could not clean up account component artifacts " + f"of plugin {plugin_id}: {str(e)}") + + +# ------------------------------------------------------------ authorization + +def not_found_response() -> Dict: + """Uniform 404 that never confirms whether a Custom_Node_Type exists""" + return error_response(404, 'NODE_TYPE_NOT_FOUND', 'Custom node type not found') + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, + required: Permission) -> Dict: + """Standard authorization error envelope with a denied-access audit + entry (13.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='custom_node_type', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [required.value], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [required.value], + 'usecase_id': usecase_id + }) + + +def can_read_node_type(user: Dict, item: Dict) -> bool: + """Read access via the owning Use_Case or any scoped Use_Case (13.3)""" + usecase_ids = [item['usecase_id']] + [ + uc for uc in (item.get('usecase_ids') or []) if uc != item['usecase_id'] + ] + return any( + has_node_designer_permission(user, uc, Permission.NODE_DESIGNER_READ) + for uc in usecase_ids + ) + + +def authorize_node_type_access(user: Dict, event: Dict, item: Dict, + manage: bool = False, + permission: Permission = Permission.NODE_DESIGNER_MANAGE + ) -> Optional[Dict]: + """ + Authorize an operation on an existing Custom_Node_Type. Returns an + error response, or None when authorized. A user without any + resolvable read access receives the same 404 as for a missing type so + existence is never leaked across tenants; manage operations require + the permission on the owning Use_Case (13.1). + """ + if not can_read_node_type(user, item): + return not_found_response() + if manage and not has_node_designer_permission(user, item['usecase_id'], + permission): + return forbidden_response(user, event, item['usecase_id'], permission) + return None + + +# ------------------------------------------------------ declaration checks + +def prepare_declaration(declaration: Dict, record: Dict + ) -> Tuple[Optional[Dict], Optional[Dict]]: + """ + Normalize and validate a submitted wire declaration against the + backing Plugin_Record version. + + Stamps the record's DeepStream flag (5.3 restriction enforced by + descriptor_from_declaration), records the plugin dependency + ``custom:{usecase_id}/{plugin_name}`` in every mapping (8.6), then + validates through descriptor_from_declaration, rejecting invalid + declarations with the offending field identified (8.5), and rejects + mappings that target Target_Architectures without a successfully + built Plugin_Artifact (8.1). + + Returns (normalized_declaration, None) or (None, error_response). + """ + plugin_name = sanitize_plugin_name(record.get('name'), record['plugin_id']) + dependency = plugin_dependency_for(record['usecase_id'], plugin_name) + + declaration = inject_plugin_dependency(declaration, dependency) + declaration['deepstream'] = bool(record.get('deepstream', False)) + + try: + descriptor_from_declaration(declaration) + except DeclarationError as e: + return None, error_response(400, 'INVALID_DECLARATION', str(e), { + 'field': e.field, + }) + + mappings = declaration.get('mappings') or [] + if not mappings: + return None, error_response( + 400, 'INVALID_DECLARATION', + 'mappings: must declare the element/property mapping for at ' + 'least one built Target_Architecture', + {'field': 'mappings'}) + + built = successful_build_archs(record) + offending = unbuilt_mapping_architectures(declaration, built) + if offending: + field, arch = offending[0] + return None, error_response( + 400, 'UNBUILT_ARCHITECTURE', + f"{field}: architecture {arch!r} has no successfully built " + f"Plugin_Artifact on Plugin_Record " + f"{record['plugin_id']} v{record['version']}", + { + 'field': field, + 'offending_mappings': [ + {'field': f, 'arch': a} for f, a in offending + ], + 'built_architectures': built, + }) + + return declaration, None + + +def parse_usecase_ids(body: Dict, default_usecase_id: str + ) -> Tuple[Optional[List[str]], Optional[Dict]]: + """Use_Case scoping selected at registration (8.2); defaults to the + plugin's own Use_Case""" + usecase_ids = body.get('usecase_ids') + if usecase_ids is None: + return [default_usecase_id], None + if (not isinstance(usecase_ids, list) or not usecase_ids + or not all(isinstance(uc, str) and uc for uc in usecase_ids)): + return None, error_response( + 400, 'INVALID_USECASE_IDS', + 'usecase_ids must be a non-empty list of use case id strings') + return list(dict.fromkeys(usecase_ids)), None + + +# ----------------------------------------------------------------- handlers + +def list_node_types(event: Dict, user: Dict) -> Dict: + """ + GET /custom-node-types?plugin_id=... + Latest version of every Custom_Node_Type backed by the plugin. + + Serves the registration wizard's duplicate detection: a plugin that + already backs a node type is offered an update (a new version of + the existing registration) instead of registering a duplicate + palette entry. Readable with node-designer:read on the plugin's + Use_Case. + """ + params = event.get('queryStringParameters') or {} + plugin_id = params.get('plugin_id') + if not plugin_id: + return error_response(400, 'MISSING_PLUGIN_ID', + 'plugin_id query parameter is required') + + record = get_latest_plugin_version_item(plugin_id) + if not record: + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + if not has_node_designer_permission(user, record['usecase_id'], + Permission.NODE_DESIGNER_READ): + # Same 404 as a missing record: existence never leaks cross-tenant. + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + + from boto3.dynamodb.conditions import Attr + items: List[Dict] = [] + kwargs: Dict = {'FilterExpression': Attr('plugin_id').eq(plugin_id)} + while True: + response = node_types_table().scan(**kwargs) + items.extend(response.get('Items', [])) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + + # Latest version per node_type_id (14.1 retains every version). + latest: Dict[str, Dict] = {} + for item in items: + type_id = item.get('node_type_id') + current = latest.get(type_id) + if current is None or int(item.get('version', 0)) > int(current.get('version', 0)): + latest[type_id] = item + + node_types = [node_type_summary(latest[type_id]) + for type_id in sorted(latest)] + return create_response(200, {'nodeTypes': node_types, + 'count': len(node_types)}) + + +def register_node_type(event: Dict, user: Dict) -> Dict: + """ + POST /custom-node-types + Body: {plugin_id, plugin_version?, declaration, usecase_ids?} + + Registers a Custom_Node_Type for a built Plugin_Record version (8.1): + the declaration collects the display name, palette category, Ports + with types, parameters (types, defaults, constraints, descriptions, + examples), the hardware-dependence flag, and the element/property + mapping per built Target_Architecture. Requires + node-designer:register on the plugin's Use_Case. + """ + body, err = parse_body(event) + if err: + return err + + plugin_id = body.get('plugin_id') + declaration = body.get('declaration') + missing = [f for f in ('plugin_id', 'declaration') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + if not isinstance(declaration, dict): + return error_response(400, 'INVALID_DECLARATION', + 'declaration must be a JSON object', + {'field': 'declaration'}) + + if body.get('plugin_version') is not None: + try: + plugin_version = int(body['plugin_version']) + except (TypeError, ValueError): + return error_response(400, 'INVALID_PLUGIN_VERSION', + 'plugin_version must be an integer') + record = get_plugin_version_item(plugin_id, plugin_version) + else: + record = get_latest_plugin_version_item(plugin_id) + + if not record: + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + + usecase_id = record['usecase_id'] + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_READ): + # Same 404 as a missing record: existence never leaks cross-tenant. + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_REGISTER): + return forbidden_response(user, event, usecase_id, + Permission.NODE_DESIGNER_REGISTER) + + usecase_ids, err = parse_usecase_ids(body, usecase_id) + if err: + return err + + declaration, err = prepare_declaration(declaration, record) + if err: + return err + + node_type_id = declaration['typeId'] + if node_type_id in BUILTIN_TYPE_IDS: + return error_response( + 409, 'TYPE_ID_CONFLICT', + f"typeId {node_type_id!r} collides with a built-in node type", + {'field': 'typeId', 'node_type_id': node_type_id}) + if query_node_type_versions(node_type_id): + return error_response( + 409, 'TYPE_ID_CONFLICT', + f"Custom node type {node_type_id!r} is already registered; " + f"update it to create a new version", + {'field': 'typeId', 'node_type_id': node_type_id}) + + timestamp = now_ms() + item = new_node_type_item( + node_type_id=node_type_id, version=1, usecase_id=usecase_id, + usecase_ids=usecase_ids, plugin_id=plugin_id, + plugin_version=record['version'], declaration=declaration, + user_id=user['user_id'], timestamp=timestamp, + ) + node_types_table().put_item( + Item=to_dynamo_json(item), + ConditionExpression='attribute_not_exists(node_type_id)', + ) + + log_audit_event( + user_id=user['user_id'], + action='register_custom_node_type', + resource_type='custom_node_type', + resource_id=node_type_id, + result='success', + details={'usecase_id': usecase_id, 'usecase_ids': usecase_ids, + 'plugin_id': plugin_id, 'plugin_version': record['version'], + 'version': 1} + ) + + return create_response(201, {'nodeType': node_type_detail(item)}) + + +def get_node_type(event: Dict, user: Dict, node_type_id: str) -> Dict: + """ + GET /custom-node-types/{id} + Latest version detail plus the retained version history (14.1). + """ + versions = query_node_type_versions(node_type_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_node_type_access(user, event, latest) + if err: + return err + return create_response(200, { + 'nodeType': node_type_detail(latest), + 'versions': [node_type_summary(item) for item in versions], + }) + + +def update_node_type(event: Dict, user: Dict, node_type_id: str) -> Dict: + """ + PUT /custom-node-types/{id} + Body: {declaration?, plugin_version?, usecase_ids?} + + A declaration update creates a new CustomNodeTypes version item and + retains every prior version (14.1). The new version pins the + (possibly updated) backing Plugin_Record version. Requires + node-designer:manage on the owning Use_Case. + """ + body, err = parse_body(event) + if err: + return err + + versions = query_node_type_versions(node_type_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_node_type_access(user, event, latest, manage=True) + if err: + return err + + if not any(f in body for f in ('declaration', 'plugin_version', 'usecase_ids')): + return error_response(400, 'NO_UPDATES', + 'Provide declaration, plugin_version, or usecase_ids') + + declaration = body.get('declaration') + if declaration is None: + declaration = latest.get('declaration') or {} + if not isinstance(declaration, dict): + return error_response(400, 'INVALID_DECLARATION', + 'declaration must be a JSON object', + {'field': 'declaration'}) + + plugin_id = latest['plugin_id'] + plugin_version = body.get('plugin_version', latest.get('plugin_version')) + try: + plugin_version = int(plugin_version) + except (TypeError, ValueError): + return error_response(400, 'INVALID_PLUGIN_VERSION', + 'plugin_version must be an integer') + record = get_plugin_version_item(plugin_id, plugin_version) + if not record: + return error_response(404, 'PLUGIN_NOT_FOUND', + 'Backing plugin record version not found', + {'plugin_id': plugin_id, + 'plugin_version': plugin_version}) + + if 'usecase_ids' in body: + usecase_ids, err = parse_usecase_ids(body, latest['usecase_id']) + if err: + return err + else: + usecase_ids = latest.get('usecase_ids') or [latest['usecase_id']] + + declaration, err = prepare_declaration(declaration, record) + if err: + return err + if declaration['typeId'] != node_type_id: + return error_response( + 400, 'TYPE_ID_MISMATCH', + f"typeId {declaration['typeId']!r} must match the registered " + f"custom node type id {node_type_id!r}", + {'field': 'typeId'}) + + timestamp = now_ms() + item = new_node_type_item( + node_type_id=node_type_id, + version=next_node_type_version(latest['version']), + usecase_id=latest['usecase_id'], + usecase_ids=usecase_ids, + plugin_id=plugin_id, + plugin_version=record['version'], + declaration=declaration, + user_id=user['user_id'], + timestamp=timestamp, + deprecated=latest.get('deprecated', False), + ) + node_types_table().put_item( + Item=to_dynamo_json(item), + ConditionExpression='attribute_not_exists(version)', + ) + + log_audit_event( + user_id=user['user_id'], + action='update_custom_node_type', + resource_type='custom_node_type', + resource_id=node_type_id, + result='success', + details={'usecase_id': latest['usecase_id'], 'version': item['version'], + 'plugin_id': plugin_id, 'plugin_version': record['version']} + ) + + return create_response(201, {'nodeType': node_type_detail(item)}) + + +def deprecate_node_type(event: Dict, user: Dict, node_type_id: str) -> Dict: + """ + POST /custom-node-types/{id}/deprecate + Body: {deprecated?: bool} (default true) + + Flips the deprecated flag on every version item (14.3): the palette + stops offering the type for new placement while saved workflows + referencing it remain loadable, packagable, and deployable (the merge + in task 9.2 excludes deprecated types from the palette only). + Requires node-designer:manage on the owning Use_Case. + """ + body, err = parse_body(event) + if err: + return err + + versions = query_node_type_versions(node_type_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_node_type_access(user, event, latest, manage=True) + if err: + return err + + deprecated = body.get('deprecated', True) + if not isinstance(deprecated, bool): + return error_response(400, 'INVALID_DEPRECATED', + 'deprecated must be a boolean') + + timestamp = now_ms() + for item in versions: + node_types_table().update_item( + Key={'node_type_id': node_type_id, 'version': item['version']}, + UpdateExpression='SET deprecated = :d, updated_at = :t', + ExpressionAttributeValues={':d': deprecated, ':t': timestamp}, + ) + + log_audit_event( + user_id=user['user_id'], + action='deprecate_custom_node_type', + resource_type='custom_node_type', + resource_id=node_type_id, + result='success', + details={'usecase_id': latest['usecase_id'], 'deprecated': deprecated, + 'versions': [item['version'] for item in versions]} + ) + + updated = query_node_type_versions(node_type_id) + return create_response(200, { + 'nodeType': node_type_detail(updated[0]), + 'versions': [node_type_summary(item) for item in updated], + }) + + +def remove_node_type(event: Dict, user: Dict, node_type_id: str) -> Dict: + """ + DELETE /custom-node-types/{id} + + Reference-checked removal (14.4, 14.5): scans WorkflowVersions for + references (inverted-index GSI when available, full scan otherwise). + Zero references deletes every catalog version item, the plugin's + Plugin_Library artifacts, and the plugin's Plugin_Component versions; + otherwise the removal is rejected listing the referencing workflows. + Requires node-designer:manage on the owning Use_Case. + """ + versions = query_node_type_versions(node_type_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_node_type_access(user, event, latest, manage=True) + if err: + return err + + references = find_workflow_references(node_type_id) + rejection = evaluate_removal(node_type_id, references) + if rejection: + log_audit_event( + user_id=user['user_id'], + action='remove_custom_node_type', + resource_type='custom_node_type', + resource_id=node_type_id, + result='denied', + details={'usecase_id': latest['usecase_id'], + 'referencing_workflows': references} + ) + return error_response(409, rejection['code'], rejection['message'], + rejection['details']) + + plugin_id = latest['plugin_id'] + + # Plugin_Component versions in the Use_Case account registry (+ the + # account-bucket component artifacts), then the portal Plugin_Library + # artifacts, then the catalog items last so a partial failure leaves + # the type visible and the removal retryable. + delete_plugin_component_versions(latest['usecase_id'], plugin_id) + deleted_artifacts = delete_plugin_library_artifacts(plugin_id) + + for item in versions: + node_types_table().delete_item( + Key={'node_type_id': node_type_id, 'version': item['version']}) + + log_audit_event( + user_id=user['user_id'], + action='remove_custom_node_type', + resource_type='custom_node_type', + resource_id=node_type_id, + result='success', + details={'usecase_id': latest['usecase_id'], 'plugin_id': plugin_id, + 'versions_removed': [item['version'] for item in versions], + 'artifacts_deleted': deleted_artifacts} + ) + + return create_response(200, { + 'removed': True, + 'node_type_id': node_type_id, + 'versions_removed': [item['version'] for item in versions], + }) + + +# ------------------------------------------------------------------ routing + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + node_type_id = path_params.get('id') + + if resource == '/custom-node-types': + if http_method == 'GET': + return list_node_types(event, user) + if http_method == 'POST': + return register_node_type(event, user) + elif resource == '/custom-node-types/{id}' and node_type_id: + if http_method == 'GET': + return get_node_type(event, user, node_type_id) + if http_method == 'PUT': + return update_node_type(event, user, node_type_id) + if http_method == 'DELETE': + return remove_node_type(event, user, node_type_id) + elif resource == '/custom-node-types/{id}/deprecate' and node_type_id: + if http_method == 'POST': + return deprecate_node_type(event, user, node_type_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/data_accounts.py b/edge-cv-portal/backend/functions/data_accounts.py index 55be6022..d5024ab7 100644 --- a/edge-cv-portal/backend/functions/data_accounts.py +++ b/edge-cv-portal/backend/functions/data_accounts.py @@ -6,12 +6,28 @@ allowing usecases to access data cross-account. Only PortalAdmin users can manage Data Accounts. + +Bedrock_Configuration (workflow-manager Requirement 10.6): +This handler also serves the Bedrock_Configuration settings API used by +the Workflow_Generator. No dedicated /settings API Gateway route exists +and no new routes may be added, so the configuration rides the existing +PortalAdmin-only /data-accounts/{id} GET/PUT routes using the reserved +id 'bedrock-configuration' (which can never collide with a real Data +Account id - those are 12-digit AWS account ids). This handler already +backs the portal Settings page, making it the natural carrier. Access +is restricted to PortalAdmin via Permission.BEDROCK_CONFIG_WRITE. The +stored item shape matches exactly what workflow_generator.py reads: + {setting_key: 'bedrock_configuration', + value: {model_id, region, max_tokens, temperature, top_p, + timeout_seconds}} """ import json +import math import os import logging -from typing import Dict, Any +from typing import Dict, Any, List, Optional from datetime import datetime +from decimal import Decimal import boto3 from botocore.exceptions import ClientError import uuid @@ -22,7 +38,7 @@ from shared_utils import ( create_response, get_user_from_event, log_audit_event, validate_required_fields, assume_cross_account_role as assume_role, - require_super_user + require_super_user, rbac_manager, Permission ) logger = logging.getLogger() @@ -34,6 +50,62 @@ # Environment variables DATA_ACCOUNTS_TABLE = os.environ.get('DATA_ACCOUNTS_TABLE') +SETTINGS_TABLE = os.environ.get('SETTINGS_TABLE') + +# -------------------------------------------------------------------------- +# Bedrock_Configuration (workflow-manager Requirements 10.6, 10.7) +# -------------------------------------------------------------------------- + +# Reserved path id on /data-accounts/{id} that routes to the Bedrock +# configuration handlers instead of the Data Account CRUD. +BEDROCK_CONFIG_RESOURCE_ID = 'bedrock-configuration' + +# Settings-table key read by workflow_generator.get_bedrock_configuration(). +BEDROCK_CONFIG_SETTING_KEY = 'bedrock_configuration' + +# -------------------------------------------------------------------------- +# Camera_Registry Staleness_Threshold (camera-registry-sync Requirement 4.3) +# -------------------------------------------------------------------------- + +# Reserved path id on /data-accounts/{id} that routes to the Camera_Registry +# configuration handlers instead of the Data Account CRUD (same carrier as +# 'bedrock-configuration' above; can never collide with a real Data Account +# id - those are 12-digit AWS account ids). +CAMERA_REGISTRY_CONFIG_RESOURCE_ID = 'camera-registry-configuration' + +# Settings-table key read by camera_registry.staleness_threshold_hours() +# and deployments._camera_staleness_threshold_ms(). Stored item shape: +# {setting_key: 'camera_registry.staleness_threshold_hours', +# value: } +CAMERA_STALENESS_SETTING_KEY = 'camera_registry.staleness_threshold_hours' + +# Must mirror the readers' fallback so reads return the effective value +# even before anything is stored (camera-registry-sync Req 4.3). +DEFAULT_CAMERA_STALENESS_THRESHOLD_HOURS = 24 + +# Requirement 10.7: invocation timeout is configurable up to 60 seconds. +MAX_BEDROCK_TIMEOUT_SECONDS = 60 + +# Must mirror workflow_generator.DEFAULT_BEDROCK_CONFIG so reads return +# the effective configuration even before anything is stored. +DEFAULT_BEDROCK_CONFIG = { + 'model_id': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'region': os.environ.get('AWS_REGION', 'us-east-1'), + 'max_tokens': 4096, + # Sampling parameters are unset by default: they are sent to Bedrock + # only when explicitly configured (or overridden per-request). Recent + # Anthropic models reject requests setting temperature at all, and + # never accept temperature AND top_p together, so the generators omit + # None values and send at most one of the two + # (see workflow_generator.invoke_generation). + 'temperature': None, + 'top_p': None, + 'timeout_seconds': MAX_BEDROCK_TIMEOUT_SECONDS, +} + +# bedrock control-plane clients (list-foundation-models / +# list-inference-profiles) cached per region for warm invocations. +_bedrock_control_clients: Dict[str, Any] = {} def is_portal_admin(user: Dict) -> bool: @@ -103,6 +175,17 @@ def handler(event: Dict, context: Any) -> Dict: PUT /api/v1/data-accounts/{id} - Update Data Account (PortalAdmin only) DELETE /api/v1/data-accounts/{id} - Delete Data Account (PortalAdmin only) POST /api/v1/data-accounts/{id}/test - Test connection (PortalAdmin only) + + Reserved id 'bedrock-configuration' (workflow-manager Requirement 10.6, + PortalAdmin only via bedrock-config:write): + GET /api/v1/data-accounts/bedrock-configuration - Read Bedrock_Configuration + PUT /api/v1/data-accounts/bedrock-configuration - Update Bedrock_Configuration + GET /api/v1/data-accounts/bedrock-configuration/models - List invokable model options + + Reserved id 'camera-registry-configuration' (camera-registry-sync + Requirement 4.3, PortalAdmin only): + GET /api/v1/data-accounts/camera-registry-configuration - Read Staleness_Threshold + PUT /api/v1/data-accounts/camera-registry-configuration - Update Staleness_Threshold """ try: http_method = event.get('httpMethod') @@ -123,6 +206,17 @@ def handler(event: Dict, context: Any) -> Dict: user = get_user_from_event(event) + # Reserved id: Bedrock_Configuration settings API (Requirement 10.6). + # Handled before Data Account CRUD; PortalAdmin-only via + # Permission.BEDROCK_CONFIG_WRITE. + if path_params.get('id') == BEDROCK_CONFIG_RESOURCE_ID: + return handle_bedrock_configuration(event, user, http_method) + + # Reserved id: Camera_Registry Staleness_Threshold settings API + # (camera-registry-sync Requirement 4.3). PortalAdmin-only. + if path_params.get('id') == CAMERA_REGISTRY_CONFIG_RESOURCE_ID: + return handle_camera_registry_configuration(event, user, http_method) + # List Data Accounts is allowed for all authenticated users (read-only for dropdown) # All other operations require PortalAdmin is_list_operation = http_method == 'GET' and not path_params.get('id') @@ -404,3 +498,497 @@ def test_connection(event: Dict, user: Dict, data_account_id: str) -> Dict: except Exception as e: logger.error(f"Error testing connection: {str(e)}") return create_response(500, {'error': 'Failed to test connection'}) + + +# -------------------------------------------------------------------------- +# Bedrock_Configuration settings API (workflow-manager Requirement 10.6) +# -------------------------------------------------------------------------- + +def _decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: _decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_decimal_to_native(i) for i in obj] + return obj + + +def _native_to_dynamo(obj): + """Convert native Python floats to Decimal for DynamoDB storage""" + if isinstance(obj, float): + return Decimal(str(obj)) + elif isinstance(obj, dict): + return {k: _native_to_dynamo(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_native_to_dynamo(i) for i in obj] + return obj + + +def read_stored_bedrock_configuration() -> Dict: + """ + Effective Bedrock_Configuration: stored values merged over defaults, + timeout clamped to at most 60 seconds. Mirrors the read logic in + workflow_generator.get_bedrock_configuration() so the settings UI + shows exactly what the Workflow_Generator will use. + """ + config = dict(DEFAULT_BEDROCK_CONFIG) + if SETTINGS_TABLE: + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': BEDROCK_CONFIG_SETTING_KEY} + ) + item = response.get('Item') + if item: + stored = item.get('value') if isinstance(item.get('value'), dict) else item + stored = _decimal_to_native(stored) + for key in DEFAULT_BEDROCK_CONFIG: + if key in ('temperature', 'top_p'): + # Sampling parameters may be explicitly stored as + # null (unset); carry the null through so it reads + # back as unset instead of being masked by the + # default. Mirrors + # workflow_generator.get_bedrock_configuration(). + if key in stored: + config[key] = stored[key] + elif stored.get(key) is not None: + config[key] = stored[key] + except ClientError as e: + logger.warning(f"Could not read Bedrock configuration, using defaults: {str(e)}") + + try: + timeout = int(config['timeout_seconds']) + except (TypeError, ValueError): + timeout = MAX_BEDROCK_TIMEOUT_SECONDS + config['timeout_seconds'] = max(1, min(timeout, MAX_BEDROCK_TIMEOUT_SECONDS)) + return config + + +def _is_number(value) -> bool: + """True for int/float but not bool (bool is a subclass of int).""" + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def validate_bedrock_configuration(config: Dict) -> List[str]: + """ + Validate a complete Bedrock_Configuration value. Returns a list of + human-readable validation errors (empty when valid). + """ + errors = [] + + model_id = config.get('model_id') + if not isinstance(model_id, str) or not model_id.strip(): + errors.append('model_id must be a non-empty string') + + region = config.get('region') + if not isinstance(region, str) or not region.strip(): + errors.append('region must be a non-empty string') + + max_tokens = config.get('max_tokens') + if not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens < 1: + errors.append('max_tokens must be a positive integer') + + # None is a valid stored state for the sampling parameters (unset: + # the parameter is omitted at invocation); non-None values must be + # numbers in [0, 1]. + temperature = config.get('temperature') + if temperature is not None and ( + not _is_number(temperature) or not (0 <= temperature <= 1)): + errors.append('temperature must be a number between 0 and 1') + + top_p = config.get('top_p') + if top_p is not None and (not _is_number(top_p) or not (0 <= top_p <= 1)): + errors.append('top_p must be a number between 0 and 1') + + timeout_seconds = config.get('timeout_seconds') + if (not isinstance(timeout_seconds, int) or isinstance(timeout_seconds, bool) + or not (1 <= timeout_seconds <= MAX_BEDROCK_TIMEOUT_SECONDS)): + errors.append( + f'timeout_seconds must be an integer between 1 and {MAX_BEDROCK_TIMEOUT_SECONDS}') + + return errors + + +def handle_bedrock_configuration(event: Dict, user: Dict, http_method: str) -> Dict: + """ + Route Bedrock_Configuration requests. Restricted to PortalAdmin via + Permission.BEDROCK_CONFIG_WRITE (Requirement 10.6); denied attempts + are audit-logged. + """ + if not rbac_manager.has_permission( + user['user_id'], 'global', Permission.BEDROCK_CONFIG_WRITE, user_info=user): + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='setting', + resource_id=BEDROCK_CONFIG_SETTING_KEY, + result='denied', + details={ + 'required_permissions': [Permission.BEDROCK_CONFIG_WRITE.value], + 'method': http_method, + 'path': event.get('path'), + } + ) + return create_response(403, { + 'error': 'PortalAdmin access required', + 'required_permissions': [Permission.BEDROCK_CONFIG_WRITE.value], + }) + + # GET /data-accounts/bedrock-configuration/models: invokable model + # options for the settings-page model dropdown. Read-gated exactly like + # the configuration GET above (same PortalAdmin permission check). + is_models_path = (event.get('path') or '').rstrip('/').endswith('/models') + if http_method == 'GET' and is_models_path: + return list_bedrock_model_options(event, user) + if http_method == 'GET': + return get_bedrock_configuration_setting(event, user) + if http_method == 'PUT': + return update_bedrock_configuration_setting(event, user) + return create_response(404, {'error': 'Not found'}) + + +def get_bedrock_configuration_setting(event: Dict, user: Dict) -> Dict: + """Return the effective Bedrock_Configuration (stored over defaults).""" + try: + if not SETTINGS_TABLE: + return create_response(500, {'error': 'Settings storage is not configured'}) + return create_response(200, { + 'bedrock_configuration': read_stored_bedrock_configuration(), + 'defaults': DEFAULT_BEDROCK_CONFIG, + 'max_timeout_seconds': MAX_BEDROCK_TIMEOUT_SECONDS, + }) + except Exception as e: + logger.error(f"Error reading Bedrock configuration: {str(e)}") + return create_response(500, {'error': 'Failed to read Bedrock configuration'}) + + +def update_bedrock_configuration_setting(event: Dict, user: Dict) -> Dict: + """ + Update the Bedrock_Configuration. Accepts any subset of the known + keys; the provided values are merged over the current effective + configuration, the merged result is validated, and the complete + value is written in the exact shape workflow_generator.py reads: + {setting_key: 'bedrock_configuration', value: {...}} + """ + try: + if not SETTINGS_TABLE: + return create_response(500, {'error': 'Settings storage is not configured'}) + + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Request body is not valid JSON'}) + if not isinstance(body, dict): + return create_response(400, {'error': 'Request body must be a JSON object'}) + + # Merge provided keys over the current effective configuration; + # unknown keys are ignored. + config = read_stored_bedrock_configuration() + for key in DEFAULT_BEDROCK_CONFIG: + if key in body: + config[key] = body[key] + + errors = validate_bedrock_configuration(config) + if errors: + return create_response(400, { + 'error': 'Invalid Bedrock configuration', + 'validation_errors': errors, + }) + + value = {key: config[key] for key in DEFAULT_BEDROCK_CONFIG} + value['model_id'] = value['model_id'].strip() + value['region'] = value['region'].strip() + + timestamp = int(datetime.utcnow().timestamp() * 1000) + dynamodb.Table(SETTINGS_TABLE).put_item(Item={ + 'setting_key': BEDROCK_CONFIG_SETTING_KEY, + 'value': _native_to_dynamo(value), + 'updated_at': timestamp, + 'updated_by': user['user_id'], + }) + + log_audit_event( + user_id=user['user_id'], + action='update_bedrock_configuration', + resource_type='setting', + resource_id=BEDROCK_CONFIG_SETTING_KEY, + result='success', + details=_native_to_dynamo(value), + ) + + return create_response(200, { + 'message': 'Bedrock configuration updated successfully', + 'bedrock_configuration': value, + }) + + except Exception as e: + logger.error(f"Error updating Bedrock configuration: {str(e)}") + return create_response(500, {'error': 'Failed to update Bedrock configuration'}) + + +# -------------------------------------------------------------------------- +# Camera_Registry Staleness_Threshold settings API +# (camera-registry-sync task 6.5, Requirement 4.3) +# -------------------------------------------------------------------------- + +def read_stored_camera_staleness_threshold() -> float: + """The effective Staleness_Threshold in hours (stored over default). + + Mirrors the read logic in camera_registry.staleness_threshold_hours() + and deployments._camera_staleness_threshold_ms() so the settings API + shows exactly what the cameras route will use. + """ + if SETTINGS_TABLE: + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': CAMERA_STALENESS_SETTING_KEY} + ) + value = (response.get('Item') or {}).get('value') + if value is not None: + hours = float(value) + if hours > 0: + return hours + except (ClientError, TypeError, ValueError) as e: + logger.warning(f"Could not read staleness threshold setting: {e}") + return DEFAULT_CAMERA_STALENESS_THRESHOLD_HOURS + + +def validate_camera_staleness_threshold(hours) -> List[str]: + """Positive finite number of hours; returns human-readable errors.""" + if (not _is_number(hours) + or not math.isfinite(hours) + or hours <= 0): + return ['staleness_threshold_hours must be a positive number of hours'] + return [] + + +def handle_camera_registry_configuration(event: Dict, user: Dict, + http_method: str) -> Dict: + """ + Route Camera_Registry configuration requests. Restricted to + PortalAdmin (camera-registry-sync Requirement 4.3); denied attempts + are audit-logged. + """ + if not is_portal_admin(user): + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='setting', + resource_id=CAMERA_STALENESS_SETTING_KEY, + result='denied', + details={ + 'required_role': 'PortalAdmin', + 'method': http_method, + 'path': event.get('path'), + } + ) + return create_response(403, {'error': 'PortalAdmin access required'}) + + if http_method == 'GET': + return get_camera_registry_configuration_setting(event, user) + if http_method == 'PUT': + return update_camera_registry_configuration_setting(event, user) + return create_response(404, {'error': 'Not found'}) + + +def get_camera_registry_configuration_setting(event: Dict, user: Dict) -> Dict: + """Return the effective Staleness_Threshold (stored over default).""" + try: + if not SETTINGS_TABLE: + return create_response(500, {'error': 'Settings storage is not configured'}) + return create_response(200, { + 'staleness_threshold_hours': read_stored_camera_staleness_threshold(), + 'default_staleness_threshold_hours': + DEFAULT_CAMERA_STALENESS_THRESHOLD_HOURS, + }) + except Exception as e: + logger.error(f"Error reading camera registry configuration: {str(e)}") + return create_response(500, {'error': 'Failed to read camera registry configuration'}) + + +def update_camera_registry_configuration_setting(event: Dict, user: Dict) -> Dict: + """ + Update the Staleness_Threshold. Validates a positive number of hours + and writes the exact item shape read by + camera_registry.staleness_threshold_hours(): + {setting_key: 'camera_registry.staleness_threshold_hours', + value: } + """ + try: + if not SETTINGS_TABLE: + return create_response(500, {'error': 'Settings storage is not configured'}) + + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Request body is not valid JSON'}) + if not isinstance(body, dict): + return create_response(400, {'error': 'Request body must be a JSON object'}) + + hours = body.get('staleness_threshold_hours') + errors = validate_camera_staleness_threshold(hours) + if errors: + return create_response(400, { + 'error': 'Invalid camera registry configuration', + 'validation_errors': errors, + }) + + timestamp = int(datetime.utcnow().timestamp() * 1000) + dynamodb.Table(SETTINGS_TABLE).put_item(Item={ + 'setting_key': CAMERA_STALENESS_SETTING_KEY, + 'value': _native_to_dynamo(hours), + 'updated_at': timestamp, + 'updated_by': user['user_id'], + }) + + log_audit_event( + user_id=user['user_id'], + action='update_camera_registry_configuration', + resource_type='setting', + resource_id=CAMERA_STALENESS_SETTING_KEY, + result='success', + details={'staleness_threshold_hours': _native_to_dynamo(hours)}, + ) + + return create_response(200, { + 'message': 'Camera registry configuration updated successfully', + 'staleness_threshold_hours': hours, + }) + + except Exception as e: + logger.error(f"Error updating camera registry configuration: {str(e)}") + return create_response(500, {'error': 'Failed to update camera registry configuration'}) + + +# -------------------------------------------------------------------------- +# Bedrock model options (settings-page model dropdown) +# -------------------------------------------------------------------------- + +def _get_bedrock_control_client(region: str): + """bedrock control-plane client (list APIs), cached per region.""" + client = _bedrock_control_clients.get(region) + if client is None: + client = boto3.client('bedrock', region_name=region) + _bedrock_control_clients[region] = client + return client + + +def _is_access_denied(error: ClientError) -> bool: + code = error.response.get('Error', {}).get('Code', '') + return code in ('AccessDenied', 'AccessDeniedException', 'UnauthorizedOperation') + + +def _list_inference_profiles(client) -> List[Dict]: + """All system-defined inference profile summaries (paginated).""" + summaries: List[Dict] = [] + next_token = None + while True: + kwargs = {'maxResults': 100} + if next_token: + kwargs['nextToken'] = next_token + response = client.list_inference_profiles(**kwargs) + summaries.extend(response.get('inferenceProfileSummaries', [])) + next_token = response.get('nextToken') + if not next_token: + return summaries + + +def list_bedrock_model_options(event: Dict, user: Dict) -> Dict: + """ + GET /data-accounts/bedrock-configuration/models + + Invokable model options for the settings-page model dropdown, resolved + for the configured region (or ?region=... override): + + - Inference profiles (bedrock:ListInferenceProfiles): these are the + invokable ids for current Anthropic models (prefixed us./global./...). + Listed as {id: inferenceProfileId, label: inferenceProfileName}. + - Foundation models (bedrock:ListFoundationModels) with + modelLifecycle.status == ACTIVE, included only when directly + invokable (inferenceTypesSupported contains ON_DEMAND). + + Deduplicated (an inference profile wins over the foundation model it + fronts) and sorted anthropic-first, then alphabetically. When the + Lambda lacks the bedrock list permissions, returns an empty list plus + a 'permissions' hint so the UI can fall back to free-text entry. + """ + try: + query = event.get('queryStringParameters') or {} + region = (query.get('region') or '').strip() \ + or read_stored_bedrock_configuration()['region'] + client = _get_bedrock_control_client(region) + + access_denied = False + profile_options: List[Dict] = [] + try: + for profile in _list_inference_profiles(client): + profile_id = profile.get('inferenceProfileId') + if profile_id: + profile_options.append({ + 'id': profile_id, + 'label': profile.get('inferenceProfileName') or profile_id, + }) + except ClientError as e: + if not _is_access_denied(e): + raise + access_denied = True + + # Foundation model ids fronted by a listed inference profile are + # dropped: the profile id (e.g. us.anthropic....) is the invokable + # one for those models. Profile ids are '.'. + profile_base_ids = { + option['id'].split('.', 1)[1] + for option in profile_options if '.' in option['id'] + } + + model_options: List[Dict] = [] + try: + response = client.list_foundation_models() + for summary in response.get('modelSummaries', []): + model_id = summary.get('modelId') + if not model_id: + continue + if summary.get('modelLifecycle', {}).get('status') != 'ACTIVE': + continue + if 'ON_DEMAND' not in (summary.get('inferenceTypesSupported') or []): + continue + if model_id in profile_base_ids: + continue + model_options.append({ + 'id': model_id, + 'label': summary.get('modelName') or model_id, + }) + except ClientError as e: + if not _is_access_denied(e): + raise + access_denied = True + + def sort_key(option: Dict): + model_id = option['id'] + base_id = model_id.split('.', 1)[1] if '.' in model_id else model_id + is_anthropic = (base_id.startswith('anthropic.') + or model_id.startswith('anthropic.')) + return (0 if is_anthropic else 1, option['label'].lower(), model_id) + + # Deduplicate by id (profiles first so they win), then sort. + seen = set() + options = [] + for option in profile_options + model_options: + if option['id'] not in seen: + seen.add(option['id']) + options.append(option) + options.sort(key=sort_key) + + payload: Dict[str, Any] = {'models': options, 'region': region} + if access_denied: + payload['permissions'] = ( + 'Missing bedrock:ListInferenceProfiles and/or ' + 'bedrock:ListFoundationModels permission; enter the model ' + 'id manually.' + ) + return create_response(200, payload) + + except Exception as e: + logger.error(f"Error listing Bedrock model options: {str(e)}") + return create_response(500, {'error': 'Failed to list Bedrock model options'}) diff --git a/edge-cv-portal/backend/functions/deployments.py b/edge-cv-portal/backend/functions/deployments.py index f31a8e30..f18c2645 100644 --- a/edge-cv-portal/backend/functions/deployments.py +++ b/edge-cv-portal/backend/functions/deployments.py @@ -8,19 +8,85 @@ import re import uuid from datetime import datetime +from decimal import Decimal import boto3 +from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError from shared_utils import ( create_response, get_user_from_event, log_audit_event, check_user_access, is_super_user, get_usecase, - get_usecase_client, get_usecase_region + get_usecase_client, get_usecase_region, + rbac_manager, Permission ) +# Deployment guard for Workflow_Components (Requirements 4.7, 4.10): +# a workflow version may only be deployed when it has a recorded +# passed-validation run with zero error findings. workflow_guards does +# not need the workflow_core layer, so it is importable here. +import workflow_guards logger = logging.getLogger() logger.setLevel(logging.INFO) dynamodb = boto3.resource('dynamodb') DEPLOYMENTS_TABLE = os.environ.get('DEPLOYMENTS_TABLE', 'dda-portal-deployments') +WORKFLOWS_TABLE = os.environ.get('WORKFLOWS_TABLE') + +# --------------------------------------------------------------------------- +# Plugin_Component deployment gates (custom-node-designer, Requirements +# 9.7, 9.8, 9.11, 16.3, 16.5, 16.6) +# --------------------------------------------------------------------------- + +# Plugin_Components are named dda.plugin.{pluginId} by plugin_components.py +PLUGIN_COMPONENT_PREFIX = 'dda.plugin.' + +# Backing Plugin_Records (lifecycle_state + component pointer with the +# published platform-manifest architectures) +PLUGIN_RECORDS_TABLE = os.environ.get('PLUGIN_RECORDS_TABLE') + +# Devices table: carries the UseCaseAdmin-set `test_device` flag (9.8) and +# the device's recorded Target_Architecture (16.6) +DEVICES_TABLE = os.environ.get('DEVICES_TABLE') + +# Lifecycle_State values (dev → test → prod). dev-state components are +# rejected for any deployment target; test-state components deploy only to +# devices flagged test_device; prod deploys anywhere in the Use_Case. +LIFECYCLE_TEST = 'test' +LIFECYCLE_PROD = 'prod' + +# --------------------------------------------------------------------------- +# Workflow_Component deployment constants (Workflow Manager, Requirement 8) +# --------------------------------------------------------------------------- + +# Greengrass component naming assigned by the Component_Packager +# (workflow_packaging.py): dda.workflow.{workflowId} v{workflowVersion}.0.0 +WORKFLOW_COMPONENT_PREFIX = 'dda.workflow.' + +# LocalServer Greengrass components are named aws.edgeml.dda.LocalServer. +LOCAL_SERVER_COMPONENT_PREFIX = 'aws.edgeml.dda.LocalServer' + +# Named shadows the camera-registry-sync feature keeps in sync between the +# device and IoT Core. These must match the edge-side constants: +# - src/backend/camera_sync/agent.py (SHADOW_NAME): the Edge_Sync_Agent +# writes camera registry reports to this named shadow via ShadowManager +# IPC (device -> cloud). +# - src/backend/workflow_engine/camera_binding_store.py +# (BINDINGS_SHADOW_NAME): the workflow engine reads camera bindings from +# this named shadow (cloud -> device). +# Without a ShadowManager `synchronize` configuration listing them, these +# local shadows never mirror to IoT Core and the Portal sees devices as +# "Never synced". +CAMERA_REGISTRY_SHADOW_NAME = 'dda-camera-registry' +CAMERA_BINDINGS_SHADOW_NAME = 'dda-camera-bindings' + +# Minimum LocalServer component version a Workflow_Component requires +# (Requirement 8.4). The Component_Packager writes this same value into each +# packaged component's manifest.json (minLocalServerVersion) from the same +# environment configuration, so resolving it here matches the packaged +# manifests. A per-version override recorded on the WorkflowVersions item +# (min_local_server_version) takes precedence when present. +WORKFLOW_MIN_LOCAL_SERVER_VERSION = os.environ.get( + 'WORKFLOW_MIN_LOCAL_SERVER_VERSION', + os.environ.get('DDA_LOCAL_SERVER_VERSION', '1.0.0')) # Minimum Greengrass Nucleus version required for DDA components # Nucleus is already installed on the device — we don't pin versions in deployments. @@ -38,6 +104,15 @@ # (Cli / ShadowManager / DockerApplicationManager). LOG_MANAGER_VERSION = '2.3.12' +# Shadow manager for named-shadow synchronization (camera-registry-sync). +# The real CreateDeployment API requires a componentVersion on every component +# entry ("Missing required parameter in components.aws.greengrass.ShadowManager: +# componentVersion"), so the auto-included ShadowManager must be pinned. Like +# LOG_MANAGER_VERSION this is the fallback pin used when the newest +# Nucleus-compatible public version can't be resolved; 2.3.15's Nucleus ceiling +# matches the other auto-included AWS components. +SHADOW_MANAGER_VERSION = '2.3.15' + # Components that require Nucleus to be explicitly included in deployment # Model components (model-*) also need Nucleus since they depend on DDA components DDA_COMPONENTS_REQUIRING_NUCLEUS = [ @@ -166,39 +241,41 @@ def _nucleus_satisfies(version, requirement): return True -def resolve_log_manager_version(greengrass_client, region, running_nucleus): - """Return the newest aws.greengrass.LogManager version whose Nucleus +def resolve_public_component_version(greengrass_client, region, component_name, + running_nucleus, fallback_version): + """Return the newest public `component_name` version whose Nucleus dependency is satisfied by the device's running Nucleus. - LogManager declares a Nucleus VersionRequirement (e.g. '>=2.1.0 <2.15.0'). - Auto-including a LogManager whose ceiling excludes the device's pinned - Nucleus produces FAILED_NO_STATE_CHANGE / NoAvailableComponentVersion. We - therefore select the highest LogManager version compatible with - `running_nucleus`. Falls back to the static LOG_MANAGER_VERSION when the - running Nucleus is unknown or resolution fails (network/permission), so the - behavior is never worse than the previous hard-coded pin.""" + Public AWS components (LogManager, ShadowManager, ...) declare a Nucleus + VersionRequirement (e.g. '>=2.1.0 <2.15.0'). Auto-including a version whose + ceiling excludes the device's pinned Nucleus produces + FAILED_NO_STATE_CHANGE / NoAvailableComponentVersion. We therefore select + the highest version compatible with `running_nucleus`. Falls back to the + static `fallback_version` when the running Nucleus is unknown or resolution + fails (network/permission), so the behavior is never worse than a + hard-coded pin.""" if not running_nucleus: - return LOG_MANAGER_VERSION + return fallback_version - lm_arn = f"arn:aws:greengrass:{region}:aws:components:aws.greengrass.LogManager" + component_arn = f"arn:aws:greengrass:{region}:aws:components:{component_name}" candidates = [] try: paginator = greengrass_client.get_paginator('list_component_versions') - for page in paginator.paginate(arn=lm_arn): + for page in paginator.paginate(arn=component_arn): for cv in page.get('componentVersions', []): v = cv.get('componentVersion') if v: candidates.append(v) except Exception as e: - logger.warning(f"Could not list LogManager versions, using {LOG_MANAGER_VERSION}: {e}") - return LOG_MANAGER_VERSION + logger.warning(f"Could not list {component_name} versions, using {fallback_version}: {e}") + return fallback_version - # Highest LogManager version first; return the first one compatible with the + # Highest version first; return the first one compatible with the # device's running Nucleus. - for lm_version in sorted(candidates, key=_version_key, reverse=True): + for candidate_version in sorted(candidates, key=_version_key, reverse=True): try: resp = greengrass_client.get_component( - arn=f"{lm_arn}:versions:{lm_version}", + arn=f"{component_arn}:versions:{candidate_version}", recipeOutputFormat='JSON' ) recipe_raw = resp.get('recipe') @@ -212,19 +289,39 @@ def resolve_log_manager_version(greengrass_client, region, running_nucleus): ) if _nucleus_satisfies(running_nucleus, nucleus_req): logger.info( - f"Resolved LogManager {lm_version} (Nucleus requirement " - f"'{nucleus_req}') for running Nucleus {running_nucleus}" + f"Resolved {component_name} {candidate_version} (Nucleus " + f"requirement '{nucleus_req}') for running Nucleus " + f"{running_nucleus}" ) - return lm_version + return candidate_version except Exception as e: - logger.warning(f"Could not inspect LogManager {lm_version}: {e}") + logger.warning(f"Could not inspect {component_name} {candidate_version}: {e}") continue logger.warning( - f"No LogManager version found compatible with Nucleus {running_nucleus}; " - f"falling back to {LOG_MANAGER_VERSION}" + f"No {component_name} version found compatible with Nucleus " + f"{running_nucleus}; falling back to {fallback_version}" ) - return LOG_MANAGER_VERSION + return fallback_version + + +def resolve_log_manager_version(greengrass_client, region, running_nucleus): + """Newest aws.greengrass.LogManager version compatible with the device's + running Nucleus, falling back to the pinned LOG_MANAGER_VERSION.""" + return resolve_public_component_version( + greengrass_client, region, 'aws.greengrass.LogManager', + running_nucleus, LOG_MANAGER_VERSION) + + +def resolve_shadow_manager_version(greengrass_client, region, running_nucleus): + """Newest aws.greengrass.ShadowManager version compatible with the + device's running Nucleus, falling back to the pinned + SHADOW_MANAGER_VERSION. The auto-included ShadowManager must carry a + componentVersion: the CreateDeployment API rejects component entries + without one.""" + return resolve_public_component_version( + greengrass_client, region, 'aws.greengrass.ShadowManager', + running_nucleus, SHADOW_MANAGER_VERSION) def handler(event, context): @@ -232,8 +329,12 @@ def handler(event, context): Handle deployment management requests GET /api/v1/deployments - List deployments + (?workflow_id=... lists workflow + deployments with per-device status) GET /api/v1/deployments/{id} - Get deployment details POST /api/v1/deployments - Create deployment + (component_type: workflow deploys a + packaged Workflow_Component) DELETE /api/v1/deployments/{id} - Cancel deployment """ try: @@ -251,14 +352,30 @@ def handler(event, context): user = get_user_from_event(event) if http_method == 'GET' and not path_parameters.get('id'): + # Deploy-time Camera_Binding context (camera-registry-sync + # Requirements 8.1, 8.5): per-target Camera_Sources for each + # Camera_Input_Node with hint-matching pre-selection. Checked + # before the target-deployment dispatch because this view also + # names its targets in the query string. + if (path.endswith('/binding-context') + or query_parameters.get('view') == 'binding-context'): + return get_camera_binding_context(user, query_parameters) # Sub-resource: look up the existing deployment for a specific target if path.endswith('/target-deployment') or query_parameters.get('target_device') or query_parameters.get('target_thing_group'): return get_target_deployment(user, query_parameters) + # Workflow page: workflow deployment associations with per-device + # Greengrass status (Requirements 8.2, 8.3) + if query_parameters.get('workflow_id'): + return list_workflow_deployments(user, query_parameters) return list_deployments(user, query_parameters) elif http_method == 'GET' and path_parameters.get('id'): return get_deployment(path_parameters['id'], user, query_parameters) elif http_method == 'POST': body = json.loads(event.get('body') or '{}') + # Workflow_Component deployments carry component_type: workflow + # (Requirements 8.1-8.5, 11.5) + if body.get('component_type') == 'workflow' or body.get('workflow_id'): + return create_workflow_deployment(body, user) return create_deployment(body, user) elif http_method == 'DELETE' and path_parameters.get('id'): return cancel_deployment(path_parameters['id'], user, query_parameters) @@ -757,6 +874,26 @@ def create_deployment(body, user): target_thing_group, region, account_id ) + # Standalone Plugin_Component deployments (custom-node-designer + # 16.3, 16.6): any dda.plugin.* component in the requested set is + # subject to the pre-submit lifecycle gate (test-state only to + # devices flagged test_device; dev-state rejected for any target) + # and the per-device architecture gate before submission. + plugin_component_targets = { + comp['component_name']: comp.get('component_version') + for comp in components + if str(comp.get('component_name', '')).startswith( + PLUGIN_COMPONENT_PREFIX) + } + resolved_plugin_devices = [] + if plugin_component_targets: + resolved_plugin_devices = resolve_target_thing_names( + iot_client, target_devices, target_thing_group) + gate_error = check_plugin_deployment_gates( + plugin_component_targets, resolved_plugin_devices) + if gate_error: + return gate_error + # Auto-include CloudWatch log manager for device logging if needs_nucleus and 'aws.greengrass.LogManager' not in components_map: # Build componentLogsConfigurationMap dynamically from all components in the deployment @@ -853,6 +990,52 @@ def create_deployment(body, user): elif not enable_inference_uploader: logger.info("InferenceUploader not included - disabled in UseCase configuration") + # Auto-include ShadowManager with a synchronization config for the + # camera-registry-sync named shadows. ShadowManager is already an + # implicit dependency of the LocalServer component (VersionRequirement + # >=2.2.0), but without an explicit `synchronize` configuration it runs + # with its default config and the dda-camera-registry / + # dda-camera-bindings local shadows never mirror to IoT Core + # (devices stay "Never synced" in the Portal). The entry is pinned to + # the newest public version compatible with the device's running + # Nucleus (fallback SHADOW_MANAGER_VERSION): the CreateDeployment API + # requires componentVersion on every component entry and rejects the + # deployment otherwise ("Missing required parameter in + # components.aws.greengrass.ShadowManager: componentVersion"). + if needs_nucleus and 'aws.greengrass.ShadowManager' not in components_map: + shadow_manager_config = { + 'synchronize': { + 'direction': 'betweenDeviceAndCloud', + 'coreThing': { + 'classic': True, + 'namedShadows': [ + CAMERA_REGISTRY_SHADOW_NAME, + CAMERA_BINDINGS_SHADOW_NAME + ] + } + } + } + shadow_manager_version = resolve_shadow_manager_version( + greengrass_client, region, running_nucleus + ) + components_map['aws.greengrass.ShadowManager'] = { + 'componentVersion': shadow_manager_version, + 'configurationUpdate': { + 'merge': json.dumps(shadow_manager_config) + } + } + auto_included.append({ + 'component_name': 'aws.greengrass.ShadowManager', + 'component_version': shadow_manager_version, + 'reason': (f'Syncs the {CAMERA_REGISTRY_SHADOW_NAME} and ' + f'{CAMERA_BINDINGS_SHADOW_NAME} named shadows with ' + 'IoT Core for camera registry synchronization') + }) + logger.info( + f"Auto-included aws.greengrass.ShadowManager {shadow_manager_version} with " + f"synchronization for named shadows: {CAMERA_REGISTRY_SHADOW_NAME}, " + f"{CAMERA_BINDINGS_SHADOW_NAME}") + # Determine target ARN (iot_client and running_nucleus were resolved above) # Greengrass deployments must target thing groups, not individual things if target_thing_group: @@ -948,7 +1131,17 @@ def create_deployment(body, user): response = greengrass_client.create_deployment(**deployment_params) deployment_id = response.get('deploymentId') - + + # Standalone Plugin_Component deployments are recorded in the + # Deployments table with component_type: 'plugin' (task 10.5). + if plugin_component_targets: + record_plugin_deployment( + deployment_id, usecase_id, plugin_component_targets, + target_arn, resolved_plugin_devices, target_thing_group, + is_revision, + existing_deployment.get('deploymentId') if is_revision else None, + user) + log_audit_event( user['user_id'], 'revise_deployment' if is_revision else 'create_deployment', @@ -1070,3 +1263,1673 @@ def list_public_components(greengrass_client): except ClientError as e: logger.warning(f"Could not list public components: {e}") return [] + + +# =========================================================================== +# Workflow_Component deployments (Workflow Manager) +# +# Deployment_Service extension (design section 7): adds the workflow +# component type with device/thing-group targeting within the Use_Case +# (Requirement 8.1), records workflow version -> deployment -> devices +# associations in the Deployments table with component_type: workflow +# (Requirement 8.2), surfaces per-device Greengrass deployment status for +# the workflow page (Requirement 8.3), performs the pre-submit LocalServer +# compatibility check (Requirement 8.4), relies on Greengrass revision +# semantics for version replacement (Requirement 8.5), and writes audit +# log entries for deploy operations (Requirement 11.5). +# +# The association record shape matches what workflows.py's delete flow +# scans for: component_type == 'workflow' plus workflow_id (Requirement 5.6). +# =========================================================================== + + +def _workflow_error(status_code, code, message, details=None): + """Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def _decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: _decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_decimal_to_native(i) for i in obj] + return obj + + +# --------------------------------------------------------------------------- +# Plugin lifecycle and architecture gates (custom-node-designer) +# +# Both gates extend the pre-submit pass that already inspects each target +# device before submission (check_local_server_compatibility): the +# lifecycle gate evaluates the Lifecycle_State of every depended-on +# Plugin_Component over the dependency closure (9.7, 9.8, 9.11, 16.3), +# and the architecture gate checks each target device's recorded +# Target_Architecture against the platform manifests of every depended-on +# Plugin_Component version (16.6). Greengrass dependency resolution +# delivers the depended-on Plugin_Component versions with workflow +# deployments (16.5), so the gates are the only deployment-side change. +# +# evaluate_plugin_lifecycle_gate and evaluate_plugin_arch_gate are pure +# over plain dicts so the gate decision logic is property-testable +# without AWS (tasks 10.6 / 10.7). +# --------------------------------------------------------------------------- + + +def evaluate_plugin_lifecycle_gate(closure_states, device_flags): + """ + Lifecycle gate over the dependency closure (9.7, 9.8, 9.11, 16.3). + + ``closure_states``: {plugin component identifier: Lifecycle_State} + ``device_flags``: {device thing name: bool test_device flag} + + Returns [] when the deployment may be submitted, otherwise the + complete list of violations, each identifying the Plugin_Component + and its Lifecycle_State plus the offending target devices: + + - dev state (or unknown — fail closed) is rejected for any target; + - test state is permitted only to devices flagged ``test_device``; + - prod deploys anywhere in the Use_Case. + """ + violations = [] + devices = sorted(device_flags) + for component in sorted(closure_states): + state = closure_states[component] + if state == LIFECYCLE_PROD: + continue + if state == LIFECYCLE_TEST: + offending = [d for d in devices if not device_flags[d]] + if offending: + violations.append({ + 'pluginComponent': component, + 'lifecycleState': state, + 'devices': offending, + }) + else: + # dev, or an unresolvable/unknown state: fail closed for + # every target device. + violations.append({ + 'pluginComponent': component, + 'lifecycleState': state, + 'devices': devices, + }) + return violations + + +def evaluate_plugin_arch_gate(component_manifests, device_archs): + """ + Architecture gate (16.6): each target device's recorded + Target_Architecture must appear in the platform manifests of every + depended-on Plugin_Component version. + + ``component_manifests``: {plugin component name: + {'version': component version, 'architectures': [archs]}} + ``device_archs``: {device thing name: recorded Target_Architecture + or None when the device has none recorded} + + Architectures are matched by exact name — ``x86_64`` and + ``x86_64_nvidia`` are distinct with no fallback in either direction. + A device with no recorded Target_Architecture fails closed. + + Returns [] when every device is covered, otherwise one offending + entry {pluginComponent, version, device, deviceArch} per + (component, device) miss. + """ + offending = [] + for name in sorted(component_manifests): + manifest = component_manifests[name] + supported = set(manifest.get('architectures') or []) + for device in sorted(device_archs): + device_arch = device_archs[device] + if device_arch not in supported: + offending.append({ + 'pluginComponent': name, + 'version': manifest.get('version'), + 'device': device, + 'deviceArch': device_arch, + }) + return offending + + +def parse_plugin_component_ref(component_name, component_version): + """(plugin_id, Plugin_Record version) of a dda.plugin.* component + reference. The record version is the leading integer of the component + version ({pluginVersion}.0.0); None when it cannot be derived.""" + if not str(component_name).startswith(PLUGIN_COMPONENT_PREFIX): + return None, None + plugin_id = str(component_name)[len(PLUGIN_COMPONENT_PREFIX):] + try: + record_version = int(str(component_version).split('.')[0]) + except (TypeError, ValueError): + return plugin_id, None + return plugin_id, record_version + + +def load_plugin_record(plugin_id, record_version): + """The backing Plugin_Record of one Plugin_Component version, or None + (gates fail closed on unresolvable records).""" + if not PLUGIN_RECORDS_TABLE or not plugin_id or record_version is None: + return None + try: + response = dynamodb.Table(PLUGIN_RECORDS_TABLE).get_item( + Key={'plugin_id': plugin_id, 'version': record_version}) + except ClientError as e: + logger.warning( + f"Could not read plugin record {plugin_id} v{record_version}: {e}") + return None + item = response.get('Item') + return _decimal_to_native(item) if item else None + + +def plugin_component_architectures(record): + """The Target_Architectures a Plugin_Component version's platform + manifests cover. The component pointer's ``architectures`` list is + written by plugin_components.py as exactly the successfully built + architectures the recipe carries manifests for (16.1); fall back to + the per-arch artifact entries when the pointer is absent.""" + if not record: + return [] + component = record.get('component') or {} + architectures = component.get('architectures') + if architectures: + return [str(arch) for arch in architectures] + artifacts = record.get('artifacts') or {} + return sorted(arch for arch, entry in artifacts.items() + if (entry or {}).get('buildStatus') == 'succeeded') + + +def load_device_gate_info(thing_names): + """The Devices-table gate attributes of each target device: the + UseCaseAdmin-set ``test_device`` flag (9.8) and the recorded + ``target_architecture`` (16.6). Devices without a record fail closed + (not a Test_Device; no recorded architecture).""" + device_flags = {} + device_archs = {} + table = dynamodb.Table(DEVICES_TABLE) if DEVICES_TABLE else None + for thing_name in thing_names: + item = None + if table is not None: + try: + item = table.get_item(Key={'device_id': thing_name}).get('Item') + except ClientError as e: + logger.warning( + f"Could not read device record for {thing_name}: {e}") + item = item or {} + device_flags[thing_name] = bool(item.get('test_device')) + device_archs[thing_name] = item.get('target_architecture') or None + return device_flags, device_archs + + +def check_plugin_deployment_gates(plugin_components, thing_names): + """ + Pre-submit Plugin_Component gates over a deployment's dependency + closure (9.7, 9.8, 9.11, 16.3, 16.6). ``plugin_components`` maps each + depended-on Plugin_Component name to its component version — a + Workflow_Component's recorded closure, or the dda.plugin.* components + of a standalone deployment. Returns an error response when the + deployment must be rejected, otherwise None. + """ + if not plugin_components: + return None + + device_flags, device_archs = load_device_gate_info(thing_names) + + closure = {} + for component_name in sorted(plugin_components): + component_version = plugin_components[component_name] + plugin_id, record_version = parse_plugin_component_ref( + component_name, component_version) + record = load_plugin_record(plugin_id, record_version) + closure[component_name] = { + 'version': str(component_version), + 'plugin_id': plugin_id, + 'lifecycle_state': (record or {}).get('lifecycle_state'), + 'architectures': plugin_component_architectures(record), + } + + # Lifecycle gate over the closure (9.7, 9.8, 9.11, 16.3) + closure_states = {name: info['lifecycle_state'] + for name, info in closure.items()} + violations = evaluate_plugin_lifecycle_gate(closure_states, device_flags) + if violations: + detailed = [dict(v, + version=closure[v['pluginComponent']]['version'], + plugin_id=closure[v['pluginComponent']]['plugin_id']) + for v in violations] + return _workflow_error( + 409, 'PLUGIN_LIFECYCLE_VIOLATION', + 'One or more depended-on plugin components are not deployable ' + 'to the requested target devices in their current lifecycle ' + 'state; the deployment was not submitted', + {'violations': detailed}) + + # Architecture gate: recorded device Target_Architecture vs the + # platform manifests of every depended-on Plugin_Component (16.6) + component_manifests = { + name: {'version': info['version'], + 'architectures': info['architectures']} + for name, info in closure.items() + } + unsupported = evaluate_plugin_arch_gate(component_manifests, device_archs) + if unsupported: + return _workflow_error( + 409, 'PLUGIN_ARCH_UNSUPPORTED', + 'One or more target devices have no published Plugin_Artifact ' + 'for their recorded Target_Architecture in a depended-on ' + 'plugin component version; the deployment was not submitted', + {'unsupported': unsupported}) + + return None + + +def record_plugin_deployment(deployment_id, usecase_id, plugin_components, + target_arn, target_devices, target_thing_group, + is_revision, superseded_deployment_id, user): + """ + Record a standalone Plugin_Component deployment in the Deployments + table with component_type: 'plugin' (task 10.5). Mirrors the + workflow association record shape. + """ + timestamp = int(datetime.utcnow().timestamp() * 1000) + names = sorted(plugin_components) + item = { + 'deployment_id': deployment_id, + 'usecase_id': usecase_id, + 'component_type': 'plugin', + 'plugin_components': {name: str(plugin_components[name]) + for name in names}, + 'component_name': names[0], + 'component_version': str(plugin_components[names[0]]), + 'target_arn': target_arn, + 'target_devices': list(target_devices), + 'target_thing_group': target_thing_group or None, + 'status': 'IN_PROGRESS', + 'deployment_status': 'IN_PROGRESS', + 'is_revision': is_revision, + 'superseded_deployment_id': superseded_deployment_id, + 'created_by': user['user_id'], + 'created_at': timestamp, + 'updated_at': timestamp + } + dynamodb.Table(DEPLOYMENTS_TABLE).put_item(Item=item) + return item + + +def has_workflow_permission(user, usecase_id, permission): + """Check a workflow permission for the acting user on a Use_Case""" + if is_super_user(user['user_id']): + return True + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, + user_info=user) + + +def get_workflow_metadata(workflow_id): + """Fetch a Workflows-table metadata item, or None""" + if not WORKFLOWS_TABLE: + return None + try: + response = dynamodb.Table(WORKFLOWS_TABLE).get_item( + Key={'workflow_id': workflow_id}) + except ClientError as e: + logger.error(f"Error reading workflow {workflow_id}: {str(e)}") + return None + item = response.get('Item') + return _decimal_to_native(item) if item else None + + +def workflow_component_name(workflow_id): + """Greengrass component name assigned by the Component_Packager""" + return f"{WORKFLOW_COMPONENT_PREFIX}{workflow_id}" + + +def workflow_component_version(workflow_version): + """Component version derived from the workflow version""" + return f"{int(workflow_version)}.0.0" + + +def resolve_target_thing_names(iot_client, target_devices, target_thing_group): + """ + The individual device thing names a deployment targets: the explicit + device list, or the current members of the targeted thing group. + """ + if target_devices: + return list(target_devices) + thing_names = [] + if target_thing_group: + try: + paginator = iot_client.get_paginator('list_things_in_thing_group') + for page in paginator.paginate(thingGroupName=target_thing_group, + maxResults=100): + thing_names.extend(page.get('things', [])) + except ClientError as e: + logger.warning( + f"Could not list things in group {target_thing_group}: {e}") + return thing_names + + +def get_device_local_server_version(greengrass_client, thing_name): + """ + The LocalServer component version installed on a core device + (components are named aws.edgeml.dda.LocalServer.), or None when + no LocalServer component is reported installed. + + Raises ClientError when the installed components cannot be listed, so + the caller can distinguish "no LocalServer" from "could not determine". + """ + paginator = greengrass_client.get_paginator('list_installed_components') + for page in paginator.paginate(coreDeviceThingName=thing_name): + for comp in page.get('installedComponents', []): + name = comp.get('componentName', '') + if name.startswith(LOCAL_SERVER_COMPONENT_PREFIX): + return comp.get('componentVersion') + return None + + +def check_local_server_compatibility(greengrass_client, thing_names, + min_local_server_version): + """ + Pre-submit compatibility check (Requirement 8.4): compare each target + device's installed LocalServer component version against the + Workflow_Component's minLocalServerVersion. Returns the list of + incompatible devices, each with a clear reason; an empty list means + every checked device is compatible. + """ + incompatible = [] + min_key = _version_key(min_local_server_version) + for thing_name in thing_names: + try: + installed = get_device_local_server_version(greengrass_client, thing_name) + except ClientError as e: + logger.warning( + f"Could not read installed components for {thing_name}: {e}") + incompatible.append({ + 'device': thing_name, + 'local_server_version': None, + 'min_local_server_version': min_local_server_version, + 'reason': ('The installed LocalServer version could not be ' + 'determined for this device') + }) + continue + if installed is None: + incompatible.append({ + 'device': thing_name, + 'local_server_version': None, + 'min_local_server_version': min_local_server_version, + 'reason': 'No LocalServer component is installed on this device' + }) + elif _version_key(installed) < min_key: + incompatible.append({ + 'device': thing_name, + 'local_server_version': installed, + 'min_local_server_version': min_local_server_version, + 'reason': (f'Installed LocalServer version {installed} is older ' + f'than the required minimum {min_local_server_version}') + }) + return incompatible + + +def record_workflow_deployment(deployment_id, usecase_id, workflow_id, + workflow_version, target_arn, target_devices, + target_thing_group, is_revision, + superseded_deployment_id, user, + camera_bindings=None): + """ + Record the workflow version -> deployment -> devices association in the + Deployments table (Requirement 8.2). component_type: 'workflow' plus + workflow_id is the shape workflows.py's delete flow matches on (5.6). + Delivered Camera_Bindings are stored on the record for display and + audit (camera-registry-sync Requirements 8.2, 12.3). + """ + timestamp = int(datetime.utcnow().timestamp() * 1000) + item = { + 'deployment_id': deployment_id, + 'usecase_id': usecase_id, + 'component_type': 'workflow', + 'workflow_id': workflow_id, + 'workflow_version': int(workflow_version), + 'component_name': workflow_component_name(workflow_id), + 'component_version': workflow_component_version(workflow_version), + 'target_arn': target_arn, + 'target_devices': target_devices, + 'target_thing_group': target_thing_group or None, + # 'status' feeds the status-index GSI; 'deployment_status' is what + # workflows.py's active-deployment check reads. + 'status': 'IN_PROGRESS', + 'deployment_status': 'IN_PROGRESS', + 'is_revision': is_revision, + 'superseded_deployment_id': superseded_deployment_id, + 'created_by': user['user_id'], + 'created_at': timestamp, + 'updated_at': timestamp + } + if camera_bindings is not None: + item['camera_bindings'] = _dynamo_safe(camera_bindings) + dynamodb.Table(DEPLOYMENTS_TABLE).put_item(Item=item) + return item + + +# --------------------------------------------------------------------------- +# Deploy-time Camera_Binding validation (camera-registry-sync +# Requirements 8.3, 8.4, 8.7, 8.8, 8.9, 9.1-9.5, 11.1) +# +# validate_camera_bindings is a pure function over plain dicts (like the +# plugin gates above) so the binding decision logic is property-testable +# without AWS. The workflow version item's packager-recorded discriminator +# separates the two regimes: +# +# - has_binding_points true: every Camera_Input_Node needs a Camera_Binding +# (registered source or manual override) per target device — errors +# reject the deployment (8.7, 9.1, 9.2, 9.4, 8.4); degraded sources and +# never-synced targets produce warnings that must be confirmed (9.3, 8.8). +# - has_binding_points false (legacy versions): the compiled-in device +# paths are compared against each target's registry and unmatched paths +# produce warnings only, never errors (9.5, 11.1). +# +# Workflow versions with no Camera_Input_Nodes produce no errors and no +# warnings (8.9). +# --------------------------------------------------------------------------- + +# Error codes (deployment rejected) +CAMERA_ERROR_UNBOUND = 'CAMERA_NODE_UNBOUND' # 8.7 +CAMERA_ERROR_SOURCE_MISSING = 'CAMERA_SOURCE_MISSING' # 9.1, 9.2 +CAMERA_ERROR_TYPE_INCOMPATIBLE = 'CAMERA_TYPE_INCOMPATIBLE' # 9.4 +CAMERA_ERROR_OVERRIDE_INVALID = 'CAMERA_OVERRIDE_INVALID' # 8.4 + +# Warning codes (require a matching confirmed_warnings id to submit) +CAMERA_WARNING_SOURCE_DEGRADED = 'CAMERA_SOURCE_DEGRADED' # 9.3 +CAMERA_WARNING_NEVER_SYNCED = 'DEVICE_NEVER_SYNCED' # 8.8 +CAMERA_WARNING_LEGACY_PATH = 'COMPILED_PATH_UNREGISTERED' # 9.5 + +#: Camera_Source types (registry ``type`` attribute) compatible with each +#: built-in Camera_Input_Node type. camera_source captures from a device +#: camera (v4l2src, the JP4/5 camera adapter, or the JP6 CSI host +#: service), so folder and network-stream sources cannot back it; a +#: registered GenICam camera (AravisDiscovered) is a legitimate +#: camera-backed source for it on the adapter-fed architectures +#: (aravis-camera-input Requirement 5.3). aravis_camera_source must bind +#: to an Aravis-backed source: a discovered bus camera or a configured +#: Camera-type Image_Source (aravis-camera-input Requirement 5.2). +_CAMERA_COMPATIBLE_SOURCE_TYPES = { + 'camera_source': frozenset({'Camera', 'ICam', 'NvidiaCSI', + 'V4L2Discovered', 'AravisDiscovered'}), + 'aravis_camera_source': frozenset({'Camera', 'AravisDiscovered'}), +} + +#: Camera_Source types that are never a camera. Custom camera-backed node +#: types declare no backing transport, so only the categorically +#: incompatible Folder type (Requirement 9.4's example) is rejected for +#: them; everything else is accepted. +_NEVER_CAMERA_SOURCE_TYPES = frozenset({'Folder'}) + +#: Registry-entry param keys that may carry the source's device path +#: (the edge report shape writes ``devicePath``; ``device`` is accepted +#: for parity with the node parameter name). +_DEVICE_PATH_PARAM_KEYS = ('devicePath', 'device') + + +def _registry_device_paths(cameras): + """Every device path registered in one device's camera map.""" + paths = set() + for entry in cameras.values(): + params = (entry or {}).get('params') or {} + for key in _DEVICE_PATH_PARAM_KEYS: + value = params.get(key) + if isinstance(value, str) and value: + paths.add(value) + return paths + + +def _camera_source_type_compatible(node_type, source_type): + """Whether a Camera_Source type may back a Camera_Input_Node type + (Requirement 9.4).""" + compatible = _CAMERA_COMPATIBLE_SOURCE_TYPES.get(node_type) + if compatible is not None: + return source_type in compatible + return source_type not in _NEVER_CAMERA_SOURCE_TYPES + + +def _degraded_source_conditions(entry): + """The Requirement 9.3 warning conditions a registry entry is in: + absent, stale, and/or sync status pending/failed.""" + conditions = [] + if entry.get('absent'): + conditions.append('absent') + if entry.get('stale'): + conditions.append('stale') + if entry.get('sync_status') in ('pending', 'failed'): + conditions.append(entry['sync_status']) + return conditions + + +def _camera_node_descriptor(node_type, descriptors): + """The NodeTypeDescriptor of a Camera_Input_Node type: the caller's + merged-catalog mapping when it covers the type (camera-backed + Custom_Node_Types), otherwise the built-in workflow_core catalog. + None when unresolvable — override validation fails closed.""" + if descriptors and node_type in descriptors: + return descriptors[node_type] + # Imported lazily: only override validation needs the workflow_core + # layer; every other deployments.py path stays importable without it. + from workflow_core.catalog import get_node_type + return get_node_type(node_type) + + +def _override_errors(thing_name, node_id, node_type, override, descriptors): + """Manual-override constraint violations (Requirement 8.4): each + supplied value is checked against the node type's declared parameter + constraints with the workflow_core parameter validator. Values are + validated as supplied — the compiled document keeps rendered defaults + for parameters an override omits.""" + descriptor = _camera_node_descriptor(node_type, descriptors) + if descriptor is None: + # Fail closed, matching the plugin gates' unresolvable-record rule. + return [{ + 'code': CAMERA_ERROR_OVERRIDE_INVALID, + 'device': thing_name, + 'nodeId': node_id, + 'message': (f"Override values for camera input node '{node_id}' " + f"on device '{thing_name}' cannot be validated: no " + f"parameter declaration is available for node type " + f"'{node_type}'"), + }] + from workflow_core.validator import check_parameter_value + parameters = {p.name: p for p in descriptor.parameters} + errors = [] + for name in sorted(override): + parameter = parameters.get(name) + if parameter is None: + errors.append({ + 'code': CAMERA_ERROR_OVERRIDE_INVALID, + 'device': thing_name, + 'nodeId': node_id, + 'parameter': name, + 'message': (f"Override for camera input node '{node_id}' on " + f"device '{thing_name}' sets '{name}', which is " + f"not a declared parameter of node type " + f"'{node_type}'"), + }) + continue + violation = check_parameter_value(parameter, override[name]) + if violation is not None: + errors.append({ + 'code': CAMERA_ERROR_OVERRIDE_INVALID, + 'device': thing_name, + 'nodeId': node_id, + 'parameter': name, + 'violation': violation.code, + 'message': (f"Override for camera input node '{node_id}' on " + f"device '{thing_name}': {violation.message}"), + }) + return errors + + +def _legacy_path_warnings(camera_nodes, thing_name, cameras, confirmed_ids): + """Requirement 9.5 / 11.1: for a version without binding points, each + compiled-in device path that matches no registered Camera_Source on + the target produces a warning — never an error.""" + registered_paths = _registry_device_paths(cameras) + warnings = [] + for node in camera_nodes: + node_id = node.get('node_id') + compiled_paths = node.get('compiled_device_paths') or {} + unmatched = {} + for arch in sorted(compiled_paths): + path = compiled_paths[arch] + if isinstance(path, str) and path and path not in registered_paths: + unmatched.setdefault(path, []).append(arch) + for path in sorted(unmatched): + warning_id = f'legacy-path:{thing_name}:{node_id}:{path}' + warnings.append({ + 'id': warning_id, + 'code': CAMERA_WARNING_LEGACY_PATH, + 'device': thing_name, + 'nodeId': node_id, + 'path': path, + 'architectures': unmatched[path], + 'confirmed': warning_id in confirmed_ids, + 'message': (f"Compiled-in device path '{path}' of camera " + f"input node '{node_id}' matches no camera " + f"registered on device '{thing_name}'"), + }) + return warnings + + +def validate_camera_bindings(version_item, targets, registry_snapshot, + bindings, confirmed, descriptors=None): + """ + Pre-submit Camera_Binding validation (camera-registry-sync + Requirements 8.3, 8.4, 8.7, 8.8, 8.9, 9.1-9.5, 11.1). Pure over its + inputs; returns ``(errors, warnings)``. + + ``version_item``: the workflow version item carrying the + packager-recorded ``has_binding_points`` flag and + ``camera_input_nodes`` records ({node_id, node_type, + binding_hint?, compiled_device_paths: {arch: path}}). + ``targets``: the target device thing names. + ``registry_snapshot``: {thing_name: {'never_synced': bool, 'cameras': + {camera_source_id: registry entry}}} with per-entry ``stale`` + precomputed by the caller against the Staleness_Threshold (the + function itself is time-free). A device absent from the snapshot + is treated as never synced with an empty registry (fail-safe). + ``bindings``: {thing_name: {node_id: {'cameraSourceId': id} | + {'override': {param: value}}}}. When both keys are present the + registered-source binding is authoritative. + ``confirmed``: the submitted ``confirmed_warnings`` ids; each returned + warning carries ``confirmed`` so the caller accepts the deployment + only when every warning is confirmed. + ``descriptors``: optional {node_type: NodeTypeDescriptor} for + camera-backed Custom_Node_Types (built-ins resolve from the + workflow_core catalog). + + Errors reject the deployment: unbound Camera_Input_Node on any target + (8.7); referenced cameraSourceId absent from the target's registry + (9.1, 9.2, and the never-synced manual-override restriction of 8.8); + Camera_Source type incompatible with the node type (9.4); override + values violating declared parameter constraints (8.4). Distinct + bindings per device for the same node are the natural map shape (8.3). + """ + camera_nodes = (version_item or {}).get('camera_input_nodes') or [] + if not camera_nodes: + # No Camera_Input_Nodes: deployment proceeds without bindings (8.9) + return [], [] + + confirmed_ids = set(confirmed or []) + bindings = bindings or {} + registry_snapshot = registry_snapshot or {} + errors = [] + warnings = [] + + for thing_name in sorted(targets or []): + device_snapshot = registry_snapshot.get(thing_name) + cameras = (device_snapshot or {}).get('cameras') or {} + if device_snapshot is None: + never_synced = True + else: + never_synced = bool(device_snapshot.get('never_synced')) + + if not version_item.get('has_binding_points'): + # Legacy regime: compiled-in path comparison, warnings only + # (9.5, 11.1) + warnings.extend(_legacy_path_warnings( + camera_nodes, thing_name, cameras, confirmed_ids)) + continue + + if never_synced and not cameras: + # 8.8: warn, and permit binding only through manual override + # (a cameraSourceId binding below fails the existence check + # against the empty registry). + warning_id = f'never-synced:{thing_name}' + warnings.append({ + 'id': warning_id, + 'code': CAMERA_WARNING_NEVER_SYNCED, + 'device': thing_name, + 'confirmed': warning_id in confirmed_ids, + 'message': (f"Device '{thing_name}' has never completed a " + f"camera registry synchronization; camera " + f"bindings are restricted to manual override"), + }) + + device_bindings = bindings.get(thing_name) or {} + for node in camera_nodes: + node_id = node.get('node_id') + node_type = node.get('node_type') + binding = device_bindings.get(node_id) + camera_source_id = None + override = None + if isinstance(binding, dict): + if isinstance(binding.get('cameraSourceId'), str) \ + and binding['cameraSourceId']: + camera_source_id = binding['cameraSourceId'] + elif isinstance(binding.get('override'), dict): + override = binding['override'] + + if camera_source_id is None and override is None: + # Neither a selected Camera_Source nor a manual override (8.7) + errors.append({ + 'code': CAMERA_ERROR_UNBOUND, + 'device': thing_name, + 'nodeId': node_id, + 'message': (f"Camera input node '{node_id}' has no camera " + f"binding for target device '{thing_name}'"), + }) + continue + + if override is not None: + errors.extend(_override_errors( + thing_name, node_id, node_type, override, descriptors)) + continue + + entry = cameras.get(camera_source_id) + if entry is None: + # Referenced source not in the target's registry (9.1, 9.2) + errors.append({ + 'code': CAMERA_ERROR_SOURCE_MISSING, + 'device': thing_name, + 'nodeId': node_id, + 'cameraSourceId': camera_source_id, + 'message': (f"Camera source '{camera_source_id}' bound to " + f"node '{node_id}' is not registered on " + f"device '{thing_name}'"), + }) + continue + + source_type = entry.get('type') + if not _camera_source_type_compatible(node_type, source_type): + errors.append({ + 'code': CAMERA_ERROR_TYPE_INCOMPATIBLE, + 'device': thing_name, + 'nodeId': node_id, + 'cameraSourceId': camera_source_id, + 'sourceType': source_type, + 'nodeType': node_type, + 'message': (f"Camera source '{camera_source_id}' of type " + f"'{source_type}' is not compatible with " + f"node '{node_id}' of type '{node_type}' on " + f"device '{thing_name}'"), + }) + + conditions = _degraded_source_conditions(entry) + if conditions: + # 9.3: degraded source needs explicit confirmation + warning_id = (f"camera-degraded:{thing_name}:{node_id}:" + f"{camera_source_id}:{'+'.join(conditions)}") + warnings.append({ + 'id': warning_id, + 'code': CAMERA_WARNING_SOURCE_DEGRADED, + 'device': thing_name, + 'nodeId': node_id, + 'cameraSourceId': camera_source_id, + 'conditions': conditions, + 'confirmed': warning_id in confirmed_ids, + 'message': (f"Camera source '{camera_source_id}' bound to " + f"node '{node_id}' on device '{thing_name}' " + f"is {', '.join(conditions)}"), + }) + + return errors, warnings + + +# --------------------------------------------------------------------------- +# Camera_Registry snapshot, binding context, and Camera_Binding delivery +# (camera-registry-sync task 11.7 — Requirements 8.1, 8.2, 8.5, 8.6, 12.3) +# --------------------------------------------------------------------------- + +# Per-device Camera_Registry written by the Portal_Sync_Service +# (camera_sync.py); read here for the binding context endpoint and the +# pre-submit binding validation. +CAMERA_REGISTRY_TABLE = os.environ.get('CAMERA_REGISTRY_TABLE') +SETTINGS_TABLE = os.environ.get('SETTINGS_TABLE') + +# Item-type SK prefixes of the dda-portal-camera-registry table. +CAMERA_SK_META = 'META' +CAMERA_SK_PREFIX = 'CAMERA#' + +# Staleness_Threshold setting (camera_registry.py owns the same key): +# per-entry ``stale`` is computed here before the snapshot reaches the +# time-free validate_camera_bindings (Req 4.1 feeding 9.3). +CAMERA_STALENESS_SETTING_KEY = 'camera_registry.staleness_threshold_hours' +CAMERA_DEFAULT_STALENESS_HOURS = 24 + +# Camera_Binding delivery shadow: desired.bindings["{workflowId}/{version}"] +# per target thing, written at deployment submission (Req 8.6). +CAMERA_BINDINGS_SHADOW_NAME = 'dda-camera-bindings' + +# Rejection codes of the submission flow +CAMERA_ERROR_REGISTRY_UNAVAILABLE = 'REGISTRY_UNAVAILABLE' +CAMERA_ERROR_BINDING_DELIVERY = 'BINDING_DELIVERY_FAILED' +CAMERA_ERROR_BINDINGS_INVALID = 'CAMERA_BINDINGS_INVALID' +CAMERA_ERROR_WARNINGS_UNCONFIRMED = 'CAMERA_WARNINGS_UNCONFIRMED' + + +class CameraRegistryUnavailable(Exception): + """The Camera_Registry could not be read. Submission rejects with + REGISTRY_UNAVAILABLE rather than skipping binding validation.""" + + +def _dynamo_safe(obj): + """A JSON-shaped value with floats as Decimal, safe for DynamoDB.""" + return json.loads(json.dumps(obj), parse_float=Decimal) + + +def _camera_staleness_threshold_ms(): + """The configured Staleness_Threshold in milliseconds (default 24 h). + + A settings-read failure degrades to the default: staleness only grades + warnings, so it must not turn into a registry-availability rejection. + """ + hours = CAMERA_DEFAULT_STALENESS_HOURS + if SETTINGS_TABLE: + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': CAMERA_STALENESS_SETTING_KEY}) + value = (response.get('Item') or {}).get('value') + if value is not None and float(value) > 0: + hours = float(value) + except (ClientError, TypeError, ValueError) as e: + logger.warning(f"Could not read staleness threshold setting: {e}") + return hours * 3600 * 1000 + + +def load_camera_registry_snapshot(thing_names): + """The per-target Camera_Registry snapshot consumed by + validate_camera_bindings and the binding context endpoint: + ``{thing_name: {'never_synced': bool, 'cameras': {csid: entry}}}`` + with per-entry ``stale`` precomputed against the Staleness_Threshold. + + Raises CameraRegistryUnavailable when the registry cannot be read — + the caller must reject the submission, never skip validation. + """ + if not CAMERA_REGISTRY_TABLE: + raise CameraRegistryUnavailable( + 'Camera registry table is not configured') + table = dynamodb.Table(CAMERA_REGISTRY_TABLE) + threshold_ms = _camera_staleness_threshold_ms() + now = int(datetime.utcnow().timestamp() * 1000) + snapshot = {} + for thing_name in thing_names: + items = [] + kwargs = {'KeyConditionExpression': Key('device_id').eq(thing_name)} + try: + while True: + response = table.query(**kwargs) + items.extend(response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + except ClientError as e: + raise CameraRegistryUnavailable( + f"Camera registry read failed for device " + f"'{thing_name}': {e}") from e + meta = next((item for item in items + if item.get('sk') == CAMERA_SK_META), None) + cameras = {} + for item in items: + sk = item.get('sk') or '' + if not sk.startswith(CAMERA_SK_PREFIX): + continue + entry = _decimal_to_native(dict(item)) + last_reported_at = entry.get('last_reported_at') + entry['stale'] = (last_reported_at is not None + and (now - int(last_reported_at)) > threshold_ms) + csid = entry.get('camera_source_id') or sk[len(CAMERA_SK_PREFIX):] + cameras[csid] = entry + snapshot[thing_name] = { + 'never_synced': meta is None or bool(meta.get('never_synced', True)), + 'cameras': cameras, + } + return snapshot + + +def _binding_camera_view(csid, entry): + """One registry entry as a selectable binding option (Reqs 8.1, 7.4 + display fields plus the degraded-condition inputs of 9.3).""" + view = { + 'camera_source_id': csid, + 'name': entry.get('name'), + 'type': entry.get('type'), + 'params': entry.get('params') or {}, + 'capabilities': entry.get('capabilities') or {}, + 'origin': entry.get('origin'), + 'sync_status': entry.get('sync_status'), + 'last_reported_at': entry.get('last_reported_at'), + 'absent': bool(entry.get('absent', False)), + 'stale': bool(entry.get('stale', False)), + } + if view['absent'] and entry.get('absent_since') is not None: + view['absent_since'] = entry['absent_since'] + return view + + +def get_camera_binding_context(user, query_params): + """ + GET /deployments?view=binding-context — the CreateDeployment binding + matrix's data source (camera-registry-sync Requirements 8.1, 8.5, 8.9). + + Query: usecase_id, workflow_id, workflow_version? (defaults to the + latest version), and target_devices (comma-separated thing names) or + target_thing_group. + + Returns, for each Camera_Input_Node of the workflow version and each + target Edge_Device, the device's registered Camera_Sources as binding + options (8.1), with a pre-selected cameraSourceId per node when the + node's binding hint matches a source present in that device's + registry (8.5). Versions without Camera_Input_Nodes return an empty + matrix so the frontend skips the step entirely (8.9). + """ + try: + usecase_id = query_params.get('usecase_id') + workflow_id = query_params.get('workflow_id') + if not usecase_id or not workflow_id: + return _workflow_error(400, 'MISSING_FIELDS', + 'usecase_id and workflow_id required') + + if not has_workflow_permission(user, usecase_id, + Permission.WORKFLOW_DEPLOY): + log_audit_event( + user['user_id'], 'unauthorized_access', 'workflow', + workflow_id, 'denied', { + 'required_permissions': [Permission.WORKFLOW_DEPLOY.value], + 'usecase_id': usecase_id, + 'operation': 'camera_binding_context' + }) + return _workflow_error(403, 'FORBIDDEN', + 'Insufficient permissions', { + 'required_permissions': + [Permission.WORKFLOW_DEPLOY.value], + 'usecase_id': usecase_id + }) + + workflow_item = get_workflow_metadata(workflow_id) + if not workflow_item or workflow_item.get('usecase_id') != usecase_id: + return _workflow_error(404, 'WORKFLOW_NOT_FOUND', + 'Workflow not found') + + version_param = query_params.get( + 'workflow_version', workflow_item.get('latest_version', 1)) + try: + workflow_version = int(version_param) + except (TypeError, ValueError): + return _workflow_error(400, 'INVALID_VERSION', + 'workflow_version must be an integer') + + version_item = workflow_guards.get_version_item( + workflow_id, workflow_version) + if not version_item: + return _workflow_error(404, 'VERSION_NOT_FOUND', + 'Workflow version not found') + version_item = _decimal_to_native(version_item) + + camera_nodes = version_item.get('camera_input_nodes') or [] + node_views = [] + for node in camera_nodes: + node_view = { + 'node_id': node.get('node_id'), + 'node_type': node.get('node_type'), + } + if node.get('binding_hint'): + node_view['binding_hint'] = node['binding_hint'] + node_views.append(node_view) + + context = { + 'workflow_id': workflow_id, + 'workflow_version': workflow_version, + 'has_binding_points': bool(version_item.get('has_binding_points')), + # Frontend skip discriminator: no Camera_Input_Nodes, no + # binding step (8.9). + 'binding_required': bool(version_item.get('has_binding_points') + and camera_nodes), + 'camera_input_nodes': node_views, + 'targets': {}, + } + if not camera_nodes: + return create_response(200, context) + + target_devices = [name for name in + (query_params.get('target_devices') or '').split(',') + if name] + target_thing_group = query_params.get('target_thing_group') + if not target_devices and not target_thing_group: + return _workflow_error( + 400, 'MISSING_FIELDS', + 'Either target_devices or target_thing_group required') + + usecase = get_usecase(usecase_id) + if not usecase: + return _workflow_error(404, 'USECASE_NOT_FOUND', + 'Use case not found') + if target_devices: + resolved_devices = list(target_devices) + else: + region = get_usecase_region(usecase) + iot_client = get_usecase_client('iot', usecase, region=region) + resolved_devices = resolve_target_thing_names( + iot_client, [], target_thing_group) + + try: + registry_snapshot = load_camera_registry_snapshot(resolved_devices) + except CameraRegistryUnavailable as e: + logger.error(f"Camera registry unavailable: {e}") + return _workflow_error( + 503, CAMERA_ERROR_REGISTRY_UNAVAILABLE, + 'The camera registry could not be read for the target ' + 'devices', {'reason': str(e)}) + + for thing_name in resolved_devices: + device_snapshot = registry_snapshot.get(thing_name) or {} + cameras = device_snapshot.get('cameras') or {} + camera_views = sorted( + (_binding_camera_view(csid, entry) + for csid, entry in cameras.items()), + key=lambda c: (c.get('name') or '', c['camera_source_id'])) + # Hint-matching pre-selection (8.5): the node's recorded + # binding hint proposes a source only when that source is + # present in THIS device's registry; the user confirms or + # changes it in the matrix. + preselected = {} + for node in camera_nodes: + hint = node.get('binding_hint') or {} + hinted_csid = hint.get('cameraSourceId') + if hinted_csid and hinted_csid in cameras: + preselected[node['node_id']] = hinted_csid + context['targets'][thing_name] = { + 'state': ('never-synced' + if device_snapshot.get('never_synced', True) + else 'synced'), + 'cameras': camera_views, + 'preselected': preselected, + } + + return create_response(200, context) + + except Exception as e: + logger.error(f"Error building camera binding context: {str(e)}", + exc_info=True) + return _workflow_error(500, 'INTERNAL_ERROR', + 'Failed to build camera binding context') + + +def _workflow_binding_key(workflow_id, workflow_version): + """The dda-camera-bindings desired.bindings key of one deployed + workflow version (design: '{workflowId}/{version}').""" + return f"{workflow_id}/{int(workflow_version)}" + + +def _deployed_workflow_binding_keys(components_map): + """The binding keys of every Workflow_Component in a deployment's + component set — the keys that must survive a shadow prune.""" + keys = set() + for name in components_map or {}: + if not name.startswith(WORKFLOW_COMPONENT_PREFIX): + continue + wf_id = name[len(WORKFLOW_COMPONENT_PREFIX):] + version = str((components_map[name] or {}).get('componentVersion', '')) + major = version.split('.')[0] + if wf_id and major.isdigit(): + keys.add(_workflow_binding_key(wf_id, int(major))) + return keys + + +def _existing_binding_keys(iot_data, thing_name): + """The desired.bindings keys currently in one thing's camera-bindings + shadow (empty when the shadow does not exist yet).""" + try: + response = iot_data.get_thing_shadow( + thingName=thing_name, shadowName=CAMERA_BINDINGS_SHADOW_NAME) + payload = json.loads(response['payload'].read()) + except ClientError as e: + if e.response.get('Error', {}).get('Code') == 'ResourceNotFoundException': + return [] + raise + desired = (payload.get('state') or {}).get('desired') or {} + return list((desired.get('bindings') or {}).keys()) + + +def rollback_camera_bindings(iot_data, thing_names, binding_key): + """Best-effort prune of an aborted submission's already-written + binding keys (mid-submission failure handling, Req 8.6).""" + for thing_name in thing_names: + try: + iot_data.update_thing_shadow( + thingName=thing_name, + shadowName=CAMERA_BINDINGS_SHADOW_NAME, + payload=json.dumps({'state': {'desired': {'bindings': { + binding_key: None}}}})) + except Exception as e: # noqa: BLE001 — best-effort by contract + logger.warning( + f"Best-effort binding rollback failed for {thing_name}: {e}") + + +def deliver_camera_bindings(iot_data, thing_names, binding_key, + camera_bindings, deployed_keys): + """ + Write ``desired.bindings[binding_key]`` (this device's per-node + Camera_Bindings) into each target thing's dda-camera-bindings shadow + and prune keys for workflow versions no longer deployed to the device + (Reqs 8.2, 8.6). The packaged artifact is untouched — bindings travel + only in the shadow. + + Returns ``(written, failure)``: the things written so far and, on the + first failure, a failure record. On failure the already-written + targets are best-effort pruned and the caller aborts deployment + creation. + """ + written = [] + for thing_name in thing_names: + bindings_update = {binding_key: camera_bindings.get(thing_name) or {}} + try: + for key in _existing_binding_keys(iot_data, thing_name): + if key != binding_key and key not in deployed_keys: + # Version no longer deployed to this device: prune. + bindings_update[key] = None + iot_data.update_thing_shadow( + thingName=thing_name, + shadowName=CAMERA_BINDINGS_SHADOW_NAME, + payload=json.dumps( + {'state': {'desired': {'bindings': bindings_update}}}, + default=lambda o: (float(o) if isinstance(o, Decimal) + else str(o)))) + except Exception as e: # noqa: BLE001 — any write failure aborts + logger.error( + f"Camera binding shadow write failed for {thing_name}: {e}") + rollback_camera_bindings(iot_data, written, binding_key) + return written, {'device': thing_name, 'error': str(e)} + written.append(thing_name) + return written, None + + +def create_workflow_deployment(body, user): + """ + Deploy a packaged Workflow_Component to devices or thing groups within + the Use_Case (Requirements 8.1, 8.2, 8.4, 8.5, 11.5). + + Body: { + "component_type": "workflow", + "usecase_id": "...", + "workflow_id": "...", + "workflow_version": N, # defaults to the latest version + "target_devices": ["thing", ...] # or + "target_thing_group": "group", + "deployment_name": "..."?, # optional + "rollout_config": {...}? # optional, same as create_deployment + } + """ + try: + usecase_id = body.get('usecase_id') + workflow_id = body.get('workflow_id') + target_devices = body.get('target_devices', []) + target_thing_group = body.get('target_thing_group') + deployment_name = body.get('deployment_name', '') + + if not usecase_id: + return _workflow_error(400, 'MISSING_FIELDS', 'usecase_id required') + if not workflow_id: + return _workflow_error(400, 'MISSING_FIELDS', 'workflow_id required') + if not target_devices and not target_thing_group: + return _workflow_error( + 400, 'MISSING_FIELDS', + 'Either target_devices or target_thing_group required') + + # RBAC: workflow deployments require workflow:deploy + # (Operator, UseCaseAdmin, PortalAdmin — Requirements 11.2, 11.4) + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_DEPLOY): + log_audit_event( + user['user_id'], 'unauthorized_access', 'workflow', workflow_id, + 'denied', { + 'required_permissions': [Permission.WORKFLOW_DEPLOY.value], + 'usecase_id': usecase_id, + 'operation': 'deploy_workflow' + } + ) + return _workflow_error(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [Permission.WORKFLOW_DEPLOY.value], + 'usecase_id': usecase_id + }) + + # The workflow must exist and belong to the Use_Case being deployed + # into (device/thing-group targeting stays within the Use_Case, 8.1). + workflow_item = get_workflow_metadata(workflow_id) + if not workflow_item or workflow_item.get('usecase_id') != usecase_id: + return _workflow_error(404, 'WORKFLOW_NOT_FOUND', 'Workflow not found') + + version_param = body.get('workflow_version', + workflow_item.get('latest_version', 1)) + try: + workflow_version = int(version_param) + except (TypeError, ValueError): + return _workflow_error(400, 'INVALID_VERSION', + 'workflow_version must be an integer') + + # Deployment guard: only versions with a recorded passed-validation + # run and zero error findings may be deployed (Requirements 4.7, 4.10) + guard_failure = workflow_guards.check_workflow_version_validated( + workflow_id, workflow_version) + if guard_failure: + return _workflow_error( + guard_failure['status_code'], guard_failure['code'], + guard_failure['message'], guard_failure['details']) + + # The version must have been packaged as a Greengrass component + version_item = workflow_guards.get_version_item(workflow_id, workflow_version) + if not version_item or not version_item.get('component_arn'): + return _workflow_error( + 409, 'WORKFLOW_NOT_PACKAGED', + 'Workflow version has not been packaged as a Greengrass ' + 'component; package it before deploying', + {'workflow_id': workflow_id, 'version': workflow_version}) + + usecase = get_usecase(usecase_id) + if not usecase: + return _workflow_error(404, 'USECASE_NOT_FOUND', 'Use case not found') + + region = get_usecase_region(usecase) + account_id = usecase.get('account_id', '') + session_name = f"gg-wf-{user['user_id'][:20]}-{int(datetime.utcnow().timestamp())}"[:64] + greengrass_client = get_usecase_client( + 'greengrassv2', usecase, session_name=session_name, region=region) + iot_client = get_usecase_client( + 'iot', usecase, session_name=session_name, region=region) + + # Pre-submit LocalServer compatibility check (Requirement 8.4): + # every target device's installed LocalServer version must satisfy + # the component's minLocalServerVersion. The Component_Packager + # writes the same resolved value into the packaged manifest.json. + min_local_server = (version_item.get('min_local_server_version') + or WORKFLOW_MIN_LOCAL_SERVER_VERSION) + resolved_devices = resolve_target_thing_names( + iot_client, target_devices, target_thing_group) + incompatible = check_local_server_compatibility( + greengrass_client, resolved_devices, min_local_server) + if incompatible: + return _workflow_error( + 409, 'INCOMPATIBLE_LOCAL_SERVER', + 'One or more target devices do not have a LocalServer ' + 'component version compatible with this workflow component; ' + 'the deployment was not submitted', + { + 'workflow_id': workflow_id, + 'workflow_version': workflow_version, + 'min_local_server_version': min_local_server, + 'incompatible_devices': incompatible + }) + + # Plugin lifecycle + architecture gates over the dependency closure + # (custom-node-designer 9.7, 9.8, 9.11, 16.3, 16.6), alongside the + # minLocalServerVersion pass above. The Component_Packager records + # the workflow's depended-on Plugin_Components (dda.plugin.* name -> + # component version) on the version item; Greengrass dependency + # resolution delivers those versions with the deployment (16.5), so + # only the pre-submit gates run here. + plugin_closure = { + str(name): str(version) + for name, version in + (version_item.get('plugin_components') or {}).items() + } + gate_error = check_plugin_deployment_gates(plugin_closure, + resolved_devices) + if gate_error: + return gate_error + + # Deploy-time Camera_Binding validation (camera-registry-sync + # 8.3, 8.4, 8.7-8.9, 9.1-9.5), alongside the pre-submit gates + # above. Versions without Camera_Input_Nodes skip the step + # entirely (8.9). A registry read failure rejects the submission + # with REGISTRY_UNAVAILABLE — validation is never skipped. + camera_bindings = body.get('camera_bindings') or {} + confirmed_warnings = body.get('confirmed_warnings') or [] + native_version_item = _decimal_to_native(version_item) + camera_nodes = native_version_item.get('camera_input_nodes') or [] + camera_warnings = [] + if camera_nodes: + try: + registry_snapshot = load_camera_registry_snapshot( + resolved_devices) + except CameraRegistryUnavailable as e: + logger.error(f"Camera registry unavailable: {e}") + return _workflow_error( + 503, CAMERA_ERROR_REGISTRY_UNAVAILABLE, + 'The camera registry could not be read for the target ' + 'devices; camera binding validation cannot run and the ' + 'deployment was not submitted', {'reason': str(e)}) + camera_errors, camera_warnings = validate_camera_bindings( + native_version_item, resolved_devices, registry_snapshot, + camera_bindings, confirmed_warnings) + if camera_errors: + return _workflow_error( + 409, CAMERA_ERROR_BINDINGS_INVALID, + 'One or more camera bindings are invalid; the ' + 'deployment was not submitted', + {'errors': camera_errors, 'warnings': camera_warnings}) + unconfirmed = [w for w in camera_warnings + if not w.get('confirmed')] + if unconfirmed: + return _workflow_error( + 409, CAMERA_ERROR_WARNINGS_UNCONFIRMED, + 'Camera binding warnings require explicit confirmation ' + 'before the deployment can be created', + {'warnings': camera_warnings}) + + if target_thing_group: + target_arn = f"arn:aws:iot:{region}:{account_id}:thinggroup/{target_thing_group}" + else: + target_arn = f"arn:aws:iot:{region}:{account_id}:thing/{target_devices[0]}" + + # Greengrass revision semantics (Requirement 8.5): deployments are + # immutable and per-target — creating a new deployment for the same + # target ARN revises the existing one, replacing any older + # Workflow_Component version. We merge with the target's current + # component set so revising never drops LocalServer or other + # components already on the device (Requirement 13.3). + existing_deployment = find_latest_deployment_for_target( + greengrass_client, target_arn) + is_revision = existing_deployment is not None + + components_map = {} + if is_revision: + try: + detail = greengrass_client.get_deployment( + deploymentId=existing_deployment['deploymentId']) + components_map = dict(detail.get('components', {}) or {}) + except ClientError as e: + logger.warning( + f"Could not read components of existing deployment " + f"{existing_deployment.get('deploymentId')}: {e}") + + component_name = workflow_component_name(workflow_id) + component_version = workflow_component_version(workflow_version) + # Setting the entry (re)places the workflow component at the new + # version; Greengrass replaces the older version on the device (8.5). + components_map[component_name] = {'componentVersion': component_version} + + if not deployment_name: + if is_revision and existing_deployment.get('deploymentName'): + deployment_name = existing_deployment['deploymentName'] + else: + timestamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S') + deployment_name = f"portal-deployment-{timestamp}" + + deployment_params = { + 'targetArn': target_arn, + 'deploymentName': deployment_name, + 'components': components_map, + 'tags': { + 'dda-portal:managed': 'true', + 'dda-portal:usecase-id': usecase_id, + 'dda-portal:workflow-id': workflow_id, + 'dda-portal:workflow-version': str(workflow_version), + 'dda-portal:created-by': user['user_id'] + } + } + rollout_config = body.get('rollout_config', {}) + if rollout_config: + deployment_params['deploymentPolicies'] = { + 'failureHandlingPolicy': 'ROLLBACK' if rollout_config.get('auto_rollback', True) else 'DO_NOTHING', + 'componentUpdatePolicy': { + 'timeoutInSeconds': rollout_config.get('timeout_seconds', 60), + 'action': 'NOTIFY_COMPONENTS' + } + } + + # Camera_Binding delivery (Reqs 8.2, 8.6): each target thing's + # dda-camera-bindings shadow gets desired.bindings["{wf}/{ver}"] + # via the assumed-role iot-data client, with keys for versions no + # longer deployed pruned. The Greengrass artifact stays untouched. + # A mid-submission shadow write failure aborts deployment creation + # with best-effort pruning of the already-written targets. + delivered_bindings = None + if camera_nodes and native_version_item.get('has_binding_points'): + binding_key = _workflow_binding_key(workflow_id, workflow_version) + iot_data_client = get_usecase_client( + 'iot-data', usecase, session_name=session_name, region=region) + written, failure = deliver_camera_bindings( + iot_data_client, resolved_devices, binding_key, + camera_bindings, _deployed_workflow_binding_keys(components_map)) + if failure: + return _workflow_error( + 502, CAMERA_ERROR_BINDING_DELIVERY, + 'Camera binding delivery to a target device failed; the ' + 'deployment was not submitted and bindings already ' + 'written were pruned', + {'failed_device': failure['device'], + 'error': failure['error'], + 'rolled_back_devices': written}) + delivered_bindings = camera_bindings + + response = greengrass_client.create_deployment(**deployment_params) + deployment_id = response.get('deploymentId') + + # Association record: workflow version -> deployment -> devices + # (8.2); delivered Camera_Bindings are stored on the record for + # display and audit (camera-registry-sync 8.2, 12.3). + superseded_id = existing_deployment.get('deploymentId') if is_revision else None + record_workflow_deployment( + deployment_id, usecase_id, workflow_id, workflow_version, + target_arn, resolved_devices, target_thing_group, + is_revision, superseded_id, user, + camera_bindings=delivered_bindings) + + # Audit log entry for deploy (Requirement 11.5; camera-registry-sync + # 12.3 — a deployment created with Camera_Bindings records them). + audit_details = { + 'usecase_id': usecase_id, + 'workflow_version': workflow_version, + 'deployment_id': deployment_id, + 'component_name': component_name, + 'component_version': component_version, + 'target_arn': target_arn, + 'target_devices': resolved_devices, + 'target_thing_group': target_thing_group, + 'is_revision': is_revision, + 'superseded_deployment_id': superseded_id + } + if delivered_bindings is not None: + audit_details['camera_bindings'] = _dynamo_safe(delivered_bindings) + log_audit_event( + user['user_id'], 'deploy_workflow', 'workflow', workflow_id, + 'success', audit_details + ) + + logger.info( + f"{'Revised' if is_revision else 'Created'} workflow deployment " + f"{deployment_id} for workflow {workflow_id} v{workflow_version}") + + return create_response(201, { + 'deployment_id': deployment_id, + 'iot_job_id': response.get('iotJobId', ''), + 'iot_job_arn': response.get('iotJobArn', ''), + 'workflow_id': workflow_id, + 'workflow_version': workflow_version, + 'component_name': component_name, + 'component_version': component_version, + 'target_arn': target_arn, + 'target_devices': resolved_devices, + 'target_thing_group': target_thing_group, + 'is_revision': is_revision, + 'superseded_deployment_id': superseded_id, + 'camera_bindings_delivered': delivered_bindings is not None, + 'camera_warnings': camera_warnings, + 'message': ('Workflow deployment updated successfully' if is_revision + else 'Workflow deployment created successfully') + }) + + except ClientError as e: + logger.error(f"AWS error creating workflow deployment: {str(e)}") + return _workflow_error(500, 'DEPLOYMENT_FAILED', + f'Failed to create workflow deployment: {str(e)}') + except Exception as e: + logger.error(f"Error creating workflow deployment: {str(e)}", exc_info=True) + return _workflow_error(500, 'INTERNAL_ERROR', + 'Failed to create workflow deployment') + + +def get_device_workflow_deployment_status(greengrass_client, thing_name, + deployment_id): + """ + Per-device Greengrass status of one deployment via the device's + effective deployments (Requirement 8.3). Returns a status dict, or a + PENDING placeholder when the deployment has not reached the device yet. + """ + try: + response = greengrass_client.list_effective_deployments( + coreDeviceThingName=thing_name) + for eff_dep in response.get('effectiveDeployments', []): + if eff_dep.get('deploymentId') != deployment_id: + continue + modified_ts = eff_dep.get('modifiedTimestamp') + if modified_ts and hasattr(modified_ts, 'isoformat'): + modified_ts = modified_ts.isoformat() + return { + 'device': thing_name, + 'deployment_status': eff_dep.get('coreDeviceExecutionStatus', 'UNKNOWN'), + 'reason': eff_dep.get('reason', ''), + 'description': eff_dep.get('description', ''), + 'modified_timestamp': modified_ts + } + except ClientError as e: + logger.warning(f"Could not get effective deployments for {thing_name}: {e}") + return { + 'device': thing_name, + 'deployment_status': 'UNKNOWN', + 'reason': 'Could not read the device deployment status', + 'description': '', + 'modified_timestamp': None + } + return { + 'device': thing_name, + 'deployment_status': 'PENDING', + 'reason': 'Deployment has not been reported by this device yet', + 'description': '', + 'modified_timestamp': None + } + + +def query_workflow_deployment_records(usecase_id, workflow_id): + """ + Workflow association records (component_type: workflow) of one workflow + from the Deployments table via the usecase-deployments-index GSI. + """ + records = [] + table = dynamodb.Table(DEPLOYMENTS_TABLE) + kwargs = { + 'IndexName': 'usecase-deployments-index', + 'KeyConditionExpression': 'usecase_id = :uid', + 'FilterExpression': 'component_type = :ct AND workflow_id = :wid', + 'ExpressionAttributeValues': { + ':uid': usecase_id, + ':ct': 'workflow', + ':wid': workflow_id + } + } + while True: + response = table.query(**kwargs) + records.extend(response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + return [_decimal_to_native(r) for r in records] + + +def refresh_workflow_deployment_status(deployment_id, overall_status): + """Best-effort sync of the association record with the latest overall + Greengrass deployment status (keeps 5.6 active-deployment checks fresh)""" + try: + dynamodb.Table(DEPLOYMENTS_TABLE).update_item( + Key={'deployment_id': deployment_id}, + UpdateExpression='SET #s = :s, deployment_status = :s, updated_at = :t', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':s': overall_status, + ':t': int(datetime.utcnow().timestamp() * 1000) + } + ) + except ClientError as e: + logger.warning(f"Could not refresh status of deployment {deployment_id}: {e}") + + +def list_workflow_deployments(user, query_params): + """ + GET /deployments?usecase_id=...&workflow_id=...[&workflow_version=N] + + Workflow deployment associations with per-device Greengrass deployment + status for the workflow page (Requirements 8.2, 8.3). Viewers may read + deployment status (Requirement 11.3), so this requires workflow:read. + """ + try: + usecase_id = query_params.get('usecase_id') + workflow_id = query_params.get('workflow_id') + + if not usecase_id: + return _workflow_error(400, 'MISSING_FIELDS', + 'usecase_id parameter required') + + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return _workflow_error(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [Permission.WORKFLOW_READ.value], + 'usecase_id': usecase_id + }) + + usecase = get_usecase(usecase_id) + if not usecase: + return _workflow_error(404, 'USECASE_NOT_FOUND', 'Use case not found') + + records = query_workflow_deployment_records(usecase_id, workflow_id) + + version_filter = query_params.get('workflow_version') + if version_filter is not None: + try: + version_filter = int(version_filter) + records = [r for r in records + if int(r.get('workflow_version', -1)) == version_filter] + except (TypeError, ValueError): + return _workflow_error(400, 'INVALID_VERSION', + 'workflow_version must be an integer') + + region = get_usecase_region(usecase) + session_name = f"gg-wfls-{user['user_id'][:20]}-{int(datetime.utcnow().timestamp())}"[:64] + greengrass_client = get_usecase_client( + 'greengrassv2', usecase, session_name=session_name, region=region) + + deployments = [] + for record in sorted(records, key=lambda r: r.get('created_at', 0), + reverse=True): + deployment_id = record.get('deployment_id') + + # Overall Greengrass deployment status + overall_status = record.get('deployment_status', 'UNKNOWN') + try: + detail = greengrass_client.get_deployment(deploymentId=deployment_id) + latest = detail.get('deploymentStatus') + if latest and latest != overall_status: + overall_status = latest + refresh_workflow_deployment_status(deployment_id, latest) + except ClientError as e: + logger.warning(f"Could not get deployment {deployment_id}: {e}") + + # Per-device Greengrass deployment status (Requirement 8.3) + device_statuses = [ + get_device_workflow_deployment_status( + greengrass_client, thing_name, deployment_id) + for thing_name in record.get('target_devices', []) or [] + ] + + deployments.append({ + 'deployment_id': deployment_id, + 'usecase_id': usecase_id, + 'component_type': 'workflow', + 'workflow_id': record.get('workflow_id'), + 'workflow_version': record.get('workflow_version'), + 'component_name': record.get('component_name'), + 'component_version': record.get('component_version'), + 'target_arn': record.get('target_arn'), + 'target_devices': record.get('target_devices', []), + 'target_thing_group': record.get('target_thing_group'), + 'deployment_status': overall_status, + 'device_statuses': device_statuses, + 'is_revision': record.get('is_revision', False), + 'created_by': record.get('created_by'), + 'created_at': record.get('created_at'), + 'updated_at': record.get('updated_at') + }) + + return create_response(200, { + 'deployments': deployments, + 'count': len(deployments) + }) + + except ClientError as e: + logger.error(f"AWS error listing workflow deployments: {str(e)}") + return _workflow_error(500, 'LIST_FAILED', + f'Failed to list workflow deployments: {str(e)}') + except Exception as e: + logger.error(f"Error listing workflow deployments: {str(e)}", exc_info=True) + return _workflow_error(500, 'INTERNAL_ERROR', + 'Failed to list workflow deployments') diff --git a/edge-cv-portal/backend/functions/devices.py b/edge-cv-portal/backend/functions/devices.py index 7d944cb1..fbec1234 100644 --- a/edge-cv-portal/backend/functions/devices.py +++ b/edge-cv-portal/backend/functions/devices.py @@ -7,10 +7,12 @@ import os import boto3 from botocore.exceptions import ClientError +from datetime import datetime + from shared_utils import ( create_response, get_user_from_event, log_audit_event, check_user_access, is_super_user, assume_cross_account_role, get_usecase, - create_boto3_client + create_boto3_client, rbac_manager, Permission ) logger = logging.getLogger() @@ -19,6 +21,30 @@ dynamodb = boto3.resource('dynamodb') USECASES_TABLE = os.environ.get('USECASES_TABLE') +# Devices table: portal-recorded device attributes — the UseCaseAdmin-set +# `test_device` flag and the device's recorded Target_Architecture +# (custom-node-designer, Requirements 9.7, 9.8, 16.3, 16.6). The live +# Greengrass/IoT state continues to come from the Use_Case account. +DEVICES_TABLE = os.environ.get('DEVICES_TABLE') + +# DDA Target_Architectures a device can be recorded as (matched exactly by +# the deployment architecture gate — x86_64 and x86_64_nvidia are distinct) +TARGET_ARCHITECTURES = ('x86_64', 'x86_64_nvidia', + 'arm64_jp4', 'arm64_jp5', 'arm64_jp6') + + +def get_device_record(device_id): + """The Devices-table record of one device, or {} when none exists""" + if not DEVICES_TABLE or not device_id: + return {} + try: + response = dynamodb.Table(DEVICES_TABLE).get_item( + Key={'device_id': device_id}) + except ClientError as e: + logger.warning(f"Could not read device record for {device_id}: {e}") + return {} + return response.get('Item') or {} + def handler(event, context): """ @@ -65,6 +91,9 @@ def handler(event, context): return list_devices(user, query_parameters) elif http_method == 'GET' and device_id: return get_device(device_id, user, query_parameters) + elif http_method == 'PUT' and device_id: + body = json.loads(event.get('body') or '{}') + return update_device_flags(device_id, user, query_parameters, body) return create_response(404, {'error': 'Not found'}) @@ -73,6 +102,103 @@ def handler(event, context): return create_response(500, {'error': 'Internal server error'}) +def update_device_flags(device_id, user, query_params, body): + """ + PUT /api/v1/devices/{id} + + Record portal-managed device attributes on the Devices table + (custom-node-designer task 10.5): + + - ``test_device`` (bool): designates the device as a Test_Device, the + only kind of target a test-state plugin may be deployed to + (Requirements 9.7, 9.8, 16.3). Set by a UseCaseAdmin. + - ``target_architecture`` (optional): the device's recorded DDA + Target_Architecture, checked by the deployment architecture gate + against Plugin_Component platform manifests (Requirement 16.6). + + Body: {"usecase_id": "...", "test_device": true|false, + "target_architecture": "x86_64"|...|null} + """ + try: + usecase_id = body.get('usecase_id') or query_params.get('usecase_id') + if not usecase_id: + return create_response(400, {'error': 'usecase_id required'}) + + # Designating a Test_Device is a UseCaseAdmin action within their + # own Use_Case (PortalAdmin anywhere) — the same principal set as + # node-designer:manage, which this flag exists to serve (the + # test-state deployment gate). Operator-held device permissions + # (manage_devices) deliberately do not grant it. + if not is_super_user(user['user_id']) and not rbac_manager.has_permission( + user['user_id'], usecase_id, Permission.NODE_DESIGNER_MANAGE, + user_info=user): + log_audit_event( + user['user_id'], 'update_device_flags', 'device', device_id, + 'denied', {'usecase_id': usecase_id, + 'required_permissions': [ + Permission.NODE_DESIGNER_MANAGE.value]} + ) + return create_response(403, {'error': 'Access denied'}) + + if 'test_device' not in body and 'target_architecture' not in body: + return create_response(400, { + 'error': 'test_device or target_architecture required'}) + + if not DEVICES_TABLE: + return create_response(500, {'error': 'Devices table not configured'}) + + updates = {} + if 'test_device' in body: + if not isinstance(body['test_device'], bool): + return create_response(400, {'error': 'test_device must be a boolean'}) + updates['test_device'] = body['test_device'] + if 'target_architecture' in body: + target_architecture = body['target_architecture'] + if target_architecture is not None and \ + target_architecture not in TARGET_ARCHITECTURES: + return create_response(400, { + 'error': (f"target_architecture must be one of " + f"{list(TARGET_ARCHITECTURES)} or null")}) + updates['target_architecture'] = target_architecture + + set_parts = ['usecase_id = :uid', 'updated_at = :at', 'updated_by = :by'] + values = { + ':uid': usecase_id, + ':at': int(datetime.utcnow().timestamp() * 1000), + ':by': user['user_id'], + } + for index, (attr, value) in enumerate(sorted(updates.items())): + set_parts.append(f"{attr} = :v{index}") + values[f":v{index}"] = value + + result = dynamodb.Table(DEVICES_TABLE).update_item( + Key={'device_id': device_id}, + UpdateExpression='SET ' + ', '.join(set_parts), + ExpressionAttributeValues=values, + ReturnValues='ALL_NEW' + ) + record = result.get('Attributes', {}) + + log_audit_event( + user['user_id'], 'update_device_flags', 'device', device_id, + 'success', {'usecase_id': usecase_id, **updates} + ) + + return create_response(200, { + 'device_id': device_id, + 'usecase_id': usecase_id, + 'test_device': bool(record.get('test_device')), + 'target_architecture': record.get('target_architecture'), + }) + + except ClientError as e: + logger.error(f"AWS error updating device flags: {str(e)}") + return create_response(500, {'error': f'Failed to update device: {str(e)}'}) + except Exception as e: + logger.error(f"Error updating device flags: {str(e)}", exc_info=True) + return create_response(500, {'error': 'Failed to update device'}) + + def list_devices(user, query_params): """ List Greengrass Core Devices tagged with dda-portal:managed=true. @@ -173,6 +299,11 @@ def list_devices(user, query_params): if last_status: last_status = last_status.isoformat() if hasattr(last_status, 'isoformat') else str(last_status) + # Portal-recorded attributes (Devices table): the + # UseCaseAdmin-set Test_Device flag and the + # recorded Target_Architecture. + device_record = get_device_record(thing_name) + devices.append({ 'device_id': thing_name, 'thing_name': thing_name, @@ -181,6 +312,8 @@ def list_devices(user, query_params): 'last_status_update': last_status, 'platform': platform, 'architecture': architecture, + 'test_device': bool(device_record.get('test_device')), + 'target_architecture': device_record.get('target_architecture'), 'tags': tags, 'usecase_id': usecase_id, 'installed_components': installed_components, @@ -288,6 +421,10 @@ def get_device(device_id, user, query_params): if last_status: last_status = last_status.isoformat() if hasattr(last_status, 'isoformat') else str(last_status) + # Portal-recorded attributes (Devices table): the UseCaseAdmin-set + # Test_Device flag and the recorded Target_Architecture. + device_record = get_device_record(device_id) + device = { 'device_id': device_id, 'thing_name': device_id, @@ -301,6 +438,8 @@ def get_device(device_id, user, query_params): 'greengrass_version': gg_status.get('coreVersion', ''), 'platform': gg_status.get('platform', ''), 'architecture': gg_status.get('architecture', ''), + 'test_device': bool(device_record.get('test_device')), + 'target_architecture': device_record.get('target_architecture'), 'installed_components': installed_components, 'deployments': deployments, 'usecase_id': usecase_id diff --git a/edge-cv-portal/backend/functions/gst_properties.py b/edge-cv-portal/backend/functions/gst_properties.py new file mode 100644 index 00000000..9e004b58 --- /dev/null +++ b/edge-cv-portal/backend/functions/gst_properties.py @@ -0,0 +1,730 @@ +""" +GStreamer Introspection_Report parsing and serialization (Node Designer, +gst-parameter-prepopulation) + +Pure module — deliberately imports no boto3 and touches no AWS at module +scope, so it is directly importable and hypothesis-testable. It defines the +stable JSON shape of the Introspection_Report (version 1) that the build-time +capture script (`dda-gst-introspect`) emits, `plugin_builds.py` validates, +and the `GET /plugins/{id}/versions/{v}/gst-properties` route serves +(Requirements 8.1, 8.3). + +Structure (Introspection_Report version 1): + + { + "reportVersion": 1, + "status": "captured" | "failed", + "message": str | null, + "gstVersion": str | null, + "capturedAt": str | null, + "elements": [ + { + "factory": str, + "elementGType": str, + "instantiationError": str | null, + "properties": [ + { "name": str, "gtype": str, "owner": str, "writable": bool, + "blurb": str | null, "default": JSON scalar | null, + "min": number | null, "max": number | null, + "enumValues": [{"value": int, "nick": str}, ...] | null } + ] + } + ] + } + +`parse_report` raises the typed `ReportError` on ANY non-conforming input +(callers map it to the "introspection_failed" unavailability reason instead +of an internal error, Requirement 8.3). `serialize_report` is its inverse: +for every valid report, `parse_report(serialize_report(report)) == report` +and the serialized form survives a `json.dumps`/`json.loads` cycle unchanged +(Requirements 8.1, 8.2). +""" +import math +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Union + +# The one report shape version this module reads and writes. +REPORT_VERSION = 1 + +# Capture status values (Requirement 1.4: a failed capture is still a +# well-formed, storable report). +STATUS_CAPTURED = 'captured' +STATUS_FAILED = 'failed' +VALID_STATUSES = (STATUS_CAPTURED, STATUS_FAILED) + +# JSON scalar type for property default values. +JsonScalar = Union[str, int, float, bool] + +# Pad_Template direction and presence vocabularies +# (port-guidance-and-pad-prepopulation, Requirement 4.4). +PAD_DIRECTION_SINK = 'sink' +PAD_DIRECTION_SRC = 'src' +VALID_PAD_DIRECTIONS = (PAD_DIRECTION_SINK, PAD_DIRECTION_SRC) +PAD_PRESENCE_ALWAYS = 'always' +VALID_PAD_PRESENCES = ('always', 'sometimes', 'request') + +# Maximum stored caps string length; capture truncates longer caps and marks +# them with capsTruncated (Requirement 3.4). The parser rejects longer caps +# as malformed, making the truncation contract enforceable here. +MAX_CAPS_LEN = 4096 + + +class ReportError(Exception): + """A stored/received Introspection_Report document is malformed. + + Raised by `parse_report` for any non-conforming input; the API route + maps it to the "introspection_failed" unavailability reason rather + than surfacing an internal error (Requirement 8.3). + """ + + +@dataclass(frozen=True) +class EnumValue: + """One GEnum value: its integer value and its nick (string identifier).""" + value: int + nick: str + + +@dataclass(frozen=True) +class GstProperty: + """One GStreamer_Property captured from a GObject pspec. + + `owner` records the GType name of the GObject class that declared the + property (base-class filtering ground truth, Requirements 1.3, 4.x). + `min`/`max` are present only for ranged numeric GTypes; `enum_values` + only for GEnum GTypes (Requirement 1.2). + """ + name: str + gtype: str + owner: str + writable: bool + blurb: Optional[str] = None + default: Optional[JsonScalar] = None + min: Optional[Union[int, float]] = None + max: Optional[Union[int, float]] = None + enum_values: Optional[List[EnumValue]] = None + + +@dataclass(frozen=True) +class PadTemplate: + """One static Pad_Template captured from the element factory + (port-guidance-and-pad-prepopulation, Requirement 4.1).""" + name: str # name template, e.g. 'sink', 'src', 'src_%u' + direction: str # 'sink' | 'src' + presence: str # 'always' | 'sometimes' | 'request' + caps: str # caps string, at most MAX_CAPS_LEN chars + caps_truncated: bool # True when capture truncated the caps (3.4) + + +@dataclass(frozen=True) +class ReportElement: + """One element factory registered by the introspected Plugin_Artifact. + + ``pads`` is None when the report predates pad capture (legacy version-1 + reports, Requirement 4.2). Domain invariant: ``pads_error`` is non-None + only when ``pads == []`` (a per-element pad read failure, 3.2). + """ + factory: str + element_gtype: str + instantiation_error: Optional[str] = None + properties: List[GstProperty] = field(default_factory=list) + pads: Optional[List[PadTemplate]] = None # None = not captured (legacy) + pads_error: Optional[str] = None # meaningful only when pads is not None + + +@dataclass(frozen=True) +class Report: + """A parsed Introspection_Report (version 1).""" + status: str + message: Optional[str] = None + gst_version: Optional[str] = None + captured_at: Optional[str] = None + elements: List[ReportElement] = field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Parsing (JSON document -> Report), Requirement 8.1 / 8.3 +# --------------------------------------------------------------------------- + +def _require_dict(value: Any, where: str) -> Dict[str, Any]: + if not isinstance(value, dict): + raise ReportError(f'{where}: expected an object, got {type(value).__name__}') + return value + + +def _require_list(value: Any, where: str) -> List[Any]: + if not isinstance(value, list): + raise ReportError(f'{where}: expected an array, got {type(value).__name__}') + return value + + +def _require_str(value: Any, where: str) -> str: + if not isinstance(value, str): + raise ReportError(f'{where}: expected a string, got {type(value).__name__}') + return value + + +def _optional_str(value: Any, where: str) -> Optional[str]: + if value is None: + return None + return _require_str(value, where) + + +def _require_bool(value: Any, where: str) -> bool: + if not isinstance(value, bool): + raise ReportError(f'{where}: expected a boolean, got {type(value).__name__}') + return value + + +def _require_int(value: Any, where: str) -> int: + # bool is a subclass of int in Python; reject it explicitly. + if isinstance(value, bool) or not isinstance(value, int): + raise ReportError(f'{where}: expected an integer, got {type(value).__name__}') + return value + + +def _optional_number(value: Any, where: str) -> Optional[Union[int, float]]: + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ReportError(f'{where}: expected a number or null, got {type(value).__name__}') + return value + + +def _optional_scalar(value: Any, where: str) -> Optional[JsonScalar]: + if value is None: + return None + if not isinstance(value, (str, int, float, bool)): + raise ReportError(f'{where}: expected a JSON scalar or null, got {type(value).__name__}') + return value + + +def _parse_enum_values(value: Any, where: str) -> Optional[List[EnumValue]]: + if value is None: + return None + entries = _require_list(value, where) + parsed: List[EnumValue] = [] + for index, entry in enumerate(entries): + entry_where = f'{where}[{index}]' + entry_dict = _require_dict(entry, entry_where) + parsed.append(EnumValue( + value=_require_int(entry_dict.get('value'), f'{entry_where}.value'), + nick=_require_str(entry_dict.get('nick'), f'{entry_where}.nick'), + )) + return parsed + + +def _parse_property(value: Any, where: str) -> GstProperty: + prop = _require_dict(value, where) + return GstProperty( + name=_require_str(prop.get('name'), f'{where}.name'), + gtype=_require_str(prop.get('gtype'), f'{where}.gtype'), + owner=_require_str(prop.get('owner'), f'{where}.owner'), + writable=_require_bool(prop.get('writable'), f'{where}.writable'), + blurb=_optional_str(prop.get('blurb'), f'{where}.blurb'), + default=_optional_scalar(prop.get('default'), f'{where}.default'), + min=_optional_number(prop.get('min'), f'{where}.min'), + max=_optional_number(prop.get('max'), f'{where}.max'), + enum_values=_parse_enum_values(prop.get('enumValues'), f'{where}.enumValues'), + ) + + +def _parse_pad(value: Any, where: str) -> PadTemplate: + pad = _require_dict(value, where) + direction = _require_str(pad.get('direction'), f'{where}.direction') + if direction not in VALID_PAD_DIRECTIONS: + raise ReportError(f'{where}.direction: expected one of {VALID_PAD_DIRECTIONS}, ' + f'got {direction!r}') + presence = _require_str(pad.get('presence'), f'{where}.presence') + if presence not in VALID_PAD_PRESENCES: + raise ReportError(f'{where}.presence: expected one of {VALID_PAD_PRESENCES}, ' + f'got {presence!r}') + caps = _require_str(pad.get('caps'), f'{where}.caps') + if len(caps) > MAX_CAPS_LEN: + raise ReportError(f'{where}.caps: expected at most {MAX_CAPS_LEN} characters, ' + f'got {len(caps)}') + return PadTemplate( + name=_require_str(pad.get('name'), f'{where}.name'), + direction=direction, + presence=presence, + caps=caps, + caps_truncated=_require_bool(pad.get('capsTruncated'), f'{where}.capsTruncated'), + ) + + +def _parse_element(value: Any, where: str) -> ReportElement: + element = _require_dict(value, where) + properties_raw = _require_list(element.get('properties', []), f'{where}.properties') + + # Pad data is an optional, strictly additive extension of the version-1 + # element shape (port-guidance-and-pad-prepopulation). An absent `pads` + # key is the legacy report shape: pads were not captured (Requirement + # 4.2); a stray `padsError` without `pads` is ignored. When `pads` is + # present, every entry is validated as strictly as properties are — any + # violation raises ReportError (Requirement 4.4). + pads: Optional[List[PadTemplate]] = None + pads_error: Optional[str] = None + if 'pads' in element: + pads_raw = _require_list(element.get('pads'), f'{where}.pads') + pads = [_parse_pad(pad, f'{where}.pads[{i}]') + for i, pad in enumerate(pads_raw)] + pads_error = _optional_str(element.get('padsError'), f'{where}.padsError') + + return ReportElement( + factory=_require_str(element.get('factory'), f'{where}.factory'), + element_gtype=_require_str(element.get('elementGType'), f'{where}.elementGType'), + instantiation_error=_optional_str(element.get('instantiationError'), + f'{where}.instantiationError'), + properties=[_parse_property(prop, f'{where}.properties[{i}]') + for i, prop in enumerate(properties_raw)], + pads=pads, + pads_error=pads_error, + ) + + +def parse_report(document: Any) -> Report: + """Parse a stored Introspection_Report JSON document into a Report. + + Raises ReportError on any non-conforming input — wrong top-level type, + unsupported reportVersion, unknown status, or any mistyped field at any + depth (Requirements 8.1, 8.3). Never raises anything else for + JSON-decodable input. + """ + doc = _require_dict(document, 'report') + + version = doc.get('reportVersion') + if isinstance(version, bool) or version != REPORT_VERSION: + raise ReportError(f'report.reportVersion: expected {REPORT_VERSION}, got {version!r}') + + status = _require_str(doc.get('status'), 'report.status') + if status not in VALID_STATUSES: + raise ReportError(f'report.status: expected one of {VALID_STATUSES}, got {status!r}') + + elements_raw = _require_list(doc.get('elements', []), 'report.elements') + + return Report( + status=status, + message=_optional_str(doc.get('message'), 'report.message'), + gst_version=_optional_str(doc.get('gstVersion'), 'report.gstVersion'), + captured_at=_optional_str(doc.get('capturedAt'), 'report.capturedAt'), + elements=[_parse_element(element, f'report.elements[{i}]') + for i, element in enumerate(elements_raw)], + ) + + +# --------------------------------------------------------------------------- +# Serialization (Report -> JSON document), Requirement 8.1 / 8.2 +# --------------------------------------------------------------------------- + +def _serialize_property(prop: GstProperty) -> Dict[str, Any]: + return { + 'name': prop.name, + 'gtype': prop.gtype, + 'owner': prop.owner, + 'writable': prop.writable, + 'blurb': prop.blurb, + 'default': prop.default, + 'min': prop.min, + 'max': prop.max, + 'enumValues': (None if prop.enum_values is None else + [{'value': ev.value, 'nick': ev.nick} for ev in prop.enum_values]), + } + + +def _serialize_pad(pad: PadTemplate) -> Dict[str, Any]: + return { + 'name': pad.name, + 'direction': pad.direction, + 'presence': pad.presence, + 'caps': pad.caps, + 'capsTruncated': pad.caps_truncated, + } + + +def _serialize_element(element: ReportElement) -> Dict[str, Any]: + document = { + 'factory': element.factory, + 'elementGType': element.element_gtype, + 'instantiationError': element.instantiation_error, + 'properties': [_serialize_property(prop) for prop in element.properties], + } + # When pads were never captured (legacy element), both keys are omitted + # so legacy-shaped reports serialize byte-identically to the pre-pad + # output (Requirements 4.2, 4.3). + if element.pads is not None: + document['pads'] = [_serialize_pad(pad) for pad in element.pads] + document['padsError'] = element.pads_error + return document + + +def serialize_report(report: Report) -> Dict[str, Any]: + """Serialize a Report to its version-1 JSON document shape. + + Inverse of `parse_report`: every field is emitted explicitly (optional + fields as null) so the document is stable and + `parse_report(serialize_report(report)) == report` for every valid + report (Requirements 8.1, 8.2). + """ + return { + 'reportVersion': REPORT_VERSION, + 'status': report.status, + 'message': report.message, + 'gstVersion': report.gst_version, + 'capturedAt': report.captured_at, + 'elements': [_serialize_element(element) for element in report.elements], + } + + +# --------------------------------------------------------------------------- +# Type_Mapping (GstProperty -> Parameter_Suggestion | Skipped) +# Requirements 2.1-2.6, 3.1, 3.2 +# --------------------------------------------------------------------------- + +# GType -> paramType mapping table (Requirement 2.1). +GTYPE_INT = frozenset({'gint', 'guint', 'gint64', 'guint64', 'glong', 'gulong', 'guchar'}) +GTYPE_FLOAT = frozenset({'gfloat', 'gdouble'}) +GTYPE_BOOL = 'gboolean' +GTYPE_STRING = 'gchararray' + +PARAM_INT = 'int' +PARAM_FLOAT = 'float' +PARAM_BOOL = 'bool' +PARAM_STRING = 'string' +PARAM_ENUM = 'enum' + + +@dataclass(frozen=True) +class Skipped: + """A GStreamer_Property excluded from the Parameter_Suggestions. + + Carries the property name and a non-empty human-readable reason + (Requirement 2.5); serialized on the wire as ``{name, reason}``. + """ + name: str + reason: str + + +def _int_default(prop: GstProperty) -> Optional[int]: + """Convert the property default to an int, or None when unconvertible + or outside the property's declared range (no usable default, 3.1).""" + default = prop.default + if isinstance(default, bool): + return None + if isinstance(default, int): + converted = default + elif isinstance(default, float) and default.is_integer(): + converted = int(default) + else: + return None + if prop.min is not None and not converted >= prop.min: + return None + if prop.max is not None and not converted <= prop.max: + return None + return converted + + +def _float_default(prop: GstProperty) -> Optional[float]: + """Convert the property default to a float, or None when unconvertible + or outside the property's declared range (no usable default, 3.1).""" + default = prop.default + if isinstance(default, bool) or not isinstance(default, (int, float)): + return None + converted = float(default) + if prop.min is not None and not converted >= prop.min: + return None + if prop.max is not None and not converted <= prop.max: + return None + return converted + + +def _string_default(prop: GstProperty) -> Optional[str]: + """A string default is usable only when non-NULL and non-empty + (whitespace-only counts as empty), Requirement 3.1.""" + default = prop.default + if isinstance(default, str) and default.strip(): + return default + return None + + +def _bool_default(prop: GstProperty) -> Optional[bool]: + return prop.default if isinstance(prop.default, bool) else None + + +def _enum_default_nick(prop: GstProperty) -> Optional[str]: + """Resolve the property default to one of the enum's nicks: a string + default must match a nick, an integer default is looked up by its + enum value. Anything else is not a usable default (3.1).""" + default = prop.default + enum_values = prop.enum_values or [] + if isinstance(default, str): + for entry in enum_values: + if entry.nick == default: + return entry.nick + return None + if isinstance(default, int) and not isinstance(default, bool): + for entry in enum_values: + if entry.value == default: + return entry.nick + return None + + +def _numeric_constraints(prop: GstProperty) -> Optional[Dict[str, Any]]: + """min/max constraints for ranged numeric properties (Requirement 2.2).""" + constraints: Dict[str, Any] = {} + if prop.min is not None: + constraints['min'] = prop.min + if prop.max is not None: + constraints['max'] = prop.max + return constraints or None + + +def _int_example(prop: GstProperty) -> int: + """Synthesized example for a required int suggestion: the range minimum + when ranged, else a bound-respecting fallback (Requirement 2.6).""" + if prop.min is not None: + return prop.min if isinstance(prop.min, int) else math.ceil(prop.min) + if prop.max is not None: + return prop.max if isinstance(prop.max, int) else math.floor(prop.max) + return 0 + + +def _float_example(prop: GstProperty) -> float: + if prop.min is not None: + return float(prop.min) + if prop.max is not None: + return float(prop.max) + return 0.0 + + +def _description(prop: GstProperty) -> str: + """The blurb when non-empty, else a synthesized description naming the + property and its GType (Requirement 2.4).""" + if prop.blurb and prop.blurb.strip(): + return prop.blurb + return f'{prop.name} ({prop.gtype}) property of the plugin element' + + +def _suggestion(prop: GstProperty, param_type: str, default: Optional[JsonScalar], + constraints: Optional[Dict[str, Any]], + synthesized_example: JsonScalar) -> Dict[str, Any]: + """Assemble the ParameterDeclaration wire shape. + + ``required`` is true iff there is no usable default (3.1); an optional + suggestion carries the default and uses it as the example (3.2, 2.3); + a required suggestion carries a type-appropriate synthesized example so + every suggestion passes declaration validation (2.6). + """ + suggestion: Dict[str, Any] = { + 'name': prop.name, + 'paramType': param_type, + 'required': default is None, + } + if default is not None: + suggestion['default'] = default + if constraints is not None: + suggestion['constraints'] = constraints + suggestion['description'] = _description(prop) + suggestion['examples'] = [default if default is not None else synthesized_example] + return suggestion + + +def map_property(prop: GstProperty) -> Union[Dict[str, Any], Skipped]: + """Apply the Type_Mapping to one GStreamer_Property. + + Returns either a Parameter_Suggestion in the ``ParameterDeclaration`` + wire shape validated by ``workflow_core.catalog.custom`` (``{name, + paramType, required, default?, constraints?, description, examples}``) + or a :class:`Skipped` entry with a non-empty reason. + + Mapping table (Requirement 2.1): + gint/guint/gint64/guint64/glong/gulong/guchar -> int (min/max carried) + gfloat/gdouble -> float (min/max carried) + gboolean -> bool + gchararray -> string + GEnum (enum_values present) -> enum ({values: nicks}) + anything else, or writable == False -> Skipped (2.5) + + Required/optional (3.1, 3.2): required iff the property has no usable + default — a NULL/empty string default, or a default that cannot be + converted to the mapped paramType (including numeric defaults outside + the property's own declared range, which would fail the declaration's + own validation); otherwise optional with the default carried. + """ + if not prop.writable: + return Skipped(name=prop.name, reason='property is not writable') + + if prop.gtype in GTYPE_INT: + return _suggestion(prop, PARAM_INT, _int_default(prop), + _numeric_constraints(prop), _int_example(prop)) + + if prop.gtype in GTYPE_FLOAT: + return _suggestion(prop, PARAM_FLOAT, _float_default(prop), + _numeric_constraints(prop), _float_example(prop)) + + if prop.gtype == GTYPE_BOOL: + return _suggestion(prop, PARAM_BOOL, _bool_default(prop), None, False) + + if prop.gtype == GTYPE_STRING: + return _suggestion(prop, PARAM_STRING, _string_default(prop), None, 'value') + + if prop.enum_values is not None: + if not prop.enum_values: + return Skipped(name=prop.name, + reason=f"GEnum type '{prop.gtype}' declares no enum values") + nicks = [entry.nick for entry in prop.enum_values] + return _suggestion(prop, PARAM_ENUM, _enum_default_nick(prop), + {'values': nicks}, nicks[0]) + + return Skipped(name=prop.name, + reason=f"no parameter type mapping for GType '{prop.gtype}'") + + +# --------------------------------------------------------------------------- +# Base_Class_Property filtering and per-element suggestion derivation +# Requirements 4.1, 4.2, 1.5 +# --------------------------------------------------------------------------- + +def is_base_class_property(prop: GstProperty, element_gtype: str) -> bool: + """True iff the property is a Base_Class_Property of the element. + + A property is a Base_Class_Property when its ``owner`` (the GType name + of the GObject class that declared it) differs from the element's own + GType (Requirement 4.1). Owner equality keeps properties the element's + own class re-declares (overrides/shadows), even when the same name also + exists on a base class — the introspection records the shadowing pspec + with the element's own GType as owner (Requirement 4.2). + """ + return prop.owner != element_gtype + + +def suggestions_for_element(element: ReportElement) -> Dict[str, List[Dict[str, Any]]]: + """Derive the Parameter_Scan result for one report element. + + Excludes every Base_Class_Property entirely — base-class plumbing like + ``name``/``parent``/``qos`` appears neither in the suggestions nor in + the skipped list (Requirement 4.1) — then applies :func:`map_property` + to the element's own properties, preserving property order. + + Returns the wire shape served by the gst-properties route + (Requirement 1.5):: + + { + "suggestions": [ParameterDeclaration dict, ...], + "skipped": [{"name": str, "reason": str}, ...] + } + """ + suggestions: List[Dict[str, Any]] = [] + skipped: List[Dict[str, Any]] = [] + for prop in element.properties: + if is_base_class_property(prop, element.element_gtype): + continue + mapped = map_property(prop) + if isinstance(mapped, Skipped): + skipped.append({'name': mapped.name, 'reason': mapped.reason}) + else: + suggestions.append(mapped) + return {'suggestions': suggestions, 'skipped': skipped} + + +# --------------------------------------------------------------------------- +# Port_Suggestion derivation (ports_for_element) +# port-guidance-and-pad-prepopulation, Requirements 4.7, 4.8, 5.1-5.7 +# --------------------------------------------------------------------------- + +# The only Port_Type derivable from caps: caps beginning with the exact +# case-sensitive prefix `video/x-raw` map confidently to VideoFrames +# (Requirement 5.2); InferenceMeta and EventSignal are DDA semantic concepts +# GStreamer caps cannot express (Requirement 5.3). +PORT_TYPE_VIDEO_FRAMES = 'VideoFrames' +CONFIDENT_CAPS_PREFIX = 'video/x-raw' # exact, case-sensitive (5.2) + +# Machine-readable reasons for an element with no derivable pad data +# (mutually exclusive, Requirements 4.7, 4.8, 3.2 surfacing). +PADS_REASON_NOT_CAPTURED = 'pads_not_captured' # report predates pad capture (4.7) +PADS_REASON_NO_TEMPLATES = 'no_pad_templates' # element declares none (4.8) +PADS_REASON_READ_FAILED = 'pads_read_failed' # per-element capture failure (3.2) + +# Caveat / reason texts, defined once so derivation is deterministic (5.7). +_CAVEAT_RUNTIME_PADS = ('{presence} pads are created at runtime and do not ' + 'correspond to fixed declared Ports') +_CAVEAT_INVALID_NAME = ('the pad name template is not a valid Port name ' + '(Port names must be non-empty)') +_REASON_CONFIDENT = f"the pad's caps begin with {CONFIDENT_CAPS_PREFIX}" +_REASON_UNCONFIRMED = ('InferenceMeta and EventSignal are DDA semantic concepts ' + 'that GStreamer caps cannot express; confirm the Port_Type ' + 'yourself if this pad does not carry raw video') + + +def ports_for_element(element: ReportElement) -> Dict[str, Any]: + """Derive the Port_Scan result for one report element. + + Pure and deterministic (Requirement 5.7). Returns the wire shape served + alongside `suggestions_for_element` by the gst-properties route:: + + { + "portSuggestions": [{"name", "direction", "portType", "confident", + "caps", "capsTruncated", "reason"}, ...], + "unmappedPads": [{"name", "direction", "presence", "caveat"}, ...], + "padsReason": str | None, + "padsMessage": str | None + } + + Reason classification (mutually exclusive, Requirements 4.7, 4.8): + pads is None -> 'pads_not_captured' (legacy report) + pads == [] with pads_error -> 'pads_read_failed' + the diagnostic + pads == [] without pads_error -> 'no_pad_templates' + pads non-empty -> None (derivation runs) + + Derivation walks the pads in report order (5.1); each pad lands in + exactly one output list: + presence != 'always' -> Unmapped_Pad, runtime-pads caveat (5.4) + empty/whitespace name template -> Unmapped_Pad, invalid-name caveat (5.6) + otherwise -> Port_Suggestion: sink -> input, + src -> output, name verbatim, + portType VideoFrames, confident iff + caps start with video/x-raw (5.1-5.3) + """ + port_suggestions: List[Dict[str, Any]] = [] + unmapped_pads: List[Dict[str, Any]] = [] + + if element.pads is None: + return {'portSuggestions': port_suggestions, 'unmappedPads': unmapped_pads, + 'padsReason': PADS_REASON_NOT_CAPTURED, 'padsMessage': None} + if not element.pads: + if element.pads_error is not None: + return {'portSuggestions': port_suggestions, 'unmappedPads': unmapped_pads, + 'padsReason': PADS_REASON_READ_FAILED, + 'padsMessage': element.pads_error} + return {'portSuggestions': port_suggestions, 'unmappedPads': unmapped_pads, + 'padsReason': PADS_REASON_NO_TEMPLATES, 'padsMessage': None} + + for pad in element.pads: + if pad.presence != PAD_PRESENCE_ALWAYS: + unmapped_pads.append({ + 'name': pad.name, + 'direction': pad.direction, + 'presence': pad.presence, + 'caveat': _CAVEAT_RUNTIME_PADS.format(presence=pad.presence), + }) + continue + if not pad.name.strip(): + unmapped_pads.append({ + 'name': pad.name, + 'direction': pad.direction, + 'presence': pad.presence, + 'caveat': _CAVEAT_INVALID_NAME, + }) + continue + confident = pad.caps.startswith(CONFIDENT_CAPS_PREFIX) + port_suggestions.append({ + 'name': pad.name, + 'direction': 'input' if pad.direction == PAD_DIRECTION_SINK else 'output', + 'portType': PORT_TYPE_VIDEO_FRAMES, + 'confident': confident, + 'caps': pad.caps, + 'capsTruncated': pad.caps_truncated, + 'reason': _REASON_CONFIDENT if confident else _REASON_UNCONFIRMED, + }) + + return {'portSuggestions': port_suggestions, 'unmappedPads': unmapped_pads, + 'padsReason': None, 'padsMessage': None} diff --git a/edge-cv-portal/backend/functions/node_catalog_resolution.py b/edge-cv-portal/backend/functions/node_catalog_resolution.py new file mode 100644 index 00000000..f0e1ef3c --- /dev/null +++ b/edge-cv-portal/backend/functions/node_catalog_resolution.py @@ -0,0 +1,370 @@ +""" +Merged Node_Type_Catalog resolution for Custom_Node_Types +(custom-node-designer task 9.2). + +Shared by the existing catalog consumers in this Lambda bundle: + + workflow_validation.py GET /workflows/node-catalog serves the merged + palette catalog for a Use_Case (8.2, 8.3, 9.2, + 9.6, 14.3); POST /workflows/{id}/validate + validates against the merged resolution + catalog (14.2, 14.3) + workflow_generator.py serializes the merged palette catalog into + the generation system prompt + workflows.py workflow save records the Custom_Node_Type + version used per custom node (14.2) via + referenced_node_type_versions + +Two distinct merges exist: + +- The **palette** merge (`resolve_palette_catalog`): what the Node_Palette + may offer for *new placement*. Only the latest version of each + Custom_Node_Type, only when the backing Plugin_Record version's + Lifecycle_State is test or prod (dev excluded, Requirement 9.2), and + never deprecated types (Requirement 14.3). Test-state entries carry a + ``lifecycleState: "test"`` marker on the wire (Requirement 9.6). + +- The **resolution** merge (`resolve_resolution_catalog`): what existing + saved workflows resolve against for loading/validating/packaging. + Deprecated types and every Lifecycle_State remain resolvable + (Requirement 14.3: saved workflows stay loadable, packagable, and + deployable), and versions pinned at workflow save are honored + (Requirement 14.2), falling back to the latest registered version. + +Everything in the "pure resolution logic" section is pure over plain +dicts (stored CustomNodeTypes items and wire declarations) so tasks +9.3/9.4 can property-test the merge/marker/exclusion logic without AWS. +The DynamoDB loaders live at the bottom, cleanly separated. + +Reference-index note (task 9.2 decision): the removal scan in +custom_node_types.py prefers an inverted-index GSI (node-type-refs-index, +attribute ref_node_type_id) over WorkflowVersions. A DynamoDB GSI can +index only ONE scalar attribute value per item, but a workflow version +may reference SEVERAL Custom_Node_Types, so a scalar ref_node_type_id +cannot represent the reference set. The GSI is therefore NOT created; +workflow save records the ``custom_node_types`` map attribute +({typeId: typeVersion}) on every WorkflowVersions item instead, which +the existing scan fallback in custom_node_types.py already honors +without loading definition documents from S3. +""" +import logging +import os +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from workflow_core.catalog.custom import ( + DeclarationError, + descriptor_from_declaration, + resolve_catalog, +) +from workflow_core.catalog.nodes import NODE_CATALOG + +logger = logging.getLogger() + +# Lifecycle_State values of the backing Plugin_Record version +# (plugin_records.py owns the state machine). +LIFECYCLE_DEV = 'dev' +LIFECYCLE_TEST = 'test' +LIFECYCLE_PROD = 'prod' + +#: States whose Custom_Node_Types the palette may offer (Requirement 9.2: +#: dev excluded from the Node_Palette). +PALETTE_LIFECYCLE_STATES = frozenset({LIFECYCLE_TEST, LIFECYCLE_PROD}) + +#: Built-in type ids never count as custom references (resolve_catalog +#: lets built-ins win on collision). +BUILTIN_TYPE_IDS = frozenset(descriptor.type_id for descriptor in NODE_CATALOG) + + +# ------------------------------------------------------ pure resolution logic +# +# Pure over plain dicts: no AWS, no environment. Property tests (tasks +# 9.3/9.4) exercise these directly. + +def latest_versions(node_type_items: Sequence[Dict]) -> Dict[str, Dict]: + """Highest-version CustomNodeTypes item per node_type_id (14.1 retains + every version; the palette and unpinned resolution serve the latest).""" + latest: Dict[str, Dict] = {} + for item in node_type_items: + type_id = item.get('node_type_id') + if not type_id: + continue + current = latest.get(type_id) + if current is None or int(item.get('version', 0)) > int(current.get('version', 0)): + latest[type_id] = item + return latest + + +def lifecycle_marker(lifecycle_state: Optional[str]) -> Optional[str]: + """Palette marker of one entry (Requirement 9.6): test-state entries + carry the 'test' marker; prod entries carry none.""" + return LIFECYCLE_TEST if lifecycle_state == LIFECYCLE_TEST else None + + +def _backing_state(item: Dict, lifecycle_states: Dict[Tuple[str, int], str] + ) -> Optional[str]: + """Lifecycle_State of the item's pinned backing Plugin_Record version; + None when unknown (fails closed for the palette).""" + plugin_id = item.get('plugin_id') + plugin_version = item.get('plugin_version') + if plugin_id is None or plugin_version is None: + return None + return lifecycle_states.get((plugin_id, int(plugin_version))) + + +def palette_entries(node_type_items: Sequence[Dict], + lifecycle_states: Dict[Tuple[str, int], str] + ) -> List[Tuple[Dict, Optional[str]]]: + """The Custom_Node_Type version items the Node_Palette may offer, + each paired with its lifecycle marker. + + Selection (deterministic, ordered by node_type_id): + - the latest version of each type (14.1), + - deprecated types excluded from new placement (14.3), + - backing Plugin_Record Lifecycle_State test or prod only; dev or + unknown states are excluded (9.2 — fail closed). + """ + entries: List[Tuple[Dict, Optional[str]]] = [] + latest = latest_versions(node_type_items) + for type_id in sorted(latest): + item = latest[type_id] + if item.get('deprecated'): + continue + state = _backing_state(item, lifecycle_states) + if state not in PALETTE_LIFECYCLE_STATES: + continue + entries.append((item, lifecycle_marker(state))) + return entries + + +def resolution_items(node_type_items: Sequence[Dict], + pinned_versions: Optional[Dict[str, Any]] = None + ) -> List[Dict]: + """The Custom_Node_Type version items existing saved workflows + resolve against for loading/validating/packaging. + + Versions pinned at workflow save are honored (14.2); types without a + pin resolve to their latest version. Deprecated types and every + Lifecycle_State stay resolvable (14.3). A pinned version that no + longer exists falls back to the latest version of that type. + """ + pinned = pinned_versions or {} + by_version: Dict[Tuple[str, int], Dict] = {} + for item in node_type_items: + type_id = item.get('node_type_id') + if not type_id: + continue + by_version[(type_id, int(item.get('version', 0)))] = item + + resolved: List[Dict] = [] + latest = latest_versions(node_type_items) + for type_id in sorted(latest): + item = latest[type_id] + pin = pinned.get(type_id) + if pin is not None: + item = by_version.get((type_id, int(pin)), item) + resolved.append(item) + return resolved + + +def descriptors_from_items(items: Sequence[Dict]) -> List: + """Frozen NodeTypeDescriptors from stored declaration JSON. + + Registration already validated every stored declaration through + descriptor_from_declaration, so a conversion failure indicates a + corrupted item; it is skipped with a log rather than failing the + whole catalog. + """ + descriptors = [] + for item in items: + declaration = item.get('declaration') + if not isinstance(declaration, dict): + continue + try: + descriptors.append(descriptor_from_declaration(declaration)) + except DeclarationError as e: + logger.warning( + "Skipping stored custom node type %r v%s with an invalid " + "declaration: %s", + item.get('node_type_id'), item.get('version'), str(e)) + return descriptors + + +def resolve_palette_catalog(node_type_items: Sequence[Dict], + lifecycle_states: Dict[Tuple[str, int], str] + ) -> Tuple[tuple, Dict[str, str]]: + """The merged palette catalog of one Use_Case (8.2, 9.2, 9.6, 14.3). + + Returns ``(catalog, markers)``: the built-in NODE_CATALOG merged with + the eligible custom descriptors via resolve_catalog (built-ins win on + type-id collision), plus ``{type_id: 'test'}`` for merged test-state + entries (prod entries carry no marker). + """ + entries = palette_entries(node_type_items, lifecycle_states) + descriptors = [] + marker_by_type_id: Dict[str, str] = {} + for item, marker in entries: + converted = descriptors_from_items([item]) + if not converted: + continue + descriptor = converted[0] + descriptors.append(descriptor) + if marker: + marker_by_type_id[descriptor.type_id] = marker + merged = resolve_catalog(descriptors) + merged_type_ids = {d.type_id for d in merged[len(NODE_CATALOG):]} + markers = {type_id: marker + for type_id, marker in marker_by_type_id.items() + if type_id in merged_type_ids} + return merged, markers + + +def resolve_resolution_catalog(node_type_items: Sequence[Dict], + pinned_versions: Optional[Dict[str, Any]] = None + ) -> tuple: + """The merged catalog existing workflows load/validate/package + against (14.2 pinning, 14.3 deprecated-but-resolvable).""" + items = resolution_items(node_type_items, pinned_versions) + return resolve_catalog(descriptors_from_items(items)) + + +def referenced_node_type_versions(definition: Any, + node_type_items: Sequence[Dict] + ) -> Dict[str, int]: + """The Custom_Node_Type versions a Workflow_Definition uses (14.2). + + Returns ``{typeId: version}`` for every canvas node whose type is a + registered Custom_Node_Type, recording the latest registered version + at save time (the version the palette served). Built-in type ids are + never treated as custom references. + """ + if not isinstance(definition, dict): + return {} + nodes = definition.get('nodes') + if not isinstance(nodes, list): + return {} + latest = latest_versions(node_type_items) + references: Dict[str, int] = {} + for node in nodes: + if not isinstance(node, dict): + continue + node_type = node.get('type') + if node_type in BUILTIN_TYPE_IDS or node_type not in latest: + continue + references[node_type] = int(latest[node_type].get('version', 0)) + return references + + +# ----------------------------------------------------------- AWS loaders +# +# Thin persistence layer over the node-designer tables. Everything above +# stays pure; everything below degrades to the built-in catalog when the +# node-designer stack (CUSTOM_NODE_TYPES_TABLE / PLUGIN_RECORDS_TABLE) is +# not deployed alongside the workflow handlers. + +import boto3 # noqa: E402 (kept below the pure section deliberately) +from botocore.exceptions import ClientError # noqa: E402 + +dynamodb = boto3.resource('dynamodb') + +CUSTOM_NODE_TYPES_TABLE = os.environ.get('CUSTOM_NODE_TYPES_TABLE') +PLUGIN_RECORDS_TABLE = os.environ.get('PLUGIN_RECORDS_TABLE') + + +def _decimal_to_native(obj): + from decimal import Decimal + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + if isinstance(obj, dict): + return {k: _decimal_to_native(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_decimal_to_native(i) for i in obj] + return obj + + +def load_registered_node_types(usecase_id: str) -> List[Dict]: + """Every CustomNodeTypes version item visible to a Use_Case: items it + owns plus items scoped to it at registration (usecase_ids, 8.2). + + A type may be owned by one Use_Case and scoped to others, which the + owning-Use_Case GSI cannot answer, so this scans the (small) catalog + table with a containment filter. Returns [] when the node-designer + stack is not deployed, so consumers fall back to the built-in + catalog unchanged. + """ + if not CUSTOM_NODE_TYPES_TABLE or not usecase_id: + return [] + from boto3.dynamodb.conditions import Attr + table = dynamodb.Table(CUSTOM_NODE_TYPES_TABLE) + items: List[Dict] = [] + kwargs: Dict = { + 'FilterExpression': (Attr('usecase_id').eq(usecase_id) + | Attr('usecase_ids').contains(usecase_id)), + } + try: + while True: + response = table.scan(**kwargs) + items.extend(response.get('Items', [])) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + except ClientError as e: + logger.warning("Could not load custom node types for use case %s: %s", + usecase_id, str(e)) + return [] + return [_decimal_to_native(item) for item in items] + + +def load_lifecycle_states(node_type_items: Sequence[Dict] + ) -> Dict[Tuple[str, int], str]: + """Lifecycle_State of each distinct backing Plugin_Record version + pinned by the given items. Missing records or an undeployed + PluginRecords table yield no entry (the palette fails closed).""" + if not PLUGIN_RECORDS_TABLE: + return {} + table = dynamodb.Table(PLUGIN_RECORDS_TABLE) + keys = set() + for item in node_type_items: + plugin_id = item.get('plugin_id') + plugin_version = item.get('plugin_version') + if plugin_id is not None and plugin_version is not None: + keys.add((plugin_id, int(plugin_version))) + states: Dict[Tuple[str, int], str] = {} + for plugin_id, plugin_version in keys: + try: + response = table.get_item( + Key={'plugin_id': plugin_id, 'version': plugin_version}) + except ClientError as e: + logger.warning("Could not load plugin record %s v%s: %s", + plugin_id, plugin_version, str(e)) + continue + record = response.get('Item') + if record and record.get('lifecycle_state'): + states[(plugin_id, plugin_version)] = str(record['lifecycle_state']) + return states + + +def palette_catalog_for_usecase(usecase_id: Optional[str] + ) -> Tuple[tuple, Dict[str, str]]: + """The merged palette catalog served for one Use_Case (9.2, 9.6, + 14.3). Without a Use_Case (or without the node-designer stack) the + built-in catalog is served unchanged.""" + if not usecase_id: + return NODE_CATALOG, {} + items = load_registered_node_types(usecase_id) + if not items: + return NODE_CATALOG, {} + return resolve_palette_catalog(items, load_lifecycle_states(items)) + + +def resolution_catalog_for_usecase(usecase_id: Optional[str], + pinned_versions: Optional[Dict[str, Any]] = None + ) -> tuple: + """The merged catalog a Use_Case's existing workflows validate/load + against, honoring versions pinned at save (14.2, 14.3).""" + if not usecase_id: + return NODE_CATALOG + items = load_registered_node_types(usecase_id) + if not items: + return NODE_CATALOG + return resolve_resolution_catalog(items, pinned_versions) diff --git a/edge-cv-portal/backend/functions/node_generator.py b/edge-cv-portal/backend/functions/node_generator.py new file mode 100644 index 00000000..938742c0 --- /dev/null +++ b/edge-cv-portal/backend/functions/node_generator.py @@ -0,0 +1,1039 @@ +""" +Node_Generator API Lambda function (Custom Node Designer) + +Prompt-based Plugin_Scaffold generation via Amazon Bedrock +(Node_Generator, Requirements 2.1, 2.2, 2.4, 2.5, 2.6, 2.7). + +Routes (API Gateway REST) - start/poll pattern. Bedrock generation takes +45-50 s, which exceeds the API Gateway REST 29 s integration cap, so the +start routes return 202 immediately and the actual generation turn runs in +an asynchronous self-invocation of this Lambda (InvocationType='Event'); +the frontend polls the status route until the turn settles (mirrors the +Plugin_Simulator start/poll flow): + + POST /plugins/generate Start a scaffold-generation chat + session: validate the request, + persist the session with + turn_status=pending, dispatch the + generation worker, and return 202 + with the session id. + GET /plugins/generate/{session} Poll the current generation turn: + {turn_status: pending|running| + completed|failed}; completed adds + the generated files + assistant + text (the former synchronous + response shape), failed adds + turn_error {code, message, + details, http_status}. + POST /plugins/generate/{session}/message Continue a session: either a + follow-up prompt modifying the + current generated source (2.4, + async, 202 + poll like the start + route), or {"accept": true} to + accept the current source into a + Plugin_Record entering the standard + build/simulate/lifecycle path with + the generation prompt recorded as + provenance (2.5, synchronous). + +Design (design.md "Plugin_Scaffold and Node_Generator") - mirrors +workflow_generator.py: +- Chat sessions live in the TTL'd NodeGenSessions DynamoDB table, holding + the message history and the declaration; the current scaffold source + snapshot is stored in portal S3, referenced by ``current_source_key``. +- Invocation uses the Bedrock Converse API with a forced + ``create_plugin_scaffold`` tool whose input schema is the scaffold file + map ({files: {path: content}}) plus the declaration; the system prompt + embeds the scaffold template conventions (the rendered reference + scaffold for the declaration) and the Frame_Processing_Hook contract + (2.2). +- Bedrock_Configuration handling is reused verbatim from + workflow_generator.get_bedrock_configuration(): model id, region, and + inference parameters from the portal settings table, invocation timeout + clamped to at most 60 seconds, cached bedrock-runtime client with a + client-side read timeout and retries disabled (2.7). +- Follow-up prompts include the current generated source and instruct + modification rather than regeneration (2.4). +- Tool output failing scaffold validation (workflow_core.scaffold. + validate_scaffold) marks the turn failed with a descriptive error in the + poll response without touching the session's message history or source + snapshot, so the user's prompt is preserved for retry (2.6); Bedrock + failures/timeouts likewise surface descriptive errors through the poll + with no history/snapshot mutation (2.7). +- Accepted source is written under plugin-sources/{usecase}/{plugin}/1/ + and a Plugin_Record (kind "generated", Lifecycle_State dev, review + pending) is created with the generation prompt(s) recorded as + provenance, entering the standard build/simulate/lifecycle path (2.5). + +Error envelope: {"error": {"code", "message", "details"}} with 403 RBAC +denial (13.4) and 404s that avoid cross-tenant existence leaks. +""" +import json +import os +import logging +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + ReadTimeoutError, +) + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, rbac_manager, Permission +) +from workflow_core.scaffold import ( + HOOK_FILE, + ScaffoldError, + render_scaffold, + scaffold_defects, +) + +# Bedrock_Configuration and client handling are shared with the workflow +# generator (workflow_generator.py lives in the same Lambda bundle): +# settings-table configuration, timeout clamped <= 60 s, cached client +# with a client-side read timeout and no retries (Requirement 2.7). +from workflow_generator import get_bedrock_client, get_bedrock_configuration + +# Plugin_Record construction is shared with the records API +# (plugin_records.py lives in the same Lambda bundle) so accepted source +# enters the identical dev/pending record shape (Requirement 2.5). +from plugin_records import ( + new_version_item, + plugin_table, + source_s3_prefix, + version_detail, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') +lambda_client = boto3.client('lambda') + +# Environment variables +NODE_GEN_SESSIONS_TABLE = os.environ.get('NODE_GEN_SESSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +PLUGIN_SOURCES_PREFIX = os.environ.get('PLUGIN_SOURCES_PREFIX', 'plugin-sources') + +# Chat sessions expire after 24 hours (DynamoDB TTL attribute 'ttl'); +# the TTL is refreshed on every message (mirrors WorkflowChatSessions). +SESSION_TTL_SECONDS = 24 * 60 * 60 + +# Cap of prior conversation turns replayed to the model per invocation. +# History carries the raw prompts and assistant commentary only; the +# current scaffold source is embedded once, in the new user turn (2.4). +MAX_HISTORY_MESSAGES = 20 + +TOOL_NAME = 'create_plugin_scaffold' + +# Generation-turn states carried on the session record (start/poll flow). +TURN_PENDING = 'pending' +TURN_RUNNING = 'running' +TURN_COMPLETED = 'completed' +TURN_FAILED = 'failed' +TURN_IN_PROGRESS = (TURN_PENDING, TURN_RUNNING) + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def can_generate(user: Dict, usecase_id: str) -> bool: + """node-designer:generate - UseCaseAdmin within the Use_Case or + PortalAdmin (Requirement 13.1)""" + return rbac_manager.has_permission( + user['user_id'], usecase_id, + Permission.NODE_DESIGNER_GENERATE, user_info=user) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str) -> Dict: + """Standard authorization error envelope with a denied-access audit + entry (13.4), matching the node-designer feature area's shape""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='plugin_scaffold_generation', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [Permission.NODE_DESIGNER_GENERATE.value], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [Permission.NODE_DESIGNER_GENERATE.value], + 'usecase_id': usecase_id + }) + + +# -------------------------------------------------------------------------- +# Prompt and tool assembly (Requirements 2.2, 2.4) +# -------------------------------------------------------------------------- + +def build_tool_config(declaration: Dict, reference_files: Dict) -> Dict: + """ + Converse toolConfig forcing structured output through the + create_plugin_scaffold tool. The input schema is the scaffold file map + ({files: {path: content}}) plus the declaration: every file of the + reference scaffold rendered for the declaration is a required string + property, so the model must return a complete buildable scaffold + (design "Plugin_Scaffold and Node_Generator"). + """ + required_paths = sorted(reference_files) + schema = { + 'type': 'object', + 'properties': { + 'files': { + 'type': 'object', + 'description': ( + 'Complete Plugin_Scaffold source as a file map: relative ' + 'path -> full file content. Must contain every file of ' + 'the scaffold layout: ' + ', '.join(required_paths) + ), + 'properties': { + path: {'type': 'string'} for path in required_paths + }, + 'required': required_paths, + 'additionalProperties': {'type': 'string'}, + }, + 'declaration': { + 'type': 'object', + 'description': ( + 'The Custom_Node_Type declaration this scaffold ' + 'implements (echo the CUSTOM NODE TYPE DECLARATION ' + 'from the system prompt unchanged).' + ), + }, + }, + 'required': ['files'], + } + return { + 'tools': [{ + 'toolSpec': { + 'name': TOOL_NAME, + 'description': ( + 'Return the complete Plugin_Scaffold source that ' + 'fulfils the user request. Always call this tool with ' + 'the full file map (every scaffold file with its ' + 'complete content).' + ), + 'inputSchema': {'json': schema} + } + }], + 'toolChoice': {'tool': {'name': TOOL_NAME}} + } + + +def build_system_prompt(declaration: Dict, reference_files: Dict) -> str: + """ + System prompt embedding the scaffold template conventions (the + reference scaffold rendered for the declaration) and the + Frame_Processing_Hook contract (Requirement 2.2). + """ + return ( + 'You are the custom node generation assistant of the DDA edge ' + 'computer vision portal. Users describe the per-frame processing ' + 'behavior of a workflow node in natural language; you produce the ' + 'complete Plugin_Scaffold source of a GStreamer plugin ' + 'implementing that behavior.\n' + '\n' + 'FRAME_PROCESSING_HOOK CONTRACT:\n' + f'- The file {HOOK_FILE} MUST define ' + 'process_frame(frame, params) -> frame.\n' + '- "frame" is the raw frame payload (bytes) arriving at the ' + 'node\'s input port; the returned bytes are emitted on the ' + 'node\'s output port.\n' + '- "params" is a dict carrying the current value of every ' + 'parameter declared on the node type, keyed by the declared ' + 'parameter name. Read parameters from it; never hard-code values ' + 'the user may want to tune.\n' + '- Implement the user-described behavior INSIDE process_frame ' + '(plus any helpers in the same file). Use only the Python ' + 'standard library.\n' + '\n' + 'SCAFFOLD TEMPLATE CONVENTIONS:\n' + '- The scaffold is a file map {path: content} with exactly the ' + 'layout of the reference scaffold below: the ' + 'Frame_Processing_Hook file, the C skeleton element source, one ' + 'meson.build build configuration per selected Target_Architecture, ' + 'and the README.\n' + '- The C skeleton element and the build configurations are ' + 'generated boilerplate: reproduce them from the reference ' + 'scaffold, changing them only when the requested behavior ' + 'requires it. The scaffold must remain buildable.\n' + '- Always respond by calling the create_plugin_scaffold tool with ' + 'the complete file map. Do not answer with prose only.\n' + '- When CURRENT PLUGIN SCAFFOLD SOURCE is provided, apply the ' + 'requested modification to it and return the complete modified ' + 'file map; do not regenerate an unrelated scaffold from scratch.\n' + '\n' + 'CUSTOM NODE TYPE DECLARATION (JSON):\n' + f'{json.dumps(declaration, sort_keys=True)}\n' + '\n' + 'REFERENCE SCAFFOLD FOR THIS DECLARATION (JSON file map):\n' + f'{json.dumps(reference_files, sort_keys=True)}' + ) + + +def build_user_message(prompt: str, current_source_json: Optional[str]) -> str: + """ + The user turn sent to the model. Follow-up prompts include the current + generated source and instruct modification rather than regeneration + (Requirement 2.4). + """ + if not current_source_json: + return prompt + return ( + f'{prompt}\n' + '\n' + 'CURRENT PLUGIN SCAFFOLD SOURCE (JSON file map):\n' + f'{current_source_json}\n' + '\n' + 'Apply the requested change to this current scaffold source rather ' + 'than generating new source from scratch, and return the complete ' + 'modified file map via the create_plugin_scaffold tool.' + ) + + +# -------------------------------------------------------------------------- +# Chat sessions (NodeGenSessions, TTL'd) +# -------------------------------------------------------------------------- + +def sessions_table(): + return dynamodb.Table(NODE_GEN_SESSIONS_TABLE) + + +def source_snapshot_s3_key(usecase_id: str, session_id: str) -> str: + """S3 key of a session's current scaffold source snapshot""" + return (f"{PLUGIN_SOURCES_PREFIX}/{usecase_id}/gen-sessions/" + f"{session_id}/current_source.json") + + +def get_session(session_id: str) -> Optional[Dict]: + """Fetch a chat session item, or None (expired items may still be absent)""" + response = sessions_table().get_item(Key={'session_id': session_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def save_session(session: Dict) -> None: + """Persist a chat session with a refreshed TTL""" + session['ttl'] = int(time.time()) + SESSION_TTL_SECONDS + session['updated_at'] = now_ms() + sessions_table().put_item(Item=session) + + +def load_source_snapshot(s3_key: str) -> Optional[Dict]: + """Load a stored scaffold source snapshot ({path: content}), or None""" + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + except ClientError as e: + logger.warning(f"Could not load session source snapshot {s3_key}: {str(e)}") + return None + + +def put_source_snapshot(s3_key: str, files: Dict) -> None: + """Store the current scaffold source snapshot in portal S3""" + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=s3_key, + Body=json.dumps(files, sort_keys=True).encode('utf-8'), + ContentType='application/json' + ) + + +def session_prompts(session: Dict) -> List[str]: + """Every user prompt of the session, in order (provenance, 2.5)""" + return [m['text'] for m in (session.get('messages') or []) + if m.get('role') == 'user'] + + +def set_turn_state(session: Dict, status: str, + error: Optional[Dict] = None) -> None: + """Persist the session's generation-turn state (start/poll flow). + + Only turn_status/turn_error change: a failed turn never mutates the + message history or the source snapshot, so the user's prompt stays + retryable (2.6, 2.7). + """ + session['turn_status'] = status + session['turn_error'] = error + save_session(session) + + +def dispatch_generation_worker(session_id: str, prompt: str, + user: Dict) -> None: + """ + Asynchronously self-invoke this Lambda (InvocationType='Event') with + the worker payload: the generation turn runs outside the API Gateway + 29 s integration cap while the start route returns 202 immediately. + """ + function_name = (os.environ.get('NODE_GENERATOR_FUNCTION_NAME') + or os.environ.get('AWS_LAMBDA_FUNCTION_NAME')) + lambda_client.invoke( + FunctionName=function_name, + InvocationType='Event', + Payload=json.dumps({ + 'node_gen_worker': True, + 'session_id': session_id, + 'prompt': prompt, + 'user': user, + }).encode('utf-8'), + ) + + +def run_generation_worker(event: Dict) -> Dict: + """ + Worker entry (async self-invocation): run one generation turn for the + dispatched session and persist the outcome on the session record. + Success is persisted by run_generation_turn (turn_status=completed with + the source snapshot as the result pointer); any failure marks the turn + failed with the error envelope contents in turn_error, leaving the + message history and snapshot untouched (2.6, 2.7). + """ + session_id = event.get('session_id') + prompt = event.get('prompt') + user = event.get('user') or {} + session = get_session(str(session_id)) if session_id else None + if not session: + logger.error(f"Generation worker: session {session_id} not found/expired") + return {'ok': False} + try: + set_turn_state(session, TURN_RUNNING) + current_source = None + if session.get('current_source_key'): + # A follow-up turn modifies the current source snapshot (2.4). + current_source = load_source_snapshot(session['current_source_key']) + if current_source is None: + set_turn_state(session, TURN_FAILED, { + 'code': 'NO_CURRENT_SOURCE', + 'message': 'The generated source snapshot is no longer ' + 'available; please retry the prompt.', + 'details': {}, + 'http_status': 409, + }) + return {'ok': False} + result = run_generation_turn(session, prompt, current_source, user) + if result.get('statusCode') == 200: + return {'ok': True} + body = json.loads(result.get('body') or '{}') + error = body.get('error') or {} + set_turn_state(session, TURN_FAILED, { + 'code': error.get('code', 'GENERATION_FAILED'), + 'message': error.get('message', 'Scaffold generation failed. ' + 'Your prompt was not lost - please retry.'), + 'details': error.get('details') or {}, + 'http_status': result.get('statusCode'), + }) + return {'ok': False} + except Exception as exc: + logger.error(f"Generation worker failed unexpectedly: {str(exc)}", + exc_info=True) + set_turn_state(session, TURN_FAILED, { + 'code': 'INTERNAL_ERROR', + 'message': 'Scaffold generation failed unexpectedly. ' + 'Your prompt was not lost - please retry.', + 'details': {}, + 'http_status': 500, + }) + return {'ok': False} + + +# -------------------------------------------------------------------------- +# Bedrock invocation (Requirements 2.2, 2.7) +# -------------------------------------------------------------------------- + +def converse_messages(history: List[Dict], user_text: str) -> List[Dict]: + """Converse-format message list: replayed history plus the new user turn""" + messages = [ + {'role': m['role'], 'content': [{'text': m['text']}]} + for m in history[-MAX_HISTORY_MESSAGES:] + ] + messages.append({'role': 'user', 'content': [{'text': user_text}]}) + return messages + + +def invoke_generation(config: Dict, system_prompt: str, messages: List[Dict], + tool_config: Dict) -> Tuple[Optional[Dict], Optional[str], Optional[Dict]]: + """ + Invoke the configured Bedrock model via the Converse API. + + Returns (tool_input, assistant_text, None) on success or + (None, None, error_response). Timeouts and invocation failures are + returned as descriptive errors; the session is never mutated on + failure, so the user's prompt is preserved for retry (Requirement 2.7). + """ + client = get_bedrock_client(config['region'], config['timeout_seconds']) + # Never send temperature and top_p together (recent Anthropic models + # reject both); temperature wins when set - same rule as + # workflow_generator.invoke_generation. + inference_config = {'maxTokens': int(config['max_tokens'])} + temperature = config.get('temperature') + top_p = config.get('top_p') + if temperature is not None: + inference_config['temperature'] = float(temperature) + elif top_p is not None: + inference_config['topP'] = float(top_p) + try: + response = client.converse( + modelId=config['model_id'], + system=[{'text': system_prompt}], + messages=messages, + inferenceConfig=inference_config, + toolConfig=tool_config + ) + except (ReadTimeoutError, ConnectTimeoutError): + logger.error(f"Bedrock invocation exceeded the configured timeout " + f"({config['timeout_seconds']}s, model {config['model_id']})") + return None, None, error_response( + 504, 'GENERATION_TIMEOUT', + f"Scaffold generation timed out after {config['timeout_seconds']} seconds. " + 'Your prompt was not lost - please retry.', + {'timeout_seconds': config['timeout_seconds'], 'model_id': config['model_id']} + ) + except EndpointConnectionError as e: + logger.error(f"Bedrock endpoint unreachable: {str(e)}") + return None, None, error_response( + 502, 'BEDROCK_UNREACHABLE', + f"The Bedrock endpoint in region {config['region']} could not be reached. " + 'Check the Bedrock configuration. Your prompt was not lost - please retry.', + {'region': config['region']} + ) + except ClientError as e: + error = e.response.get('Error', {}) + logger.error(f"Bedrock invocation failed: {error.get('Code')}: {error.get('Message')}") + return None, None, error_response( + 502, 'BEDROCK_INVOCATION_FAILED', + f"The Bedrock model invocation failed: {error.get('Message', 'unknown error')}. " + 'Your prompt was not lost - please retry.', + {'bedrock_error_code': error.get('Code'), 'model_id': config['model_id']} + ) + + content = (response.get('output', {}).get('message', {}) or {}).get('content', []) + tool_input = None + text_parts: List[str] = [] + for block in content: + if 'toolUse' in block and block['toolUse'].get('name') == TOOL_NAME: + tool_input = block['toolUse'].get('input') + elif 'text' in block: + text_parts.append(block['text']) + assistant_text = '\n'.join(text_parts).strip() + + if not isinstance(tool_input, dict): + logger.error(f"Model returned no {TOOL_NAME} tool call " + f"(stopReason={response.get('stopReason')})") + return None, None, error_response( + 502, 'NO_SCAFFOLD_RETURNED', + 'The model did not return Plugin_Scaffold source. ' + 'Your prompt was not lost - please retry or rephrase it.', + {'stop_reason': response.get('stopReason'), 'model_text': assistant_text[:500]} + ) + return tool_input, assistant_text, None + + +# -------------------------------------------------------------------------- +# Shared generation turn (first prompt and follow-ups) +# -------------------------------------------------------------------------- + +def run_generation_turn(session: Dict, prompt: str, + current_source: Optional[Dict], + user: Dict) -> Dict: + """ + Execute one generation turn: assemble the prompts and the forced tool, + invoke Bedrock, validate the returned scaffold, and persist the session + plus the source snapshot. Any failure returns the error without + mutating the session, preserving the prompt for retry (2.6, 2.7). + """ + declaration = json.loads(session['declaration_json']) + + # The reference scaffold doubles as declaration validation and as the + # template conventions embedded in the system prompt (2.2). + try: + reference_files = render_scaffold(declaration) + except ScaffoldError as exc: + return error_response(400, 'INVALID_DECLARATION', str(exc), + {'field': exc.field}) + + current_source_json = (json.dumps(current_source, sort_keys=True) + if current_source else None) + user_text = build_user_message(prompt, current_source_json) + history = session.get('messages') or [] + messages = converse_messages(history, user_text) + + config = get_bedrock_configuration() + tool_input, assistant_text, err = invoke_generation( + config, build_system_prompt(declaration, reference_files), + messages, build_tool_config(declaration, reference_files)) + if err: + return err + + files = tool_input.get('files') + + # Output that does not form a buildable Plugin_Scaffold returns a + # descriptive error; the session stays untouched so the prompt is + # preserved for retry (2.6). + defects = scaffold_defects(files, declaration) + if defects: + logger.error(f"Generated scaffold failed validation: {defects}") + return error_response( + 422, 'GENERATED_SCAFFOLD_INVALID', + 'The generated output does not form a buildable Plugin_Scaffold: ' + + '; '.join(defects) + + '. Your prompt was not lost - please retry or rephrase it.', + {'defects': defects} + ) + # Drop non-string extras defensively (schema allows extra string files + # only; anything else would fail the build submission later). + files = {path: content for path, content in files.items() + if isinstance(path, str) and isinstance(content, str)} + + # ------------------------------------------------------- persist session + # The generated file map becomes the session's source snapshot for + # follow-up modification prompts (2.4). History carries the raw prompt + # (not the embedded source) to keep session items small; the follow-up + # turn re-embeds the latest source from the snapshot. + snapshot_key = source_snapshot_s3_key(session['usecase_id'], session['session_id']) + put_source_snapshot(snapshot_key, files) + + timestamp = now_ms() + session['messages'] = (history + [ + {'role': 'user', 'text': prompt, 'at': timestamp}, + {'role': 'assistant', + 'text': assistant_text or 'Produced Plugin_Scaffold source (see the current scaffold source).', + 'at': timestamp}, + ])[-MAX_HISTORY_MESSAGES * 2:] + session['current_source_key'] = snapshot_key + session['model_id'] = config['model_id'] + # The snapshot is the completed turn's result pointer for the poll route. + session['turn_status'] = TURN_COMPLETED + session['turn_error'] = None + save_session(session) + + log_audit_event( + user_id=user['user_id'], + action='generate_plugin_scaffold', + resource_type='plugin_scaffold_generation', + resource_id=session['session_id'], + result='success', + details={'usecase_id': session['usecase_id'], + 'model_id': config['model_id'], + 'file_count': len(files)} + ) + + return create_response(200, { + 'session_id': session['session_id'], + 'usecase_id': session['usecase_id'], + 'files': files, + 'assistant_text': assistant_text, + 'model_id': config['model_id'], + }) + + +# -------------------------------------------------------------------------- +# POST /plugins/generate - first prompt of a new session +# -------------------------------------------------------------------------- + +def generate_scaffold(event: Dict, user: Dict) -> Dict: + """ + POST /plugins/generate + Body: {usecase_id, prompt, declaration} + + Starts a scaffold-generation chat session: validates the request, + persists the session with turn_status=pending, dispatches the + asynchronous generation worker, and returns 202 with the session id + immediately; the caller polls GET /plugins/generate/{session} for the + generated Plugin_Scaffold source (2.1, 2.2, 2.3). Nothing is built or + recorded until the user accepts the source (2.5). + """ + body, err = parse_body(event) + if err: + return err + + usecase_id = body.get('usecase_id') + prompt = body.get('prompt') + declaration = body.get('declaration') + + missing = [f for f in ('usecase_id', 'prompt', 'declaration') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + if not isinstance(prompt, str) or not prompt.strip(): + return error_response(400, 'INVALID_PROMPT', 'prompt must be a non-empty string') + if not isinstance(declaration, dict): + return error_response(400, 'INVALID_DECLARATION', + 'declaration must be a JSON object (the Custom_Node_Type ' + 'declaration the scaffold implements)') + + if not can_generate(user, usecase_id): + return forbidden_response(user, event, usecase_id) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + # Validate the declaration up front so an invalid one fails fast with + # the offending field identified, before any Bedrock invocation. + try: + render_scaffold(declaration) + except ScaffoldError as exc: + return error_response(400, 'INVALID_DECLARATION', str(exc), + {'field': exc.field}) + + session = { + 'session_id': str(uuid.uuid4()), + 'usecase_id': usecase_id, + 'user_id': user['user_id'], + 'declaration_json': json.dumps(declaration, sort_keys=True), + 'messages': [], + 'current_source_key': None, + 'created_at': now_ms(), + 'turn_status': TURN_PENDING, + 'turn_error': None, + } + save_session(session) + return start_generation_turn(session, prompt.strip(), user) + + +def start_generation_turn(session: Dict, prompt: str, user: Dict) -> Dict: + """ + Dispatch the asynchronous generation worker for a pending turn and + return 202 with the session id (well under the API Gateway 29 s cap); + the caller polls GET /plugins/generate/{session} for the outcome. + """ + try: + dispatch_generation_worker(session['session_id'], prompt, user) + except Exception as exc: + logger.error(f"Could not dispatch the generation worker: {str(exc)}", + exc_info=True) + set_turn_state(session, TURN_FAILED, { + 'code': 'GENERATION_DISPATCH_FAILED', + 'message': 'The generation could not be started. ' + 'Your prompt was not lost - please retry.', + 'details': {}, + 'http_status': 502, + }) + return error_response( + 502, 'GENERATION_DISPATCH_FAILED', + 'The generation could not be started. ' + 'Your prompt was not lost - please retry.') + return create_response(202, { + 'session_id': session['session_id'], + 'usecase_id': session['usecase_id'], + 'turn_status': TURN_PENDING, + }) + + +# -------------------------------------------------------------------------- +# GET /plugins/generate/{session} - poll the current generation turn +# -------------------------------------------------------------------------- + +def get_generation_status(event: Dict, user: Dict, session_id: str) -> Dict: + """ + GET /plugins/generate/{session} + + Poll the session's current generation turn. While the turn is + pending/running only the status is returned; a completed turn adds the + generated files, the assistant commentary, and the model id (the former + synchronous response shape of run_generation_turn); a failed turn adds + turn_error {code, message, details, http_status} so the client can + surface the error and preserve the prompt for retry (2.6, 2.7). + """ + session, err = load_authorized_session(event, user, session_id) + if err: + return err + + status = session.get('turn_status') or TURN_PENDING + payload: Dict[str, Any] = { + 'session_id': session['session_id'], + 'usecase_id': session['usecase_id'], + 'turn_status': status, + } + + if status == TURN_FAILED: + payload['turn_error'] = session.get('turn_error') or { + 'code': 'GENERATION_FAILED', + 'message': 'Scaffold generation failed. ' + 'Your prompt was not lost - please retry.', + 'details': {}, + 'http_status': 502, + } + elif status == TURN_COMPLETED: + files = None + if session.get('current_source_key'): + files = load_source_snapshot(session['current_source_key']) + if files is None: + # Result pointer missing (snapshot expired/deleted): report the + # turn failed so the client can retry rather than hang. + payload['turn_status'] = TURN_FAILED + payload['turn_error'] = { + 'code': 'NO_CURRENT_SOURCE', + 'message': 'The generated source snapshot is no longer ' + 'available; please retry the prompt.', + 'details': {}, + 'http_status': 409, + } + else: + assistant_text = next( + (m.get('text', '') for m in reversed(session.get('messages') or []) + if m.get('role') == 'assistant'), '') + payload['files'] = files + payload['assistant_text'] = assistant_text + payload['model_id'] = session.get('model_id') + + return create_response(200, payload) + + +# -------------------------------------------------------------------------- +# POST /plugins/generate/{session}/message - follow-up or acceptance +# -------------------------------------------------------------------------- + +def load_authorized_session(event: Dict, user: Dict, + session_id: str) -> Tuple[Optional[Dict], Optional[Dict]]: + """ + Load a session for the acting user. Expired/unknown sessions and + sessions of other users all yield the same 404, so existence is never + leaked. Returns (session, None) or (None, error_response). + """ + session = get_session(session_id) + if not session or session.get('user_id') != user['user_id']: + return None, error_response(404, 'SESSION_NOT_FOUND', + 'Generation session not found or expired') + if not can_generate(user, session['usecase_id']): + return None, forbidden_response(user, event, session['usecase_id']) + return session, None + + +def session_message(event: Dict, user: Dict, session_id: str) -> Dict: + """ + POST /plugins/generate/{session}/message + Body: {prompt} follow-up modification (2.4): + 202 + poll, like the start route + {accept: true, name, description?} accept the current source (2.5) + """ + body, err = parse_body(event) + if err: + return err + + session, err = load_authorized_session(event, user, session_id) + if err: + return err + + # A turn is already in flight: neither a second prompt nor acceptance + # may race the worker's persistence of the current turn. + if session.get('turn_status') in TURN_IN_PROGRESS: + return error_response(409, 'GENERATION_IN_PROGRESS', + 'A generation turn is already running for this ' + 'session; poll GET /plugins/generate/{session} ' + 'until it settles') + + if body.get('accept'): + return accept_scaffold(event, user, session, body) + + prompt = body.get('prompt') + if not isinstance(prompt, str) or not prompt.strip(): + return error_response(400, 'INVALID_PROMPT', + 'prompt must be a non-empty string (or pass accept: true)') + + if not session.get('current_source_key'): + return error_response(409, 'NO_CURRENT_SOURCE', + 'The session has no generated source to modify; ' + 'start over with POST /plugins/generate') + + # Same async treatment as the start route: mark the turn pending and + # dispatch the worker, which re-embeds the current source snapshot (2.4). + set_turn_state(session, TURN_PENDING) + return start_generation_turn(session, prompt.strip(), user) + + +def accept_scaffold(event: Dict, user: Dict, session: Dict, body: Dict) -> Dict: + """ + Accept the session's current generated source: the source is validated + once more, written under the standard plugin-sources prefix, and a + Plugin_Record (kind "generated", Lifecycle_State dev, review pending) + is created with the generation prompt recorded as provenance - from + here the source follows the same build, simulation, lifecycle, and + security review path as user-written scaffold source (2.5). + """ + declaration = json.loads(session['declaration_json']) + + files = None + if session.get('current_source_key'): + files = load_source_snapshot(session['current_source_key']) + if files is None: + return error_response(409, 'NO_CURRENT_SOURCE', + 'The session has no generated source to accept; ' + 'submit a prompt first') + + defects = scaffold_defects(files, declaration) + if defects: + return error_response( + 422, 'GENERATED_SCAFFOLD_INVALID', + 'The current generated source does not form a buildable ' + 'Plugin_Scaffold: ' + '; '.join(defects), + {'defects': defects} + ) + + name = body.get('name') or declaration.get('displayName') + if not isinstance(name, str) or not name.strip(): + return error_response(400, 'INVALID_NAME', + 'name must be a non-empty string') + + usecase_id = session['usecase_id'] + plugin_id = str(uuid.uuid4()) + timestamp = now_ms() + prompts = session_prompts(session) + + # Provenance records the generation prompt(s) (2.5) alongside the + # declaration and the generating user/timestamp/model. + provenance = { + 'prompt': '\n\n'.join(prompts), + 'prompts': prompts, + 'scaffoldDeclaration': session['declaration_json'], + 'generatedBy': user['user_id'], + 'generatedAt': timestamp, + 'modelId': session.get('model_id'), + 'generationSessionId': session['session_id'], + } + + item = new_version_item( + plugin_id=plugin_id, version=1, usecase_id=usecase_id, + name=name.strip(), kind='generated', user_id=user['user_id'], + timestamp=timestamp, + description=body.get('description', + declaration.get('description') or ''), + deepstream=body.get('deepstream', False), + provenance=provenance, + ) + + # Source lands under the standard plugin-sources layout used by the + # create wizard and the Plugin_Build_Service. + prefix = source_s3_prefix(usecase_id, plugin_id, 1) + for path, content in sorted(files.items()): + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=prefix + path, + Body=content.encode('utf-8'), + ContentType='text/plain; charset=utf-8' + ) + + plugin_table().put_item( + Item=item, + ConditionExpression='attribute_not_exists(plugin_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='accept_generated_scaffold', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': usecase_id, 'name': name.strip(), + 'kind': 'generated', 'version': 1, + 'generation_session_id': session['session_id']} + ) + + return create_response(201, {'plugin': version_detail(item)}) + + +# -------------------------------------------------------------------------- +# Lambda handler +# -------------------------------------------------------------------------- + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + # Asynchronous worker invocation (self Event-invoke from the start + # routes): not an API Gateway event, so it is dispatched before any + # HTTP routing or user extraction. + if event.get('node_gen_worker'): + return run_generation_worker(event) + + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + + if resource == '/plugins/generate' and http_method == 'POST': + return generate_scaffold(event, user) + + if resource == '/plugins/generate/{session}' and http_method == 'GET': + session_id = path_params.get('session') + if not session_id: + return error_response(400, 'MISSING_SESSION', 'Session id is required') + return get_generation_status(event, user, str(session_id)) + + if resource == '/plugins/generate/{session}/message' and http_method == 'POST': + session_id = path_params.get('session') + if not session_id: + return error_response(400, 'MISSING_SESSION', 'Session id is required') + return session_message(event, user, str(session_id)) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/plugin_builds.py b/edge-cv-portal/backend/functions/plugin_builds.py new file mode 100644 index 00000000..19409c66 --- /dev/null +++ b/edge-cv-portal/backend/functions/plugin_builds.py @@ -0,0 +1,1049 @@ +""" +Plugin_Build_Service orchestration Lambda function (Custom Node Designer) + +Build orchestration, per-arch build status, artifact + signature +recording, and the prebuilt-binary upload path for Plugin_Records +(Requirements 1.6, 3.1, 3.3, 3.4, 3.5, 3.6, 5.1, 5.2). + +Routes (API Gateway REST): + POST /plugins/{id}/versions/{v}/build Submit the version's source to + the per-arch CodeBuild projects + (marks per-arch build status + "building", 3.1; a plugin-set + import's recorded + selected_plugins are passed to + StartBuild as the + PLUGIN_TARGETS env override) + and/or accept + prebuilt .so binaries per arch + (checksummed + signed identically, + provenance prebuilt: true, 3.6) + GET /plugins/{id}/versions/{v}/builds Per-arch build status for the + Node_Designer UI (3.5) + +EventBridge (rule 'dda-portal-plugin-build-results'): + CodeBuild Build State Change events for the dda-plugin-build-{arch} + projects AND the dda-plugin-fetch repository-fetch project are + delivered to this same handler. Fetch results are delegated to + plugin_importer.handle_fetch_result (same Lambda bundle), which + advances the asynchronously imported Plugin_Record out of its + 'fetching' import status. Per-arch build result recording is + idempotent on the build id: a duplicate delivery, or an event from a + superseded build, never double-records an artifact. + + - SUCCEEDED: the build image has promoted the .so to the Plugin_Library + custom prefix (workflow-plugins/custom/{usecase_id}/{arch}/{plugin}.so). + The handler streams the promoted artifact, records its SHA-256 + checksum, signs the digest with the portal KMS key (ECDSA P-256), + stores the detached signature alongside as {plugin}.so.sig, and + records {s3Key, checksum, signature, buildStatus} on the + Plugin_Record (3.3). + - FAILED / FAULT / STOPPED / TIMED_OUT: the CloudWatch log tail is + recorded on the per-arch entry and no artifact is stored (3.4). + +When all requested Target_Architecture builds have settled with at least +one success, plugin_components.py (auto Plugin_Component packaging, +Requirement 16.1) is invoked asynchronously by name; its absence or +failure never fails the build (design: "auto-packaging failure never +fails the build"). The trigger is idempotent via a conditional +components_triggered marker on the Plugin_Record version. + +DeepStream-flagged records restrict selectable architectures to the +JetPack builds arm64_jp4/jp5/jp6 (5.1); the JetPack build projects pin +the DeepStream SDK matching each release (5.2, infrastructure). + +Access control: build submission and prebuilt upload require +node-designer:manage (UseCaseAdmin within the Use_Case, PortalAdmin); +build status is readable by every role of the Use_Case. +""" +import base64 +import hashlib +import json +import logging +import os +import posixpath +import re +from typing import Any, Dict, List, Optional, Tuple + +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, Permission +) +from workflow_core.catalog import ( + DEVICE_ARCHITECTURES, + DEEPSTREAM_ARCHITECTURES, +) + +# Reuse the Plugin_Record item shape, persistence helpers, and error +# envelope from plugin_records.py (same deployment bundle). +import plugin_records +from plugin_records import ( + authorize_record_access, + error_response, + get_version_item, + not_found_response, + now_ms, + parse_body, + plugin_table, + successful_build_archs, +) +# Introspection_Report shape validation (gst-parameter-prepopulation +# design component 4): pure module shipped alongside this handler. +from gst_properties import ( + ReportError, + STATUS_CAPTURED, + STATUS_FAILED, + parse_report, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +s3 = boto3.client('s3') +kms = boto3.client('kms') +codebuild = boto3.client('codebuild') +logs_client = boto3.client('logs') +lambda_client = boto3.client('lambda') + +# Environment variables (node-designer-stack.ts lambdaEnvironment) +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +PLUGIN_SIGNING_KEY_ARN = os.environ.get('PLUGIN_SIGNING_KEY_ARN') +PLUGIN_COMPONENTS_FUNCTION_NAME = os.environ.get('PLUGIN_COMPONENTS_FUNCTION_NAME') +PLUGIN_STAGING_PREFIX = os.environ.get('PLUGIN_STAGING_PREFIX', 'plugin-staging') +PLUGIN_LIBRARY_CUSTOM_PREFIX = os.environ.get( + 'PLUGIN_LIBRARY_CUSTOM_PREFIX', 'workflow-plugins/custom') + +#: arch -> CodeBuild project name (BuildProjectsJson stack output) +BUILD_PROJECTS: Dict[str, str] = json.loads(os.environ.get('BUILD_PROJECTS_JSON', '{}')) +#: project name -> arch (EventBridge result attribution) +PROJECT_ARCHITECTURES: Dict[str, str] = {v: k for k, v in BUILD_PROJECTS.items()} + +#: The lightweight repository-fetch project (plugin_importer.py's +#: asynchronous import). Its build state changes arrive on the same +#: EventBridge rule and are delegated to +#: plugin_importer.handle_fetch_result. Defaults to the fixed project +#: name node-designer-stack.ts assigns (this Lambda's environment does +#: not carry the variable). +FETCH_PROJECT_NAME = os.environ.get('FETCH_PROJECT_NAME', 'dda-plugin-fetch') + +# ---------------------------------------------------------------- constants + +# Per-arch build statuses on the Plugin_Record artifacts map. "queued" is +# written by plugin_importer.py at import time; once the fetch settles +# the record to 'imported' the importer calls start_queued_builds (same +# Lambda bundle) which advances queued -> building, and the EventBridge +# result handler settles building -> succeeded/failed. +BUILD_QUEUED = 'queued' +BUILD_BUILDING = 'building' +BUILD_SUCCEEDED = 'succeeded' +BUILD_FAILED = 'failed' +SETTLED_STATUSES = (BUILD_SUCCEEDED, BUILD_FAILED) + +#: CodeBuild terminal statuses that map to a failed Plugin_Artifact build. +CODEBUILD_FAILURE_STATUSES = ('FAILED', 'FAULT', 'STOPPED', 'TIMED_OUT') + +#: Signing algorithm for the portal ECDSA P-256 key (3.3). +SIGNING_ALGORITHM = 'ECDSA_SHA_256' + +#: CloudWatch log excerpt recorded for failed builds (3.4). A failed +#: CodeBuild run emits ~100+ lines of post-failure phase/upload +#: boilerplate after the actual compiler/configure error, so the +#: excerpt fetches a generous window and centers on the last real +#: error before the BUILD-phase failure marker. +LOG_TAIL_MAX_EVENTS = 500 +LOG_TAIL_MAX_CHARS = 8 * 1024 +#: Context lines preserved before the last matched error line. +LOG_TAIL_CONTEXT_BEFORE = 40 + +#: The CodeBuild phase-failure marker: everything after it is +#: post-failure boilerplate (artifact upload, phase bookkeeping). +BUILD_FAILED_MARKER = re.compile( + r'Phase complete: \w+ State: FAILED') + +#: Lines that look like the actual failure cause. +ERROR_LINE_PATTERN = re.compile( + r'\bERROR\b|error:|\berror\b:|Error while executing|fatal error' + r'|undefined reference|No such file|command not found' + r'|ninja: build stopped|FAILED:', re.IGNORECASE) + +#: Maximum prebuilt .so accepted inline (base64) - bounded by the API +#: Gateway payload limit; larger binaries arrive via source_key (an +#: object already synced under the version's plugin-sources prefix). +MAX_PREBUILT_INLINE_BYTES = 6 * 1024 * 1024 + +#: Property_Introspection runs on x86_64 only (gst-parameter-prepopulation +#: design: GObject property declarations are architecture-independent and +#: the x86_64 build is the designer's gating artifact everywhere else). +INTROSPECTION_ARCH = 'x86_64' + +#: Size cap on stored Introspection_Report objects +#: (gst-parameter-prepopulation design component 3). +GST_REPORT_MAX_BYTES = 256 * 1024 + + +# ------------------------------------------------------------ pure helpers + +def sanitize_plugin_name(name: Optional[str], fallback: str) -> str: + """File-system/S3-safe plugin base name for {plugin}.so keys""" + cleaned = re.sub(r'[^A-Za-z0-9_.-]+', '-', name or '').strip('-.') + return cleaned or fallback + + +def library_so_key(usecase_id: str, arch: str, plugin_name: str) -> str: + """Plugin_Library custom key: workflow-plugins/custom/{usecase}/{arch}/{plugin}.so""" + return f"{PLUGIN_LIBRARY_CUSTOM_PREFIX}/{usecase_id}/{arch}/{plugin_name}.so" + + +def signature_key(so_key: str) -> str: + """Detached signature key alongside the artifact (design S3 layout)""" + return so_key + '.sig' + + +def gst_report_key(so_key: str) -> str: + """Introspection_Report key alongside the promoted artifact + ({plugin}.so.gstinspect.json, gst-parameter-prepopulation design)""" + return so_key + '.gstinspect.json' + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def build_id_from_arn(build_arn: str) -> str: + """Extract 'project:uuid' from the EventBridge detail build-id ARN""" + if ':build/' in build_arn: + return build_arn.split(':build/', 1)[1] + return build_arn + + +def env_vars_from_detail(detail: Dict) -> Dict[str, str]: + """Environment variables of the finished build (StartBuild overrides)""" + env = ((detail.get('additional-information') or {}) + .get('environment') or {}).get('environment-variables') or [] + return {var.get('name'): var.get('value') for var in env + if isinstance(var, dict) and var.get('name')} + + +def plugin_targets_value(item: Dict) -> str: + """ + PLUGIN_TARGETS env value for the per-arch CodeBuild builds: the + comma-separated individual plugin names the user selected for a + plugin-set import (recorded as selected_plugins by + plugin_importer.py's select-plugins endpoint), or '' when no + selection exists (single-plugin repositories, scaffolds, generated + plugins). The build image entrypoint builds only the named plugin + targets when PLUGIN_TARGETS is non-empty, and the whole source tree + otherwise. + """ + selected = item.get('selected_plugins') or [] + return ','.join(str(name) for name in selected) + + +def selected_plugins_value(item: Dict) -> str: + """ + SELECTED_PLUGINS env value for the per-arch CodeBuild builds: the + comma-separated plugin selection the record's provenance carries + (provenance.selectedPlugins — written by the import-time selection + on POST /plugins/import and by the select-plugins endpoint), with + the record-level selected_plugins field as fallback; '' when no + selection exists. The build image entrypoint meson-enables only the + named plugins (-Dauto_features=disabled -D=enabled) when + SELECTED_PLUGINS is present. + """ + provenance = item.get('provenance') or {} + selected = (provenance.get('selectedPlugins') + or item.get('selected_plugins') or []) + return ','.join(str(name) for name in selected) + + +def arch_source_prefix(item: Dict, arch: str) -> str: + """ + S3 source prefix one architecture's build reads from. Multi-revision + imports (plugin_importer arch_revisions) record a per-revision fetch + map on the version item — the arch resolves through + arch_revisions[arch] -> fetches[slug].source_prefix so architectures + pinned to different source revisions build from their own synced + tree. Everything else (single-revision imports, scaffolds, generated + plugins, existing records) falls back to the flat source_s3_prefix. + """ + slug = (item.get('arch_revisions') or {}).get(arch) + fetch = (item.get('fetches') or {}).get(slug) if slug else None + prefix = (fetch or {}).get('source_prefix') + return prefix or item.get('source_s3_prefix') or '' + + +def requested_architectures(item: Dict) -> List[str]: + """Architectures whose builds this version is waiting on""" + requested = item.get('requested_architectures') + if requested: + return [str(a) for a in requested] + return sorted((item.get('artifacts') or {}).keys()) + + +def builds_settled(item: Dict) -> bool: + """True when every requested Target_Architecture build has settled""" + artifacts = item.get('artifacts') or {} + requested = requested_architectures(item) + if not requested: + return False + return all( + (artifacts.get(arch) or {}).get('buildStatus') in SETTLED_STATUSES + for arch in requested + ) + + +def validate_architectures(architectures: List[str], + deepstream: bool) -> Optional[Tuple[str, str, Dict]]: + """ + Validate a Target_Architecture selection; returns None or an error + tuple (code, message, details). DeepStream-flagged records restrict + the selectable architectures to arm64_jp4/jp5/jp6 (5.1). + """ + invalid = [a for a in architectures if a not in DEVICE_ARCHITECTURES] + if invalid: + return ('INVALID_ARCHITECTURES', + f"Unknown Target_Architectures: {', '.join(sorted(invalid))}", + {'valid': list(DEVICE_ARCHITECTURES)}) + if deepstream: + non_jetson = [a for a in architectures + if a not in DEEPSTREAM_ARCHITECTURES] + if non_jetson: + return ('INVALID_ARCHITECTURES', + 'DeepStream-flagged plugins may only target: ' + f"{', '.join(DEEPSTREAM_ARCHITECTURES)}", + {'invalid': sorted(non_jetson)}) + return None + + +def builds_view(item: Dict) -> Dict: + """Per-arch build status view for the Node_Designer UI (3.5)""" + artifacts = item.get('artifacts') or {} + return { + 'plugin_id': item['plugin_id'], + 'version': item['version'], + 'requested_architectures': requested_architectures(item), + 'builds': { + arch: { + 'buildStatus': (entry or {}).get('buildStatus'), + 's3Key': (entry or {}).get('s3Key'), + 'checksum': (entry or {}).get('checksum'), + 'signature': (entry or {}).get('signature'), + 'logTail': (entry or {}).get('logTail', ''), + 'prebuilt': (entry or {}).get('prebuilt', False), + } + for arch, entry in artifacts.items() + }, + 'settled': builds_settled(item), + 'component_packaging_triggered': bool(item.get('components_triggered')), + } + + +# --------------------------------------------------------- signing/storage + +def sign_digest(digest: bytes) -> str: + """KMS-sign a SHA-256 digest with the portal signing key (3.3)""" + response = kms.sign( + KeyId=PLUGIN_SIGNING_KEY_ARN, + Message=digest, + MessageType='DIGEST', + SigningAlgorithm=SIGNING_ALGORITHM, + ) + return base64.b64encode(response['Signature']).decode('ascii') + + +def store_signed_artifact(usecase_id: str, arch: str, plugin_name: str, + data: bytes) -> Dict: + """ + Store artifact bytes to the Plugin_Library custom prefix with the + detached signature ({plugin}.so + {plugin}.so.sig) and return the + per-arch artifact entry fields (s3Key, checksum, signature). + Used by the prebuilt upload path (3.6). + """ + so_key = library_so_key(usecase_id, arch, plugin_name) + checksum = sha256_hex(data) + signature = sign_digest(hashlib.sha256(data).digest()) + s3.put_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=so_key, Body=data) + s3.put_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=signature_key(so_key), + Body=base64.b64decode(signature)) + return {'s3Key': so_key, 'checksum': checksum, 'signature': signature} + + +def build_gst_introspection_stanza(so_key: str) -> Dict: + """ + gstIntrospection stanza for the x86_64 artifact entry + (gst-parameter-prepopulation Requirements 1.1, 1.6): fetch the + Introspection_Report the build uploaded next to the promoted .so + ({plugin}.so.gstinspect.json), enforce the 256 KiB size cap, and + validate its shape via gst_properties.parse_report. + + Returns either: + - {status: "captured", s3Key, gstVersion, capturedAt} for a valid + captured report, or + - {status: "failed", message} for a report that itself recorded a + capture failure (its message is carried through), a missing + object, an oversized object, or malformed JSON/shape (8.3 + handled at write time too). + + Never raises: any unexpected error is folded into a failed stanza so + the SUCCEEDED handling is untouched (Requirement 1.4). + """ + report_key = gst_report_key(so_key) + try: + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=report_key) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return {'status': STATUS_FAILED, + 'message': 'No introspection report was uploaded ' + 'by the build'} + raise + + size = obj.get('ContentLength') + if size is not None and size > GST_REPORT_MAX_BYTES: + return {'status': STATUS_FAILED, + 'message': f'Introspection report exceeds the ' + f'{GST_REPORT_MAX_BYTES // 1024} KiB size cap ' + f'({size} bytes)'} + data = obj['Body'].read(GST_REPORT_MAX_BYTES + 1) + if len(data) > GST_REPORT_MAX_BYTES: + return {'status': STATUS_FAILED, + 'message': f'Introspection report exceeds the ' + f'{GST_REPORT_MAX_BYTES // 1024} KiB size cap'} + + try: + report = parse_report(json.loads(data.decode('utf-8'))) + except (json.JSONDecodeError, UnicodeDecodeError): + return {'status': STATUS_FAILED, + 'message': 'Introspection report is not valid JSON'} + except ReportError as e: + return {'status': STATUS_FAILED, + 'message': f'Introspection report is malformed: {e}'} + + if report.status != STATUS_CAPTURED: + return {'status': STATUS_FAILED, + 'message': report.message + or 'Introspection reported a capture failure'} + + return {'status': STATUS_CAPTURED, + 's3Key': report_key, + 'gstVersion': report.gst_version, + 'capturedAt': report.captured_at} + except Exception as e: # introspection recording never fails the build (1.4) + logger.warning( + f"Could not record introspection report {report_key}: {e}") + return {'status': STATUS_FAILED, + 'message': f'Could not record introspection report: {e}'} + + +def record_promoted_artifact(usecase_id: str, arch: str, + plugin_name: str) -> Optional[Dict]: + """ + Checksum + sign the artifact a successful CodeBuild run promoted to + the Plugin_Library (3.3): stream the .so, record its SHA-256, sign + the digest with the portal key, and (re)write the authoritative + detached signature. Returns the artifact entry fields, or None when + the promoted object is missing. + + For the x86_64 artifact the entry additionally carries the + gstIntrospection stanza validated from the report the build uploaded + next to the promoted .so (gst-parameter-prepopulation 1.1, 1.6); + stanza recording is best-effort and never alters the build outcome + (1.4). Non-x86_64 entries are unchanged. + """ + so_key = library_so_key(usecase_id, arch, plugin_name) + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=so_key) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return None + raise + data = obj['Body'].read() + checksum = sha256_hex(data) + signature = sign_digest(hashlib.sha256(data).digest()) + s3.put_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=signature_key(so_key), + Body=base64.b64decode(signature)) + fields = {'s3Key': so_key, 'checksum': checksum, 'signature': signature} + if arch == INTROSPECTION_ARCH: + fields['gstIntrospection'] = build_gst_introspection_stanza(so_key) + return fields + + +def extract_error_excerpt(lines: List[str]) -> str: + """ + Reduce a failed build's log lines to the excerpt that shows the + actual failure (3.4). CodeBuild keeps running post-failure phases + (UPLOAD_ARTIFACTS etc.), so a plain tail shows only boilerplate: + + 1. Cut the log at the first 'Phase complete: State: FAILED' + marker (keeping the phase-context message right after it) - + everything later is post-failure bookkeeping. + 2. Within what remains, find the last line that looks like a real + error (compiler/meson/ninja/entrypoint) and keep a window of + context before it through the failure marker. + 3. Fall back to the plain tail when no error line matches. + """ + cut = len(lines) + for i, line in enumerate(lines): + if BUILD_FAILED_MARKER.search(line): + cut = min(i + 2, len(lines)) # keep the phase-context line + break + trimmed = lines[:cut] + + error_idx = None + for i in range(len(trimmed) - 1, -1, -1): + if (ERROR_LINE_PATTERN.search(trimmed[i]) + and not BUILD_FAILED_MARKER.search(trimmed[i])): + error_idx = i + break + if error_idx is not None: + start = max(0, error_idx - LOG_TAIL_CONTEXT_BEFORE) + trimmed = trimmed[start:] + + return '\n'.join(trimmed)[-LOG_TAIL_MAX_CHARS:] + + +def fetch_log_tail(detail: Dict) -> str: + """CloudWatch error excerpt of a failed build for the Plugin_Record (3.4)""" + log_info = (detail.get('additional-information') or {}).get('logs') or {} + group = log_info.get('group-name') + stream = log_info.get('stream-name') + if not group or not stream: + return '' + try: + response = logs_client.get_log_events( + logGroupName=group, + logStreamName=stream, + limit=LOG_TAIL_MAX_EVENTS, + startFromHead=False, + ) + except ClientError as e: + logger.warning(f"Could not fetch build log tail {group}/{stream}: {e}") + return '' + lines = [e.get('message', '').rstrip('\n') + for e in response.get('events', [])] + return extract_error_excerpt(lines) + + +# ------------------------------------------------------------- persistence + +def set_arch_entry(plugin_id: str, version: int, arch: str, entry: Dict) -> None: + """Write one per-arch artifact entry on the Plugin_Record version""" + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET artifacts.#a = :entry, updated_at = :t', + ExpressionAttributeNames={'#a': arch}, + ExpressionAttributeValues={':entry': entry, ':t': now_ms()}, + ) + + +def trigger_component_packaging(item: Dict) -> bool: + """ + Invoke plugin_components.py asynchronously when all requested arch + builds have settled with at least one success (Requirement 16.1 + trigger). Idempotent: a conditional components_triggered marker on + the version item guarantees a single trigger per build round even + under duplicate EventBridge delivery. plugin_components.py absence + or invocation failure never fails the build. + """ + if not builds_settled(item) or not successful_build_archs(item): + return False + + plugin_id, version = item['plugin_id'], item['version'] + try: + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET components_triggered = :t', + ConditionExpression='attribute_not_exists(components_triggered)', + ExpressionAttributeValues={':t': now_ms()}, + ) + except ClientError as e: + if e.response.get('Error', {}).get('Code') == 'ConditionalCheckFailedException': + return False # already triggered for this build round + raise + + if not PLUGIN_COMPONENTS_FUNCTION_NAME: + logger.warning('PLUGIN_COMPONENTS_FUNCTION_NAME unset; skipping ' + 'Plugin_Component auto-packaging trigger') + return False + try: + lambda_client.invoke( + FunctionName=PLUGIN_COMPONENTS_FUNCTION_NAME, + InvocationType='Event', + Payload=json.dumps({ + 'action': 'package_plugin_component', + 'plugin_id': plugin_id, + 'version': version, + 'usecase_id': item.get('usecase_id'), + }).encode('utf-8'), + ) + return True + except Exception as e: # auto-packaging failure never fails the build + logger.warning( + f"Plugin_Component auto-packaging trigger failed for " + f"{plugin_id} v{version}: {e}") + return False + + +# -------------------------------------------------------- build submission + +def submit_arch_builds(item: Dict, build_archs: List[str]) -> Dict[str, Dict]: + """ + StartBuild the matching per-arch CodeBuild project for each + Target_Architecture in `build_archs` (3.1), the version's + plugin-sources tree as the source location (per-arch: multi-revision + imports resolve each arch's own rev-{slug}/ prefix via + arch_source_prefix). Returns the per-arch artifact entries + {buildStatus: building, buildId, logTail} for the caller to persist. + Every arch in `build_archs` must have a configured BUILD_PROJECTS + entry. + """ + plugin_id, version = item['plugin_id'], item['version'] + usecase_id = item['usecase_id'] + plugin_name = sanitize_plugin_name(item.get('name'), plugin_id) + plugin_targets = plugin_targets_value(item) + selected_plugins = selected_plugins_value(item) + entries: Dict[str, Dict] = {} + for arch in build_archs: + env_overrides = [ + {'name': 'USECASE_ID', 'value': usecase_id, 'type': 'PLAINTEXT'}, + {'name': 'PLUGIN_ID', 'value': plugin_id, 'type': 'PLAINTEXT'}, + {'name': 'PLUGIN_VERSION', 'value': str(version), 'type': 'PLAINTEXT'}, + {'name': 'PLUGIN_NAME', 'value': plugin_name, 'type': 'PLAINTEXT'}, + {'name': 'TARGET_ARCH', 'value': arch, 'type': 'PLAINTEXT'}, + # PLUGIN_TARGETS: comma-separated individual plugin + # names selected for a plugin-set import (e.g. + # gst-plugins-good). The build image entrypoint builds + # only the named plugin targets when non-empty, and the + # whole source tree when empty (single-plugin repos, + # scaffolds, generated plugins). + {'name': 'PLUGIN_TARGETS', 'value': plugin_targets, + 'type': 'PLAINTEXT'}, + ] + if selected_plugins: + # SELECTED_PLUGINS: added only when the record's provenance + # carries a plugin selection, so the build entrypoint can + # meson-enable exactly those plugins + # (-Dauto_features=disabled -D=enabled). + env_overrides.append( + {'name': 'SELECTED_PLUGINS', 'value': selected_plugins, + 'type': 'PLAINTEXT'}) + # Per-arch source tree: multi-revision imports resolve the + # arch's own rev-{slug}/ prefix; everything else builds from + # the flat source_s3_prefix as before. + source_prefix = arch_source_prefix(item, arch) + start = codebuild.start_build( + projectName=BUILD_PROJECTS[arch], + sourceLocationOverride=f"{PORTAL_ARTIFACTS_BUCKET}/{source_prefix}", + environmentVariablesOverride=env_overrides, + ) + entries[arch] = { + 'buildStatus': BUILD_BUILDING, + 'buildId': start['build']['id'], + 'logTail': '', + } + return entries + + +def start_queued_builds(plugin_id: str, version: int) -> Dict[str, Dict]: + """ + Start the builds an asynchronous import queued: plugin_importer.py + (same Lambda bundle) calls this once a fetch settles the record to + import_status 'imported' with per-arch {'buildStatus': 'queued'} + artifact entries (4.3). Finds the requested architectures still + queued, StartBuilds them via submit_arch_builds, and persists the + advanced entries per arch. + + Safe and idempotent: an already-started (non-queued) arch is left + alone, architectures without a configured CodeBuild project are + skipped (left queued), a StartBuild failure is recorded as a failed + arch entry instead of raising, and nothing here ever raises to the + caller — auto-start failure must never fail the fetch-result + handler. Audit-logged as the record's created_by (there is no + authenticated user on the EventBridge path). + """ + try: + # Consistent read: this runs in the same invocation as the write + # that queued the builds (fetch-settle or revision adjustment), so + # an eventually-consistent read could miss the just-written + # arch_revisions mapping and resolve the wrong source prefix. + item = get_version_item(plugin_id, version, consistent_read=True) + if not item: + logger.warning(f"Auto-start: Plugin_Record {plugin_id} " + f"v{version} not found") + return {} + artifacts = item.get('artifacts') or {} + queued = [arch for arch in requested_architectures(item) + if (artifacts.get(arch) or {}).get('buildStatus') + == BUILD_QUEUED] + unconfigured = [a for a in queued if a not in BUILD_PROJECTS] + if unconfigured: + logger.warning( + 'Auto-start: no CodeBuild project is configured for ' + f"{', '.join(sorted(unconfigured))}; leaving queued") + + entries: Dict[str, Dict] = {} + for arch in queued: + if arch not in BUILD_PROJECTS: + continue + try: + entries.update(submit_arch_builds(item, [arch])) + except Exception as e: + logger.warning(f"Auto-start of {arch} build for " + f"{plugin_id} v{version} failed: {e}") + entries[arch] = { + 'buildStatus': BUILD_FAILED, + 'logTail': f'Automatic build start failed: {e}', + } + if not entries: + return {} + + for arch, entry in entries.items(): + set_arch_entry(plugin_id, version, arch, entry) + + started = sorted(a for a, e in entries.items() + if e['buildStatus'] == BUILD_BUILDING) + log_audit_event( + user_id=item.get('created_by') or 'system', + action='build_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success' if started else 'failure', + details={'usecase_id': item.get('usecase_id'), + 'version': version, + 'architectures': started, + 'trigger': 'import_auto_start'} + ) + logger.info(f"Auto-started builds for {plugin_id} v{version}: " + f"{', '.join(started) or 'none'}") + return entries + except Exception as e: # auto-start failure never fails the caller + logger.warning(f"Auto-start of queued builds for {plugin_id} " + f"v{version} failed: {e}") + return {} + + +# ----------------------------------------------------------------- handlers + +def start_builds(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/build + Body: {architectures?: [arch, ...], + prebuilt?: {arch: {source_key} | {content_base64}}} + + Marks the per-arch build status building and StartBuilds the + matching CodeBuild project per selected Target_Architecture with the + version's plugin-sources tree as the source location (3.1). + Architectures under `prebuilt` skip the build: the provided .so is + accepted as the Plugin_Artifact, checksummed and KMS-signed + identically, stored in the Plugin_Library, and recorded with + prebuilt: true provenance (3.6). Without an explicit architectures + list the record's previously requested architectures (e.g. queued by + an import) are built. + """ + body, err = parse_body(event) + if err: + return err + + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item, manage=True, + permission=Permission.NODE_DESIGNER_MANAGE) + if err: + return err + + prebuilt = body.get('prebuilt') or {} + if not isinstance(prebuilt, dict) or not all( + isinstance(v, dict) for v in prebuilt.values()): + return error_response(400, 'INVALID_PREBUILT', + 'prebuilt must map architectures to ' + '{source_key} or {content_base64} objects') + + architectures = body.get('architectures') + if architectures is None: + architectures = [a for a in requested_architectures(item) + if a not in prebuilt] + if (not isinstance(architectures, list) + or not all(isinstance(a, str) for a in architectures)): + return error_response(400, 'INVALID_ARCHITECTURES', + 'architectures must be a list of ' + 'Target_Architecture identifiers') + + build_archs = sorted(set(architectures) - set(prebuilt)) + all_archs = sorted(set(architectures) | set(prebuilt)) + if not all_archs: + return error_response(400, 'INVALID_ARCHITECTURES', + 'Select at least one Target_Architecture to ' + 'build or provide a prebuilt binary') + + arch_error = validate_architectures(all_archs, bool(item.get('deepstream'))) + if arch_error: + return error_response(400, *arch_error) + + unconfigured = [a for a in build_archs if a not in BUILD_PROJECTS] + if unconfigured: + return error_response( + 500, 'BUILD_PROJECT_UNCONFIGURED', + 'No CodeBuild project is configured for: ' + f"{', '.join(unconfigured)}", + {'configured': sorted(BUILD_PROJECTS)}) + + usecase_id = item['usecase_id'] + plugin_name = sanitize_plugin_name(item.get('name'), plugin_id) + entries: Dict[str, Dict] = {} + + # --- prebuilt binaries: checksum + sign identically (3.6) + for arch in sorted(prebuilt): + data, err = _load_prebuilt_bytes(item, arch, prebuilt[arch]) + if err: + return err + entry = store_signed_artifact(usecase_id, arch, plugin_name, data) + entry.update({'buildStatus': BUILD_SUCCEEDED, 'logTail': '', + 'prebuilt': True}) + entries[arch] = entry + + # --- source builds: mark building, StartBuild per architecture (3.1) + entries.update(submit_arch_builds(item, build_archs)) + + # Persist the build round: replace the requested-arch entries, reset + # the auto-packaging trigger marker so this round can trigger again. + artifacts = dict(item.get('artifacts') or {}) + artifacts.update(entries) + update_expr = ('SET artifacts = :a, requested_architectures = :r, ' + 'updated_at = :t REMOVE components_triggered') + expr_values = {':a': artifacts, ':r': all_archs, ':t': now_ms()} + if prebuilt: + update_expr = ('SET artifacts = :a, requested_architectures = :r, ' + 'updated_at = :t, provenance.prebuilt = :p ' + 'REMOVE components_triggered') + expr_values[':p'] = True + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression=update_expr, + ExpressionAttributeValues=expr_values, + ) + + log_audit_event( + user_id=user['user_id'], + action='build_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': usecase_id, 'version': version, + 'architectures': build_archs, + 'prebuilt_architectures': sorted(prebuilt)} + ) + + # A prebuilt-only submission settles immediately (16.1 trigger). + updated = get_version_item(plugin_id, version) + trigger_component_packaging(updated) + updated = get_version_item(plugin_id, version) + return create_response(202, builds_view(updated)) + + +def _load_prebuilt_bytes(item: Dict, arch: str, + spec: Dict) -> Tuple[Optional[bytes], Optional[Dict]]: + """ + Resolve the prebuilt .so bytes for one architecture (3.6): either + inline base64 content or a source_key relative to the version's + plugin-sources tree (e.g. a binary shipped in an imported repo). + """ + content_b64 = spec.get('content_base64') + source_key = spec.get('source_key') + if bool(content_b64) == bool(source_key): + return None, error_response( + 400, 'INVALID_PREBUILT', + f"prebuilt.{arch} must provide exactly one of content_base64 " + 'or source_key') + + if content_b64: + try: + data = base64.b64decode(content_b64, validate=True) + except Exception: + return None, error_response( + 400, 'INVALID_PREBUILT', + f"prebuilt.{arch}.content_base64 is not valid base64") + if not data or len(data) > MAX_PREBUILT_INLINE_BYTES: + return None, error_response( + 400, 'INVALID_PREBUILT', + f"prebuilt.{arch} binary must be non-empty and at most " + f"{MAX_PREBUILT_INLINE_BYTES} bytes inline") + return data, None + + normalized = posixpath.normpath(str(source_key)).lstrip('/') + if normalized.startswith('..'): + return None, error_response(400, 'INVALID_PREBUILT', + f"prebuilt.{arch}.source_key is invalid") + key = (item.get('source_s3_prefix') or '') + normalized + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return None, error_response( + 400, 'INVALID_PREBUILT', + f"prebuilt.{arch}.source_key does not exist in the " + 'version source tree', {'source_key': source_key}) + raise + return obj['Body'].read(), None + + +def get_builds(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + GET /plugins/{id}/versions/{v}/builds + Per-arch build status (succeeded/failed/building + log excerpt) for + the Node_Designer UI (3.5). Readable by every role of the Use_Case. + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item) + if err: + return err + return create_response(200, builds_view(item)) + + +# ------------------------------------------------- EventBridge result path + +def handle_build_result(event: Dict) -> Dict: + """ + EventBridge CodeBuild Build State Change handler (rule + 'dda-portal-plugin-build-results'). Records the per-arch build + outcome on the Plugin_Record, idempotent on the build id. + """ + detail = event.get('detail') or {} + project = detail.get('project-name') + if project == FETCH_PROJECT_NAME: + # Repository-fetch result of an asynchronous import: delegate to + # plugin_importer.handle_fetch_result (same Lambda bundle). + # Imported lazily so the API request path never pays for the + # importer module's dependencies. + import plugin_importer + return plugin_importer.handle_fetch_result(detail) + arch = PROJECT_ARCHITECTURES.get(project) + if not arch: + # Not a plugin build or fetch project - nothing to record. + logger.info(f"Ignoring build result for project '{project}'") + return {'recorded': False, 'reason': 'not a plugin build project'} + + build_id = build_id_from_arn(detail.get('build-id') or '') + build_status = detail.get('build-status') + env = env_vars_from_detail(detail) + plugin_id = env.get('PLUGIN_ID') + version_raw = env.get('PLUGIN_VERSION') + if not plugin_id or not version_raw: + logger.warning(f"Build {build_id} carries no PLUGIN_ID/PLUGIN_VERSION; skipping") + return {'recorded': False, 'reason': 'missing build metadata'} + version = int(version_raw) + + item = get_version_item(plugin_id, version) + if not item: + logger.warning(f"Build {build_id}: Plugin_Record {plugin_id} v{version} not found") + return {'recorded': False, 'reason': 'plugin record not found'} + + # Idempotency on the build id: skip duplicate deliveries of an + # already-settled result and events from superseded builds. + current = (item.get('artifacts') or {}).get(arch) or {} + recorded_build_id = current.get('buildId') + if recorded_build_id and recorded_build_id != build_id: + logger.info(f"Build {build_id} superseded by {recorded_build_id}; skipping") + return {'recorded': False, 'reason': 'superseded build'} + if (recorded_build_id == build_id + and current.get('buildStatus') in SETTLED_STATUSES): + logger.info(f"Build {build_id} already recorded; skipping duplicate delivery") + return {'recorded': False, 'reason': 'already recorded'} + + usecase_id = env.get('USECASE_ID') or item.get('usecase_id') + plugin_name = env.get('PLUGIN_NAME') or sanitize_plugin_name( + item.get('name'), plugin_id) + + if build_status == 'SUCCEEDED': + fields = record_promoted_artifact(usecase_id, arch, plugin_name) + if fields is None: + entry = { + 'buildStatus': BUILD_FAILED, + 'buildId': build_id, + 'logTail': 'Build reported success but no artifact was ' + f"promoted to " + f"{library_so_key(usecase_id, arch, plugin_name)}", + } + else: + entry = {'buildStatus': BUILD_SUCCEEDED, 'buildId': build_id, + 'logTail': '', **fields} + else: + # Failed builds store the CloudWatch log tail and no artifact (3.4). + entry = { + 'buildStatus': BUILD_FAILED, + 'buildId': build_id, + 'logTail': fetch_log_tail(detail), + } + + set_arch_entry(plugin_id, version, arch, entry) + logger.info(f"Recorded {arch} build {build_id} for {plugin_id} v{version}: " + f"{entry['buildStatus']}") + + updated = get_version_item(plugin_id, version) + triggered = trigger_component_packaging(updated) + return {'recorded': True, 'arch': arch, + 'buildStatus': entry['buildStatus'], + 'component_packaging_triggered': triggered} + + +# ------------------------------------------------------------------ routing + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler: API Gateway routes + EventBridge results""" + # EventBridge CodeBuild results arrive on the same handler as the + # API routes; distinguish by the event source (design build service). + if event.get('source') == 'aws.codebuild': + try: + return handle_build_result(event) + except Exception as e: + logger.error(f"Build result handler error: {str(e)}", exc_info=True) + raise # let EventBridge retry delivery + + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + plugin_id = path_params.get('id') + try: + version = int(path_params.get('v')) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', + 'version must be an integer') + + if resource == '/plugins/{id}/versions/{v}/build' and plugin_id: + if http_method == 'POST': + return start_builds(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/builds' and plugin_id: + if http_method == 'GET': + return get_builds(event, user, plugin_id, version) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/plugin_components.py b/edge-cv-portal/backend/functions/plugin_components.py new file mode 100644 index 00000000..40676b19 --- /dev/null +++ b/edge-cv-portal/backend/functions/plugin_components.py @@ -0,0 +1,694 @@ +""" +Plugin_Component auto-packaging Lambda function (Custom Node Designer) + +Packages a Plugin_Record version's successfully built Plugin_Artifacts +into the versioned Greengrass component ``dda.plugin.{pluginId}`` / +``{pluginVersion}.0.0`` in the Use_Case account (Requirements 16.1, +16.7), following the workflow_packaging.py conventions. + +Invocation paths: + 1. Asynchronous Lambda invoke from plugin_builds.py when all + requested Target_Architecture builds have settled with at least + one success: + {"action": "package_plugin_component", + "plugin_id": ..., "version": ..., "usecase_id": ...} + Auto-packaging failure never fails the build: failures are + recorded on the Plugin_Record ``component`` pointer and logged, + never raised back to the caller. + 2. API Gateway REST: + GET /plugins/{id}/versions/{v}/component + Returns the Plugin_Record's ``component`` status pointer for the + Node_Designer UI. + +Recipe (Requirement 16.1): install-only (no Run lifecycle - installing +or removing a plugin never restarts LocalServer), one platform manifest +per successfully built Target_Architecture using ARCH_TO_GG_PLATFORM +plus platform attributes: + - ``variant: {arch}`` on the JetPack arm64 manifests (a JetPack + build is specific to its L4T/DeepStream release, so unlike + build_recipe the attribute is always present, not only when + several arm64 flavors are packaged); + - ``runtime: nvidia`` on the x86_64_nvidia manifest, with the plain + x86_64 manifest ordered after x86_64_nvidia so attribute-less + amd64 devices match plain x86_64 while NVIDIA devices match the + more specific manifest first. +Each manifest's artifacts are the signed ``.so`` plus a small +``plugin-manifest.json`` (name, version, arch, checksum), copied to the +Use_Case account bucket under +``plugins/components/{pluginId}/{pluginVersion}/{arch}/`` via the +staging prefix and installed on the device to +``/aws_dda/plugins/{pluginId}/{pluginVersion}/{arch}/``. + +All-or-nothing: artifacts stage -> promote -> register; a failed +registration deletes the freshly created component version so nothing +partial exists. Retry is idempotent on plugin id + version: an already +"registered" component pointer short-circuits, and a ConflictException +from the registry re-describes the existing version instead of failing. +Rebuilds always create a new Plugin_Record version (existing design), +which packages as a new Plugin_Component version; previously published +versions are never modified or deleted here (16.7). + +Recipe assembly (build_plugin_recipe and its helpers) is pure over the +record's built architectures so it is property-testable without AWS +(tasks 6.4 / 6.5). +""" +import json +import logging +import os +import posixpath +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime + +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, get_usecase_client +) + +# Reuse the Plugin_Record persistence helpers and error envelope from +# plugin_records.py, and the Use_Case-account packaging conventions +# (platform map, staging cleanup, failure type) from workflow_packaging.py +# (same deployment bundle). +from plugin_records import ( + authorize_record_access, + error_response, + get_version_item, + not_found_response, + now_ms, + plugin_table, + successful_build_archs, +) +from workflow_packaging import ( + ARCH_TO_GG_PLATFORM as WORKFLOW_ARCH_TO_GG_PLATFORM, + PackagingError, + delete_prefix, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients (portal account) +s3 = boto3.client('s3') + +# Environment variables +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') + +# Greengrass component naming (design: Plugin_Component packaging, 16.1) +PLUGIN_COMPONENT_PREFIX = 'dda.plugin.' +COMPONENT_PUBLISHER = 'DDA Portal Node Designer' + +# Use_Case account S3 prefixes for Plugin_Component artifacts (mirrors the +# workflows/components + workflows/staging layout) +COMPONENT_S3_PREFIX = 'plugins/components' +STAGING_S3_PREFIX = 'plugins/staging' + +# Where the LocalServer plugin loader discovers installed Plugin_Components +DEVICE_PLUGINS_ROOT = '/aws_dda/plugins' + +# The per-arch plugin metadata artifact shipped beside the signed .so +PLUGIN_MANIFEST_FILENAME = 'plugin-manifest.json' + +# arch id -> Greengrass platform architecture. Task 10.1 extends +# workflow_packaging.ARCH_TO_GG_PLATFORM with x86_64_nvidia; until then the +# mapping is completed locally (established deviation - workflow_packaging.py +# is not modified here). Both x86_64 flavors map to Greengrass amd64 and are +# disambiguated by the 'runtime: nvidia' platform attribute. +ARCH_TO_GG_PLATFORM: Dict[str, str] = dict(WORKFLOW_ARCH_TO_GG_PLATFORM) +ARCH_TO_GG_PLATFORM.setdefault('x86_64_nvidia', 'amd64') + +ARCH_X86_64 = 'x86_64' +ARCH_X86_64_NVIDIA = 'x86_64_nvidia' + +# Component pointer statuses on the Plugin_Record (design data model) +COMPONENT_PACKAGING = 'packaging' +COMPONENT_REGISTERED = 'registered' +COMPONENT_FAILED = 'failed' + +# Polling for the registered component to become DEPLOYABLE +COMPONENT_STATUS_MAX_ATTEMPTS = 30 +COMPONENT_STATUS_POLL_SECONDS = 2 + +# Async trigger action name (plugin_builds.py payload) +ACTION_PACKAGE = 'package_plugin_component' + +# Acting principal recorded for the automatic (non-interactive) packaging path +SYSTEM_USER_ID = 'system:plugin-build-service' + + +# ------------------------------------------------------------ pure helpers +# +# Everything from here to build_plugin_recipe is pure over the record's +# built architectures, so Plugin_Component recipe assembly is +# property-testable without AWS (tasks 6.4 / 6.5). + +def component_name_for(plugin_id: str) -> str: + return f"{PLUGIN_COMPONENT_PREFIX}{plugin_id}" + + +def component_version_for(plugin_version: int) -> str: + """Component version derived from the Plugin_Record version (16.1)""" + return f"{int(plugin_version)}.0.0" + + +def artifact_final_prefix(plugin_id: str, plugin_version: int, arch: str) -> str: + """Account-bucket prefix of one arch's Plugin_Component artifacts""" + return f"{COMPONENT_S3_PREFIX}/{plugin_id}/{plugin_version}/{arch}" + + +def device_install_dir(plugin_id: str, plugin_version: int, arch: str) -> str: + """Where the component installs its artifacts on the device (16.1)""" + return f"{DEVICE_PLUGINS_ROOT}/{plugin_id}/{plugin_version}/{arch}" + + +def build_plugin_manifest(plugin_name: str, plugin_version: int, arch: str, + checksum: str) -> Dict: + """plugin-manifest.json content: name, version, arch, checksum (16.1)""" + return { + 'name': plugin_name, + 'version': int(plugin_version), + 'arch': arch, + 'checksum': checksum, + } + + +def manifest_arch_order(archs) -> List[str]: + """ + Deterministic platform-manifest order. Sorted, except the plain + x86_64 manifest is listed after the x86_64_nvidia one: both map to + Greengrass amd64, so ordering plain x86_64 last lets attribute-less + amd64 devices match it while devices declaring 'runtime: nvidia' + match the more specific manifest first. + """ + ordered = sorted(archs) + if ARCH_X86_64 in ordered and ARCH_X86_64_NVIDIA in ordered: + ordered.remove(ARCH_X86_64) + ordered.insert(ordered.index(ARCH_X86_64_NVIDIA) + 1, ARCH_X86_64) + return ordered + + +def platform_for(arch: str) -> Dict[str, str]: + """ + Greengrass platform block for one Target_Architecture: os + + ARCH_TO_GG_PLATFORM architecture, plus the platform attributes that + split one Greengrass architecture into DDA Target_Architectures - + 'variant' for the JetPack arm64 builds (declared in the device's + Nucleus platform overrides) and 'runtime: nvidia' for x86_64_nvidia. + """ + platform = {'os': 'linux', 'architecture': ARCH_TO_GG_PLATFORM[arch]} + if ARCH_TO_GG_PLATFORM[arch] == 'aarch64': + platform['variant'] = arch + elif arch == ARCH_X86_64_NVIDIA: + platform['runtime'] = 'nvidia' + return platform + + +def build_plugin_recipe(plugin_id: str, plugin_version: int, bucket: str, + arch_so_names: Dict[str, str]) -> Dict: + """ + Install-only Greengrass recipe for a Plugin_Component (16.1): one + platform manifest per successfully built Target_Architecture (the + keys of arch_so_names, mapping each arch to its .so file name). + There is deliberately no Run lifecycle - installing or removing a + plugin never restarts LocalServer; the LocalServer plugin loader + discovers the installed files under /aws_dda/plugins/ at runtime. + + Pure over (plugin_id, plugin_version, bucket, arch_so_names) so + recipe assembly is property-testable without AWS. + """ + manifests = [] + for arch in manifest_arch_order(arch_so_names): + so_name = arch_so_names[arch] + final_prefix = artifact_final_prefix(plugin_id, plugin_version, arch) + install_dir = device_install_dir(plugin_id, plugin_version, arch) + install_script = ( + f"mkdir -p {install_dir} && " + f"cp -f {{artifacts:path}}/{so_name} " + f"{{artifacts:path}}/{PLUGIN_MANIFEST_FILENAME} {install_dir}/" + ) + manifests.append({ + 'Platform': platform_for(arch), + 'Lifecycle': { + 'Install': { + 'Script': install_script, + 'Timeout': 300, + 'requiresPrivilege': True + } + }, + 'Artifacts': [ + { + 'Uri': f"s3://{bucket}/{final_prefix}/{so_name}", + 'Permission': {'Read': 'ALL'} + }, + { + 'Uri': f"s3://{bucket}/{final_prefix}/{PLUGIN_MANIFEST_FILENAME}", + 'Permission': {'Read': 'ALL'} + }, + ] + }) + + return { + 'RecipeFormatVersion': '2020-01-25', + 'ComponentName': component_name_for(plugin_id), + 'ComponentVersion': component_version_for(plugin_version), + 'ComponentType': 'aws.greengrass.generic', + 'ComponentPublisher': COMPONENT_PUBLISHER, + 'ComponentConfiguration': { + 'DefaultConfiguration': { + 'PluginId': plugin_id, + 'PluginVersion': str(plugin_version) + } + }, + 'Manifests': manifests, + 'Lifecycle': {} + } + + +def registry_tags(usecase_id: str, plugin_id: str, plugin_version: int) -> Dict[str, str]: + """Registry tags linking the component back to its Plugin_Record""" + return { + 'dda-portal:managed': 'true', + 'dda-portal:usecase-id': usecase_id, + 'dda-portal:plugin-id': plugin_id, + 'dda-portal:plugin-version': str(plugin_version), + } + + +def component_version_arn(region: str, account_id: str, plugin_id: str, + plugin_version: int) -> str: + """ARN of one Plugin_Component version in a Use_Case account registry""" + return (f"arn:aws:greengrass:{region}:{account_id}:components:" + f"{component_name_for(plugin_id)}:versions:" + f"{component_version_for(plugin_version)}") + + +# ------------------------------------------------------------- persistence + +def set_component_pointer(plugin_id: str, version: int, pointer: Dict) -> None: + """Write the Plugin_Record 'component' status pointer (data model)""" + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET component = :c, updated_at = :t', + ExpressionAttributeValues={':c': pointer, ':t': now_ms()}, + ) + + +# ----------------------------------------------- staging (all-or-nothing) + +def load_portal_artifact(s3_key: str, label: str) -> bytes: + """Read a signed .so from the portal Plugin_Library, or raise + PackagingError identifying the missing artifact.""" + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code in ('NoSuchKey', '404'): + raise PackagingError( + label, + f"Plugin artifact was not found in the Plugin_Library " + f"(s3://{PORTAL_ARTIFACTS_BUCKET}/{s3_key})") + raise PackagingError( + label, + f"Plugin artifact could not be read from the Plugin_Library: " + f"{code or str(e)}") + return obj['Body'].read() + + +def stage_and_promote_artifacts(usecase_s3, bucket: str, plugin_id: str, + plugin_version: int, + arch_payloads: Dict[str, Dict[str, bytes]] + ) -> Tuple[str, Dict[str, List[str]]]: + """ + Upload every arch's artifacts ({arch: {filename: bytes}}) to a + temporary staging prefix in the Use_Case account bucket, then + promote all of them to the final component prefix (all-or-nothing). + + Returns (staging_root, {arch: [final keys]}). Raises PackagingError + with the failing artifact identified; the caller cleans up. + """ + stage_id = uuid.uuid4().hex + staging_root = f"{STAGING_S3_PREFIX}/{plugin_id}/{plugin_version}/{stage_id}" + + staged: List[Tuple[str, str, str]] = [] # (label, stage_key, final_key) + for arch in sorted(arch_payloads): + final_prefix = artifact_final_prefix(plugin_id, plugin_version, arch) + for filename in sorted(arch_payloads[arch]): + label = f"{arch}/{filename}" + stage_key = f"{staging_root}/{label}" + try: + usecase_s3.put_object(Bucket=bucket, Key=stage_key, + Body=arch_payloads[arch][filename]) + except ClientError as e: + raise PackagingError( + label, + f"Failed to upload artifact '{label}' to the staging " + f"area: {str(e)}") + staged.append((label, stage_key, f"{final_prefix}/{filename}")) + + # Every artifact staged successfully -> promote to the final prefix. + final_keys: Dict[str, List[str]] = {arch: [] for arch in arch_payloads} + for label, stage_key, final_key in staged: + try: + usecase_s3.copy_object(Bucket=bucket, Key=final_key, + CopySource={'Bucket': bucket, 'Key': stage_key}) + except ClientError as e: + raise PackagingError( + label, + f"Failed to promote artifact '{label}' from the staging " + f"area: {str(e)}") + final_keys[label.split('/', 1)[0]].append(final_key) + + return staging_root, final_keys + + +# --------------------------------------------------------------- registry + +def register_plugin_component(greengrass, recipe: Dict, usecase: Dict, + usecase_id: str, plugin_id: str, + plugin_version: int) -> str: + """ + Register the Plugin_Component version in the Use_Case account + Greengrass registry and wait until it is DEPLOYABLE. A freshly + created version that fails to become DEPLOYABLE is deleted so + nothing partial exists. A ConflictException (version already + registered - idempotent retry on plugin id + version) re-describes + the existing version instead of failing; the pre-existing version is + never deleted (16.7). + """ + component_label = (f"component {recipe['ComponentName']} " + f"v{recipe['ComponentVersion']}") + created = True + try: + response = greengrass.create_component_version( + inlineRecipe=json.dumps(recipe), + tags=registry_tags(usecase_id, plugin_id, plugin_version), + ) + component_arn = response['arn'] + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code != 'ConflictException': + raise PackagingError(component_label, + f"Component registration failed: {str(e)}") + # Idempotent retry: the version already exists - re-describe it. + created = False + region = getattr(getattr(greengrass, 'meta', None), 'region_name', None) \ + or os.environ.get('AWS_REGION', 'us-east-1') + component_arn = component_version_arn( + region, str(usecase.get('account_id')), plugin_id, plugin_version) + + component_status = 'REQUESTED' + status_message = '' + for attempt in range(COMPONENT_STATUS_MAX_ATTEMPTS): + try: + status_response = greengrass.describe_component(arn=component_arn) + except ClientError as e: + raise PackagingError( + component_label, + f"Component could not be described after registration: {str(e)}") + component_status = status_response['status']['componentState'] + status_message = status_response['status'].get('message', '') + if component_status not in ('REQUESTED', 'IN_PROGRESS'): + break + time.sleep(COMPONENT_STATUS_POLL_SECONDS) + + if component_status != 'DEPLOYABLE': + if created: + # Remove the failed registration so nothing partial exists. + try: + greengrass.delete_component(arn=component_arn) + except ClientError as e: + logger.error(f"Error deleting failed component " + f"{component_arn}: {str(e)}") + raise PackagingError( + component_label, + f"Component did not become DEPLOYABLE (state {component_status}): " + f"{status_message or 'no status message'}") + + return component_arn + + +# ---------------------------------------------------- packaging operation + +def package_plugin_component(payload: Dict) -> Dict: + """ + Package a Plugin_Record version's successfully built Plugin_Artifacts + into the Plugin_Component dda.plugin.{pluginId} v{pluginVersion}.0.0 + in the Use_Case account (16.1). Never raises: auto-packaging failure + is recorded on the Plugin_Record component pointer and never fails + the triggering build. + """ + plugin_id = payload.get('plugin_id') + try: + version = int(payload.get('version')) + except (TypeError, ValueError): + logger.error(f"package_plugin_component: invalid version in {payload}") + return {'packaged': False, 'reason': 'invalid version'} + if not plugin_id: + logger.error(f"package_plugin_component: missing plugin_id in {payload}") + return {'packaged': False, 'reason': 'missing plugin_id'} + + item = get_version_item(plugin_id, version) + if not item: + logger.error(f"package_plugin_component: Plugin_Record {plugin_id} " + f"v{version} not found") + return {'packaged': False, 'reason': 'plugin record not found'} + + name = component_name_for(plugin_id) + comp_version = component_version_for(version) + + # Idempotent retry on plugin id + version: registered short-circuits. + existing = item.get('component') or {} + if existing.get('status') == COMPONENT_REGISTERED: + logger.info(f"Plugin_Component {name} v{comp_version} already " + f"registered; short-circuiting") + return {'packaged': True, 'short_circuited': True, + 'component_name': name, 'component_version': comp_version, + 'component_arn': existing.get('arn')} + + built = successful_build_archs(item) + if not built: + logger.error(f"package_plugin_component: {plugin_id} v{version} has " + f"no successfully built Plugin_Artifact") + return {'packaged': False, 'reason': 'no successful builds'} + + usecase_id = item['usecase_id'] + try: + return _package(item, plugin_id, version, usecase_id, built) + except PackagingError as e: + _record_failure(plugin_id, version, usecase_id, built, e.message, + failing_artifact=e.artifact) + return {'packaged': False, 'reason': e.message, + 'failing_artifact': e.artifact} + except Exception as e: # never fails the build (16.1 trigger contract) + logger.error(f"Plugin_Component packaging failed for {plugin_id} " + f"v{version}: {str(e)}", exc_info=True) + _record_failure(plugin_id, version, usecase_id, built, str(e)) + return {'packaged': False, 'reason': str(e)} + + +def _record_failure(plugin_id: str, version: int, usecase_id: str, + built: List[str], message: str, + failing_artifact: Optional[str] = None) -> None: + """Best-effort failure bookkeeping on the component pointer + audit""" + try: + set_component_pointer(plugin_id, version, { + 'name': component_name_for(plugin_id), + 'version': component_version_for(version), + 'arn': None, + 'architectures': built, + 'status': COMPONENT_FAILED, + 'packagedAt': None, + 'failure': message, + }) + except Exception as e: + logger.error(f"Could not record packaging failure for {plugin_id} " + f"v{version}: {str(e)}") + log_audit_event( + user_id=SYSTEM_USER_ID, + action='package_plugin_component', + resource_type='plugin_record', + resource_id=plugin_id, + result='failure', + details={'usecase_id': usecase_id, 'version': version, + 'architectures': built, 'error': message, + 'failing_artifact': failing_artifact} + ) + + +def _package(item: Dict, plugin_id: str, version: int, usecase_id: str, + built: List[str]) -> Dict: + """The stage -> promote -> register sequence; raises PackagingError.""" + name = component_name_for(plugin_id) + comp_version = component_version_for(version) + + try: + usecase = get_usecase(usecase_id) + except ValueError: + raise PackagingError(f"component {name} v{comp_version}", + f"Use case '{usecase_id}' not found") + bucket = usecase.get('s3_bucket') + if not bucket: + raise PackagingError( + f"component {name} v{comp_version}", + f"Use case '{usecase_id}' has no S3 bucket configured for " + f"component artifacts") + + set_component_pointer(plugin_id, version, { + 'name': name, + 'version': comp_version, + 'arn': None, + 'architectures': built, + 'status': COMPONENT_PACKAGING, + 'packagedAt': None, + 'failure': None, + }) + + # Assemble each successfully built arch's payload: the signed .so from + # the Plugin_Library plus its plugin-manifest.json (16.1). + plugin_name = item.get('name') or plugin_id + artifacts = item.get('artifacts') or {} + arch_payloads: Dict[str, Dict[str, bytes]] = {} + arch_so_names: Dict[str, str] = {} + for arch in built: + entry = artifacts.get(arch) or {} + so_key = entry.get('s3Key') + checksum = entry.get('checksum') + if not so_key or not checksum: + raise PackagingError( + f"{arch}/.so", + f"Successful build for '{arch}' has no recorded artifact " + f"key/checksum on the Plugin_Record") + so_name = posixpath.basename(so_key) + manifest = build_plugin_manifest(plugin_name, version, arch, checksum) + arch_payloads[arch] = { + so_name: load_portal_artifact(so_key, f"{arch}/{so_name}"), + PLUGIN_MANIFEST_FILENAME: json.dumps( + manifest, sort_keys=True, indent=2).encode('utf-8'), + } + arch_so_names[arch] = so_name + + # Use_Case account clients via the assumed cross-account role. + session_name = f"plugin-pkg-{plugin_id[:24]}-{int(datetime.utcnow().timestamp())}"[:64] + usecase_s3 = get_usecase_client('s3', usecase, session_name=session_name) + greengrass = get_usecase_client('greengrassv2', usecase, + session_name=session_name) + + staging_root = f"{STAGING_S3_PREFIX}/{plugin_id}/{version}/" + final_root = f"{COMPONENT_S3_PREFIX}/{plugin_id}/{version}/" + component_arn = None + try: + stage_root, _final_keys = stage_and_promote_artifacts( + usecase_s3, bucket, plugin_id, version, arch_payloads) + + # Register only after every artifact promoted successfully. + recipe = build_plugin_recipe(plugin_id, version, bucket, arch_so_names) + component_arn = register_plugin_component( + greengrass, recipe, usecase, usecase_id, plugin_id, version) + except PackagingError: + # All-or-nothing: delete the stage and any promoted artifacts of + # this version; previously published component versions and their + # artifacts live under other version prefixes and stay untouched. + delete_prefix(usecase_s3, bucket, staging_root) + delete_prefix(usecase_s3, bucket, final_root) + raise + finally: + if component_arn is not None: + delete_prefix(usecase_s3, bucket, staging_root) + + set_component_pointer(plugin_id, version, { + 'name': name, + 'version': comp_version, + 'arn': component_arn, + 'architectures': built, + 'status': COMPONENT_REGISTERED, + 'packagedAt': now_ms(), + 'failure': None, + }) + log_audit_event( + user_id=SYSTEM_USER_ID, + action='package_plugin_component', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': usecase_id, 'version': version, + 'architectures': built, 'component_name': name, + 'component_version': comp_version, + 'component_arn': component_arn} + ) + return {'packaged': True, 'component_name': name, + 'component_version': comp_version, 'component_arn': component_arn, + 'architectures': built} + + +# ------------------------------------------------------------- API route + +def get_component(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + GET /plugins/{id}/versions/{v}/component + The Plugin_Record's component status pointer (packaging/registered/ + failed) for the Node_Designer UI. Readable by every role of the + Use_Case. + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item) + if err: + return err + return create_response(200, { + 'plugin_id': plugin_id, + 'version': version, + 'component': item.get('component') or {}, + }) + + +# ------------------------------------------------------------------ routing + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler: async packaging trigger + API Gateway route""" + # Asynchronous invoke from plugin_builds.py (16.1 trigger). + if event.get('action') == ACTION_PACKAGE: + return package_plugin_component(event) + + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + plugin_id = path_params.get('id') + try: + version = int(path_params.get('v')) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', + 'version must be an integer') + + if resource == '/plugins/{id}/versions/{v}/component' and plugin_id: + if http_method == 'GET': + return get_component(event, user, plugin_id, version) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/plugin_importer.py b/edge-cv-portal/backend/functions/plugin_importer.py new file mode 100644 index 00000000..4c1cf954 --- /dev/null +++ b/edge-cv-portal/backend/functions/plugin_importer.py @@ -0,0 +1,2625 @@ +""" +Plugin_Importer API Lambda function (Custom Node Designer) + +Repository import orchestration (Requirements 4.1, 4.2, 4.3, 4.4, 4.5, +15.4, 15.5). + +Routes (API Gateway REST): + POST /plugins/import Import a GStreamer plugin from a public + repository, asynchronously: validate the + URL, create the Plugin_Record immediately + with import_status 'fetching' (provenance + + classification recorded), StartBuild the + lightweight CodeBuild fetch step (clone at + the requested revision, default branch when + omitted, sync the tree to + plugin-sources/{usecase_id}/{plugin_id}/{version}/) + WITHOUT polling, and answer 202 right away. + API Gateway REST integrations cap at 29 s, + so slow clones must never block the + response; the fetch outcome arrives via + EventBridge (handle_fetch_result below, + delegated from plugin_builds.py's result + handler) which scans buildability, advances + the record (failed / pending_selection / + imported), and queues + auto-starts builds + for the user-selected + Target_Architectures. The UI + polls GET /plugins/{id}/versions/{v} until + import_status leaves 'fetching'. + POST /plugins/{id}/versions/{v}/select-plugins + Record which of the enumerated individual + plugins to import for a plugin-set import + awaiting selection (import status + pending_selection): validates the selection + is non-empty and a subset of plugins_found, + records selected_plugins on the + Plugin_Record (and in provenance), and + submits builds for the previously requested + Target_Architectures. plugin_builds.py + passes the selection to CodeBuild as the + PLUGIN_TARGETS env override. + POST /plugins/{id}/versions/{v}/adjust-revision + Apply a per-platform source-revision + override to a settled imported plugin + (imported-plugin-revision-adjustment-fix): + fetch (or reuse) the adjusted revision's + tree into the record's `fetches` map, + map arch_revisions[arch] on fetch + success, and re-run the affected + platform's build. See adjust_revision. + GET /plugin-modules Module_Listing (Requirement 6): fetch the + official GStreamer module index from + https://gstreamer.freedesktop.org/modules/, + parse it server-side into {name, + description, repoUrl, classification} + entries, cache the parsed index in the + ModuleIndexCache DynamoDB item with + fetchedAt and a 24-hour TTL, and reuse the + cached index for subsequent views (6.4). + Fetch/parse failure returns the distinct + MODULE_LISTING_UNAVAILABLE code so the UI + offers manual URL entry (6.3). Selecting a + module feeds its published repository + location (repoUrl) into POST + /plugins/import (6.2). + +Flow (design "Plugin_Importer and Module_Listing", async import): + + 1. Validate the repository URL and the selected Target_Architectures. + 2. Create the Plugin_Record (version 1, lifecycle dev, review + pending - reusing plugin_records.new_version_item) with import + provenance {repoUrl, revision, importedBy, importedAt, + classification} via workflow_core's `classify_plugin_set` (4.2, + 15.4, 15.5) and import_status 'fetching' (no plugins_found yet). + 3. StartBuild on the lightweight fetch CodeBuild project + (FETCH_PROJECT_NAME, env overrides REPO_URL / REVISION / + DEST_PREFIX plus PLUGIN_ID / PLUGIN_VERSION / USECASE_ID so the + result handler can attribute it) and answer 202 without polling. + 4. The EventBridge rule 'dda-portal-plugin-build-results' delivers + the fetch build state change to plugin_builds.py, which delegates + to `handle_fetch_result` here (same Lambda bundle). On SUCCEEDED + the synced source tree is scanned for a GStreamer plugin build + definition (meson.build / configure.ac with a plugin target, or a + prebuilt .so; `scan_buildability` is a pure function over the + file listing, design Property 4) and the record advances: + unbuildable imports mark the record failed with the finding + reported (4.5); buildable imports submit builds for the selected + Target_Architectures (4.3) by queueing per-arch artifact entries + and immediately starting them via + plugin_builds.start_queued_builds (auto-start failure never + fails the fetch-result handler), or wait in pending_selection + for plugin sets. An unreachable repository or + missing revision marks the record failed with a REPO_FETCH_FAILED + finding — the record now exists (a deliberate change from the old + synchronous no-record behavior) so the UI can show why (4.4). + Result recording is idempotent on the fetch build id. + +Error envelope: {"error": {"code", "message", "details"}} matching +plugin_records.py; 403 RBAC denials use the standard authorization +envelope (node-designer:import - UseCaseAdmin within the Use_Case or +PortalAdmin, 13.1/13.4). +""" +import json +import logging +import os +import posixpath +import re +import uuid +from html.parser import HTMLParser +from typing import Dict, List, Mapping, Optional, Tuple +from urllib.parse import urlsplit + +import boto3 +import requests +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, Permission +) +from workflow_core.catalog import ( + DEVICE_ARCHITECTURES, + DEEPSTREAM_ARCHITECTURES, + classify_plugin_set, +) + +# Reuse the Plugin_Record item shape, persistence helpers, and error +# envelope from plugin_records.py (same deployment bundle). +import plugin_records +from plugin_records import ( + authorize_record_access, + can_manage, + error_response, + forbidden_response, + get_version_item, + new_version_item, + not_found_response, + now_ms, + parse_body, + plugin_table, + source_s3_prefix, + version_detail, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +codebuild = boto3.client('codebuild') +s3 = boto3.client('s3') +dynamodb = boto3.resource('dynamodb') + +# Environment variables. FETCH_PROJECT_NAME defaults to the fixed +# project name node-designer-stack.ts assigns, so handle_fetch_result +# also resolves it when this module runs inside the plugin_builds +# Lambda (whose environment does not carry the variable). +FETCH_PROJECT_NAME = os.environ.get('FETCH_PROJECT_NAME', 'dda-plugin-fetch') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +MODULE_INDEX_CACHE_TABLE = os.environ.get('MODULE_INDEX_CACHE_TABLE') + +# ---------------------------------------------------------------- constants + +#: Revision recorded in provenance when the user omitted one and the +#: fetch cloned the repository default branch (4.1). +DEFAULT_REVISION = 'default' + +#: Per-arch build status recorded when the import submits the source to +#: the Plugin_Build_Service (4.3). Once the record settles to +#: 'imported', the fetch-result path calls +#: plugin_builds.start_queued_builds, which StartBuilds the per-arch +#: projects (queued -> building); the EventBridge build results then +#: settle the status to succeeded/failed. +BUILD_QUEUED = 'queued' + +#: Per-arch build status recorded when a post-import revision +#: adjustment's fetch fails: the affected architecture's entry settles +#: failed with the fetch-failure logTail +#: (_handle_adjustment_fetch_result), mirroring +#: plugin_builds.BUILD_FAILED. +BUILD_FAILED = 'failed' + +#: Import outcome recorded on the Plugin_Record (4.5). A fresh import +#: starts in 'fetching' while the asynchronous CodeBuild fetch clones +#: the repository (the UI polls GET /plugins/{id}/versions/{v} until +#: the status leaves 'fetching'). A buildable plugin set with more than +#: one enumerated plugin waits in pending_selection until the user +#: picks the subset to import; the selection endpoint advances the +#: record to imported. +IMPORT_STATUS_FETCHING = 'fetching' +IMPORT_STATUS_IMPORTED = 'imported' +IMPORT_STATUS_FAILED = 'failed' +IMPORT_STATUS_PENDING_SELECTION = 'pending_selection' + +#: Per-revision fetch status recorded in the `fetches` map of a +#: multi-revision import (arch_revisions). Each distinct effective +#: revision fetches once to its own rev-{slug}/ prefix; the record's +#: import_status leaves 'fetching' only when every fetch settles. +FETCH_STATUS_FETCHING = 'fetching' +FETCH_STATUS_SUCCEEDED = 'succeeded' +FETCH_STATUS_FAILED = 'failed' + +#: import_finding recorded when the asynchronous fetch fails: the +#: repository was unreachable or the requested revision missing +#: (REPO_FETCH_FAILED semantics; the record exists so the UI can show +#: it — a deliberate change from the old synchronous no-record path). +FETCH_FAILURE_FINDING = ( + 'Could not retrieve the repository: it is unreachable or the ' + 'requested revision does not exist') + +#: URL schemes accepted for public repository imports. +REPO_URL_SCHEMES = ('http', 'https', 'git') + +#: Build-definition file names whose *content* the buildability scan +#: consults (every other file participates by name only). +BUILD_DEFINITION_FILES = ('meson.build', 'configure.ac', 'configure.in') + +#: Maximum bytes of a build-definition file read for the scan. +MAX_BUILD_DEFINITION_BYTES = 256 * 1024 + +#: The official GStreamer Module_Listing page (Requirement 6.1). +MODULE_LISTING_URL = 'https://gstreamer.freedesktop.org/modules/' + +#: Published repository location for an official module: the module's +#: freedesktop.org GitLab repository. Selecting a module feeds this URL +#: into the repository import path (6.2). +MODULE_REPO_URL_TEMPLATE = 'https://gitlab.freedesktop.org/gstreamer/{name}.git' + +#: Single ModuleIndexCache item key holding the parsed index (6.4). +MODULE_INDEX_CACHE_KEY = 'gst-modules' + +#: GitLab API tree endpoint of the gstreamer monorepo. One request per +#: plugin-set root (gst/, ext/, sys/) lists the module's individual +#: plugin directories for GET /plugin-modules?module=. +MODULE_PLUGINS_TREE_URL = ( + 'https://gitlab.freedesktop.org/api/v4/projects/' + 'gstreamer%2Fgstreamer/repository/tree') + +#: GitLab tree page size (each per-module root stays under one page in +#: practice; pagination is followed via the x-next-page header anyway). +MODULE_PLUGINS_PER_PAGE = 100 + +#: ModuleIndexCache key prefix for one module's plugin list (same table +#: and 24-hour TTL pattern as the module index itself). +MODULE_PLUGINS_CACHE_KEY_PREFIX = 'gst-module-plugins/' + +#: Module names accepted by the ?module= query parameter. +_MODULE_NAME_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$') + +#: Per-module plugin metadata the GStreamer monorepo ships at +#: subprojects//docs/gst_plugins_cache.json: a JSON object keyed +#: by plugin name whose values carry a "description" field. Joined onto +#: plugin listings as a display enhancement — never a blocker. +PLUGIN_DOCS_CACHE_FILENAME = 'gst_plugins_cache.json' + +#: Raw GitLab location of one official module's plugin metadata cache. +MODULE_PLUGIN_DESCRIPTIONS_URL_TEMPLATE = ( + 'https://gitlab.freedesktop.org/gstreamer/gstreamer/-/raw/main/' + 'subprojects/{module}/docs/' + PLUGIN_DOCS_CACHE_FILENAME) + +#: Size cap for a gst_plugins_cache.json fetch/read (the file runs to +#: several MB for the large plugin sets). Anything over the cap is +#: dropped: entries simply lack descriptions. +MAX_PLUGIN_DESCRIPTION_CACHE_BYTES = 16 * 1024 * 1024 + +#: The cached index is reused for at most 24 hours (6.4). Also written +#: to the item's `ttl` attribute (epoch seconds) so DynamoDB expires it. +MODULE_INDEX_TTL_SECONDS = 24 * 60 * 60 + +#: HTTP timeout for the Module_Listing fetch. +MODULE_LISTING_TIMEOUT_SECONDS = float( + os.environ.get('MODULE_LISTING_TIMEOUT_SECONDS', '10')) + + +# ------------------------------------------------- pure: URL validation + +def validate_repo_url(repo_url: Optional[str]) -> Optional[str]: + """ + Validate a public repository URL for import; returns an error + message, or None when the URL is acceptable. + + Accepted: http / https / git URLs with a hostname (4.1). Anything + else (file paths, ssh scp-style strings, missing host, embedded + whitespace) is rejected before any fetch is attempted. + """ + if not repo_url or not isinstance(repo_url, str): + return 'repo_url is required' + if any(c.isspace() for c in repo_url): + return 'repo_url must not contain whitespace' + try: + parts = urlsplit(repo_url) + except ValueError: + return 'repo_url is not a valid URL' + if parts.scheme.lower() not in REPO_URL_SCHEMES: + return (f"repo_url scheme must be one of: {', '.join(REPO_URL_SCHEMES)}") + if not parts.hostname: + return 'repo_url must include a host' + return None + + +def default_plugin_name(repo_url: str) -> str: + """Plugin name derived from the repository URL's last path segment + (``.git`` suffix stripped), falling back to the host.""" + parts = urlsplit(repo_url) + segments = [s for s in parts.path.split('/') if s] + if segments: + name = segments[-1] + if name.endswith('.git'): + name = name[:-len('.git')] + if name: + return name + return parts.hostname or 'imported-plugin' + + +def derive_import_name(explicit_name: Optional[str], repo_url: str, + selected_plugins: List[str]) -> str: + """ + Record name for POST /plugins/import. An explicitly provided name + always wins. Otherwise the URL-derived base name is used — and when + the import-time selection contains exactly one plugin, the plugin + is appended ("{base}-{plugin}", e.g. "gst-plugins-good-rtsp") so + the record shows which plugin was imported rather than looking like + the whole plugin set. Multi-plugin or absent selections keep the + base name. Hyphenated names stay sanitize-safe for the S3 artifact + keys plugin_builds.sanitize_plugin_name derives. + """ + if explicit_name: + return explicit_name + base = default_plugin_name(repo_url) + if len(selected_plugins) == 1: + return f'{base}-{selected_plugins[0]}' + return base + + +def selection_rename(current_name: Optional[str], + repo_url: Optional[str], + selected: List[str]) -> Optional[str]: + """ + New record name for a post-fetch plugin selection + (POST /plugins/{id}/versions/{v}/select-plugins), or None when the + name should stay. A single-plugin selection renames the record to + "{current_name}-{plugin}" so the library and detail views show + which plugin was imported — but only while the name is still the + URL-derived default: an explicitly chosen name is never overwritten. + Without a provenance repoUrl the default cannot be recomputed, so + the rename applies unconditionally on a single-plugin selection. + """ + if len(selected) != 1: + return None + if repo_url and current_name != default_plugin_name(repo_url): + return None # explicitly named record: keep the name + plugin = selected[0] + return f'{current_name}-{plugin}' if current_name else plugin + + +# --------------------------------------------- pure: buildability scan +# +# The scan is a pure function over a source-tree file mapping +# {relative_path: content-or-None} so it is unit- and property-testable +# without AWS (design Property 4). Content is only consulted for the +# BUILD_DEFINITION_FILES; all other entries may map to None. +# +# A tree contains a buildable GStreamer_Plugin iff at least one of: +# (a) prebuilt binary: a file whose name ends with ``.so`` exists; +# (b) meson: a ``meson.build`` file declares a plugin library target +# (a ``library(`` / ``shared_library(`` / ``shared_module(`` call) +# AND references GStreamer (``gstreamer-1.0``, a ``gst_*`` / +# ``gst-plugin`` dependency identifier); +# (c) autotools: a ``configure.ac`` / ``configure.in`` file references +# GStreamer (``gstreamer-1.0`` or a ``GST_PLUGIN`` / ``AG_GST_`` +# macro). + +_MESON_TARGET_RE = re.compile(r'\b(?:shared_library|shared_module|library)\s*\(') +_MESON_GST_RE = re.compile(r'gstreamer-1\.0|gst-plugin|\bgst_\w+', re.IGNORECASE) +_AUTOTOOLS_GST_RE = re.compile(r'gstreamer-1\.0|GST_PLUGIN|AG_GST_') + + +def _meson_declares_plugin_target(content: str) -> bool: + """meson.build content declares a GStreamer plugin library target""" + return bool(_MESON_TARGET_RE.search(content)) and bool(_MESON_GST_RE.search(content)) + + +def _autotools_declares_plugin_target(content: str) -> bool: + """configure.ac/.in content references a GStreamer plugin build""" + return bool(_AUTOTOOLS_GST_RE.search(content)) + + +def scan_buildability(files: Mapping[str, Optional[str]]) -> Dict: + """ + Scan a source-tree file mapping for a GStreamer plugin build + definition (4.5). + + Returns + {'buildable': bool, + 'kind': 'prebuilt' | 'meson' | 'autotools' | None, + 'evidence': [matching relative paths], + 'finding': str} # human-readable, non-empty when unbuildable + """ + prebuilt: List[str] = [] + meson_hits: List[str] = [] + autotools_hits: List[str] = [] + definition_files_seen: List[str] = [] + + for path in sorted(files): + name = posixpath.basename(path) + if name.endswith('.so'): + prebuilt.append(path) + continue + content = files.get(path) + if name == 'meson.build': + definition_files_seen.append(path) + if content and _meson_declares_plugin_target(content): + meson_hits.append(path) + elif name in ('configure.ac', 'configure.in'): + definition_files_seen.append(path) + if content and _autotools_declares_plugin_target(content): + autotools_hits.append(path) + + if prebuilt: + return {'buildable': True, 'kind': 'prebuilt', 'evidence': prebuilt, + 'finding': ''} + if meson_hits: + return {'buildable': True, 'kind': 'meson', 'evidence': meson_hits, + 'finding': ''} + if autotools_hits: + return {'buildable': True, 'kind': 'autotools', + 'evidence': autotools_hits, 'finding': ''} + + if definition_files_seen: + finding = ( + 'No GStreamer plugin build definition found: ' + f"{', '.join(definition_files_seen)} present but none declares " + 'a GStreamer plugin target, and no prebuilt .so binary exists' + ) + else: + finding = ( + 'No GStreamer plugin build definition found: the source tree ' + 'contains no meson.build or configure.ac declaring a GStreamer ' + 'plugin target and no prebuilt .so binary' + ) + return {'buildable': False, 'kind': None, 'evidence': [], 'finding': finding} + + +# ------------------------------------------ pure: plugin enumeration +# +# The official GStreamer plugin sets (gst-plugins-good/bad/ugly - and any +# repository following the same meson monorepo layout) carry dozens of +# individual plugins, one per directory under gst/, ext/, and sys/, each +# with its own meson.build defining a plugin library target. Rather than +# bulk-importing the entire set, the import enumerates those individual +# plugin targets so the user can select which ones to import. +# +# Like scan_buildability, the enumeration is a pure function over the +# source-tree file mapping {relative_path: content-or-None}: content is +# only consulted for meson.build files (which list_source_tree fetches). + +#: Monorepo directories whose immediate subdirectories are individual +#: plugin targets in the gst-plugins-good/bad/ugly layout. +PLUGIN_SET_ROOTS = ('gst', 'ext', 'sys') + +_PLUGIN_DIR_MESON_RE = re.compile( + r'^(?:' + '|'.join(PLUGIN_SET_ROOTS) + r')/([^/]+)/meson\.build$') + + +# ------------------------------------- pure: plugin descriptions join +# +# Per-plugin descriptions come from the gst_plugins_cache.json metadata +# the GStreamer modules ship under docs/. The parse and the join are +# pure functions so they are unit-testable, and every failure path +# (malformed JSON, wrong shape, missing fields) degrades to entries +# without descriptions — descriptions never fail or block a listing or +# an import. + +def plugin_descriptions_from_cache(cache_json) -> Dict[str, str]: + """ + {plugin_name: description} parsed from gst_plugins_cache.json + content (a JSON object keyed by plugin name whose values carry a + "description" field). Accepts the raw text or an already-parsed + mapping. Malformed or unexpected content parses to {} — never + raises. + """ + if isinstance(cache_json, bytes): + cache_json = cache_json.decode('utf-8', errors='replace') + if isinstance(cache_json, str): + try: + cache_json = json.loads(cache_json) + except ValueError: + return {} + if not isinstance(cache_json, dict): + return {} + descriptions: Dict[str, str] = {} + for name, meta in cache_json.items(): + if not (isinstance(name, str) and name and isinstance(meta, dict)): + continue + description = meta.get('description') + if isinstance(description, str) and description.strip(): + descriptions[name] = description.strip() + return descriptions + + +def join_plugin_descriptions(entries: List[Dict], + descriptions: Mapping[str, str]) -> List[Dict]: + """Plugin entries with a 'description' joined on by name where one + is known; entries without a known description are unchanged (the + key stays absent). The input entries are not mutated.""" + joined: List[Dict] = [] + for entry in entries: + entry = dict(entry) + description = descriptions.get(entry.get('name')) + if description: + entry['description'] = description + joined.append(entry) + return joined + + +def tree_plugin_descriptions( + files: Mapping[str, Optional[str]]) -> Dict[str, str]: + """{plugin_name: description} merged from every + gst_plugins_cache.json whose content is present in the source-tree + file mapping (typically docs/gst_plugins_cache.json).""" + descriptions: Dict[str, str] = {} + for path in sorted(files): + if posixpath.basename(path) != PLUGIN_DOCS_CACHE_FILENAME: + continue + content = files.get(path) + if content: + descriptions.update(plugin_descriptions_from_cache(content)) + return descriptions + + +def enumerate_plugins(files: Mapping[str, Optional[str]], + single_plugin_name: str = 'plugin') -> List[Dict]: + """ + Enumerate the individual plugin targets in a synced source tree. + + Returns [{'name', 'path', 'description'?}] entries — 'description' + joined from any gst_plugins_cache.json present in the tree, absent + when unknown: + - plugin-set layout (gst-plugins-good style): one entry per + directory exactly at {gst|ext|sys}/{plugin}/ whose meson.build + declares a GStreamer plugin library target; `name` is the + directory name, `path` the directory relative path; + - single-plugin repository (buildable per scan_buildability, but + no plugin-set entries): one entry named `single_plugin_name` + with path '' - such imports skip the selection step; + - tree with no buildable plugin: []. + """ + entries: List[Dict] = [] + for path in sorted(files): + match = _PLUGIN_DIR_MESON_RE.match(path) + if not match: + continue + content = files.get(path) + if content and _meson_declares_plugin_target(content): + entries.append({'name': match.group(1), + 'path': posixpath.dirname(path)}) + if not entries: + if scan_buildability(files)['buildable']: + entries = [{'name': single_plugin_name, 'path': ''}] + else: + return [] + return join_plugin_descriptions(entries, tree_plugin_descriptions(files)) + + +def validate_plugin_selection(selected, found_names: List[str]) -> Optional[str]: + """ + Validate a plugin selection against the enumerated plugins; returns + an error message, or None when acceptable. The selection must be a + non-empty list of plugin names and a subset of what the import + enumeration found. + """ + if not isinstance(selected, list) or not selected: + return 'selected_plugins must be a non-empty list of plugin names' + if not all(isinstance(name, str) and name for name in selected): + return 'selected_plugins entries must be non-empty strings' + unknown = sorted(set(selected) - set(found_names)) + if unknown: + return ('selected_plugins must be a subset of the plugins found ' + f"by the import; unknown: {', '.join(unknown)}") + return None + + +def validate_import_selected_plugins(selected) -> Optional[str]: + """ + Validate the optional import-time `selected_plugins` of POST + /plugins/import (sourced from GET /plugin-modules?module=...); + returns an error message, or None when acceptable. Absent or empty + means the whole module (today's behavior); when present it must be + a list of non-empty plugin-name strings. + """ + if selected is None: + return None + if not isinstance(selected, list): + return 'selected_plugins must be a list of plugin names' + if not all(isinstance(name, str) and name for name in selected): + return 'selected_plugins entries must be non-empty strings' + return None + + +def dedupe_selected_plugins(selected) -> List[str]: + """The selection de-duplicated with its original order preserved.""" + seen = set() + result: List[str] = [] + for name in selected or []: + if name not in seen: + seen.add(name) + result.append(name) + return result + + +# -------------------------------------- pure: per-arch revision plan +# +# Optional per-architecture source revisions (POST /plugins/import +# `arch_revisions: {arch: revision}`): importing e.g. gst-plugins-good +# for all platforms needs different branches per platform generation +# (main for the GStreamer 1.20+ platforms, '1.16' for arm64_jp5, '1.14' +# for arm64_jp4). Each arch's effective revision is its override or the +# top-level revision (default branch when neither is given). When more +# than one DISTINCT effective revision exists, each distinct revision +# fetches ONCE to its own rev-{slug}/ prefix and archs sharing a +# revision share the tree; a single distinct revision keeps today's +# single-fetch flat layout exactly (compatibility with source +# inspection, node-generator acceptance, and existing records). + +def validate_arch_revisions(arch_revisions, architectures: List[str] + ) -> Optional[str]: + """ + Validate the optional `arch_revisions` of POST /plugins/import; + returns an error message, or None when acceptable. Absent means + the single top-level revision applies everywhere (today's + behavior); when present it must map a subset of the requested + Target_Architectures to non-empty revision strings. + """ + if arch_revisions is None: + return None + if not isinstance(arch_revisions, dict): + return ('arch_revisions must map Target_Architectures to ' + 'revision strings') + unknown = sorted(str(a) for a in set(arch_revisions) - set(architectures)) + if unknown: + return ('arch_revisions keys must be requested ' + f"Target_Architectures; unknown: {', '.join(unknown)}") + invalid = sorted(arch for arch, rev in arch_revisions.items() + if not isinstance(rev, str) or not rev.strip()) + if invalid: + return ('arch_revisions values must be non-empty revision ' + f"strings; invalid: {', '.join(invalid)}") + return None + + +def revision_slug(revision: Optional[str]) -> str: + """ + S3-key-safe slug of one revision: DEFAULT_REVISION ('default') for + an absent revision (the repository default branch), otherwise the + revision with every non [A-Za-z0-9._-] run collapsed to '-' (e.g. + '1.16' -> '1.16', 'feature/x' -> 'feature-x'). Pure. + """ + if not revision: + return DEFAULT_REVISION + slug = re.sub(r'[^A-Za-z0-9._-]+', '-', revision).strip('-.') + return slug or 'rev' + + +def revision_fetch_plan(revision: Optional[str], arch_revisions: Dict, + architectures: List[str], + base_prefix: str) -> Dict: + """ + Fetch plan for an import's effective per-arch revisions. Pure. + + Returns {'mode': 'single', 'revision': rev-or-None} when at most + one distinct effective revision exists — one fetch of that revision + into the flat source_s3_prefix layout, exactly today's behavior. + + Otherwise {'mode': 'multi', 'fetches': {slug: {revision, + source_prefix, status}}, 'arch_revisions': {arch: slug}, + 'default_slug': slug}: one fetch per distinct revision into + {base_prefix}rev-{slug}/, every requested arch mapped to its slug, + and the default slug (the top-level revision's when among the + fetches, the first slug deterministically otherwise) naming the + tree the buildability scan and plugin enumeration run on. Slug + collisions between distinct revisions disambiguate with a numeric + suffix. + """ + overrides = {arch: rev.strip() + for arch, rev in (arch_revisions or {}).items()} + effective = {arch: overrides.get(arch) or revision or '' + for arch in architectures} + distinct = sorted(set(effective.values())) + if len(distinct) <= 1: + single = distinct[0] if distinct else (revision or '') + return {'mode': 'single', 'revision': single or None} + + slug_by_revision: Dict[str, str] = {} + used = set() + for rev in distinct: + slug = base = revision_slug(rev or None) + suffix = 2 + while slug in used: + slug = f'{base}-{suffix}' + suffix += 1 + used.add(slug) + slug_by_revision[rev] = slug + + fetches = { + slug: { + 'revision': rev or DEFAULT_REVISION, + 'source_prefix': f'{base_prefix}rev-{slug}/', + 'status': FETCH_STATUS_FETCHING, + } + for rev, slug in slug_by_revision.items() + } + arch_slugs = {arch: slug_by_revision[rev] + for arch, rev in effective.items()} + default_slug = slug_by_revision.get(revision or '', + sorted(fetches)[0]) + return {'mode': 'multi', 'fetches': fetches, + 'arch_revisions': arch_slugs, 'default_slug': default_slug} + + +def multi_fetch_failure_finding(failed_revisions: List[str]) -> str: + """import_finding for a multi-revision import with failed fetches, + naming the failing revision(s) (the per-fetch statuses stay on the + record so the UI can show which trees did sync).""" + plural = 's' if len(failed_revisions) != 1 else '' + return ('Could not retrieve the repository at revision' + f"{plural} {', '.join(failed_revisions)}: it is unreachable " + 'or the requested revision does not exist') + + +def adjustment_fetch_failure_log_tail(revision: str) -> str: + """logTail recorded on an adjusted architecture's artifact entry + when the adjustment's fetch fails (2.4). Pure.""" + return (f'The adjusted revision {revision} could not be fetched: ' + 'the repository is unreachable or the revision does not exist') + + +#: adjustment_fetch_slot actions: reuse an already-synced succeeded +#: tree (no fetch), join a concurrent adjustment fetch of the same +#: revision (no second fetch), or fetch the revision (a fresh slug, or +#: a previously failed entry reset in place). +ADJUST_REUSE = 'reuse' +ADJUST_JOIN = 'join' +ADJUST_FETCH = 'fetch' + + +def adjustment_fetch_slot(item: Dict, revision: str) -> Tuple[str, str]: + """ + Resolve the `fetches` slot a post-import revision adjustment + targets for `revision`. Pure over the Plugin_Record item. + + Returns (slug, action) where action is one of: + - ADJUST_REUSE: an existing entry records the same revision with + status 'succeeded' — the synced tree is reused, no new fetch; + - ADJUST_JOIN: an existing entry records the same revision with + status 'fetching' (a concurrent adjustment) — the arch joins + its pending_archs, no second fetch; + - ADJUST_FETCH: the revision needs fetching — either into an + existing entry recording the same revision whose fetch failed + (reset in place, same slug), or under a fresh slug allocated + via revision_slug with numeric-suffix collision disambiguation + against the existing slugs (exactly like revision_fetch_plan), + so an entry recording a DIFFERENT revision is never clobbered. + """ + fetches = item.get('fetches') or {} + for slug in sorted(fetches): + entry = fetches[slug] or {} + if entry.get('revision') != revision: + continue + status = entry.get('status') + if status == FETCH_STATUS_SUCCEEDED: + return slug, ADJUST_REUSE + if status == FETCH_STATUS_FETCHING: + return slug, ADJUST_JOIN + return slug, ADJUST_FETCH # failed entry: re-fetch in place + + slug = base = revision_slug(revision) + suffix = 2 + while slug in fetches: + slug = f'{base}-{suffix}' + suffix += 1 + return slug, ADJUST_FETCH + + +# ------------------------------------------------- pure: provenance + +def import_provenance(repo_url: str, revision: Optional[str], + module_name: Optional[str], user_id: str, + timestamp: int) -> Dict: + """ + Import provenance for the Plugin_Record (4.2, 15.5): repository URL, + retrieved revision (DEFAULT_REVISION when the default branch was + used), importing user, retrieval timestamp, and the + Plugin_Set_Classification derived via `classify_plugin_set` (15.4). + """ + provenance = { + 'repoUrl': repo_url, + 'revision': revision or DEFAULT_REVISION, + 'importedBy': user_id, + 'importedAt': timestamp, + 'classification': classify_plugin_set(module_name, repo_url), + } + if module_name: + provenance['moduleName'] = module_name + return provenance + + +# ------------------------------------------- pure: Module_Listing parse +# +# The parse is a pure function over the fetched page content +# (`parse_module_listing`) so it is unit- and property-testable without +# AWS or the network (design Property 5, task 4.5). +# +# The listing page renders the module index as the HTML table whose +# header row starts with a "module" column; each module row's first +# cell links the module page (anchor text = module name) and the second +# cell carries the description. Layout/navigation tables on the page +# have no such header row and are ignored. + +class ModuleListingParseError(ValueError): + """The Module_Listing page could not be parsed into a module index.""" + + +def module_repo_url(name: str) -> str: + """The official module's published repository location (6.2).""" + return MODULE_REPO_URL_TEMPLATE.format(name=name) + + +class _ModuleTableParser(HTMLParser): + """Collects every on the page as rows of (cell_tag, text). + + Tables are tracked on a stack, so the nested layout tables on the + listing page each surface as their own entry in `tables`. + """ + + def __init__(self): + super().__init__(convert_charrefs=True) + self.tables: List[List[List[Tuple[str, str]]]] = [] + self._table_stack: List[List[List[Tuple[str, str]]]] = [] + self._row: Optional[List[Tuple[str, str]]] = None + self._cell: Optional[List[str]] = None + self._cell_tag: Optional[str] = None + + def handle_starttag(self, tag, attrs): + if tag == 'table': + self._table_stack.append([]) + elif tag == 'tr' and self._table_stack: + self._row = [] + elif tag in ('td', 'th') and self._row is not None: + self._cell = [] + self._cell_tag = tag + + def handle_endtag(self, tag): + if tag in ('td', 'th') and self._cell is not None: + text = ' '.join(''.join(self._cell).split()) + self._row.append((self._cell_tag, text)) + self._cell = None + self._cell_tag = None + elif tag == 'tr' and self._row is not None: + if self._table_stack and self._row: + self._table_stack[-1].append(self._row) + self._row = None + elif tag == 'table' and self._table_stack: + self.tables.append(self._table_stack.pop()) + + def handle_data(self, data): + if self._cell is not None: + self._cell.append(data) + + +def parse_module_listing(page: str) -> List[Dict]: + """ + Parse the Module_Listing page content into the module index (6.1). + + Returns [{name, description, repoUrl, classification}] — one entry + per module row, classification via `classify_plugin_set` over the + module name and its published repository location (15.1). + + Raises ModuleListingParseError when the content contains no + parseable module table (an unparseable response, 6.3). + """ + if not isinstance(page, str): + raise ModuleListingParseError('page content is not text') + + parser = _ModuleTableParser() + try: + parser.feed(page) + parser.close() + except Exception as exc: # HTMLParser raises on malformed markup + raise ModuleListingParseError(f'malformed HTML: {exc}') from exc + + for table in parser.tables: + header = next( + (row for row in table if row and row[0][0] == 'th'), None) + if not header or header[0][1].lower() != 'module': + continue + + modules = [] + for row in table: + if not row or row[0][0] != 'td': + continue + name = row[0][1] + if not name: + continue + description = row[1][1] if len(row) > 1 else '' + repo_url = module_repo_url(name) + modules.append({ + 'name': name, + 'description': description, + 'repoUrl': repo_url, + 'classification': classify_plugin_set(name, repo_url), + }) + if modules: + return modules + + raise ModuleListingParseError( + 'the page contains no parseable module table') + + +# --------------------------------------------- Module_Listing cache/fetch + +def module_cache_table(): + """The ModuleIndexCache DynamoDB table""" + return dynamodb.Table(MODULE_INDEX_CACHE_TABLE) + + +def read_cached_module_index() -> Optional[Dict]: + """The cached ModuleIndexCache item, or None (missing or unreadable).""" + try: + response = module_cache_table().get_item( + Key={'cache_key': MODULE_INDEX_CACHE_KEY}) + except Exception as exc: + logger.warning(f'ModuleIndexCache read failed: {exc}') + return None + return response.get('Item') + + +def module_index_is_fresh(item: Dict, now_millis: int) -> bool: + """The cached index is reused for at most 24 hours after fetchedAt + (6.4).""" + fetched_at = item.get('fetchedAt') + if fetched_at is None: + return False + return (now_millis - int(fetched_at)) < MODULE_INDEX_TTL_SECONDS * 1000 + + +def write_module_index_cache(modules: List[Dict], fetched_at_ms: int) -> None: + """Cache the parsed index with fetchedAt and the 24-hour TTL (6.4). + A cache-write failure never fails the request that fetched the index.""" + try: + module_cache_table().put_item(Item={ + 'cache_key': MODULE_INDEX_CACHE_KEY, + 'modules': modules, + 'fetchedAt': fetched_at_ms, + 'ttl': fetched_at_ms // 1000 + MODULE_INDEX_TTL_SECONDS, + }) + except Exception as exc: + logger.warning(f'ModuleIndexCache write failed: {exc}') + + +def fetch_module_listing() -> str: + """Fetch the Module_Listing page content (6.1); raises on any HTTP + failure.""" + response = requests.get(MODULE_LISTING_URL, + timeout=MODULE_LISTING_TIMEOUT_SECONDS) + response.raise_for_status() + return response.text + + +# ------------------------------------ per-module plugin list (?module=) +# +# GET /plugin-modules?module= returns the individual plugins of +# one official module so the import view offers a plugin selection +# before the import (rather than always importing the whole set). The +# list is sourced from the GitLab API for the gstreamer monorepo: each +# immediate subdirectory of subprojects//{gst,ext,sys}/ is one +# plugin. Like the module index, the parse is a pure function +# (`module_plugins_from_trees`) over the fetched tree listings. + +def module_plugins_from_trees( + trees: Mapping[str, List[Dict]]) -> List[Dict]: + """ + Parse GitLab repository-tree listings (one per plugin-set root) + into the module's individual plugin entries [{'name': ...}]: every + 'tree' (directory) entry is one plugin; blobs and anything without + a name are ignored. Name-sorted and de-duplicated across roots. + """ + names = set() + for root in PLUGIN_SET_ROOTS: + for entry in trees.get(root) or []: + if (isinstance(entry, dict) and entry.get('type') == 'tree' + and entry.get('name')): + names.add(str(entry['name'])) + return [{'name': name} for name in sorted(names)] + + +def fetch_module_plugin_trees(module: str) -> Dict[str, List[Dict]]: + """ + Fetch the GitLab tree listings of subprojects//{gst,ext,sys} + (one plugin per subdirectory). A root the module does not carry + (404) lists as empty; any other HTTP failure raises so the caller + answers MODULE_LISTING_UNAVAILABLE. + """ + trees: Dict[str, List[Dict]] = {} + for root in PLUGIN_SET_ROOTS: + entries: List[Dict] = [] + page = 1 + while True: + response = requests.get( + MODULE_PLUGINS_TREE_URL, + params={'path': f'subprojects/{module}/{root}', + 'per_page': MODULE_PLUGINS_PER_PAGE, + 'page': page}, + timeout=MODULE_LISTING_TIMEOUT_SECONDS) + if response.status_code == 404: + # e.g. gst-plugins-ugly has no sys/ tree + entries = [] + break + response.raise_for_status() + batch = response.json() + if not isinstance(batch, list): + raise ModuleListingParseError( + f'unexpected GitLab tree response for {module}/{root}') + entries.extend(batch) + next_page = (response.headers.get('x-next-page') or '').strip() + if not next_page: + break + page = int(next_page) + trees[root] = entries + return trees + + +def fetch_module_plugin_descriptions(module: str) -> Dict[str, str]: + """ + {plugin_name: description} for one official module, fetched from + the monorepo's raw docs/gst_plugins_cache.json (size-capped). + Descriptions are an enhancement: any failure — HTTP error, timeout, + oversized file, malformed JSON — returns {} and never raises, so + the plugin listing proceeds without descriptions. + """ + try: + response = requests.get( + MODULE_PLUGIN_DESCRIPTIONS_URL_TEMPLATE.format(module=module), + timeout=MODULE_LISTING_TIMEOUT_SECONDS, stream=True) + response.raise_for_status() + chunks: List[bytes] = [] + total = 0 + for chunk in response.iter_content(chunk_size=64 * 1024): + total += len(chunk) + if total > MAX_PLUGIN_DESCRIPTION_CACHE_BYTES: + logger.warning( + f'Plugin description cache for {module} exceeds ' + f'{MAX_PLUGIN_DESCRIPTION_CACHE_BYTES} bytes; skipping') + return {} + chunks.append(chunk) + return plugin_descriptions_from_cache(b''.join(chunks)) + except Exception as exc: + logger.warning( + f'Plugin description cache unavailable for {module}: {exc}') + return {} + + +def module_plugins_cache_key(module: str) -> str: + """ModuleIndexCache key of one module's plugin list.""" + return MODULE_PLUGINS_CACHE_KEY_PREFIX + module + + +def read_cached_module_plugins(module: str) -> Optional[Dict]: + """The cached per-module plugin-list item, or None.""" + try: + response = module_cache_table().get_item( + Key={'cache_key': module_plugins_cache_key(module)}) + except Exception as exc: + logger.warning(f'ModuleIndexCache read failed: {exc}') + return None + return response.get('Item') + + +def write_module_plugins_cache(module: str, plugins: List[Dict], + fetched_at_ms: int) -> None: + """Cache one module's plugin list with fetchedAt and the 24-hour + TTL (same pattern as the module index); a cache-write failure never + fails the request that fetched the list.""" + try: + module_cache_table().put_item(Item={ + 'cache_key': module_plugins_cache_key(module), + 'module': module, + 'plugins': plugins, + 'fetchedAt': fetched_at_ms, + 'ttl': fetched_at_ms // 1000 + MODULE_INDEX_TTL_SECONDS, + }) + except Exception as exc: + logger.warning(f'ModuleIndexCache write failed: {exc}') + + +# --------------------------------------------------- fetch orchestration + +def start_fetch(repo_url: str, revision: Optional[str], dest_prefix: str, + usecase_id: str, plugin_id: str, version: int, + revision_slug_id: Optional[str] = None) -> str: + """ + StartBuild on the lightweight CodeBuild fetch step (4.1): clone + `repo_url` at `revision` (default branch when empty) and sync the + tree to s3://{bucket}/{dest_prefix}/. Never polls — the request + path answers 202 immediately (API Gateway caps REST integrations at + 29 s) and the EventBridge-delivered build state change reaches + `handle_fetch_result`, attributed back to the Plugin_Record by the + PLUGIN_ID / PLUGIN_VERSION / USECASE_ID env overrides. Fetches of a + multi-revision import (arch_revisions) additionally carry the + REVISION_SLUG override so the result handler can update the right + entry of the record's `fetches` map. + + Returns the CodeBuild build id. + """ + env_overrides = [ + {'name': 'REPO_URL', 'value': repo_url, 'type': 'PLAINTEXT'}, + {'name': 'REVISION', 'value': revision or '', 'type': 'PLAINTEXT'}, + {'name': 'DEST_PREFIX', 'value': dest_prefix, 'type': 'PLAINTEXT'}, + {'name': 'USECASE_ID', 'value': usecase_id, 'type': 'PLAINTEXT'}, + {'name': 'PLUGIN_ID', 'value': plugin_id, 'type': 'PLAINTEXT'}, + {'name': 'PLUGIN_VERSION', 'value': str(version), 'type': 'PLAINTEXT'}, + ] + if revision_slug_id: + env_overrides.append( + {'name': 'REVISION_SLUG', 'value': revision_slug_id, + 'type': 'PLAINTEXT'}) + start = codebuild.start_build( + projectName=FETCH_PROJECT_NAME, + environmentVariablesOverride=env_overrides, + ) + return start['build']['id'] + + +def fetch_build_id_from_arn(build_arn: str) -> str: + """Extract 'project:uuid' from the EventBridge detail build-id ARN + (mirrors plugin_builds.build_id_from_arn; duplicated locally so + neither module imports the other in a cycle).""" + if ':build/' in build_arn: + return build_arn.split(':build/', 1)[1] + return build_arn + + +def fetch_env_vars(detail: Dict) -> Dict[str, str]: + """Environment variables of the finished fetch build (the StartBuild + overrides, echoed back in the EventBridge detail).""" + env = ((detail.get('additional-information') or {}) + .get('environment') or {}).get('environment-variables') or [] + return {var.get('name'): var.get('value') for var in env + if isinstance(var, dict) and var.get('name')} + + +def list_source_tree(prefix: str) -> Dict[str, Optional[str]]: + """ + File mapping {relative_path: content-or-None} of the synced source + tree under the S3 prefix. Content is fetched (size-capped) only for + the build-definition files the buildability scan consults and for + gst_plugins_cache.json plugin-metadata files (per-plugin + descriptions — a fetch failure there is non-fatal: the entry stays + content-less and enumeration proceeds without descriptions). + """ + files: Dict[str, Optional[str]] = {} + kwargs = {'Bucket': PORTAL_ARTIFACTS_BUCKET, 'Prefix': prefix} + while True: + response = s3.list_objects_v2(**kwargs) + for obj in response.get('Contents', []): + relative = obj['Key'][len(prefix):] + if not relative: + continue + files[relative] = None + if not response.get('IsTruncated'): + break + kwargs['ContinuationToken'] = response['NextContinuationToken'] + + for relative in files: + name = posixpath.basename(relative) + if name in BUILD_DEFINITION_FILES: + obj = s3.get_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, Key=prefix + relative, + Range=f'bytes=0-{MAX_BUILD_DEFINITION_BYTES - 1}') + files[relative] = obj['Body'].read().decode('utf-8', errors='replace') + elif name == PLUGIN_DOCS_CACHE_FILENAME: + try: + obj = s3.get_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, Key=prefix + relative, + Range=f'bytes=0-{MAX_PLUGIN_DESCRIPTION_CACHE_BYTES - 1}') + files[relative] = obj['Body'].read().decode( + 'utf-8', errors='replace') + except Exception as exc: + logger.warning( + f'Could not read {relative} for plugin descriptions: ' + f'{exc}') + + return files + + +def submit_builds(architectures: List[str]) -> Dict[str, Dict]: + """ + Submit the imported source to the Plugin_Build_Service for the + user-selected Target_Architectures (4.3): per-arch artifact entries + queued on the Plugin_Record. Once the record is persisted as + 'imported', _start_queued_builds hands the queued entries to + plugin_builds.start_queued_builds, which StartBuilds the per-arch + CodeBuild projects (queued -> building); the EventBridge build + results then record {s3Key, checksum, signature, buildStatus, + logTail}. + """ + return {arch: {'buildStatus': BUILD_QUEUED} for arch in architectures} + + +# ------------------------------- pure: platform requirements check +# +# Advisory per-platform requirements/compatibility check at import time. +# Users import sources (e.g. gst-plugins-good main, which requires +# GStreamer >= 1.24) that cannot build on the older JetPack platforms +# and only find out through an obscure meson subproject error at build +# time — nothing tells them WHY, so failed builds get retried +# repeatedly. The check parses the minimum GStreamer version the source +# requires from its root build definition (whose content +# list_source_tree already fetches) and compares it against the +# GStreamer version each Target_Architecture's build platform ships. +# It NEVER blocks or fails an import or a build (the user may know +# better), and every parsing failure degrades to "no requirement +# determined" = compatible everywhere. + +#: GStreamer version each Target_Architecture's build platform ships, +#: from the dda-plugin-build images +#: (edge-cv-portal/plugin-build-images/Dockerfile.): +#: - x86_64: Ubuntu 22.04 -> GStreamer 1.20 +#: - x86_64_nvidia: CUDA on Ubuntu 22.04 -> GStreamer 1.20 +#: - arm64_jp4: L4T r32 (JetPack 4, Ubuntu 18.04) -> GStreamer 1.14 +#: - arm64_jp5: L4T r35 (JetPack 5, Ubuntu 20.04) -> GStreamer 1.16 +#: - arm64_jp6: L4T r36 (JetPack 6, Ubuntu 22.04) -> GStreamer 1.20 +PLATFORM_GSTREAMER_VERSIONS = { + 'x86_64': '1.20', + 'x86_64_nvidia': '1.20', + 'arm64_jp4': '1.14', + 'arm64_jp5': '1.16', + 'arm64_jp6': '1.20', +} + +#: Platforms whose build image toolchain (Ubuntu 22.04: modern meson, +#: glib, and headers) can build a newer GStreamer via meson's +#: subproject fallback when the source requires more than the platform +#: ships. Observed in production: gst-plugins-good main (requires +#: GStreamer >= 1.24) builds fine on x86_64 / x86_64_nvidia / +#: arm64_jp6 (which ship 1.20) via the fallback, while arm64_jp4 +#: (Ubuntu 18.04) and arm64_jp5 (Ubuntu 20.04) fail with an obscure +#: meson subproject error — their toolchains are too old to build a +#: current GStreamer from source. +PLATFORMS_WITH_SUBPROJECT_FALLBACK = frozenset( + {'x86_64', 'x86_64_nvidia', 'arm64_jp6'}) + +#: Human-readable platform names for compatibility reasons (kept in +#: line with the frontend's ARCHITECTURE_LABELS in +#: frontend/src/pages/node-designer/types.ts). +PLATFORM_LABELS = { + 'x86_64': 'x86_64', + 'x86_64_nvidia': 'x86_64 (NVIDIA GPU)', + 'arm64_jp4': 'arm64 JetPack 4', + 'arm64_jp5': 'arm64 JetPack 5', + 'arm64_jp6': 'arm64 JetPack 6', +} + +# meson: gst_req = '>= 1.24' / gst_req = '>= 1.24.0' (literal form, +# used by standalone plugin repositories). +_MESON_GST_REQ_LITERAL_RE = re.compile( + r"""\bgst_req\s*=\s*['"]\s*>=\s*(\d+\.\d+(?:\.\d+)?)\s*['"]""") +# meson: gst_req = '>= @0@.@1@.0'.format(gst_version_major, +# gst_version_minor) — the form every gst-plugins-good/bad/ugly +# meson.build uses (main and the 1.x release branches alike): the +# requirement is the project's own major.minor series. +_MESON_GST_REQ_FORMAT_RE = re.compile( + r"""\bgst_req\s*=\s*['"]\s*>=\s*@0@\.@1@[^'"]*['"]\s*\.\s*format\s*\(""") +# meson: the project version the format form derives major/minor from, +# e.g. project('gst-plugins-good', 'c', version : '1.24.2', ...). The +# first `version :` in a gst module meson.build is the project's +# (meson_version has no word boundary before 'version'). +_MESON_PROJECT_VERSION_RE = re.compile( + r"""\bversion\s*:\s*['"](\d+\.\d+(?:[0-9.]*))['"]""") +# meson: dependency('gstreamer-1.0', version : '>= 1.20', ...) with an +# inline literal version constraint. +_MESON_GST_DEP_VERSION_RE = re.compile( + r"""\bdependency\s*\(\s*['"]gstreamer-1\.0['"][^)]*?""" + r"""version\s*:\s*['"]\s*>=\s*(\d+\.\d+(?:\.\d+)?)""", + re.DOTALL) +# autotools: GST_REQUIRED=1.16 / GST_REQUIRED_VERSION=1.16 / +# AC_SUBST(GST_REQUIRED, 1.16) assignments in configure.ac. +_AUTOTOOLS_GST_REQUIRED_RE = re.compile( + r"""\bGST_REQUIRED\w*\s*[=,]\s*\[?\s*(\d+\.\d+(?:\.\d+)?)""") +# autotools: PKG_CHECK_MODULES(GST, gstreamer-1.0 >= 1.20) with an +# inline literal version constraint. +_AUTOTOOLS_GST_PKG_RE = re.compile( + r"""gstreamer-1\.0\s*>=\s*(\d+\.\d+(?:\.\d+)?)""") + + +def _minor_version(version: Optional[str]) -> Optional[Tuple[int, int]]: + """(major, minor) of a dotted version string, or None when it does + not parse. Comparison happens at minor precision: the platform + table carries no micro versions and GStreamer features land per + minor release series.""" + if not version or not isinstance(version, str): + return None + match = re.match(r'\s*(\d+)\.(\d+)', version) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +def gstreamer_requirement( + files: Mapping[str, Optional[str]]) -> Optional[str]: + """ + The minimum GStreamer version the source requires, parsed from the + root build definition content (which list_source_tree already + fetches for the buildability scan). Pure over the source-tree file + mapping. + + Handled patterns: + - meson ``gst_req = '>= 1.x[.y]'`` literal; + - meson ``gst_req = '>= @0@.@1@.0'.format(gst_version_major, + gst_version_minor)`` (the gst-plugins-good/bad/ugly form, main + and release branches alike) resolved against the ``project(... + version : '1.x.y')`` literal — the requirement is the project's + own major.minor series; + - meson ``dependency('gstreamer-1.0', version : '>= 1.x')``; + - autotools ``GST_REQUIRED=1.x`` / ``AC_SUBST(GST_REQUIRED, 1.x)`` + and ``PKG_CHECK_MODULES(..., gstreamer-1.0 >= 1.x)`` in + configure.ac / configure.in. + + Returns the version string (e.g. '1.24.0'), or None when no + requirement can be determined — absence of a requirement is treated + as compatible everywhere, and every parsing failure degrades to + None (the check is advisory, never blocking). + """ + meson = files.get('meson.build') + if meson: + match = _MESON_GST_REQ_LITERAL_RE.search(meson) + if match: + return match.group(1) + if _MESON_GST_REQ_FORMAT_RE.search(meson): + project_version = _MESON_PROJECT_VERSION_RE.search(meson) + minor = _minor_version( + project_version.group(1) if project_version else None) + if minor: + return f'{minor[0]}.{minor[1]}.0' + match = _MESON_GST_DEP_VERSION_RE.search(meson) + if match: + return match.group(1) + for name in ('configure.ac', 'configure.in'): + content = files.get(name) + if not content: + continue + match = _AUTOTOOLS_GST_REQUIRED_RE.search(content) + if match: + return match.group(1) + match = _AUTOTOOLS_GST_PKG_RE.search(content) + if match: + return match.group(1) + return None + + +def platform_compatibility(required_version: Optional[str], + architectures: List[str], + classification_or_module: Optional[str] = None + ) -> Dict[str, Dict]: + """ + Per-platform requirements check for the requested + Target_Architectures. Advisory only: incompatible architectures + still queue builds — the user may know better. + + Returns {arch: {compatible, platformVersion, requiredVersion, + reason, suggestedRevision}} where: + - compatible is False exactly when both the requirement and the + platform's GStreamer version are known, the platform's minor + release series is older than required, and the platform's + toolchain cannot build a newer GStreamer via meson's subproject + fallback (PLATFORMS_WITH_SUBPROJECT_FALLBACK — the Ubuntu 22.04 + platforms satisfy newer requirements that way in production); + anything unknown counts as compatible; + - reason is a plain-language explanation on incompatible entries + (e.g. "The source requires GStreamer >= 1.24; arm64 JetPack 5 + provides 1.16"), None otherwise; + - suggestedRevision is, for official GStreamer modules only + (`classification_or_module` carries the provenance moduleName + or a good/bad/ugly classification), the upstream release branch + matching the platform's GStreamer minor (e.g. '1.16' for + arm64_jp5, '1.14' for arm64_jp4) — verified working in + production. Non-official repositories get no suggestion (None): + their branch layout is unknown. + """ + required = _minor_version(required_version) + official = bool(classification_or_module) and ( + classification_or_module != 'unclassified') + result: Dict[str, Dict] = {} + for arch in architectures: + platform_version = PLATFORM_GSTREAMER_VERSIONS.get(arch) + entry: Dict = { + 'compatible': True, + 'platformVersion': platform_version, + 'requiredVersion': required_version, + 'reason': None, + 'suggestedRevision': None, + } + platform = _minor_version(platform_version) + if (required and platform and platform < required + and arch not in PLATFORMS_WITH_SUBPROJECT_FALLBACK): + label = PLATFORM_LABELS.get(arch, arch) + entry['compatible'] = False + entry['reason'] = ( + f'The source requires GStreamer >= {required_version}; ' + f'{label} provides {platform_version}') + if official: + entry['suggestedRevision'] = platform_version + result[arch] = entry + return result + + +def evaluate_fetched_tree(files: Mapping[str, Optional[str]], name: str, + selected_plugins: List[str], + architectures: List[str], + classification_or_module: Optional[str] = None + ) -> Tuple[Dict, Dict]: + """ + Post-fetch import decision, pure over the synced source-tree file + mapping (shared by the fetch-result handler; formerly inlined in + the synchronous import path). + + Returns (scan, updates) where `updates` are the Plugin_Record + fields to set: + - unbuildable tree: import_status failed + import_finding (4.5); + - buildable with an import-time selection recorded: selection on + the record and provenance, import_status imported, builds + queued (4.3); + - buildable plugin set (> 1 enumerated plugin, no selection): + import_status pending_selection with plugins_found — build + submission defers to the select-plugins endpoint; + - buildable single plugin: import_status imported, builds queued. + + Every outcome additionally carries the advisory + `platform_compatibility` map (gstreamer_requirement + + platform_compatibility over the requested Target_Architectures): + it informs the user per platform whether the source can work and + which revision would, but never blocks — incompatible architectures + still queue builds. + """ + scan = scan_buildability(files) + plugins_found = enumerate_plugins(files, single_plugin_name=name) + compatibility = platform_compatibility( + gstreamer_requirement(files), architectures, + classification_or_module) + + if not scan['buildable']: + return scan, { + 'import_status': IMPORT_STATUS_FAILED, + 'import_finding': scan['finding'], + 'platform_compatibility': compatibility, + } + + updates: Dict = {'plugins_found': plugins_found, + 'platform_compatibility': compatibility} + if selected_plugins: + # The user already picked the subset to import (from the module + # plugin list at import time): record it and queue builds now — + # the pending-selection step is skipped. + updates['selected_plugins'] = selected_plugins + updates['provenance.selectedPlugins'] = selected_plugins + updates['import_status'] = IMPORT_STATUS_IMPORTED + updates['artifacts'] = submit_builds(architectures) + elif len(plugins_found) > 1: + # Plugin-set import: the user selects which individual plugins + # to import before builds are submitted (the select-plugins + # endpoint advances the record). + updates['import_status'] = IMPORT_STATUS_PENDING_SELECTION + else: + # Single-plugin repository: selection skipped; queue builds for + # the selected Target_Architectures (4.3). + updates['import_status'] = IMPORT_STATUS_IMPORTED + updates['artifacts'] = submit_builds(architectures) + return scan, updates + + +# ----------------------------------------------------------------- handlers + +def import_detail(item: Dict) -> Dict: + """version_detail plus the import-specific fields (import status, + finding, enumerated plugins, and the recorded selection).""" + detail = version_detail(item) + detail['import_status'] = item.get('import_status') + if item.get('import_finding'): + detail['import_finding'] = item['import_finding'] + if item.get('plugins_found') is not None: + detail['plugins_found'] = item['plugins_found'] + if item.get('selected_plugins') is not None: + detail['selected_plugins'] = item['selected_plugins'] + if item.get('platform_compatibility') is not None: + detail['platform_compatibility'] = item['platform_compatibility'] + return detail + + +def import_repository(event: Dict, user: Dict) -> Dict: + """ + POST /plugins/import + Body: {usecase_id, repo_url, revision?, architectures, name?, + description?, deepstream?, module_name?, selected_plugins?} + + Asynchronous import: validates the request, creates the + Plugin_Record immediately (lifecycle dev, review pending) with + import provenance + classification (4.2, 15.5) and import_status + 'fetching', StartBuilds the fetch project WITHOUT polling, and + answers 202 with the record. The fetch outcome arrives via + EventBridge at `handle_fetch_result`, which scans buildability and + advances the record: failed with the finding (4.5), imported with + builds queued (4.3), or pending_selection for plugin sets + (deferring build submission to + POST /plugins/{id}/versions/{v}/select-plugins). Clients poll + GET /plugins/{id}/versions/{v} until import_status leaves + 'fetching'. + """ + body, err = parse_body(event) + if err: + return err + + usecase_id = body.get('usecase_id') + repo_url = body.get('repo_url') + revision = body.get('revision') or None + architectures = body.get('architectures') + module_name = body.get('module_name') or None + deepstream = bool(body.get('deepstream', False)) + + missing = [f for f in ('usecase_id', 'repo_url') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + + url_error = validate_repo_url(repo_url) + if url_error: + return error_response(400, 'INVALID_REPO_URL', url_error, + {'repo_url': repo_url}) + + if revision is not None and not isinstance(revision, str): + return error_response(400, 'INVALID_REVISION', + 'revision must be a string') + + if (not isinstance(architectures, list) or not architectures + or not all(isinstance(a, str) for a in architectures)): + return error_response(400, 'INVALID_ARCHITECTURES', + 'architectures must be a non-empty list of ' + 'Target_Architecture identifiers') + architectures = sorted(set(architectures)) + invalid = [a for a in architectures if a not in DEVICE_ARCHITECTURES] + if invalid: + return error_response(400, 'INVALID_ARCHITECTURES', + f"Unknown Target_Architectures: {', '.join(invalid)}", + {'valid': list(DEVICE_ARCHITECTURES)}) + if deepstream: + # DeepStream targets Jetson: selectable architectures restricted + # to the JetPack builds (Requirement 5.1). + non_jetson = [a for a in architectures + if a not in DEEPSTREAM_ARCHITECTURES] + if non_jetson: + return error_response( + 400, 'INVALID_ARCHITECTURES', + 'DeepStream imports may only target: ' + f"{', '.join(DEEPSTREAM_ARCHITECTURES)}", + {'invalid': non_jetson}) + + # Optional per-architecture revision overrides: each arch's + # effective revision is arch_revisions[arch] or the top-level + # revision (default branch when neither is given). Absent = one + # revision everywhere, today's behavior exactly. + arch_revisions = body.get('arch_revisions') + arch_rev_error = validate_arch_revisions(arch_revisions, architectures) + if arch_rev_error: + return error_response(400, 'INVALID_ARCH_REVISIONS', arch_rev_error, + {'architectures': architectures}) + + # Optional import-time plugin selection (from the module plugin + # list, GET /plugin-modules?module=...). Absent or empty = whole + # module, today's behavior. + selection_error = validate_import_selected_plugins( + body.get('selected_plugins')) + if selection_error: + return error_response(400, 'INVALID_PLUGIN_SELECTION', + selection_error) + selected_plugins = dedupe_selected_plugins(body.get('selected_plugins')) + + if not can_manage(user, usecase_id, Permission.NODE_DESIGNER_IMPORT): + return forbidden_response(user, event, usecase_id, + Permission.NODE_DESIGNER_IMPORT) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + # Record name: explicit `name` wins; otherwise the URL-derived base + # name, with a single-plugin import-time selection appended + # ("gst-plugins-good-rtsp") so the record shows what was imported. + name = derive_import_name(body.get('name'), repo_url, selected_plugins) + plugin_id = str(uuid.uuid4()) + version = 1 + prefix = source_s3_prefix(usecase_id, plugin_id, version) + + # Effective per-arch revision plan: a single distinct revision keeps + # today's one-fetch flat layout (mode 'single' also collapses + # arch_revisions that all name the same revision); multiple distinct + # revisions fetch once each into rev-{slug}/ prefixes (mode 'multi'). + plan = revision_fetch_plan(revision, arch_revisions, architectures, + prefix) + fetches: Optional[Dict[str, Dict]] = None + fetch_build_id: Optional[str] = None + + # --- fetch step: StartBuild only, never polled in the request path. + # API Gateway caps REST integrations at 29 s; slow clones (e.g. + # gst-plugins-good) settle via the EventBridge result instead. + try: + if plan['mode'] == 'single': + revision = plan['revision'] + fetch_build_id = start_fetch( + repo_url, revision, prefix.rstrip('/'), + usecase_id=usecase_id, plugin_id=plugin_id, version=version) + else: + fetches = plan['fetches'] + for slug in sorted(fetches): + entry = fetches[slug] + entry['fetch_build_id'] = start_fetch( + repo_url, + (None if entry['revision'] == DEFAULT_REVISION + else entry['revision']), + entry['source_prefix'].rstrip('/'), + usecase_id=usecase_id, plugin_id=plugin_id, + version=version, revision_slug_id=slug) + except Exception as exc: + logger.error(f'Fetch StartBuild failed: {exc}', exc_info=True) + log_audit_event( + user_id=user['user_id'], + action='import_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='failure', + details={'usecase_id': usecase_id, 'repo_url': repo_url, + 'revision': revision or DEFAULT_REVISION, + 'reason': 'REPO_FETCH_FAILED'} + ) + return error_response( + 502, 'REPO_FETCH_FAILED', + 'The repository fetch could not be started', + {'repo_url': repo_url, 'revision': revision or DEFAULT_REVISION}) + + # --- Plugin_Record creation (4.2, 15.5): import_status 'fetching', + # provenance recorded now, plugins_found only once the fetch settles. + timestamp = now_ms() + provenance = import_provenance(repo_url, revision, module_name, + user['user_id'], timestamp) + item = new_version_item( + plugin_id=plugin_id, version=version, usecase_id=usecase_id, + name=name, kind='imported', user_id=user['user_id'], + timestamp=timestamp, description=body.get('description', ''), + deepstream=deepstream, provenance=provenance, + ) + item['requested_architectures'] = architectures + item['import_status'] = IMPORT_STATUS_FETCHING + if plan['mode'] == 'single': + item['fetch_build_id'] = fetch_build_id + else: + # Multi-revision import: per-revision fetch map + arch->slug + # mapping. The record's source_s3_prefix points at the DEFAULT + # revision's tree so the buildability scan, plugin enumeration, + # source inspection, and node-generator acceptance all read one + # deterministic tree; per-arch builds resolve their own tree + # via arch_revisions -> fetches[slug].source_prefix. + item['fetches'] = fetches + item['arch_revisions'] = plan['arch_revisions'] + item['default_fetch_slug'] = plan['default_slug'] + item['source_s3_prefix'] = \ + fetches[plan['default_slug']]['source_prefix'] + if selected_plugins: + # Import-time selection, applied by handle_fetch_result once + # the tree proves buildable (then recorded as selected_plugins + # + provenance.selectedPlugins so plugin_builds.py passes it to + # CodeBuild as SELECTED_PLUGINS); removed when the fetch settles. + item['pending_selected_plugins'] = selected_plugins + + plugin_table().put_item( + Item=item, + ConditionExpression='attribute_not_exists(plugin_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='import_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': usecase_id, 'repo_url': repo_url, + 'revision': revision or DEFAULT_REVISION, + 'classification': provenance['classification'], + 'architectures': architectures, + 'import_status': IMPORT_STATUS_FETCHING, + **({'fetch_build_id': fetch_build_id} + if plan['mode'] == 'single' else + {'arch_revisions': plan['arch_revisions'], + 'fetch_build_ids': { + slug: fetches[slug]['fetch_build_id'] + for slug in fetches}}), + **({'selected_plugins': selected_plugins} + if selected_plugins else {})} + ) + + return create_response(202, { + 'plugin': import_detail(item), + 'import': { + 'status': IMPORT_STATUS_FETCHING, + **({'buildId': fetch_build_id} + if plan['mode'] == 'single' else + {'fetchBuildIds': {slug: fetches[slug]['fetch_build_id'] + for slug in fetches}}), + }, + }) + + +def _advance_import_record(plugin_id: str, version: int, + updates: Dict) -> bool: + """ + Apply the fetch outcome to the Plugin_Record, guarded on + import_status still being 'fetching' so a duplicate EventBridge + delivery never re-applies (idempotency, mirroring + handle_build_result's guards). The transient + pending_selected_plugins field is removed once the fetch settles. + + Returns False when the record already left 'fetching'. + """ + names: Dict[str, str] = {} + values: Dict = {':fetching': IMPORT_STATUS_FETCHING, ':t': now_ms()} + sets = ['updated_at = :t'] + for index, (field, value) in enumerate(sorted(updates.items())): + placeholder = f':v{index}' + values[placeholder] = value + if field == 'provenance.selectedPlugins': + sets.append(f'provenance.selectedPlugins = {placeholder}') + else: + names[f'#f{index}'] = field + sets.append(f'#f{index} = {placeholder}') + kwargs = dict( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression=('SET ' + ', '.join(sets) + + ' REMOVE pending_selected_plugins'), + ConditionExpression='import_status = :fetching', + ExpressionAttributeValues=values, + ) + if names: + kwargs['ExpressionAttributeNames'] = names + try: + plugin_table().update_item(**kwargs) + except ClientError as e: + if (e.response.get('Error', {}).get('Code') + == 'ConditionalCheckFailedException'): + return False + raise + return True + + +def _start_queued_builds(plugin_id: str, version: int) -> None: + """ + Auto-start the per-arch CodeBuild builds an import just queued + (import_status advanced to 'imported'): delegates to + plugin_builds.start_queued_builds, which flips the queued artifact + entries to building with their build ids. plugin_builds is imported + lazily — it already lazily imports this module for fetch results, + so a module-level import would be circular. Mirroring the + trigger_component_packaging pattern, an auto-start failure never + fails the fetch-result handler (start_queued_builds itself never + raises; this guard additionally covers the import). + """ + try: + import plugin_builds + plugin_builds.start_queued_builds(plugin_id, version) + except Exception as e: + logger.warning(f"Auto-start of queued builds for {plugin_id} " + f"v{version} failed: {e}") + + +def handle_fetch_result(detail: Dict) -> Dict: + """ + EventBridge CodeBuild Build State Change handler for the fetch + project (delegated from plugin_builds.py's handler — same rule + 'dda-portal-plugin-build-results', same Lambda bundle). Idempotent + on the fetch build id. + + - SUCCEEDED: scans the synced tree (list_source_tree + + scan_buildability + enumerate_plugins, exactly as the old + synchronous path) and advances the record via + `evaluate_fetched_tree`: failed (finding, 4.5), pending_selection + (plugin set without a recorded selection), or imported with + builds queued and immediately started (4.3, _start_queued_builds). + - FAILED / FAULT / STOPPED / TIMED_OUT: marks the record failed + with the REPO_FETCH_FAILED finding (the record exists so the UI + can show why — a deliberate change from the old synchronous + no-record behavior, 4.4). + + Post-import revision adjustments (adjust_revision) fetch against a + SETTLED record: their results — a `fetches` entry named by + REVISION_SLUG carrying the pending_archs marker — route to + _handle_adjustment_fetch_result before the import paths above. + """ + build_id = fetch_build_id_from_arn(detail.get('build-id') or '') + build_status = detail.get('build-status') + env = fetch_env_vars(detail) + plugin_id = env.get('PLUGIN_ID') + version_raw = env.get('PLUGIN_VERSION') + if not plugin_id or not version_raw: + logger.warning(f"Fetch build {build_id} carries no " + "PLUGIN_ID/PLUGIN_VERSION; skipping") + return {'recorded': False, 'reason': 'missing fetch metadata'} + version = int(version_raw) + + item = get_version_item(plugin_id, version) + if not item: + logger.warning(f"Fetch build {build_id}: Plugin_Record " + f"{plugin_id} v{version} not found") + return {'recorded': False, 'reason': 'plugin record not found'} + + fetches = item.get('fetches') or {} + adjustment_slug = env.get('REVISION_SLUG') + if (adjustment_slug + and (fetches.get(adjustment_slug) or {}).get('pending_archs')): + # Post-import revision adjustment fetch (adjust_revision): the + # slug's entry carries the pending_archs marker — a settled + # record, not an import in flight, so it must be routed before + # the import_status == 'fetching' paths below + # (imported-plugin-revision-adjustment-fix, 2.3/2.4). + return _handle_adjustment_fetch_result(item, build_id, + build_status, env) + + if fetches: + # Multi-revision import (arch_revisions): one fetch per distinct + # revision, each attributed by its REVISION_SLUG env override. + return _handle_multi_fetch_result(item, build_id, build_status, env) + + # Idempotency on the fetch build id: skip events from superseded + # builds and duplicate deliveries of an already-settled result. + recorded_build_id = item.get('fetch_build_id') + if recorded_build_id and recorded_build_id != build_id: + logger.info(f"Fetch build {build_id} superseded by " + f"{recorded_build_id}; skipping") + return {'recorded': False, 'reason': 'superseded build'} + if item.get('import_status') != IMPORT_STATUS_FETCHING: + logger.info(f"Fetch build {build_id} already recorded; " + "skipping duplicate delivery") + return {'recorded': False, 'reason': 'already recorded'} + + architectures = [str(a) for a in item.get('requested_architectures') or []] + pending_selection = [str(n) for n + in item.get('pending_selected_plugins') or []] + + if build_status == 'SUCCEEDED': + files = list_source_tree(item.get('source_s3_prefix') or '') + record_provenance = item.get('provenance') or {} + scan, updates = evaluate_fetched_tree( + files, item.get('name') or plugin_id, + pending_selection, architectures, + # Official-module signal for revision suggestions in the + # advisory platform_compatibility map: the provenance + # moduleName (module listing imports) or a good/bad/ugly + # classification. + classification_or_module=(record_provenance.get('moduleName') + or record_provenance.get('classification'))) + buildable = scan['buildable'] + else: + # Unreachable repository or missing revision (4.4). + updates = { + 'import_status': IMPORT_STATUS_FAILED, + 'import_finding': FETCH_FAILURE_FINDING, + 'import_error_code': 'REPO_FETCH_FAILED', + } + buildable = False + + if not _advance_import_record(plugin_id, version, updates): + return {'recorded': False, 'reason': 'already recorded'} + + if updates['import_status'] == IMPORT_STATUS_IMPORTED: + # The import settled with builds queued: start them now (3.1). + _start_queued_builds(plugin_id, version) + + provenance = item.get('provenance') or {} + log_audit_event( + user_id=(provenance.get('importedBy') + or item.get('created_by') or 'system'), + action='complete_plugin_import', + resource_type='plugin_record', + resource_id=plugin_id, + result='success' if buildable else 'failure', + details={'usecase_id': item.get('usecase_id'), 'version': version, + 'fetch_build_id': build_id, + 'fetch_status': build_status, + 'import_status': updates['import_status'], + **({'reason': 'REPO_FETCH_FAILED'} + if build_status != 'SUCCEEDED' else {})} + ) + logger.info(f"Recorded fetch {build_id} for {plugin_id} v{version}: " + f"{updates['import_status']}") + return {'recorded': True, 'import_status': updates['import_status']} + + +def _handle_multi_fetch_result(item: Dict, build_id: str, + build_status: Optional[str], + env: Dict[str, str]) -> Dict: + """ + One fetch result of a multi-revision import (arch_revisions). The + REVISION_SLUG env override names the entry of the record's + `fetches` map this build synced; idempotency is guarded PER SLUG + (fetch_build_id match + the slug's status still 'fetching'), + mirroring the single-fetch guards. The slug settles to + succeeded/failed and the record's import_status leaves 'fetching' + only once EVERY fetch has settled: + - any fetch failed -> import_status 'failed' with a finding + naming the failing revision(s); the per-fetch statuses stay on + the record so the UI can show which trees did sync; + - all succeeded -> buildability scan + plugin enumeration run on + the DEFAULT revision's tree (the record's source_s3_prefix) and + evaluate_fetched_tree advances the record exactly like a + single-revision import (imported / pending_selection / failed). + """ + plugin_id = item['plugin_id'] + version = int(item['version']) + fetches = item.get('fetches') or {} + slug = env.get('REVISION_SLUG') + if not slug or slug not in fetches: + logger.warning(f"Fetch build {build_id} for {plugin_id} v{version} " + f"carries no known REVISION_SLUG ({slug!r}); skipping") + return {'recorded': False, 'reason': 'missing fetch metadata'} + + entry = fetches[slug] or {} + recorded_build_id = entry.get('fetch_build_id') + if recorded_build_id and recorded_build_id != build_id: + logger.info(f"Fetch build {build_id} superseded by " + f"{recorded_build_id}; skipping") + return {'recorded': False, 'reason': 'superseded build'} + if (entry.get('status') != FETCH_STATUS_FETCHING + or item.get('import_status') != IMPORT_STATUS_FETCHING): + logger.info(f"Fetch build {build_id} already recorded; " + "skipping duplicate delivery") + return {'recorded': False, 'reason': 'already recorded'} + + settled = (FETCH_STATUS_SUCCEEDED if build_status == 'SUCCEEDED' + else FETCH_STATUS_FAILED) + # Per-slug conditional write: a duplicate delivery of the same + # result loses the condition and changes nothing (idempotency). + try: + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET fetches.#s.#st = :settled, updated_at = :t', + ConditionExpression=('fetches.#s.#st = :pending AND ' + 'import_status = :fetching'), + ExpressionAttributeNames={'#s': slug, '#st': 'status'}, + ExpressionAttributeValues={':settled': settled, + ':pending': FETCH_STATUS_FETCHING, + ':fetching': IMPORT_STATUS_FETCHING, + ':t': now_ms()}, + ) + except ClientError as e: + if (e.response.get('Error', {}).get('Code') + == 'ConditionalCheckFailedException'): + return {'recorded': False, 'reason': 'already recorded'} + raise + + # Settlement check over the just-written state (consistent read so + # concurrent slug deliveries never all see an unsettled map). + refreshed = plugin_table().get_item( + Key={'plugin_id': plugin_id, 'version': version}, + ConsistentRead=True).get('Item') or {} + fetches = refreshed.get('fetches') or {} + statuses = {s: (e or {}).get('status') for s, e in fetches.items()} + if any(status == FETCH_STATUS_FETCHING for status in statuses.values()): + logger.info(f"Recorded fetch {build_id} ({slug}: {settled}) for " + f"{plugin_id} v{version}; other fetches still running") + return {'recorded': True, 'import_status': IMPORT_STATUS_FETCHING, + 'revision_slug': slug, 'fetch_status': settled} + + failed_slugs = sorted(s for s, status in statuses.items() + if status != FETCH_STATUS_SUCCEEDED) + architectures = [str(a) for a + in refreshed.get('requested_architectures') or []] + pending_selection = [str(n) for n + in refreshed.get('pending_selected_plugins') or []] + record_provenance = refreshed.get('provenance') or {} + + if failed_slugs: + failed_revisions = [str((fetches[s] or {}).get('revision') or s) + for s in failed_slugs] + updates = { + 'import_status': IMPORT_STATUS_FAILED, + 'import_finding': multi_fetch_failure_finding(failed_revisions), + 'import_error_code': 'REPO_FETCH_FAILED', + } + buildable = False + else: + files = list_source_tree(refreshed.get('source_s3_prefix') or '') + scan, updates = evaluate_fetched_tree( + files, refreshed.get('name') or plugin_id, + pending_selection, architectures, + classification_or_module=(record_provenance.get('moduleName') + or record_provenance.get( + 'classification'))) + buildable = scan['buildable'] + + if not _advance_import_record(plugin_id, version, updates): + # A concurrent delivery finalized the record between the slug + # write and this transition; the slug outcome itself is recorded. + return {'recorded': True, 'revision_slug': slug, + 'import_status': (get_version_item(plugin_id, version) + or {}).get('import_status')} + + if updates['import_status'] == IMPORT_STATUS_IMPORTED: + # The import settled with builds queued: start them now (3.1). + _start_queued_builds(plugin_id, version) + + log_audit_event( + user_id=(record_provenance.get('importedBy') + or refreshed.get('created_by') or 'system'), + action='complete_plugin_import', + resource_type='plugin_record', + resource_id=plugin_id, + result='success' if buildable else 'failure', + details={'usecase_id': refreshed.get('usecase_id'), + 'version': version, + 'fetch_statuses': statuses, + 'import_status': updates['import_status'], + **({'reason': 'REPO_FETCH_FAILED'} + if failed_slugs else {})} + ) + logger.info(f"Recorded fetch {build_id} for {plugin_id} v{version}: " + f"{updates['import_status']}") + return {'recorded': True, 'import_status': updates['import_status']} + + +def _handle_adjustment_fetch_result(item: Dict, build_id: str, + build_status: Optional[str], + env: Dict[str, str]) -> Dict: + """ + One fetch result of a post-import revision adjustment + (adjust_revision, imported-plugin-revision-adjustment-fix): the + REVISION_SLUG env override names the `fetches` entry whose + pending_archs marker routed the delivery here. The record has + already settled — import_status is never written on this path. + + Idempotency mirrors _handle_multi_fetch_result: one per-slug + conditional write guarded on the entry's fetch_build_id matching + this build AND its status still 'fetching', so superseded builds + and duplicate deliveries change nothing. + + - SUCCEEDED: the entry settles 'succeeded' with pending_archs + cleared, every pending arch maps through arch_revisions[arch] = + slug (the map is created when the record is still flat), and the + queued builds start — their source now resolves through the + adjusted tree via plugin_builds.arch_source_prefix (2.3). + - FAILED / FAULT / STOPPED / TIMED_OUT: the entry settles 'failed' + with pending_archs cleared and each pending arch's artifact entry + records the fetch-failure logTail — arch_revisions and every + other architecture's entry are untouched, so the prior mapping + and other platforms' builds stay intact (2.4, 3.5). + + Audit-logged as the record's created_by (no authenticated user on + the EventBridge path). Never raises beyond what handle_build_result + already tolerates (only unexpected DynamoDB errors propagate, + exactly like _handle_multi_fetch_result). + """ + plugin_id = item['plugin_id'] + version = int(item['version']) + slug = env.get('REVISION_SLUG') or '' + entry = (item.get('fetches') or {}).get(slug) or {} + + recorded_build_id = entry.get('fetch_build_id') + if recorded_build_id and recorded_build_id != build_id: + logger.info(f"Adjustment fetch build {build_id} superseded by " + f"{recorded_build_id}; skipping") + return {'recorded': False, 'reason': 'superseded build'} + if entry.get('status') != FETCH_STATUS_FETCHING: + logger.info(f"Adjustment fetch build {build_id} already recorded; " + "skipping duplicate delivery") + return {'recorded': False, 'reason': 'already recorded'} + + pending = [str(a) for a in entry.get('pending_archs') or []] + revision = str(entry.get('revision') or slug) + settled = (FETCH_STATUS_SUCCEEDED if build_status == 'SUCCEEDED' + else FETCH_STATUS_FAILED) + + # One conditional write settles the slot AND applies the per-arch + # outcome (arch_revisions mapping on success, fetch-failure artifact + # entries on failure) atomically: a duplicate delivery loses the + # condition and changes nothing. + names: Dict[str, str] = {'#s': slug, '#st': 'status'} + values: Dict = {':settled': settled, ':bid': build_id, + ':pending': FETCH_STATUS_FETCHING, ':t': now_ms()} + sets = ['fetches.#s.#st = :settled', 'updated_at = :t'] + + if settled == FETCH_STATUS_SUCCEEDED: + # Flip the pending archs' mappings to the adjusted tree (2.3). + if item.get('arch_revisions') is not None: + values[':slug'] = slug + for index, arch in enumerate(pending): + names[f'#a{index}'] = arch + sets.append(f'arch_revisions.#a{index} = :slug') + else: + values[':ar'] = {arch: slug for arch in pending} + sets.append('arch_revisions = :ar') + else: + # The fetch failure surfaces on the pending archs' entries ONLY + # — arch_revisions (the prior mapping) and every other + # architecture's entry are untouched (2.4, 3.5). + values[':fail'] = { + 'buildStatus': BUILD_FAILED, + 'logTail': adjustment_fetch_failure_log_tail(revision), + } + for index, arch in enumerate(pending): + names[f'#a{index}'] = arch + sets.append(f'artifacts.#a{index} = :fail') + + try: + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression=('SET ' + ', '.join(sets) + + ' REMOVE fetches.#s.pending_archs'), + ConditionExpression=('fetches.#s.fetch_build_id = :bid AND ' + 'fetches.#s.#st = :pending'), + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, + ) + except ClientError as e: + if (e.response.get('Error', {}).get('Code') + == 'ConditionalCheckFailedException'): + return {'recorded': False, 'reason': 'already recorded'} + raise + + if settled == FETCH_STATUS_SUCCEEDED: + # The pending archs are queued with their mappings in place: + # start their builds now (only queued entries are touched). + # Auto-start failure never fails the fetch-result handler. + _start_queued_builds(plugin_id, version) + + log_audit_event( + user_id=item.get('created_by') or 'system', + action='adjust_plugin_revision', + resource_type='plugin_record', + resource_id=plugin_id, + result=('success' if settled == FETCH_STATUS_SUCCEEDED + else 'failure'), + details={'usecase_id': item.get('usecase_id'), 'version': version, + 'revision': revision, 'revision_slug': slug, + 'architectures': pending, + 'fetch_build_id': build_id, + 'fetch_status': build_status, + **({'reason': 'REPO_FETCH_FAILED'} + if settled != FETCH_STATUS_SUCCEEDED else {})} + ) + logger.info(f"Recorded adjustment fetch {build_id} ({slug}: {settled}) " + f"for {plugin_id} v{version}; archs: {', '.join(pending)}") + return {'recorded': True, 'revision_slug': slug, + 'fetch_status': settled, + 'import_status': item.get('import_status')} + + +def select_plugins(event: Dict, user: Dict, plugin_id: str, + version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/select-plugins + Body: {selected_plugins: [name, ...]} + + Complete a plugin-set import awaiting selection: validates the + selection is non-empty and a subset of the enumerated plugins_found, + records selected_plugins on the Plugin_Record (and in provenance), + advances the import status to imported, and submits builds for the + previously requested Target_Architectures (queued and immediately + started via plugin_builds.start_queued_builds). plugin_builds.py + passes the recorded selection to CodeBuild as the PLUGIN_TARGETS env + override so the build image builds only the selected plugins. + """ + body, err = parse_body(event) + if err: + return err + + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item, manage=True, + permission=Permission.NODE_DESIGNER_IMPORT) + if err: + return err + + if item.get('import_status') != IMPORT_STATUS_PENDING_SELECTION: + return error_response( + 409, 'SELECTION_NOT_PENDING', + 'This Plugin_Record version is not awaiting a plugin ' + 'selection', + {'import_status': item.get('import_status')}) + + plugins_found = item.get('plugins_found') or [] + found_names = [entry.get('name') for entry in plugins_found] + selection_error = validate_plugin_selection( + body.get('selected_plugins'), found_names) + if selection_error: + return error_response(400, 'INVALID_PLUGIN_SELECTION', + selection_error, + {'plugins_found': found_names}) + + # De-duplicated, order-normalized selection; original enumeration + # order is preserved for display. + selected_set = set(body['selected_plugins']) + selected = [name for name in found_names if name in selected_set] + + architectures = [str(a) for a in item.get('requested_architectures') or []] + artifacts = submit_builds(architectures) + + # A single-plugin selection renames the record ("{name}-{plugin}") + # while the name is still the URL-derived default, so the library + # and detail views show which plugin was imported. + new_name = selection_rename( + item.get('name'), (item.get('provenance') or {}).get('repoUrl'), + selected) + + update_expression = ('SET selected_plugins = :sp, ' + 'provenance.selectedPlugins = :sp, ' + 'artifacts = :a, import_status = :st, ' + 'updated_at = :t') + expression_values = { + ':sp': selected, + ':a': artifacts, + ':st': IMPORT_STATUS_IMPORTED, + ':t': now_ms(), + ':pending': IMPORT_STATUS_PENDING_SELECTION, + } + update_kwargs = dict( + Key={'plugin_id': plugin_id, 'version': version}, + ConditionExpression='import_status = :pending', + ) + if new_name: + update_expression += ', #n = :n' # 'name' is a reserved word + expression_values[':n'] = new_name + update_kwargs['ExpressionAttributeNames'] = {'#n': 'name'} + plugin_table().update_item( + UpdateExpression=update_expression, + ExpressionAttributeValues=expression_values, + **update_kwargs, + ) + + # The selection settled the import with builds queued: start them + # now (3.1); auto-start failure never fails the selection. + _start_queued_builds(plugin_id, version) + + log_audit_event( + user_id=user['user_id'], + action='select_import_plugins', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': item['usecase_id'], 'version': version, + 'selected_plugins': selected, + 'architectures': architectures, + **({'renamed_to': new_name} if new_name else {})} + ) + + updated = get_version_item(plugin_id, version) + return create_response(200, { + 'plugin': import_detail(updated), + 'import': { + 'status': IMPORT_STATUS_IMPORTED, + 'selected_plugins': selected, + 'submitted_architectures': architectures, + }, + }) + + +def adjust_revision(event: Dict, user: Dict, plugin_id: str, + version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/adjust-revision + Body: {architecture, revision} + + Apply a per-platform source-revision override to a settled imported + plugin (imported-plugin-revision-adjustment-fix, 2.1-2.5): the + dead-end fix for the incompatible-platform warning's + suggestedRevision. Resolves the requested revision to a `fetches` + slot via adjustment_fetch_slot: + + - reuse: the revision's tree already synced (status 'succeeded') + — map arch_revisions[arch] to it, re-queue the arch, and start + the build immediately (2.2); + - join: a concurrent adjustment is already fetching the revision + — the arch joins its pending_archs and waits for that result; + - fetch: StartBuild the fetch step for the revision's own + rev-{slug}/ prefix (REVISION_SLUG attribution) and record the + 'fetching' entry with pending_archs = [arch]. + arch_revisions[arch] is NOT changed yet: it flips only when + the fetch succeeds (_handle_adjustment_fetch_result), so a + fetch failure leaves the prior mapping intact (2.4). + + Every path re-queues ONLY the adjusted architecture's artifact + entry and REMOVEs components_triggered (a new build round, 3.6). + The record's source_s3_prefix, default_fetch_slug, plugins_found, + selected_plugins, and every other architecture's entry are never + written (3.4, 3.5). Requires node-designer:manage (2.5); rejected + with 409 for records that are not repository imports or whose + import has not settled to 'imported'. + """ + body, err = parse_body(event) + if err: + return err + + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + + architectures = [str(a) for a in item.get('requested_architectures') or []] + architecture = body.get('architecture') + if not isinstance(architecture, str) or architecture not in architectures: + return error_response( + 400, 'INVALID_ARCHITECTURE', + "architecture must be one of this version's requested " + 'Target_Architectures', + {'requested_architectures': architectures}) + + revision = body.get('revision') + if not isinstance(revision, str) or not revision.strip(): + return error_response(400, 'INVALID_REVISION', + 'revision must be a non-empty string') + revision = revision.strip() + + err = authorize_record_access(user, event, item, manage=True, + permission=Permission.NODE_DESIGNER_MANAGE) + if err: + return err + + provenance = item.get('provenance') or {} + if (item.get('kind') != 'imported' or not provenance.get('repoUrl') + or item.get('import_status') != IMPORT_STATUS_IMPORTED): + return error_response( + 409, 'REVISION_ADJUSTMENT_NOT_AVAILABLE', + 'Per-platform revision adjustment is only available for ' + 'repository imports whose import has settled', + {'kind': item.get('kind'), + 'import_status': item.get('import_status')}) + + fetches = item.get('fetches') or {} + slug, action = adjustment_fetch_slot(item, revision) + + # One update writes the whole adjustment: the adjusted arch + # re-queued and components_triggered REMOVEd (a new build round, + # exactly like start_builds) plus the action-specific mutation. + # Nothing else on the record is written (3.4, 3.5). + names: Dict[str, str] = {'#a': architecture} + values: Dict = {':q': {'buildStatus': BUILD_QUEUED}, ':t': now_ms()} + sets = ['artifacts.#a = :q', 'updated_at = :t'] + fetch_build_id: Optional[str] = None + + if action == ADJUST_REUSE: + # The revision's tree is already synced: map the arch to it now + # (creating arch_revisions when the record is still flat). + if item.get('arch_revisions') is not None: + values[':slug'] = slug + sets.append('arch_revisions.#a = :slug') + else: + values[':ar'] = {architecture: slug} + sets.append('arch_revisions = :ar') + elif action == ADJUST_JOIN: + # A concurrent adjustment is fetching this revision: the arch + # joins its pending_archs and settles with that fetch result. + pending = [str(a) for a + in (fetches.get(slug) or {}).get('pending_archs') or []] + if architecture not in pending: + pending.append(architecture) + names['#s'] = slug + values[':pa'] = pending + sets.append('fetches.#s.pending_archs = :pa') + else: # ADJUST_FETCH: fresh slug, or a failed entry reset in place + base_prefix = source_s3_prefix(item['usecase_id'], plugin_id, + version) + entry = { + 'revision': revision, + 'source_prefix': f'{base_prefix}rev-{slug}/', + 'status': FETCH_STATUS_FETCHING, + 'pending_archs': [architecture], + } + try: + fetch_build_id = start_fetch( + provenance['repoUrl'], + (None if revision == DEFAULT_REVISION else revision), + entry['source_prefix'].rstrip('/'), + usecase_id=item['usecase_id'], plugin_id=plugin_id, + version=version, revision_slug_id=slug) + except Exception as exc: + logger.error(f'Adjustment fetch StartBuild failed: {exc}', + exc_info=True) + log_audit_event( + user_id=user['user_id'], + action='adjust_plugin_revision', + resource_type='plugin_record', + resource_id=plugin_id, + result='failure', + details={'usecase_id': item['usecase_id'], + 'version': version, + 'architecture': architecture, + 'revision': revision, + 'reason': 'REPO_FETCH_FAILED'} + ) + return error_response( + 502, 'REPO_FETCH_FAILED', + 'The adjusted revision fetch could not be started', + {'revision': revision}) + entry['fetch_build_id'] = fetch_build_id + if fetches: + names['#s'] = slug + values[':fe'] = entry + sets.append('fetches.#s = :fe') + else: + values[':fm'] = {slug: entry} + sets.append('fetches = :fm') + + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression=('SET ' + ', '.join(sets) + + ' REMOVE components_triggered'), + ExpressionAttributeNames=names, + ExpressionAttributeValues=values, + ) + + if action == ADJUST_REUSE: + # The adjusted arch is queued with its mapping in place: start + # its build now; only its entry is queued, so the start touches + # nothing else. Auto-start failure never fails the adjustment. + _start_queued_builds(plugin_id, version) + + log_audit_event( + user_id=user['user_id'], + action='adjust_plugin_revision', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': item['usecase_id'], 'version': version, + 'architecture': architecture, 'revision': revision, + 'revision_slug': slug, 'mode': action, + **({'fetch_build_id': fetch_build_id} + if fetch_build_id else {})} + ) + + updated = get_version_item(plugin_id, version, consistent_read=True) + # plugin_builds lazily imports this module for fetch results, so a + # module-level import would be circular (mirrors _start_queued_builds). + import plugin_builds + return create_response(202, { + 'plugin': import_detail(updated), + 'builds': plugin_builds.builds_view(updated), + }) + + +def list_module_plugins(event: Dict, user: Dict, module: str) -> Dict: + """ + GET /plugin-modules?module= + The individual plugins of one official GStreamer module, so the + import view offers a plugin selection before the import. + + Returns {module, plugins: [{name, description?}], fetchedAt, + cached}. The list is sourced from the GitLab API for the gstreamer + monorepo (each subdirectory of subprojects//{gst,ext,sys}/ + is one plugin), per-plugin descriptions joined from the module's + docs/gst_plugins_cache.json metadata (an enhancement — a + description fetch/parse failure never fails the listing), + and cached per module in the ModuleIndexCache table with the same + 24-hour TTL pattern as the module index. Fetch failure — or a + module without any enumerable plugin — returns the existing + MODULE_LISTING_UNAVAILABLE code so the UI falls back to importing + the full set (selection is an enhancement, never a blocker). + """ + module = (module or '').strip() + if not _MODULE_NAME_RE.match(module): + return error_response(400, 'INVALID_MODULE', + 'module must be a module name from the ' + 'GStreamer module listing') + + now = now_ms() + cached = read_cached_module_plugins(module) + if cached and module_index_is_fresh(cached, now): + return create_response(200, { + 'module': module, + 'plugins': cached.get('plugins', []), + 'fetchedAt': cached.get('fetchedAt'), + 'cached': True, + }) + + try: + trees = fetch_module_plugin_trees(module) + plugins = module_plugins_from_trees(trees) + if not plugins: + raise ModuleListingParseError( + f'module {module} lists no individual plugins') + except Exception as exc: + logger.warning(f'Module plugin list unavailable for {module}: {exc}') + return error_response( + 502, 'MODULE_LISTING_UNAVAILABLE', + f"The plugin list for module '{module}' could not be " + 'retrieved; the import proceeds with the full plugin set', + {'module': module, 'source': MODULE_PLUGINS_TREE_URL}) + + # Join per-plugin descriptions from the module's metadata cache — + # an enhancement only: on any failure the entries simply lack + # descriptions. The joined entries are what gets cached. + plugins = join_plugin_descriptions( + plugins, fetch_module_plugin_descriptions(module)) + + write_module_plugins_cache(module, plugins, now) + return create_response(200, { + 'module': module, + 'plugins': plugins, + 'fetchedAt': now, + 'cached': False, + }) + + +def list_plugin_modules(event: Dict, user: Dict) -> Dict: + """ + GET /plugin-modules + Module_Listing fetch/parse/cache (Requirement 6). + + Returns {modules: [{name, description, repoUrl, classification}], + fetchedAt, cached}. A cached index younger than 24 hours is reused + (6.4); otherwise the listing is fetched and parsed server-side and + the cache refreshed (6.1). Fetch or parse failure returns the + distinct MODULE_LISTING_UNAVAILABLE code so the UI offers manual + repository URL entry as the alternative import path (6.3). The + entries' repoUrl is the module's published repository location, + which the UI feeds into POST /plugins/import on selection (6.2). + + Read-only global data: available to every authenticated portal user + (node-designer:read is granted to all roles, 13.3). + """ + now = now_ms() + + cached = read_cached_module_index() + if cached and module_index_is_fresh(cached, now): + return create_response(200, { + 'modules': cached.get('modules', []), + 'fetchedAt': cached.get('fetchedAt'), + 'cached': True, + }) + + try: + page = fetch_module_listing() + modules = parse_module_listing(page) + except Exception as exc: + logger.warning(f'Module_Listing unavailable: {exc}') + return error_response( + 502, 'MODULE_LISTING_UNAVAILABLE', + 'The official GStreamer module listing could not be retrieved ' + 'or parsed; enter a repository URL manually to import a plugin', + {'source': MODULE_LISTING_URL}) + + write_module_index_cache(modules, now) + return create_response(200, { + 'modules': modules, + 'fetchedAt': now, + 'cached': False, + }) + + +# ------------------------------------------------------------------ routing + +def handler(event: Dict, context) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + + if resource == '/plugins/import' and http_method == 'POST': + return import_repository(event, user) + if resource == '/plugin-modules' and http_method == 'GET': + # Optional ?module=: the individual plugins of one + # module (no new API Gateway route needed). + params = event.get('queryStringParameters') or {} + module = params.get('module') + if module is not None: + return list_module_plugins(event, user, module) + return list_plugin_modules(event, user) + if (resource == '/plugins/{id}/versions/{v}/select-plugins' + and http_method == 'POST'): + path_params = event.get('pathParameters') or {} + plugin_id = path_params.get('id') + try: + version = int(path_params.get('v')) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', + 'version must be an integer') + if plugin_id: + return select_plugins(event, user, plugin_id, version) + if (resource == '/plugins/{id}/versions/{v}/adjust-revision' + and http_method == 'POST'): + path_params = event.get('pathParameters') or {} + plugin_id = path_params.get('id') + try: + version = int(path_params.get('v')) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', + 'version must be an integer') + if plugin_id: + return adjust_revision(event, user, plugin_id, version) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/plugin_records.py b/edge-cv-portal/backend/functions/plugin_records.py new file mode 100644 index 00000000..7d315bf6 --- /dev/null +++ b/edge-cv-portal/backend/functions/plugin_records.py @@ -0,0 +1,1373 @@ +""" +Plugin_Record API Lambda function (Custom Node Designer) + +CRUD, versioning, lifecycle transitions, and security review of +Plugin_Records over the PluginRecords DynamoDB table +(Requirements 9.1, 9.3, 9.4, 9.5, 9.9, 9.10, 9.12, 9.13, +10.1, 10.2, 10.3, 10.5, 15.6). + +Routes (API Gateway REST): + GET /plugins List Plugin_Records (optionally + ?usecase_id=... and ?review=pending + for the PortalAdmin review queue) + POST /plugins Create a Plugin_Record (version 1, + lifecycle dev, review pending) (9.1, 10.1) + GET /plugins/{id} Latest version + version history + PUT /plugins/{id} Update metadata, or create a new + version (dev + pending) (9.13, 10.5) + DELETE /plugins/{id} Delete every version of the record + (bad/duplicate imports) plus + best-effort cleanup of its S3 + source snapshot and promoted + Plugin_Library artifacts; 409 + RECORD_IN_USE when any version + was promoted beyond dev + GET /plugins/{id}/versions/{v} Version detail: full provenance + incl. classification, per-arch + checksums/signatures (10.2, 15.6) + GET /plugins/{id}/versions/{v}/source Source inspection: file listing + or single-file content (10.2) + GET /plugins/{id}/versions/{v}/gst-properties Stored Introspection_Report with + derived per-element + Parameter_Suggestions, or a + machine-readable unavailability + reason (gst-parameter- + prepopulation 1.5, 1.6, 7.4, 8.3) + PUT /plugins/{id}/versions/{v}/source Persist submitted scaffold + source (original or edited) + ahead of a build (1.5, 1.6) + POST /plugins/{id}/versions/{v}/promote dev->test (build guard, 9.4/9.5), + test->prod (review guard, 9.9/9.10) + POST /plugins/{id}/versions/{v}/demote prod->test / test->dev, always + succeeds; gates apply only to + subsequent packaging/deployment + requests (9.12) + POST /plugins/{id}/versions/{v}/review Approve/reject security review + (PortalAdmin only), recorded in + the existing AuditLog table (10.3) + +Storage layout (design "Data Models"): + PluginRecords table (PLUGIN_RECORDS_TABLE) + PK plugin_id (S), SK version (N), GSI usecase-plugins-index + Attributes: usecase_id, name, kind (scaffold/generated/imported), + deepstream flag, provenance {repoUrl, revision, prompt, + scaffoldDeclaration, importedBy/createdBy, timestamps, + classification, prebuilt}, lifecycle_state (dev/test/prod), + review {decision, reviewer, reviewedAt}, artifacts {arch: + {s3Key, checksum, signature, buildStatus, logTail}}, component + pointer, source_s3_prefix. + Plugin sources in portal S3 under + plugin-sources/{usecase_id}/{plugin_id}/{version}/ + +Error envelope: {"error": {"code", "message", "details"}} with 400 +parse/validation, 403 RBAC denial, 404 scoped to avoid cross-tenant +existence leaks, and 409 lifecycle-guard rejections identifying the +missing build (9.5) or missing security review approval (9.10). + +Access control (design Requirement 13 table) via the node-designer +RBAC actions registered in shared_utils.Permission and mapped in +rbac_middleware.CommonPermissions: node-designer:read for every role +in the Use_Case (13.3); node-designer:create/promote-demote/manage for +UseCaseAdmin (own Use_Case) and PortalAdmin (13.1); +node-designer:security-review for PortalAdmin only (13.2). Denials +return the standard authorization error envelope (13.4). +""" +import json +import os +import logging +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, rbac_manager, Permission +) +# Server-side Plugin_Scaffold rendering/validation (workflow_core layer, +# design "Plugin_Scaffold and Node_Generator": scaffold generation is pure +# templating in workflow_core.scaffold, stored under plugin-sources/...). +from workflow_core.scaffold import ( + ScaffoldError, + render_scaffold, + scaffold_defects, +) +# Introspection_Report parsing and Parameter_Suggestion derivation +# (gst-parameter-prepopulation design component 4): pure module shipped +# alongside this handler in the functions asset. +from gst_properties import ( + ReportError, + STATUS_CAPTURED, + parse_report, + ports_for_element, + suggestions_for_element, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables +PLUGIN_RECORDS_TABLE = os.environ.get('PLUGIN_RECORDS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +PLUGIN_SOURCES_S3_PREFIX = os.environ.get('PLUGIN_SOURCES_S3_PREFIX', 'plugin-sources') + +USECASE_PLUGINS_INDEX = 'usecase-plugins-index' + +# ---------------------------------------------------------------- constants + +# Lifecycle_State machine (design "The Plugin_Record pipeline") +STATE_DEV = 'dev' +STATE_TEST = 'test' +STATE_PROD = 'prod' +LIFECYCLE_STATES = (STATE_DEV, STATE_TEST, STATE_PROD) + +# Legal transitions: promotion moves forward one step, demotion back one. +PROMOTIONS = {STATE_DEV: STATE_TEST, STATE_TEST: STATE_PROD} +DEMOTIONS = {STATE_PROD: STATE_TEST, STATE_TEST: STATE_DEV} + +# Security review decisions (Requirement 10) +REVIEW_PENDING = 'pending' +REVIEW_APPROVED = 'approved' +REVIEW_REJECTED = 'rejected' +REVIEW_DECISIONS = (REVIEW_APPROVED, REVIEW_REJECTED) + +# Plugin_Record origin kinds (design data model) +RECORD_KINDS = ('scaffold', 'generated', 'imported') + +BUILD_SUCCEEDED = 'succeeded' + +# Maximum size of a source file returned inline for inspection (10.2) +MAX_SOURCE_FILE_BYTES = 512 * 1024 + +# Property_Introspection runs on x86_64 only (gst-parameter-prepopulation +# design: GObject property declarations are architecture-independent and +# the x86_64 build is the designer's gating artifact everywhere else). +INTROSPECTION_ARCH = 'x86_64' + +# Machine-readable unavailability reasons of the gst-properties route +# (gst-parameter-prepopulation Requirements 1.6, 7.4, 8.3). +GST_REASON_NO_BUILD = 'no_x86_64_build' +GST_REASON_NOT_CAPTURED = 'not_captured' +GST_REASON_FAILED = 'introspection_failed' + + +# ------------------------------------------------------------------ helpers + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def source_s3_prefix(usecase_id: str, plugin_id: str, version: int) -> str: + """S3 prefix holding the plugin source tree of one version""" + return f"{PLUGIN_SOURCES_S3_PREFIX}/{usecase_id}/{plugin_id}/{version}/" + + +def not_found_response() -> Dict: + """Uniform 404 that never confirms whether a Plugin_Record exists""" + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + + +def has_node_designer_permission(user: Dict, usecase_id: str, + permission: Permission) -> bool: + """Check a registered node-designer RBAC action for the acting user""" + return rbac_manager.has_permission(user['user_id'], usecase_id, + permission, user_info=user) + + +def is_portal_admin(user: Dict) -> bool: + """node-designer:security-review holders only: PortalAdmin, resolved + globally and independent of Use_Case (13.2)""" + return has_node_designer_permission( + user, 'global', Permission.NODE_DESIGNER_SECURITY_REVIEW) + + +def can_manage(user: Dict, usecase_id: str, + permission: Permission = Permission.NODE_DESIGNER_MANAGE) -> bool: + """Registered manage-family action: UseCaseAdmin within the Use_Case, + or PortalAdmin (13.1)""" + return has_node_designer_permission(user, usecase_id, permission) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, + required: Permission) -> Dict: + """Standard authorization error envelope with a denied-access audit + entry (13.4), matching the workflow feature area's FORBIDDEN shape""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='plugin_record', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [required.value], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [required.value], + 'usecase_id': usecase_id + }) + + +# ------------------------------------------------------------- persistence + +def plugin_table(): + return dynamodb.Table(PLUGIN_RECORDS_TABLE) + + +def get_version_item(plugin_id: str, version: int, + consistent_read: bool = False) -> Optional[Dict]: + """Fetch one Plugin_Record version item, or None. + + consistent_read exists for same-invocation read-your-own-write + callers (auto-start after an adjustment/fetch-settle write), which + must see the mapping they just wrote — mirroring the + _handle_multi_fetch_result ConsistentRead=True settlement check in + plugin_importer.py. The default (False) issues exactly the same + eventually-consistent get_item as before, with no ConsistentRead + key at all. + """ + kwargs = {'Key': {'plugin_id': plugin_id, 'version': version}} + if consistent_read: + kwargs['ConsistentRead'] = True + response = plugin_table().get_item(**kwargs) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def query_versions(plugin_id: str) -> List[Dict]: + """All version items of a Plugin_Record, newest first""" + from boto3.dynamodb.conditions import Key + items: List[Dict] = [] + kwargs = { + 'KeyConditionExpression': Key('plugin_id').eq(plugin_id), + 'ScanIndexForward': False, + } + while True: + response = plugin_table().query(**kwargs) + items.extend(response.get('Items', [])) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + return [decimal_to_native(i) for i in items] + + +def get_latest_version_item(plugin_id: str) -> Optional[Dict]: + """Fetch the newest version item of a Plugin_Record, or None""" + from boto3.dynamodb.conditions import Key + response = plugin_table().query( + KeyConditionExpression=Key('plugin_id').eq(plugin_id), + ScanIndexForward=False, + Limit=1, + ) + items = response.get('Items', []) + return decimal_to_native(items[0]) if items else None + + +def query_plugins_by_usecase(usecase_id: str) -> List[Dict]: + """All Plugin_Record version items of a Use_Case via the GSI""" + from boto3.dynamodb.conditions import Key + items: List[Dict] = [] + kwargs = { + 'IndexName': USECASE_PLUGINS_INDEX, + 'KeyConditionExpression': Key('usecase_id').eq(usecase_id), + } + while True: + response = plugin_table().query(**kwargs) + items.extend(response.get('Items', [])) + last = response.get('LastEvaluatedKey') + if not last: + break + kwargs['ExclusiveStartKey'] = last + return [decimal_to_native(i) for i in items] + + +# -------------------------------------------------- record shape / guards +# +# These functions are pure over the item dicts, so the lifecycle state +# machine is testable (and property-testable) without AWS. + +def new_version_item(plugin_id: str, version: int, usecase_id: str, name: str, + kind: str, user_id: str, timestamp: int, + description: str = '', deepstream: bool = False, + provenance: Optional[Dict] = None) -> Dict: + """ + Build a Plugin_Record version item. + + Every new record and every new version starts in Lifecycle_State dev + (9.1, 9.13) with the security review decision pending (10.1, 10.5), + independently of any prior version's state or approvals. + """ + return { + 'plugin_id': plugin_id, + 'version': version, + 'usecase_id': usecase_id, + 'name': name, + 'description': description, + 'kind': kind, + 'deepstream': bool(deepstream), + 'provenance': provenance or {}, + 'lifecycle_state': STATE_DEV, + 'review': {'decision': REVIEW_PENDING, 'reviewer': None, 'reviewedAt': None}, + 'artifacts': {}, + 'component': {}, + 'source_s3_prefix': source_s3_prefix(usecase_id, plugin_id, version), + 'created_by': user_id, + 'created_at': timestamp, + 'updated_at': timestamp, + } + + +def successful_build_archs(item: Dict) -> List[str]: + """Architectures with a successfully built Plugin_Artifact""" + artifacts = item.get('artifacts') or {} + return sorted( + arch for arch, entry in artifacts.items() + if isinstance(entry, dict) and entry.get('buildStatus') == BUILD_SUCCEEDED + ) + + +def evaluate_promotion(item: Dict) -> Tuple[Optional[str], Optional[Dict]]: + """ + Decide a promotion request against the lifecycle state machine. + + Returns (next_state, None) when the promotion is permitted, or + (None, {code, message, details}) describing the rejection: + - dev -> test requires at least one successfully built + Plugin_Artifact; rejection identifies the missing build (9.4, 9.5) + - test -> prod requires an approved security review; rejection + identifies the missing approval (9.9, 9.10) + - anything else is an invalid transition + """ + state = item.get('lifecycle_state') + if state not in PROMOTIONS: + return None, { + 'code': 'INVALID_LIFECYCLE_TRANSITION', + 'message': f"Cannot promote from lifecycle state '{state}'", + 'details': {'lifecycle_state': state}, + } + + if state == STATE_DEV: + built = successful_build_archs(item) + if not built: + return None, { + 'code': 'PLUGIN_BUILD_REQUIRED', + 'message': 'Promotion from dev to test requires at least one ' + 'successfully built Plugin_Artifact; none exists ' + 'for this Plugin_Record version', + 'details': { + 'plugin_id': item.get('plugin_id'), + 'version': item.get('version'), + 'missing': 'successfully built Plugin_Artifact', + 'successful_architectures': [], + }, + } + return STATE_TEST, None + + # state == STATE_TEST + review = item.get('review') or {} + if review.get('decision') != REVIEW_APPROVED: + return None, { + 'code': 'SECURITY_REVIEW_REQUIRED', + 'message': 'Promotion from test to prod requires an approved ' + 'security review; the review decision is ' + f"'{review.get('decision', REVIEW_PENDING)}'", + 'details': { + 'plugin_id': item.get('plugin_id'), + 'version': item.get('version'), + 'missing': 'approved security review', + 'review_decision': review.get('decision', REVIEW_PENDING), + }, + } + return STATE_PROD, None + + +def evaluate_demotion(item: Dict) -> Tuple[Optional[str], Optional[Dict]]: + """ + Decide a demotion request. Demotion (prod->test, test->dev) always + succeeds and only changes the state (9.12): already-deployed + Workflow_Components are untouched; the demoted state's gates apply + only to subsequent packaging/deployment requests. + """ + state = item.get('lifecycle_state') + if state not in DEMOTIONS: + return None, { + 'code': 'INVALID_LIFECYCLE_TRANSITION', + 'message': f"Cannot demote from lifecycle state '{state}'", + 'details': {'lifecycle_state': state}, + } + return DEMOTIONS[state], None + + +def version_detail(item: Dict) -> Dict: + """ + Full Plugin_Record version view: provenance (repo URL/revision, + scaffold origin or generation prompt, user, timestamps, + classification, 10.2/15.6), per-arch artifact checksums and + signatures, lifecycle state, review decision, component pointer. + + Imported records also carry their import fields (import_status, + import_finding, plugins_found, selected_plugins) so clients can + poll GET /plugins/{id}/versions/{v} while the asynchronous fetch + runs (import_status 'fetching') and act on the outcome. + """ + detail = { + 'plugin_id': item['plugin_id'], + 'version': item['version'], + 'usecase_id': item['usecase_id'], + 'name': item.get('name'), + 'description': item.get('description', ''), + 'kind': item.get('kind'), + 'deepstream': item.get('deepstream', False), + 'provenance': item.get('provenance', {}), + 'lifecycle_state': item.get('lifecycle_state'), + 'review': item.get('review', {}), + 'artifacts': item.get('artifacts', {}), + 'component': item.get('component', {}), + 'source_s3_prefix': item.get('source_s3_prefix'), + 'created_by': item.get('created_by'), + 'created_at': item.get('created_at'), + 'updated_at': item.get('updated_at'), + } + if item.get('import_status') is not None: + detail['import_status'] = item['import_status'] + if item.get('import_finding'): + detail['import_finding'] = item['import_finding'] + if item.get('plugins_found') is not None: + detail['plugins_found'] = item['plugins_found'] + if item.get('selected_plugins') is not None: + detail['selected_plugins'] = item['selected_plugins'] + if item.get('platform_compatibility') is not None: + # Advisory per-platform requirements check recorded when the + # import fetch settled (plugin_importer.platform_compatibility): + # {arch: {compatible, platformVersion, requiredVersion, reason, + # suggestedRevision}}. Never blocks builds. + detail['platform_compatibility'] = item['platform_compatibility'] + if item.get('arch_revisions') is not None: + # Multi-revision import: {arch: revision-slug} into the fetches + # map, so the UI can show which source revision each + # architecture builds from. + detail['arch_revisions'] = item['arch_revisions'] + if item.get('fetches') is not None: + # Per-revision fetch map of a multi-revision import: + # {slug: {revision, source_prefix, fetch_build_id, status}}. + detail['fetches'] = item['fetches'] + return detail + + +def record_summary(item: Dict) -> Dict: + """List-view summary of one Plugin_Record version. Imported records + additionally carry their recorded plugin selection + (selected_plugins) and the enumeration size (plugins_found_count) + so the library list can show which plugins an import covers.""" + artifacts = item.get('artifacts') or {} + summary = { + 'plugin_id': item['plugin_id'], + 'version': item['version'], + 'usecase_id': item['usecase_id'], + 'name': item.get('name'), + 'kind': item.get('kind'), + 'deepstream': item.get('deepstream', False), + 'lifecycle_state': item.get('lifecycle_state'), + 'review_decision': (item.get('review') or {}).get('decision'), + 'import_status': item.get('import_status'), + 'classification': (item.get('provenance') or {}).get('classification'), + 'build_status': { + arch: (entry or {}).get('buildStatus') + for arch, entry in artifacts.items() + }, + 'updated_at': item.get('updated_at'), + } + if item.get('selected_plugins') is not None: + summary['selected_plugins'] = item['selected_plugins'] + if item.get('plugins_found') is not None: + summary['plugins_found_count'] = len(item['plugins_found']) + return summary + + +# ------------------------------------------------------------ authorization + +def authorize_record_access( + user: Dict, event: Dict, item: Dict, manage: bool = False, + permission: Permission = Permission.NODE_DESIGNER_MANAGE) -> Optional[Dict]: + """ + Authorize an operation on an existing Plugin_Record. + + Returns an error response, or None when authorized. Read access is + granted to every role of the Use_Case (13.3); manage operations + require UseCaseAdmin within the Use_Case or PortalAdmin (13.1). A + user without any resolvable access receives the same 404 as for a + missing record so existence is never leaked across tenants. + """ + usecase_id = item['usecase_id'] + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_READ): + return not_found_response() + if manage and not has_node_designer_permission(user, usecase_id, permission): + return forbidden_response(user, event, usecase_id, permission) + return None + + +# ----------------------------------------------------------------- handlers + +def create_plugin(event: Dict, user: Dict) -> Dict: + """ + POST /plugins + Body: {usecase_id, name, kind, description?, deepstream?, provenance?, + declaration?} + Creates version 1 in Lifecycle_State dev (9.1) with the security + review decision pending (10.1). + + With `declaration` (the create wizard's scaffold path, kind must be + 'scaffold'), the Plugin_Scaffold is rendered server-side via + workflow_core.scaffold and stored under the version's plugin-sources + prefix; the rendered file map is returned as `files` for preview, + download, and editing (1.2, 1.5). An invalid declaration fails with + the offending field identified and creates no Plugin_Record (1.7). + """ + body, err = parse_body(event) + if err: + return err + + usecase_id = body.get('usecase_id') + name = body.get('name') + kind = body.get('kind') + + missing = [f for f in ('usecase_id', 'name', 'kind') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + if kind not in RECORD_KINDS: + return error_response(400, 'INVALID_KIND', + f"kind must be one of: {', '.join(RECORD_KINDS)}") + + provenance = body.get('provenance') or {} + if not isinstance(provenance, dict): + return error_response(400, 'INVALID_PROVENANCE', + 'provenance must be a JSON object') + + declaration = body.get('declaration') + scaffold_files: Optional[Dict[str, str]] = None + if declaration is not None: + if kind != 'scaffold': + return error_response(400, 'INVALID_DECLARATION', + "declaration is only accepted for kind 'scaffold'") + # Scaffold generation failure identifies the failing input and + # creates no Plugin_Record (Requirement 1.7). + try: + scaffold_files = render_scaffold(declaration) + except ScaffoldError as exc: + return error_response(400, 'INVALID_DECLARATION', str(exc), + {'field': exc.field}) + + if not can_manage(user, usecase_id, Permission.NODE_DESIGNER_CREATE): + return forbidden_response(user, event, usecase_id, + Permission.NODE_DESIGNER_CREATE) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + plugin_id = str(uuid.uuid4()) + timestamp = now_ms() + provenance.setdefault('createdBy', user['user_id']) + provenance.setdefault('createdAt', timestamp) + if declaration is not None: + provenance.setdefault('scaffoldDeclaration', + json.dumps(declaration, sort_keys=True)) + + item = new_version_item( + plugin_id=plugin_id, version=1, usecase_id=usecase_id, name=name, + kind=kind, user_id=user['user_id'], timestamp=timestamp, + description=body.get('description', ''), + deepstream=body.get('deepstream', False), + provenance=provenance, + ) + + # The rendered scaffold lands under the standard plugin-sources + # layout the Plugin_Build_Service builds from (design S3 layout). + if scaffold_files is not None: + prefix = item['source_s3_prefix'] + for path, content in sorted(scaffold_files.items()): + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=prefix + path, + Body=content.encode('utf-8'), + ContentType='text/plain; charset=utf-8', + ) + + plugin_table().put_item( + Item=item, + ConditionExpression='attribute_not_exists(plugin_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='create_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': usecase_id, 'name': name, 'kind': kind, 'version': 1} + ) + + payload: Dict[str, Any] = {'plugin': version_detail(item)} + if scaffold_files is not None: + payload['files'] = scaffold_files + return create_response(201, payload) + + +def list_plugins(event: Dict, user: Dict) -> Dict: + """ + GET /plugins[?usecase_id=...][&review=pending] + Lists Plugin_Record versions of accessible Use_Cases. With + review=pending the list is restricted to versions awaiting a + security review decision (the PortalAdmin review queue). + """ + params = event.get('queryStringParameters') or {} + usecase_id = params.get('usecase_id') + review_filter = params.get('review') + + if usecase_id: + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_READ): + return forbidden_response(user, event, usecase_id, + Permission.NODE_DESIGNER_READ) + usecase_ids = [usecase_id] + else: + usecase_ids = rbac_manager.get_accessible_usecases(user['user_id'], user_info=user) + + items: List[Dict] = [] + for uc in usecase_ids: + items.extend(query_plugins_by_usecase(uc)) + + if review_filter: + items = [i for i in items + if (i.get('review') or {}).get('decision') == review_filter] + + items.sort(key=lambda i: i.get('updated_at') or 0, reverse=True) + return create_response(200, { + 'plugins': [record_summary(i) for i in items], + 'count': len(items) + }) + + +def get_plugin(event: Dict, user: Dict, plugin_id: str) -> Dict: + """ + GET /plugins/{id} + Latest version detail plus the version history. + """ + versions = query_versions(plugin_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_record_access(user, event, latest) + if err: + return err + + return create_response(200, { + 'plugin': version_detail(latest), + 'versions': [record_summary(i) for i in versions], + }) + + +def update_plugin(event: Dict, user: Dict, plugin_id: str) -> Dict: + """ + PUT /plugins/{id} + Body: {name?, description?, deepstream?, new_version?, provenance?} + + Without new_version, updates mutable metadata on the latest version. + With new_version=true (changed source or declaration), creates a new + version item whose Lifecycle_State is dev (9.13) and whose security + review decision is pending (10.5), independently of prior versions. + """ + body, err = parse_body(event) + if err: + return err + + latest = get_latest_version_item(plugin_id) + if not latest: + return not_found_response() + err = authorize_record_access(user, event, latest, manage=True) + if err: + return err + + timestamp = now_ms() + + if body.get('new_version'): + provenance = dict(latest.get('provenance') or {}) + updates = body.get('provenance') or {} + if not isinstance(updates, dict): + return error_response(400, 'INVALID_PROVENANCE', + 'provenance must be a JSON object') + provenance.update(updates) + provenance['createdBy'] = user['user_id'] + provenance['createdAt'] = timestamp + + item = new_version_item( + plugin_id=plugin_id, + version=latest['version'] + 1, + usecase_id=latest['usecase_id'], + name=body.get('name', latest.get('name')), + kind=latest.get('kind'), + user_id=user['user_id'], + timestamp=timestamp, + description=body.get('description', latest.get('description', '')), + deepstream=body.get('deepstream', latest.get('deepstream', False)), + provenance=provenance, + ) + plugin_table().put_item( + Item=item, + ConditionExpression='attribute_not_exists(version)' + ) + log_audit_event( + user_id=user['user_id'], + action='create_plugin_record_version', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': latest['usecase_id'], 'version': item['version']} + ) + return create_response(201, {'plugin': version_detail(item)}) + + # Metadata-only update of the latest version + changed = {} + for field in ('name', 'description', 'deepstream'): + if field in body: + changed[field] = body[field] + if not changed: + return error_response(400, 'NO_UPDATES', + 'Provide name, description, deepstream, or new_version') + + expr_names = {f'#{k}': k for k in changed} + expr_values = {f':{k}': v for k, v in changed.items()} + expr_values[':updated_at'] = now_ms() + update_expr = 'SET ' + ', '.join(f'#{k} = :{k}' for k in changed) + ', updated_at = :updated_at' + + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': latest['version']}, + UpdateExpression=update_expr, + ExpressionAttributeNames=expr_names, + ExpressionAttributeValues=expr_values, + ) + log_audit_event( + user_id=user['user_id'], + action='update_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': latest['usecase_id'], + 'version': latest['version'], 'fields': sorted(changed)} + ) + item = get_version_item(plugin_id, latest['version']) + return create_response(200, {'plugin': version_detail(item)}) + + +def _delete_prefix_objects(prefix: str) -> None: + """Delete every S3 object under one plugin-sources prefix.""" + paginator = s3.get_paginator('list_objects_v2') + keys: List[str] = [] + for page in paginator.paginate(Bucket=PORTAL_ARTIFACTS_BUCKET, + Prefix=prefix): + keys.extend(obj['Key'] for obj in page.get('Contents', [])) + for start in range(0, len(keys), 1000): # DeleteObjects caps at 1000 + s3.delete_objects( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Delete={'Objects': [{'Key': k} + for k in keys[start:start + 1000]]}, + ) + + +def _cleanup_record_objects(versions: List[Dict]) -> None: + """ + Best-effort S3 cleanup of a deleted Plugin_Record: the source + snapshots under every version's plugin-sources prefix + (source_s3_prefix plus any multi-revision fetches[*].source_prefix) + and the promoted Plugin_Library artifacts (artifacts[*].s3Key and + the detached .sig alongside). A cleanup failure never fails the + delete — it only logs a warning (the record itself is gone). + """ + prefixes = set() + keys = set() + for item in versions: + if item.get('source_s3_prefix'): + prefixes.add(item['source_s3_prefix']) + for fetch in (item.get('fetches') or {}).values(): + if (fetch or {}).get('source_prefix'): + prefixes.add(fetch['source_prefix']) + for entry in (item.get('artifacts') or {}).values(): + so_key = (entry or {}).get('s3Key') + if so_key: + keys.add(so_key) + keys.add(so_key + '.sig') + for prefix in sorted(prefixes): + try: + _delete_prefix_objects(prefix) + except Exception as e: + logger.warning( + f"Could not clean up plugin sources under {prefix}: {e}") + for key in sorted(keys): + try: + s3.delete_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key) + except Exception as e: + logger.warning(f"Could not clean up plugin artifact {key}: {e}") + + +def delete_plugin(event: Dict, user: Dict, plugin_id: str) -> Dict: + """ + DELETE /plugins/{id} + Deletes every version of the Plugin_Record (bad or duplicate + imports) from the table, with best-effort S3 cleanup of the source + snapshots and promoted Plugin_Library artifacts (cleanup failure + never fails the delete). Refused with 409 RECORD_IN_USE when any + version was promoted beyond the draft-like dev state (test/prod) — + demote first. Records whose import failed or is still fetching or + awaiting a plugin selection are always in dev and thus deletable. + """ + versions = query_versions(plugin_id) + if not versions: + return not_found_response() + latest = versions[0] + err = authorize_record_access(user, event, latest, manage=True) + if err: + return err + + promoted = sorted(v['version'] for v in versions + if v.get('lifecycle_state') in (STATE_TEST, STATE_PROD)) + if promoted: + return error_response( + 409, 'RECORD_IN_USE', + 'This Plugin_Record has versions promoted beyond dev and ' + 'cannot be deleted; demote them first', + {'versions': promoted}) + + _cleanup_record_objects(versions) + + deleted_versions = sorted(v['version'] for v in versions) + with plugin_table().batch_writer() as batch: + for v in versions: + batch.delete_item(Key={'plugin_id': plugin_id, + 'version': v['version']}) + + log_audit_event( + user_id=user['user_id'], + action='delete_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': latest['usecase_id'], + 'name': latest.get('name'), + 'versions': deleted_versions} + ) + return create_response(200, {'deleted': True, 'plugin_id': plugin_id, + 'versions': deleted_versions}) + + +def get_version(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + GET /plugins/{id}/versions/{v} + Full version detail for display and security review: provenance + (repo URL/revision, scaffold origin, or generation prompt, plus the + importing/creating user, timestamps, and classification), per-arch + Plugin_Artifact checksums and signatures (10.2, 15.6). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item) + if err: + return err + return create_response(200, {'plugin': version_detail(item)}) + + +def get_version_source(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + GET /plugins/{id}/versions/{v}/source[?file=relative/path] + Source inspection for the security review (10.2): without `file`, + lists the source files under the version's S3 prefix; with `file`, + returns that file's content (text, size-capped). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item) + if err: + return err + + prefix = item['source_s3_prefix'] + params = event.get('queryStringParameters') or {} + file_path = params.get('file') + + if file_path: + # Normalize and confine the key to the version's source prefix + normalized = os.path.normpath(file_path).lstrip('/') + if normalized.startswith('..'): + return error_response(400, 'INVALID_FILE_PATH', 'Invalid file path') + key = prefix + normalized + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key, + Range=f'bytes=0-{MAX_SOURCE_FILE_BYTES - 1}') + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return error_response(404, 'SOURCE_FILE_NOT_FOUND', + 'Source file not found', {'file': file_path}) + raise + content = obj['Body'].read().decode('utf-8', errors='replace') + return create_response(200, {'file': file_path, 'content': content}) + + files: List[Dict] = [] + kwargs = {'Bucket': PORTAL_ARTIFACTS_BUCKET, 'Prefix': prefix} + while True: + response = s3.list_objects_v2(**kwargs) + for obj in response.get('Contents', []): + files.append({ + 'file': obj['Key'][len(prefix):], + 'size': obj['Size'], + }) + if not response.get('IsTruncated'): + break + kwargs['ContinuationToken'] = response['NextContinuationToken'] + + return create_response(200, {'files': files, 'count': len(files)}) + + +def _gst_unavailable(reason: str, message: Optional[str] = None) -> Dict: + """200 {available: false, reason, message?} — scan unavailability is a + normal, machine-readable outcome, never an error (1.6, 7.4, 8.3).""" + payload: Dict[str, Any] = {'available': False, 'reason': reason} + if message: + payload['message'] = message + return create_response(200, payload) + + +def get_version_gst_properties(event: Dict, user: Dict, plugin_id: str, + version: int) -> Dict: + """ + GET /plugins/{id}/versions/{v}/gst-properties + + Serves the stored Introspection_Report of the version's x86_64 + Plugin_Artifact together with the derived Parameter_Suggestions + (gst-parameter-prepopulation Requirement 1.5), or a machine-readable + unavailability reason (1.6, 7.4): + + - `no_x86_64_build`: no successfully built x86_64 Plugin_Artifact + - `not_captured`: the build predates Property_Introspection (no + gstIntrospection stanza on the artifact entry) + - `introspection_failed`: capture recorded a failure, or the stored + report is missing or malformed at read time (8.3 — never a 500) + + Available responses carry per-element suggestions in the + ParameterDeclaration wire shape: base-class-filtered, type-mapped, + and required-classified by gst_properties.suggestions_for_element, + plus the skipped properties with reasons (2.5). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item) + if err: + return err + + entry = (item.get('artifacts') or {}).get(INTROSPECTION_ARCH) + if (not isinstance(entry, dict) + or entry.get('buildStatus') != BUILD_SUCCEEDED): + return _gst_unavailable(GST_REASON_NO_BUILD) + + stanza = entry.get('gstIntrospection') + if not isinstance(stanza, dict): + # Successful build recorded before this feature existed (7.4). + return _gst_unavailable(GST_REASON_NOT_CAPTURED) + + if stanza.get('status') != STATUS_CAPTURED: + return _gst_unavailable(GST_REASON_FAILED, stanza.get('message')) + + report_key = stanza.get('s3Key') + if not report_key: + return _gst_unavailable( + GST_REASON_FAILED, 'Stored introspection stanza has no report key') + + try: + obj = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=report_key) + document = json.loads(obj['Body'].read().decode('utf-8')) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return _gst_unavailable( + GST_REASON_FAILED, 'Stored introspection report is missing') + raise + except (json.JSONDecodeError, UnicodeDecodeError): + return _gst_unavailable( + GST_REASON_FAILED, 'Stored introspection report is not valid JSON') + + # Malformed stored documents map to the unavailability reason, never + # an internal error (8.3). + try: + report = parse_report(document) + except ReportError as e: + return _gst_unavailable( + GST_REASON_FAILED, f'Stored introspection report is malformed: {e}') + + if report.status != STATUS_CAPTURED: + return _gst_unavailable(GST_REASON_FAILED, report.message) + + elements = [] + for element in report.elements: + derived = suggestions_for_element(element) + # Port_Scan derivation (port-guidance-and-pad-prepopulation 4.5): + # additive per-element fields; existing keys stay untouched (4.6). + ports = ports_for_element(element) + elements.append({ + 'factory': element.factory, + 'suggestions': derived['suggestions'], + 'skipped': derived['skipped'], + 'portSuggestions': ports['portSuggestions'], + 'unmappedPads': ports['unmappedPads'], + 'padsReason': ports['padsReason'], + 'padsMessage': ports['padsMessage'], + }) + + return create_response(200, { + 'available': True, + 'gstVersion': report.gst_version or stanza.get('gstVersion'), + 'capturedAt': report.captured_at or stanza.get('capturedAt'), + 'elements': elements, + }) + + +def put_version_source(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + PUT /plugins/{id}/versions/{v}/source + Body: {files: {relative/path: content}} + + Persists user-submitted Plugin_Scaffold source (original or edited, + Requirement 1.6) under the version's plugin-sources prefix so a + subsequent build submission builds exactly what the user reviewed. + For scaffold-kind records the submitted map is the complete source + tree and is validated for buildability against the recorded + declaration (422 with every defect described) before anything is + written. + """ + body, err = parse_body(event) + if err: + return err + + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item, manage=True) + if err: + return err + + files = body.get('files') + if (not isinstance(files, dict) or not files + or not all(isinstance(k, str) and isinstance(v, str) + for k, v in files.items())): + return error_response(400, 'INVALID_FILES', + 'files must be a non-empty object mapping ' + 'relative paths to text content') + + # Confine every key to the version's source prefix. + normalized: Dict[str, str] = {} + for path, content in files.items(): + clean = os.path.normpath(path).lstrip('/') + if clean.startswith('..') or clean in ('.', ''): + return error_response(400, 'INVALID_FILE_PATH', + 'Invalid file path', {'file': path}) + normalized[clean] = content + + # Scaffold-kind records keep the buildability guarantee: reject + # non-buildable source with every defect described (1.7, 2.6). + declaration_json = (item.get('provenance') or {}).get('scaffoldDeclaration') + if item.get('kind') == 'scaffold' and declaration_json: + defects = scaffold_defects(normalized, json.loads(declaration_json)) + if defects: + return error_response( + 422, 'SCAFFOLD_INVALID', + 'The submitted source does not form a buildable ' + 'Plugin_Scaffold: ' + '; '.join(defects), + {'defects': defects}) + + prefix = item['source_s3_prefix'] + for path, content in sorted(normalized.items()): + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=prefix + path, + Body=content.encode('utf-8'), + ContentType='text/plain; charset=utf-8', + ) + + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET updated_at = :t', + ExpressionAttributeValues={':t': now_ms()}, + ) + + log_audit_event( + user_id=user['user_id'], + action='update_plugin_source', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': item['usecase_id'], 'version': version, + 'files': sorted(normalized)} + ) + + return create_response(200, {'files': sorted(normalized), + 'count': len(normalized)}) + + +def promote_version(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/promote + dev->test requires at least one successfully built Plugin_Artifact + (409 identifying the missing build, 9.4/9.5); test->prod requires an + approved security review (409 identifying the missing approval, + 9.9/9.10). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item, manage=True, + permission=Permission.NODE_DESIGNER_PROMOTE_DEMOTE) + if err: + return err + + next_state, guard_error = evaluate_promotion(item) + if guard_error: + log_audit_event( + user_id=user['user_id'], + action='promote_plugin_record', + resource_type='plugin_record', + resource_id=plugin_id, + result='denied', + details={'usecase_id': item['usecase_id'], 'version': version, + 'from': item.get('lifecycle_state'), 'reason': guard_error['code']} + ) + return error_response(409, guard_error['code'], guard_error['message'], + guard_error['details']) + + return _apply_transition(user, item, next_state, 'promote_plugin_record') + + +def demote_version(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/demote + prod->test and test->dev always succeed; the demoted state's gates + apply only to subsequent packaging/deployment requests while + deployed Workflow_Components continue to run unchanged (9.12). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + err = authorize_record_access(user, event, item, manage=True, + permission=Permission.NODE_DESIGNER_PROMOTE_DEMOTE) + if err: + return err + + next_state, guard_error = evaluate_demotion(item) + if guard_error: + return error_response(409, guard_error['code'], guard_error['message'], + guard_error['details']) + + return _apply_transition(user, item, next_state, 'demote_plugin_record') + + +def _apply_transition(user: Dict, item: Dict, next_state: str, action: str) -> Dict: + """Persist a lifecycle transition and record the audit entry (13.5)""" + plugin_id, version = item['plugin_id'], item['version'] + previous = item.get('lifecycle_state') + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET lifecycle_state = :s, updated_at = :t', + ExpressionAttributeValues={':s': next_state, ':t': now_ms()}, + ) + log_audit_event( + user_id=user['user_id'], + action=action, + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': item['usecase_id'], 'version': version, + 'from': previous, 'to': next_state} + ) + updated = get_version_item(plugin_id, version) + return create_response(200, {'plugin': version_detail(updated)}) + + +def review_version(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/review + Body: {decision: approved|rejected, notes?} + PortalAdmin only (13.2). Records the decision, the acting + PortalAdmin, and a timestamp on the Plugin_Record version and in the + existing AuditLog table (10.3). + """ + item = get_version_item(plugin_id, version) + if not item: + return not_found_response() + + if not is_portal_admin(user): + return forbidden_response(user, event, item['usecase_id'], + Permission.NODE_DESIGNER_SECURITY_REVIEW) + + body, err = parse_body(event) + if err: + return err + decision = body.get('decision') + if decision not in REVIEW_DECISIONS: + return error_response(400, 'INVALID_REVIEW_DECISION', + f"decision must be one of: {', '.join(REVIEW_DECISIONS)}") + + timestamp = now_ms() + review = { + 'decision': decision, + 'reviewer': user['user_id'], + 'reviewedAt': timestamp, + } + if body.get('notes'): + review['notes'] = body['notes'] + + plugin_table().update_item( + Key={'plugin_id': plugin_id, 'version': version}, + UpdateExpression='SET review = :r, updated_at = :t', + ExpressionAttributeValues={':r': review, ':t': timestamp}, + ) + + # Requirement 10.3: decision + acting PortalAdmin + timestamp in the + # existing audit log (log_audit_event writes AUDIT_LOG_TABLE with a + # timestamp attribute). + log_audit_event( + user_id=user['user_id'], + action=f'security_review_{decision}', + resource_type='plugin_record', + resource_id=plugin_id, + result='success', + details={'usecase_id': item['usecase_id'], 'version': version, + 'decision': decision, 'reviewer': user['user_id']} + ) + + updated = get_version_item(plugin_id, version) + return create_response(200, {'plugin': version_detail(updated)}) + + +# ------------------------------------------------------------------ routing + +def _parse_version(path_params: Dict) -> Tuple[Optional[int], Optional[Dict]]: + """Parse the {v} path parameter as an integer""" + raw = path_params.get('v') + try: + return int(raw), None + except (TypeError, ValueError): + return None, error_response(400, 'INVALID_VERSION', 'version must be an integer') + + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + plugin_id = path_params.get('id') + + if resource == '/plugins': + if http_method == 'GET': + return list_plugins(event, user) + if http_method == 'POST': + return create_plugin(event, user) + elif resource == '/plugins/{id}' and plugin_id: + if http_method == 'GET': + return get_plugin(event, user, plugin_id) + if http_method == 'PUT': + return update_plugin(event, user, plugin_id) + if http_method == 'DELETE': + return delete_plugin(event, user, plugin_id) + elif resource.startswith('/plugins/{id}/versions/{v}') and plugin_id: + version, err = _parse_version(path_params) + if err: + return err + if resource == '/plugins/{id}/versions/{v}': + if http_method == 'GET': + return get_version(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/source': + if http_method == 'GET': + return get_version_source(event, user, plugin_id, version) + if http_method == 'PUT': + return put_version_source(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/gst-properties': + if http_method == 'GET': + return get_version_gst_properties(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/promote': + if http_method == 'POST': + return promote_version(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/demote': + if http_method == 'POST': + return demote_version(event, user, plugin_id, version) + elif resource == '/plugins/{id}/versions/{v}/review': + if http_method == 'POST': + return review_version(event, user, plugin_id, version) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/plugin_simulator.py b/edge-cv-portal/backend/functions/plugin_simulator.py new file mode 100644 index 00000000..09ede980 --- /dev/null +++ b/edge-cv-portal/backend/functions/plugin_simulator.py @@ -0,0 +1,894 @@ +""" +Plugin_Simulator API Lambda function (Custom Node Designer, task 8.2) + +Starts, tracks, and finalizes Plugin_Simulator runs against the +Plugin_Simulator Step Functions state machine +(Guard -> Prepare -> RunSandbox -> Collect, node-designer-stack.ts) +running the test-sandbox image with HARNESS_MODE=simulate +(Requirements 7.1, 7.2, 7.4, 7.5, 7.6, 7.7). + +Routes (API Gateway REST): + POST /plugins/{id}/versions/{v}/simulate + Start a simulation run for one Plugin_Record version with + parameter values. Input is either an existing Test_Dataset of + the same Use_Case (dataset_id) or uploaded sample frames + (sample_frames, 7.1). Re-running with changed parameter values + is a new POST with new `parameters` (7.4). Refused with a 409 + describing the missing build when the version has no successful + x86_64 Plugin_Artifact (7.5). + GET /simulations/{runId} + Run status plus the results document the sandbox harness + flushed to S3 (input/output frame refs and per-frame metadata, + 7.3; partial results for failed/timed-out runs, 7.6/7.7). + +State machine steps (invoked by the state machine with +{step, input}, mirroring workflow_test_steps.py): + guard Re-check the x86_64-artifact guard inside the + execution; a failing guard marks the run failed + and short-circuits to a Fail state (7.5). + prepare Stage the run inputs under the run's S3 prefix: + copy the selected Test_Dataset (uploaded sample + frames are already staged by the start endpoint) + and copy the plugin's x86_64 .so from the + Plugin_Library into the run prefix, so the sandbox + task role never needs Plugin_Library access (7.2). + collect Finalize the SimulationRuns item from the results + document the harness flushed. + record_timeout Mark the run failed with a timeout indication; + the partial results flushed before termination + stay in S3 untouched (7.7). + record_failure Mark the run failed, carrying the error the + harness flushed (plugin error output included) + when available (7.6). + +Storage layout (design "Data Models"): + SimulationRuns table (SIMULATION_RUNS_TABLE) + PK run_id, GSI usecase-runs-index (usecase_id, started_at) + Attributes: plugin_id, version, usecase_id, dataset ref, + parameters, element_factory, status, results_s3_key, + failure {message, timeout}, started_at/finished_at, + created_by, execution_arn. + Run objects in portal S3 under + plugin-simulations/{usecase_id}/{run_id}/... + +Error envelope: {"error": {"code", "message", "details"}} with 400 +parse/validation, 403 RBAC denial, 404 scoped to avoid cross-tenant +existence leaks, 409 for the missing-x86_64-build guard (7.5), and +503 while the simulator state machine is not provisioned. + +Access control: node-designer:simulate (UseCaseAdmin within the +Use_Case, PortalAdmin) to start runs; node-designer:read for status/ +results (Requirement 13). Every started run writes the AuditLog table. +""" +import base64 +import json +import os +import logging +import re +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + rbac_manager, Permission +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') +stepfunctions = boto3.client('stepfunctions') + +# Environment variables +SIMULATION_RUNS_TABLE = os.environ.get('SIMULATION_RUNS_TABLE') +PLUGIN_RECORDS_TABLE = os.environ.get('PLUGIN_RECORDS_TABLE') +TEST_DATASETS_TABLE = os.environ.get('TEST_DATASETS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +PLUGIN_SIMULATIONS_PREFIX = os.environ.get('PLUGIN_SIMULATIONS_PREFIX', + 'plugin-simulations') +SIMULATOR_STATE_MACHINE_ARN = os.environ.get('SIMULATOR_STATE_MACHINE_ARN') + +# ---------------------------------------------------------------- constants + +#: The only architecture the Plugin_Simulator executes (requirements +#: glossary; the Fargate sandbox is plain x86_64). +SIMULATOR_ARCH = 'x86_64' +BUILD_SUCCEEDED = 'succeeded' + +#: Run statuses on the SimulationRuns item. +STATUS_PENDING = 'pending' +STATUS_RUNNING = 'running' +STATUS_COMPLETED = 'completed' +STATUS_FAILED = 'failed' + +#: Step Functions execution status -> simulation run status. +SFN_STATUS_MAP = { + 'RUNNING': STATUS_RUNNING, + 'SUCCEEDED': STATUS_COMPLETED, + 'FAILED': STATUS_FAILED, + 'TIMED_OUT': STATUS_FAILED, + 'ABORTED': STATUS_FAILED, +} + +TIMEOUT_FAILURE_MESSAGE = ('Simulation run exceeded the 5 minute execution ' + 'limit; partial results produced before ' + 'termination were retained') + +#: Uploaded sample frame constraints (7.1 upload path). Bounded well below +#: the API Gateway payload limit; larger inputs use a Test_Dataset. +SUPPORTED_FRAME_EXTENSIONS = {'.jpg', '.jpeg', '.png'} +MAX_SAMPLE_FRAMES = 64 +MAX_SAMPLE_FRAMES_BYTES = 6 * 1024 * 1024 + +#: GStreamer element factory name shape (mirrors the simulate harness's +#: launch-safety validation). +ELEMENT_FACTORY_PATTERN = re.compile(r'^[A-Za-z_][A-Za-z0-9_-]*$') + +#: Scalar JSON types accepted as declared parameter values. +SCALAR_TYPES = (str, int, float, bool) + + +# ------------------------------------------------------------------ helpers + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def run_s3_prefix(usecase_id: str, run_id: str) -> str: + """S3 prefix holding every object of one simulation run""" + return f"{PLUGIN_SIMULATIONS_PREFIX}/{usecase_id}/{run_id}/" + + +def run_results_key(usecase_id: str, run_id: str) -> str: + """S3 key of the incrementally flushed simulation results document""" + return run_s3_prefix(usecase_id, run_id) + 'results.json' + + +def run_uploads_prefix(usecase_id: str, run_id: str) -> str: + """S3 prefix uploaded sample frames are staged under (7.1)""" + return run_s3_prefix(usecase_id, run_id) + 'uploads/' + + +def run_inputs_prefix(usecase_id: str, run_id: str) -> str: + """S3 prefix the Prepare step stages a Test_Dataset copy under""" + return run_s3_prefix(usecase_id, run_id) + 'inputs/' + + +def plugin_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a Plugin_Record exists""" + return error_response(404, 'PLUGIN_NOT_FOUND', 'Plugin record not found') + + +def run_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a simulation run exists""" + return error_response(404, 'SIMULATION_RUN_NOT_FOUND', 'Simulation run not found') + + +def dataset_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a Test_Dataset exists""" + return error_response(404, 'TEST_DATASET_NOT_FOUND', 'Test dataset not found') + + +def has_node_designer_permission(user: Dict, usecase_id: str, + permission: Permission) -> bool: + """Check a registered node-designer RBAC action for the acting user""" + return rbac_manager.has_permission(user['user_id'], usecase_id, + permission, user_info=user) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, + required: Permission) -> Dict: + """Standard authorization error envelope with a denied-access audit + entry (13.4), matching plugin_records.py""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='simulation_run', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [required.value], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [required.value], + 'usecase_id': usecase_id + }) + + +# ------------------------------------------------------------- persistence + +def runs_table(): + return dynamodb.Table(SIMULATION_RUNS_TABLE) + + +def get_run_item(run_id: str) -> Optional[Dict]: + """Fetch one SimulationRuns item, or None""" + response = runs_table().get_item(Key={'run_id': run_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_plugin_version_item(plugin_id: str, version: int) -> Optional[Dict]: + """Fetch one Plugin_Record version item, or None""" + response = dynamodb.Table(PLUGIN_RECORDS_TABLE).get_item( + Key={'plugin_id': plugin_id, 'version': version}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_dataset_item(dataset_id: str) -> Optional[Dict]: + """Fetch a Test_Dataset record, or None""" + response = dynamodb.Table(TEST_DATASETS_TABLE).get_item( + Key={'dataset_id': dataset_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def mark_run_failed(run_id: str, message: str, timeout: bool = False) -> None: + """ + Mark a run failed with {message, timeout}. Only the SimulationRuns + item is touched: partial results the harness already flushed to S3 + survive (7.6, 7.7). + """ + runs_table().update_item( + Key={'run_id': run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished, failure = :failure', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': STATUS_FAILED, + ':finished': now_ms(), + ':failure': {'message': message, 'timeout': timeout}, + }, + ) + + +# ---------------------------------------------------------------- the guard +# +# Kept pure over the Plugin_Record item dict so the start-guard decision is +# property-testable without AWS (task 8.3, Requirement 7.5). + +def evaluate_simulation_guard(item: Dict) -> Tuple[bool, Optional[Dict]]: + """ + Decide whether a simulation run may start for a Plugin_Record + version item. + + Returns (True, None) exactly when the version has a successfully + built x86_64 Plugin_Artifact with a stored Plugin_Library key; + otherwise (False, {code, message, details}) describing that + simulation requires a successful x86_64 build (7.5). + """ + artifacts = item.get('artifacts') or {} + entry = artifacts.get(SIMULATOR_ARCH) + if (isinstance(entry, dict) + and entry.get('buildStatus') == BUILD_SUCCEEDED + and entry.get('s3Key')): + return True, None + return False, { + 'code': 'SIMULATION_REQUIRES_X86_64_BUILD', + 'message': ('Simulation requires a successful x86_64 build: this ' + 'Plugin_Record version has no successfully built x86_64 ' + 'Plugin_Artifact. Build the plugin for x86_64 and retry.'), + 'details': { + 'plugin_id': item.get('plugin_id'), + 'version': item.get('version'), + 'missing': 'successful x86_64 Plugin_Artifact', + 'x86_64_build_status': (entry or {}).get('buildStatus') + if isinstance(entry, dict) else None, + }, + } + + +# ------------------------------------------------------------- input checks + +def validate_parameters(parameters: Any) -> Tuple[Optional[Dict], Optional[Dict]]: + """ + Validate the declared parameter values of a run (7.4): a flat JSON + object of scalar values. Returns (parameters, None) or + (None, error_response). + """ + if parameters is None: + return {}, None + if not isinstance(parameters, dict): + return None, error_response(400, 'INVALID_PARAMETERS', + 'parameters must be a JSON object of ' + 'scalar parameter values') + for name, value in parameters.items(): + if not ELEMENT_FACTORY_PATTERN.match(str(name)): + return None, error_response(400, 'INVALID_PARAMETERS', + f"Invalid parameter name '{name}'", + {'parameter': str(name)}) + if value is not None and not isinstance(value, SCALAR_TYPES): + return None, error_response(400, 'INVALID_PARAMETERS', + f"Parameter '{name}' must be a scalar value", + {'parameter': str(name)}) + return parameters, None + + +def validate_sample_frames(frames: Any) -> Tuple[Optional[List[Dict]], Optional[Dict]]: + """ + Validate uploaded sample frames (7.1): a bounded list of + {name, content_base64} JPEG/PNG entries. Returns + ([{name, content(bytes)}], None) or (None, error_response). + """ + if not isinstance(frames, list) or not frames: + return None, error_response(400, 'INVALID_SAMPLE_FRAMES', + 'sample_frames must be a non-empty list of ' + '{name, content_base64} entries') + if len(frames) > MAX_SAMPLE_FRAMES: + return None, error_response(400, 'INVALID_SAMPLE_FRAMES', + f'At most {MAX_SAMPLE_FRAMES} sample frames ' + 'are accepted per run', + {'max_frames': MAX_SAMPLE_FRAMES}) + decoded: List[Dict] = [] + total = 0 + for index, entry in enumerate(frames): + if not isinstance(entry, dict) or not entry.get('name') \ + or not entry.get('content_base64'): + return None, error_response(400, 'INVALID_SAMPLE_FRAMES', + f'sample_frames[{index}] must carry ' + 'name and content_base64') + name = str(entry['name']) + if '/' in name or '\\' in name or name in ('.', '..'): + return None, error_response(400, 'INVALID_SAMPLE_FRAMES', + f'sample_frames[{index}].name must be ' + 'a plain file name', {'name': name}) + extension = os.path.splitext(name)[1].lower() + if extension not in SUPPORTED_FRAME_EXTENSIONS: + return None, error_response( + 400, 'UNSUPPORTED_FORMAT', + f"Unsupported sample frame format '{extension or name}': only " + 'JPEG and PNG images are supported', + {'file': name, + 'supported_extensions': sorted(SUPPORTED_FRAME_EXTENSIONS)}) + try: + content = base64.b64decode(entry['content_base64'], validate=True) + except (ValueError, TypeError): + return None, error_response(400, 'INVALID_SAMPLE_FRAMES', + f'sample_frames[{index}].content_base64 ' + 'is not valid base64', {'name': name}) + total += len(content) + if total > MAX_SAMPLE_FRAMES_BYTES: + return None, error_response( + 400, 'SAMPLE_FRAMES_TOO_LARGE', + f'Uploaded sample frames exceed the {MAX_SAMPLE_FRAMES_BYTES} ' + 'byte limit; use a Test_Dataset for larger inputs', + {'max_bytes': MAX_SAMPLE_FRAMES_BYTES}) + decoded.append({'name': name, 'content': content}) + return decoded, None + + +def default_element_factory(item: Dict) -> str: + """Derive a launch-safe element factory name from the plugin name""" + cleaned = re.sub(r'[^A-Za-z0-9_]+', '', str(item.get('name') or '').lower()) + if cleaned and not ELEMENT_FACTORY_PATTERN.match(cleaned): + cleaned = f'_{cleaned}' + return cleaned or f"plugin{re.sub(r'[^a-z0-9]', '', str(item.get('plugin_id'))[:8])}" + + +def run_summary(item: Dict) -> Dict: + """Public shape of a SimulationRuns record""" + return { + 'run_id': item['run_id'], + 'plugin_id': item.get('plugin_id'), + 'version': item.get('version'), + 'usecase_id': item.get('usecase_id'), + 'dataset': item.get('dataset'), + 'parameters': item.get('parameters'), + 'element_factory': item.get('element_factory'), + 'status': item.get('status'), + 'results_s3_key': item.get('results_s3_key'), + 'failure': item.get('failure'), + 'started_at': item.get('started_at'), + 'finished_at': item.get('finished_at'), + 'created_by': item.get('created_by'), + } + + +# ----------------------------------------------------------------- handlers + +def start_simulation(event: Dict, user: Dict, plugin_id: str, version: int) -> Dict: + """ + POST /plugins/{id}/versions/{v}/simulate + Body: {dataset_id? | sample_frames?: [{name, content_base64}], + parameters?: {name: value}, element_factory?} + + Starts a Plugin_Simulator run (7.1). Exactly one input source is + required: an existing Test_Dataset of the same Use_Case, or + uploaded sample frames staged under the run's prefix. `parameters` + carries the declared parameter values for this run; a re-run with + changed values is simply another POST (7.4). Refused with a 409 + describing the missing build when the version has no successful + x86_64 Plugin_Artifact (7.5). + """ + item = get_plugin_version_item(plugin_id, version) + if not item: + return plugin_not_found_response() + usecase_id = item['usecase_id'] + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_READ): + return plugin_not_found_response() + if not has_node_designer_permission(user, usecase_id, + Permission.NODE_DESIGNER_SIMULATE): + return forbidden_response(user, event, usecase_id, + Permission.NODE_DESIGNER_SIMULATE) + + body, err = parse_body(event) + if err: + return err + + # The x86_64-artifact guard (7.5): refuse before anything is created. + ok, guard_error = evaluate_simulation_guard(item) + if not ok: + log_audit_event( + user_id=user['user_id'], + action='start_simulation_run', + resource_type='simulation_run', + resource_id=plugin_id, + result='rejected', + details={'usecase_id': usecase_id, 'plugin_id': plugin_id, + 'version': version, 'reason': guard_error['code']} + ) + return error_response(409, guard_error['code'], guard_error['message'], + guard_error['details']) + + parameters, err = validate_parameters(body.get('parameters')) + if err: + return err + + element_factory = body.get('element_factory') or default_element_factory(item) + if not ELEMENT_FACTORY_PATTERN.match(str(element_factory)): + return error_response(400, 'INVALID_ELEMENT_FACTORY', + 'element_factory must be a plain GStreamer ' + 'element factory name', + {'element_factory': str(element_factory)}) + + dataset_id = body.get('dataset_id') + sample_frames = body.get('sample_frames') + if bool(dataset_id) == bool(sample_frames): + return error_response(400, 'MISSING_INPUT', + 'Provide exactly one input source: dataset_id ' + '(an existing Test_Dataset) or sample_frames ' + '(uploaded frames)') + + dataset_ref: Dict[str, Any] + source_dataset_prefix: Optional[str] = None + decoded_frames: Optional[List[Dict]] = None + if dataset_id: + # The Test_Dataset must exist in the same Use_Case; a dataset of + # another tenant is indistinguishable from a missing one (7.1). + dataset = get_dataset_item(dataset_id) + if not dataset or dataset.get('usecase_id') != usecase_id: + return dataset_not_found_response() + source_dataset_prefix = dataset.get('s3_prefix') + dataset_ref = {'kind': 'dataset', 'dataset_id': dataset_id} + else: + decoded_frames, err = validate_sample_frames(sample_frames) + if err: + return err + dataset_ref = {'kind': 'uploaded', 'frame_count': len(decoded_frames)} + + if not SIMULATOR_STATE_MACHINE_ARN: + return error_response( + 503, 'SIMULATOR_NOT_CONFIGURED', + 'The plugin simulator is not configured: no Step Functions state ' + 'machine ARN is available. Deploy the node-designer simulator ' + 'infrastructure.') + + run_id = str(uuid.uuid4()) + timestamp = now_ms() + results_key = run_results_key(usecase_id, run_id) + + # Uploaded sample frames are staged under the run's prefix here, so the + # Prepare step (and the sandbox task role) only ever touch + # plugin-simulations/... (7.2). + if decoded_frames is not None: + uploads_prefix = run_uploads_prefix(usecase_id, run_id) + for frame in decoded_frames: + s3.put_object(Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=uploads_prefix + frame['name'], + Body=frame['content']) + source_dataset_prefix = uploads_prefix + + run_item = { + 'run_id': run_id, + 'plugin_id': plugin_id, + 'version': version, + 'usecase_id': usecase_id, + 'dataset': dataset_ref, + 'parameters': parameters, + 'element_factory': element_factory, + 'status': STATUS_PENDING, + 'results_s3_key': results_key, + 'failure': None, + 'started_at': timestamp, + 'finished_at': None, + 'created_by': user['user_id'], + } + runs_table().put_item(Item=run_item, + ConditionExpression='attribute_not_exists(run_id)') + + execution_input = { + 'run_id': run_id, + 'plugin_id': plugin_id, + 'version': version, + 'usecase_id': usecase_id, + 'artifacts_bucket': PORTAL_ARTIFACTS_BUCKET, + 'results_s3_key': results_key, + # Prepare-step staging sources: the Test_Dataset objects (or the + # already-staged uploads prefix) and the Plugin_Library artifact key. + 'input_kind': dataset_ref['kind'], + 'source_dataset_s3_prefix': source_dataset_prefix, + 'plugin_source_s3_key': item['artifacts'][SIMULATOR_ARCH]['s3Key'], + 'element_factory': element_factory, + # The object for readability, plus the pre-serialized JSON string the + # RunSandbox containerOverrides pass through as ELEMENT_PARAMETERS + # (env values must be strings; 7.4). + 'parameters': parameters, + 'parameters_json': json.dumps(parameters), + } + try: + execution = stepfunctions.start_execution( + stateMachineArn=SIMULATOR_STATE_MACHINE_ARN, + name=run_id, + input=json.dumps(execution_input) + ) + except ClientError as e: + logger.error(f"Error starting simulation run {run_id}: {str(e)}") + mark_run_failed(run_id, 'Simulation run could not be started') + return error_response(502, 'SIMULATION_START_FAILED', + 'The simulation run could not be started') + + runs_table().update_item( + Key={'run_id': run_id}, + UpdateExpression='SET #s = :status, execution_arn = :arn', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={':status': STATUS_RUNNING, + ':arn': execution['executionArn']} + ) + run_item['status'] = STATUS_RUNNING + run_item['execution_arn'] = execution['executionArn'] + + log_audit_event( + user_id=user['user_id'], + action='start_simulation_run', + resource_type='simulation_run', + resource_id=run_id, + result='success', + details={'usecase_id': usecase_id, 'plugin_id': plugin_id, + 'version': version, 'input': dataset_ref, + 'parameters': sorted(parameters)} + ) + + return create_response(202, {'simulation_run': run_summary(run_item)}) + + +def sync_run_status_from_execution(item: Dict) -> Dict: + """ + Refresh a non-terminal run's status from its Step Functions + execution. Best-effort: on describe failure the stored status is + returned unchanged. A TIMED_OUT execution (state-machine-level + expiry) is marked failed-with-timeout (7.7). + """ + if item.get('status') not in (STATUS_PENDING, STATUS_RUNNING) \ + or not item.get('execution_arn'): + return item + try: + execution = stepfunctions.describe_execution( + executionArn=item['execution_arn']) + except ClientError as e: + logger.warning(f"Could not describe execution for run " + f"{item['run_id']}: {str(e)}") + return item + + sfn_status = execution.get('status') + mapped = SFN_STATUS_MAP.get(sfn_status) + if not mapped or mapped == item.get('status'): + return item + + update_expr = 'SET #s = :status' + expr_values: Dict[str, Any] = {':status': mapped} + if mapped in (STATUS_COMPLETED, STATUS_FAILED): + update_expr += ', finished_at = :finished' + expr_values[':finished'] = now_ms() + if sfn_status == 'TIMED_OUT': + update_expr += ', failure = :failure' + expr_values[':failure'] = {'message': TIMEOUT_FAILURE_MESSAGE, + 'timeout': True} + try: + updated = runs_table().update_item( + Key={'run_id': item['run_id']}, + UpdateExpression=update_expr, + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues=expr_values, + ReturnValues='ALL_NEW' + ) + return decimal_to_native(updated['Attributes']) + except ClientError as e: + logger.warning(f"Could not update run status for " + f"{item['run_id']}: {str(e)}") + item['status'] = mapped + return item + + +def load_results_document(results_s3_key: Optional[str]) -> Optional[Dict]: + """ + Load the simulation results document the harness flushed to S3. + Incremental flushing means partial results are available for + running, failed, and timed-out runs (7.6, 7.7). Missing or + malformed document -> None. + """ + if not results_s3_key: + return None + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=results_s3_key) + document = json.loads(response['Body'].read().decode('utf-8')) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return None + logger.error(f"Error loading simulation results " + f"{results_s3_key}: {str(e)}") + return None + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error(f"Malformed simulation results document " + f"{results_s3_key}: {str(e)}") + return None + return document if isinstance(document, dict) else None + + +def get_simulation(event: Dict, user: Dict, run_id: str) -> Dict: + """ + GET /simulations/{runId} + Run status plus the results document produced so far: per-frame + {frameIndex, inputRef, outputRef, metadata} records for the + side-by-side display (7.3); partial results with the failure for + failed and timed-out runs (7.6, 7.7). + """ + item = get_run_item(run_id) + if not item: + return run_not_found_response() + if not has_node_designer_permission(user, item['usecase_id'], + Permission.NODE_DESIGNER_READ): + return run_not_found_response() + + item = sync_run_status_from_execution(item) + results = load_results_document(item.get('results_s3_key')) + + return create_response(200, { + 'simulation_run': run_summary(item), + 'results': results, + }) + + +# --------------------------------------------------------------------------- +# State machine steps (invoked with {step, input}; see node-designer-stack.ts) +# --------------------------------------------------------------------------- + +def step_guard(step_input: Dict) -> Dict: + """ + Guard state (7.5): re-evaluate the x86_64-artifact guard inside the + execution. A failing guard marks the run failed and returns + {ok: false}; the state machine short-circuits to its Fail state. + """ + run_id = step_input['run_id'] + item = get_plugin_version_item(step_input['plugin_id'], + int(step_input['version'])) + ok, guard_error = evaluate_simulation_guard(item or {}) + if not ok: + mark_run_failed(run_id, guard_error['message']) + return {'ok': False, 'error': guard_error} + return {'ok': True} + + +def step_prepare(step_input: Dict) -> Dict: + """ + Prepare state: stage the run inputs under the run's S3 prefix so + the sandbox task role never needs access outside + plugin-simulations/... (7.2). + + - Test_Dataset input: copy the dataset objects to the run's + inputs/ prefix. Uploaded sample frames were already staged under + the run's uploads/ prefix by the start endpoint. + - Plugin: copy the x86_64 .so from the Plugin_Library into the + run's plugin/ prefix. + + Returns {dataset_s3_prefix, plugin_s3_key} for the RunSandbox env + overrides (the simulate harness env contract). + """ + run_id = step_input['run_id'] + usecase_id = step_input['usecase_id'] + source_prefix = step_input['source_dataset_s3_prefix'] + + if step_input.get('input_kind') == 'uploaded': + staged_prefix = source_prefix + else: + staged_prefix = run_inputs_prefix(usecase_id, run_id) + continuation_token = None + while True: + kwargs = {'Bucket': PORTAL_ARTIFACTS_BUCKET, 'Prefix': source_prefix} + if continuation_token: + kwargs['ContinuationToken'] = continuation_token + listed = s3.list_objects_v2(**kwargs) + for obj in listed.get('Contents', []): + relative = obj['Key'][len(source_prefix):] + if not relative: + continue + s3.copy_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=staged_prefix + relative, + CopySource={'Bucket': PORTAL_ARTIFACTS_BUCKET, + 'Key': obj['Key']}, + ) + if not listed.get('IsTruncated'): + break + continuation_token = listed.get('NextContinuationToken') + + plugin_source_key = step_input['plugin_source_s3_key'] + plugin_key = (run_s3_prefix(usecase_id, run_id) + 'plugin/' + + os.path.basename(plugin_source_key)) + s3.copy_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=plugin_key, + CopySource={'Bucket': PORTAL_ARTIFACTS_BUCKET, + 'Key': plugin_source_key}, + ) + + return {'dataset_s3_prefix': staged_prefix, 'plugin_s3_key': plugin_key} + + +def step_collect(step_input: Dict) -> Dict: + """ + Collect state: finalize the SimulationRuns item from the results + document the harness flushed (status completed, or failed with the + harness error when the document reports one). + """ + run_id = step_input['run_id'] + document = load_results_document(step_input.get('results_s3_key')) or {} + if document.get('status') == STATUS_FAILED or document.get('error'): + error = document.get('error') or {} + mark_run_failed(run_id, str(error.get('message') + or 'Simulation run failed')) + return {'ok': False} + runs_table().update_item( + Key={'run_id': run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={':status': STATUS_COMPLETED, + ':finished': now_ms()}, + ) + return {'ok': True} + + +def step_record_timeout(step_input: Dict) -> Dict: + """ + 5-minute limit exceeded (7.7): Step Functions stopped the sandbox + task; mark the run failed with a timeout indication. The partial + results the harness flushed before termination stay in S3. + """ + mark_run_failed(step_input['run_id'], TIMEOUT_FAILURE_MESSAGE, + timeout=True) + return {'ok': True} + + +def step_record_failure(step_input: Dict) -> Dict: + """ + Sandbox or step failure (7.6): mark the run failed, preferring the + error the harness flushed (which includes the plugin's captured + error output) over the generic message. Partial results stay in S3. + """ + document = load_results_document(step_input.get('results_s3_key')) or {} + error = document.get('error') or {} + message = str(error.get('message') or 'Simulation run failed') + mark_run_failed(step_input['run_id'], message) + return {'ok': True} + + +STEP_HANDLERS = { + 'guard': step_guard, + 'prepare': step_prepare, + 'collect': step_collect, + 'record_timeout': step_record_timeout, + 'record_failure': step_record_failure, +} + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - API routes plus state machine step dispatch""" + # State machine step invocation ({step, input}), no API Gateway shape. + if isinstance(event, dict) and 'step' in event and 'httpMethod' not in event: + step = event.get('step') + step_handler = STEP_HANDLERS.get(step) + if not step_handler: + raise ValueError(f"Unknown simulator step '{step}'") + return step_handler(event.get('input') or {}) + + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + + if resource == '/plugins/{id}/versions/{v}/simulate': + plugin_id = path_params.get('id') + try: + version = int(path_params.get('v')) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', + 'version must be an integer') + if http_method == 'POST' and plugin_id: + return start_simulation(event, user, plugin_id, version) + elif resource == '/simulations/{runId}': + run_id = path_params.get('runId') + if http_method == 'GET' and run_id: + return get_simulation(event, user, run_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/rbac_middleware.py b/edge-cv-portal/backend/functions/rbac_middleware.py index 3999cca3..7088238c 100644 --- a/edge-cv-portal/backend/functions/rbac_middleware.py +++ b/edge-cv-portal/backend/functions/rbac_middleware.py @@ -288,6 +288,45 @@ class CommonPermissions: # Administrative Operations ADMIN_OPERATIONS = [Permission.MANAGE_USERS, Permission.MANAGE_SETTINGS, Permission.VIEW_AUDIT_LOGS] + # Workflow Manager Operations + # Role mapping (see shared_utils.RBACManager): + # workflow:read -> Viewer, Operator, DataScientist, UseCaseAdmin + # workflow:create/edit/save/delete/test -> DataScientist, UseCaseAdmin + # workflow:package/deploy -> Operator, UseCaseAdmin + # bedrock-config:write -> PortalAdmin only + VIEW_WORKFLOWS = [Permission.WORKFLOW_READ] + EDIT_WORKFLOWS = [ + Permission.WORKFLOW_CREATE, + Permission.WORKFLOW_EDIT, + Permission.WORKFLOW_SAVE, + Permission.WORKFLOW_DELETE, + ] + TEST_WORKFLOWS = [Permission.WORKFLOW_TEST] + DEPLOY_WORKFLOWS = [Permission.WORKFLOW_PACKAGE, Permission.WORKFLOW_DEPLOY] + MANAGE_BEDROCK_CONFIG = [Permission.BEDROCK_CONFIG_WRITE] + + # Node Designer Operations (custom-node-designer, Requirement 13) + # Role mapping (see shared_utils.RBACManager): + # node-designer:read -> Viewer, Operator, DataScientist, + # UseCaseAdmin, PortalAdmin (13.3) + # node-designer:create/generate/import/simulate/register/ + # promote-demote/manage + # -> UseCaseAdmin (own Use_Case), + # PortalAdmin (13.1) + # node-designer:security-review -> PortalAdmin only (13.2) + # Denials return the standard authorization error envelope (13.4). + VIEW_NODE_DESIGNER = [Permission.NODE_DESIGNER_READ] + MANAGE_NODE_DESIGNER = [ + Permission.NODE_DESIGNER_CREATE, + Permission.NODE_DESIGNER_GENERATE, + Permission.NODE_DESIGNER_IMPORT, + Permission.NODE_DESIGNER_SIMULATE, + Permission.NODE_DESIGNER_REGISTER, + Permission.NODE_DESIGNER_PROMOTE_DEMOTE, + Permission.NODE_DESIGNER_MANAGE, + ] + REVIEW_NODE_DESIGNER = [Permission.NODE_DESIGNER_SECURITY_REVIEW] + # Convenience decorators for common permission patterns def require_data_scientist_or_admin(usecase_param: str = 'usecase_id'): @@ -307,4 +346,40 @@ def require_usecase_admin_or_portal_admin(usecase_param: str = 'usecase_id'): def require_view_access(usecase_param: str = 'usecase_id'): """Require any role with view access""" - return rbac_check([Permission.VIEW_USECASES], usecase_param) \ No newline at end of file + return rbac_check([Permission.VIEW_USECASES], usecase_param) + + +def require_workflow_read(usecase_param: str = 'usecase_id'): + """Require workflow:read (Viewer, Operator, DataScientist, UseCaseAdmin, PortalAdmin)""" + return rbac_check(CommonPermissions.VIEW_WORKFLOWS, usecase_param) + + +def require_workflow_edit(usecase_param: str = 'usecase_id'): + """Require workflow create/edit/save/delete (DataScientist, UseCaseAdmin, PortalAdmin)""" + return rbac_check(CommonPermissions.EDIT_WORKFLOWS, usecase_param) + + +def require_workflow_test(usecase_param: str = 'usecase_id'): + """Require workflow:test (DataScientist, UseCaseAdmin, PortalAdmin)""" + return rbac_check(CommonPermissions.TEST_WORKFLOWS, usecase_param) + + +def require_workflow_deploy(usecase_param: str = 'usecase_id'): + """Require workflow package/deploy (Operator, UseCaseAdmin, PortalAdmin)""" + return rbac_check(CommonPermissions.DEPLOY_WORKFLOWS, usecase_param) + + +def require_node_designer_read(usecase_param: str = 'usecase_id'): + """Require node-designer:read (every role of the Use_Case, 13.3)""" + return rbac_check(CommonPermissions.VIEW_NODE_DESIGNER, usecase_param) + + +def require_node_designer_manage(usecase_param: str = 'usecase_id'): + """Require node-designer create/generate/import/simulate/register/ + promote-demote/manage (UseCaseAdmin within own Use_Case, PortalAdmin, 13.1)""" + return rbac_check(CommonPermissions.MANAGE_NODE_DESIGNER, usecase_param) + + +def require_node_designer_security_review(usecase_param: str = 'usecase_id'): + """Require node-designer:security-review (PortalAdmin only, 13.2)""" + return rbac_check(CommonPermissions.REVIEW_NODE_DESIGNER, usecase_param) \ No newline at end of file diff --git a/edge-cv-portal/backend/functions/user_admin.py b/edge-cv-portal/backend/functions/user_admin.py new file mode 100644 index 00000000..3250c860 --- /dev/null +++ b/edge-cv-portal/backend/functions/user_admin.py @@ -0,0 +1,1664 @@ +""" +User Admin handler for Edge CV Portal +PortalAdmin-only Cognito account management: listing, creation, password +change, forgot-password (temporary password), role change, +disable/enable, and edge account sync. + +Routed under /api/v1/admin/* behind the existing jwt_authorizer (requests +without a valid JWT are rejected before this Lambda runs). Every handler +additionally asserts the PortalAdmin role and returns 403 otherwise. +""" +import base64 +import hashlib +import json +import logging +import os +import secrets +import string +import time +import uuid +from functools import wraps +from typing import Dict, Any, List, Optional, Set +from urllib.parse import unquote + +import boto3 +from botocore.exceptions import ClientError + +from shared_utils import ( + USER_ACCOUNT_RESOURCE_TYPE, + create_response, + finalize_audit_event, + get_user_from_event, + record_audit_event_strict, +) + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Environment configuration +USER_POOL_ID = os.environ.get('USER_POOL_ID') +EDGE_CREDENTIALS_TABLE = os.environ.get( + 'EDGE_CREDENTIALS_TABLE', 'dda-portal-edge-credentials') +SES_SENDER_ADDRESS = os.environ.get('SES_SENDER_ADDRESS') +# Account_Sync_Service: per-device sync-state table and the account_sync +# Lambda invoked for an immediate sync attempt after staging (task 3.4 +# creates the function; absence is tolerated - the 5-minute schedule +# picks up staged pending changes regardless). +ACCOUNT_SYNC_TABLE = os.environ.get( + 'ACCOUNT_SYNC_TABLE', 'dda-portal-account-sync') +DEVICES_TABLE = os.environ.get('DEVICES_TABLE') +ACCOUNT_SYNC_FUNCTION = os.environ.get('ACCOUNT_SYNC_FUNCTION') + +# AWS clients +cognito_client = boto3.client('cognito-idp') +dynamodb = boto3.resource('dynamodb') +ses_client = boto3.client('ses') +lambda_client = boto3.client('lambda') + +# --- Pure credential functions ------------------------------------------- + +# Password policy (AuthStack): minimum length 12, requires lowercase, +# uppercase, digits, and symbols. +PASSWORD_MIN_LENGTH = 12 +PASSWORD_SYMBOLS = '!@#$%^&*()-_=+[]{}' + +# PBKDF2 verifier parameters (design decision D4) +VERIFIER_ALGORITHM = 'pbkdf2-sha256' +VERIFIER_ITERATIONS = 210000 +VERIFIER_SALT_BYTES = 16 +VERIFIER_HASH_BYTES = 32 + +# The five defined Portal_Role values (Requirement 5.2) +PORTAL_ROLES = ('PortalAdmin', 'UseCaseAdmin', 'DataScientist', + 'Operator', 'Viewer') + + +def validate_create_request(body: Dict[str, Any]) -> Optional[Dict[str, str]]: + """ + Pure validation gate for account creation (Requirements 12.6-12.8). + + Returns None when the payload is valid, otherwise a rejection + {'field', 'message'} identifying the offending field: + + - username, email, and role must all be present and non-empty (12.7) + - the email must consist of a non-empty local part, an '@' + separator, and a non-empty domain containing at least one dot (12.6) + - the role must be one of the five defined Portal_Role values (12.8) + + Only a payload passing every check may reach admin_create_user; a + rejection performs no User_Pool call (callers return before any + Cognito interaction). + """ + body = body or {} + for field in ('username', 'email', 'role'): + value = body.get(field) + if not isinstance(value, str) or not value: + return { + 'field': field, + 'message': f'{field} is required and must be non-empty', + } + + parts = body['email'].split('@') + if len(parts) != 2 or not parts[0] or not parts[1] or '.' not in parts[1]: + return { + 'field': 'email', + 'message': 'email address is invalid: it must consist of a ' + 'non-empty local part, an @ separator, and a ' + 'non-empty domain containing at least one dot', + } + + if body['role'] not in PORTAL_ROLES: + return { + 'field': 'role', + 'message': f"role must be one of: {', '.join(PORTAL_ROLES)}", + } + + return None + + +def generate_temp_password(length: int = 16) -> str: + """ + Generate a temporary password conforming to the pool Password_Policy: + length >= 12 with at least one lowercase, uppercase, digit, and symbol. + + Characters are picked with secrets.choice and the result is shuffled + with secrets.SystemRandom().shuffle so class positions are not predictable. + """ + if length < PASSWORD_MIN_LENGTH: + raise ValueError( + f'length must be >= {PASSWORD_MIN_LENGTH}, got {length}' + ) + + classes = [ + string.ascii_lowercase, + string.ascii_uppercase, + string.digits, + PASSWORD_SYMBOLS, + ] + + # One guaranteed character from each required class + chars = [secrets.choice(cls) for cls in classes] + + # Fill the remainder from the union of all classes + alphabet = ''.join(classes) + chars.extend(secrets.choice(alphabet) for _ in range(length - len(chars))) + + secrets.SystemRandom().shuffle(chars) + return ''.join(chars) + + +def make_verifier(password: str, iterations: int = VERIFIER_ITERATIONS) -> Dict[str, Any]: + """ + Compute a salted one-way credential verifier for a plaintext password. + + Returns {algorithm, iterations, salt (b64), hash (b64)} using + PBKDF2-HMAC-SHA256 with a fresh 16-byte random salt. The iteration + count is parameterizable for tests; production callers use the default. + """ + salt = secrets.token_bytes(VERIFIER_SALT_BYTES) + derived = hashlib.pbkdf2_hmac( + 'sha256', + password.encode('utf-8'), + salt, + iterations, + dklen=VERIFIER_HASH_BYTES, + ) + return { + 'algorithm': VERIFIER_ALGORITHM, + 'iterations': iterations, + 'salt': base64.b64encode(salt).decode('ascii'), + 'hash': base64.b64encode(derived).decode('ascii'), + } + + +# --- Pure sync-document builder -------------------------------------------- + +# Shadow dda-user-accounts document schema version (design data model). +SYNC_DOCUMENT_VERSION = 1 + +# AWS IoT named-shadow document size limit the rendered desired state +# must fit within (design: validate against the 8 KB shadow limit). +SHADOW_SIZE_LIMIT_BYTES = 8 * 1024 + + +class SyncDocumentTooLarge(ValueError): + """The rendered sync document exceeds the 8 KB shadow size limit.""" + + +def build_sync_document(accounts: Dict[str, Dict[str, Any]], + sync_id: str) -> Dict[str, Any]: + """ + Build the complete desired sync document for one device from a staged + account set (pure function, design data model for the + dda-user-accounts shadow). + + Each record carries only {email, role, enabled, deleted?, verifier?} + - the fields are copied by an explicit whitelist so plaintext + passwords can never appear in a sync payload no matter what the + input carries (Req 7.3). Disabled or deleted accounts are marked + `enabled: false` and are never dropped from the document (Req 7.8). + + Raises SyncDocumentTooLarge when the rendered desired state exceeds + the 8 KB shadow limit, with an explicit reason. + """ + doc_accounts = {} + for username, record in (accounts or {}).items(): + record = record or {} + deleted = bool(record.get('deleted', False)) + enabled = bool(record.get('enabled', False)) and not deleted + + entry: Dict[str, Any] = { + 'email': record.get('email', ''), + 'role': record.get('role') or 'Viewer', + 'enabled': enabled, + } + if deleted: + entry['deleted'] = True + + verifier = record.get('verifier') + if verifier: + entry['verifier'] = { + 'algorithm': verifier.get('algorithm'), + 'iterations': int(verifier.get('iterations', 0)), + 'salt': verifier.get('salt'), + 'hash': verifier.get('hash'), + } + + doc_accounts[username] = entry + + document = { + 'syncId': sync_id, + 'version': SYNC_DOCUMENT_VERSION, + 'accounts': doc_accounts, + } + + rendered = json.dumps({'state': {'desired': document}}, + separators=(',', ':')) + size = len(rendered.encode('utf-8')) + if size > SHADOW_SIZE_LIMIT_BYTES: + raise SyncDocumentTooLarge( + f'The rendered sync document is {size} bytes, exceeding the ' + f'{SHADOW_SIZE_LIMIT_BYTES}-byte (8 KB) IoT shadow limit; ' + f'reduce the number of selected accounts' + ) + return document + + +# --- PortalAdmin gate ------------------------------------------------------ + +def require_portal_admin(func): + """ + Decorator asserting the caller's validated JWT role is PortalAdmin. + Returns 403 without performing the operation otherwise (Requirement 1.5). + """ + @wraps(func) + def wrapper(event, *args, **kwargs): + user = get_user_from_event(event) + if user.get('role') != 'PortalAdmin': + logger.warning( + f"PortalAdmin gate rejected user {user.get('user_id')} " + f"with role {user.get('role')}" + ) + return create_response(403, { + 'error': 'Access denied', + 'message': 'PortalAdmin role required' + }) + return func(event, *args, **kwargs) + return wrapper + + +# --- Router ---------------------------------------------------------------- + +def handler(event, context): + """ + Handle user admin requests + + GET /api/v1/admin/users - List Cognito accounts + POST /api/v1/admin/users - Create a Cognito account + POST /api/v1/admin/users/{username}/password - Set account password + POST /api/v1/admin/users/{username}/forgot-password - Email a temporary password + PUT /api/v1/admin/users/{username}/role - Change account role + POST /api/v1/admin/users/{username}/disable - Disable an account + POST /api/v1/admin/users/{username}/enable - Enable an account + DELETE /api/v1/admin/users/{username} - Delete an account + GET /api/v1/admin/edge-sync/devices - Per-device sync status + POST /api/v1/admin/edge-sync/devices/{deviceId} - Stage and trigger a sync + """ + try: + http_method = event.get('httpMethod') + path = event.get('path', '') + + logger.info(f"User admin request: {http_method} {path}") + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return create_response(200, '', { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }) + + if http_method == 'GET' and path.endswith('/admin/users'): + return list_accounts(event) + elif http_method == 'POST' and path.endswith('/admin/users'): + return create_account(event) + elif http_method == 'GET' and path.endswith('/edge-sync/devices'): + return list_sync_devices(event) + elif http_method == 'POST' and '/edge-sync/devices/' in path: + return sync_device(event) + elif http_method == 'POST' and path.endswith('/password'): + return set_password(event) + elif http_method == 'POST' and path.endswith('/forgot-password'): + return forgot_password(event) + elif http_method == 'PUT' and path.endswith('/role'): + return change_role(event) + elif http_method == 'POST' and path.endswith('/disable'): + return disable_account(event) + elif http_method == 'POST' and path.endswith('/enable'): + return enable_account(event) + elif http_method == 'DELETE' and '/admin/users/' in path: + return delete_account(event) + + return create_response(404, {'error': 'Not found'}) + + except Exception as e: + logger.error(f"Error in user admin handler: {str(e)}", exc_info=True) + return create_response(500, {'error': 'Internal server error'}) + + +# --- Endpoint handlers (implemented in subsequent tasks) -------------------- + +def _list_all_pool_users() -> List[Dict[str, Any]]: + """Paginate Cognito list_users fully and return every user in the pool.""" + users = [] + params = {'UserPoolId': USER_POOL_ID, 'Limit': 60} + while True: + response = cognito_client.list_users(**params) + users.extend(response.get('Users', [])) + token = response.get('PaginationToken') + if not token: + return users + params['PaginationToken'] = token + + +def _load_edge_capable_usernames() -> Set[str]: + """ + Scan the edge-credentials table for usernames that have a captured + credential verifier. Keys are stored normalized (lowercase). + """ + table = dynamodb.Table(EDGE_CREDENTIALS_TABLE) + usernames = set() + scan_kwargs = { + 'ProjectionExpression': '#u', + 'ExpressionAttributeNames': {'#u': 'username'}, + } + while True: + page = table.scan(**scan_kwargs) + usernames.update( + item['username'] for item in page.get('Items', [])) + last_key = page.get('LastEvaluatedKey') + if not last_key: + return usernames + scan_kwargs['ExclusiveStartKey'] = last_key + + +def _account_row(user: Dict[str, Any], + edge_capable_usernames: Set[str]) -> Dict[str, Any]: + """Map a Cognito list_users record to the account listing shape.""" + attrs = {a['Name']: a['Value'] for a in user.get('Attributes', [])} + username = user.get('Username', '') + return { + 'username': username, + 'email': attrs.get('email', ''), + 'email_verified': attrs.get('email_verified') == 'true', + 'role': attrs.get('custom:role') or 'Viewer', + 'user_status': user.get('UserStatus', ''), + 'enabled': bool(user.get('Enabled', False)), + 'edge_capable': username.lower() in edge_capable_usernames, + } + + +@require_portal_admin +def list_accounts(event): + """ + GET /api/v1/admin/users + + List all User_Pool accounts (Cognito list_users paginated fully), + joined with the edge-credentials table for the edge_capable flag + (Requirements 2.1). Accounts without a custom:role default to Viewer. + """ + try: + users = _list_all_pool_users() + edge_capable_usernames = _load_edge_capable_usernames() + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error(f"Failed to retrieve account list: {message}") + return create_response(502, { + 'error': 'Failed to retrieve account list', + 'message': message, + }) + + accounts = [_account_row(u, edge_capable_usernames) for u in users] + return create_response(200, { + 'users': accounts, + 'total_count': len(accounts), + }) + + +def _username_from_path(event) -> str: + """Extract the {username} path parameter for /admin/users/{username}/... + + Prefers API Gateway pathParameters; falls back to parsing the raw + path (the segment following 'users'), URL-decoding either way. + """ + params = event.get('pathParameters') or {} + if params.get('username'): + return unquote(params['username']) + segments = [s for s in event.get('path', '').split('/') if s] + try: + return unquote(segments[segments.index('users') + 1]) + except (ValueError, IndexError): + return '' + + +@require_portal_admin +def create_account(event): + """ + POST /api/v1/admin/users + + Body {username, email, role}. Flow (audit-before-effect, D10): + validate_create_request (12.6, 12.7, 12.8 - a rejection performs no + User_Pool call) -> audit-pending (account_create) -> + admin_create_user with custom:role, email, email_verified=true and + the Cognito-native email invitation (D12 - default MessageAction, no + SES, no portal-generated password, no verifier capture) -> + audit-final carrying the created account's {username, email, role} + (12.11). + + Error mapping: UsernameExistsException / AliasExistsException -> + 409 "account already exists" with the Cognito error Message passed + through (the pool has email as an alias attribute, so Cognito's + Message names the actual conflict - a duplicate username reads + "User account already exists" satisfying 12.5, while an email held + by another account reads e.g. "An account with the given email + already exists"); nothing is created or modified. Other Cognito + errors -> 502 "account was not created" with no partial record + (creation is atomic on the Cognito side, 12.9), audit-final failure. + A pending-audit write failure -> 500 "action not applied" with + Cognito untouched (6.4, 6.5). + + _Requirements: 12.1, 12.3, 12.5, 12.6, 12.7, 12.8, 12.9, 12.11_ + """ + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Invalid JSON body'}) + + if not isinstance(body, dict): + return create_response(400, {'error': 'Invalid JSON body'}) + + # Pure validation gate: a rejection returns before any User_Pool + # call, so no account or partial record can exist (12.6-12.8). + rejection = validate_create_request(body) + if rejection is not None: + return create_response(400, { + 'error': 'Invalid account request', + 'field': rejection['field'], + 'message': rejection['message'], + }) + + username = body['username'] + email = body['email'] + role = body['role'] + + acting_user = get_user_from_event(event) + + # Audit-before-effect: the pending entry must be recorded before + # Cognito is touched; if it cannot be, the action is not applied + # (Req 6.4, 6.5). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], 'account_create', + USER_ACCOUNT_RESOURCE_TYPE, username, + details={'email': email, 'role': role}, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; account creation for " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + # Cognito-native invitation (D12): the default MessageAction sends + # the invitation email with a Cognito-generated policy-conformant + # temporary password (12.3); the portal never holds it, so no + # verifier is captured at creation. + try: + cognito_client.admin_create_user( + UserPoolId=USER_POOL_ID, + Username=username, + UserAttributes=[ + {'Name': 'email', 'Value': email}, + {'Name': 'email_verified', 'Value': 'true'}, + {'Name': 'custom:role', 'Value': role}, + ], + DesiredDeliveryMediums=['EMAIL'], + ) + except ClientError as e: + error = e.response.get('Error', {}) + code = error.get('Code', '') + message = error.get('Message', str(e)) + + if code in ('UsernameExistsException', 'AliasExistsException'): + # Account conflict: nothing was created or modified (12.5). + # The Cognito Message is passed through verbatim because it + # names the actual conflict - a duplicate username or, since + # email is a pool alias attribute, an email already used by + # another account. + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(409, { + 'error': 'account already exists', + 'message': message, + }) + + # Any other Cognito failure: creation is atomic, so no account + # or partial record remains in the User_Pool (12.9). + logger.error(f"admin_create_user failed for {username}: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, {'error': 'account was not created'}) + + # Audit-final carries the created account's username, email, and + # role (12.11). + finalize_audit_event(audit_event_id, 'success', { + 'username': username, + 'email': email, + 'role': role, + }) + + return create_response(201, { + 'message': f'Account created for {username}; an invitation with ' + f'a temporary password was sent to {email}', + 'username': username, + 'email': email, + 'role': role, + }) + + +def _store_verifier(username: str, password: str): + """ + Capture a credential verifier at password-set time (design D3). + + Stored in the edge-credentials table keyed by the normalized + (lowercase) username with an updatedAt timestamp, so the account + becomes edge-login-capable. Never stores the plaintext (Req 7.3). + + The fresh verifier is a synchronized account attribute, so its + capture also refreshes every device's staged account set and marks + those devices as having pending changes (Req 7.2). + """ + verifier = make_verifier(password) + table = dynamodb.Table(EDGE_CREDENTIALS_TABLE) + table.put_item(Item={ + 'username': username.lower(), + 'verifier': verifier, + 'updatedAt': int(time.time() * 1000), + }) + _mark_account_change_pending(username, {'verifier': verifier}) + + +def _mark_account_change_pending(username: str, changes: Dict[str, Any]): + """ + Attribute-change hook (Req 7.2): when a synchronized account + attribute changes (credential verifier, role, enabled/disabled + state), refresh the account's record in every device's staged set + in `dda-portal-account-sync` and mark the device as having pending + changes so the next sync (immediate or scheduled) delivers it. + + A fresh syncId is assigned so an in-flight ack of the previously + staged content cannot mark the refreshed content as delivered. + + Failures are logged, never raised: the primary account action has + already succeeded, and staged sets are retried by the 5-minute + schedule regardless. + """ + try: + table = dynamodb.Table(ACCOUNT_SYNC_TABLE) + scan_kwargs: Dict[str, Any] = {} + while True: + page = table.scan(**scan_kwargs) + for row in page.get('Items', []): + staged = row.get('accounts') or {} + # Staged sets key accounts by the Cognito username; + # match case-insensitively (the credentials table + # normalizes to lowercase). + key = next((k for k in staged + if k.lower() == username.lower()), None) + if key is None: + continue + record = dict(staged[key]) + record.update(changes) + table.update_item( + Key={'device_id': row['device_id']}, + UpdateExpression=( + 'SET accounts.#u = :r, syncId = :s, ' + 'pendingChanges = :p, #st = :pending'), + ExpressionAttributeNames={ + '#u': key, '#st': 'status'}, + ExpressionAttributeValues={ + ':r': record, + ':s': str(uuid.uuid4()), + ':p': True, + ':pending': 'pending', + }, + ) + last_key = page.get('LastEvaluatedKey') + if not last_key: + return + scan_kwargs['ExclusiveStartKey'] = last_key + except Exception as e: + logger.error( + f"Failed to mark staged syncs pending after an attribute " + f"change for {username}: {e}") + + +@require_portal_admin +def set_password(event): + """ + POST /api/v1/admin/users/{username}/password + + Body {password, permanent: bool}. Flow (audit-before-effect, D10): + audit-pending -> admin_set_user_password(Permanent=permanent) -> + verifier capture -> audit-final. + + Error mapping: InvalidPasswordException -> 400 with the policy + message passed through and no verifier write (3.3); + UserNotFoundException -> 404; other Cognito errors -> 502 + "password change failed" (3.5). A pending-audit write failure + -> 500 "action not applied" with Cognito untouched (6.4, 6.5). + + _Requirements: 3.1, 3.3, 3.5, 6.1, 6.4_ + """ + username = _username_from_path(event) + if not username: + return create_response(400, {'error': 'Username is required'}) + + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Invalid JSON body'}) + + password = body.get('password') + permanent = body.get('permanent') + if not isinstance(password, str) or not password: + return create_response(400, {'error': 'password is required'}) + if not isinstance(permanent, bool): + return create_response( + 400, {'error': 'permanent must be a boolean'}) + + acting_user = get_user_from_event(event) + + # Audit-before-effect: the pending entry must be recorded before + # Cognito is touched; if it cannot be, the action is not applied + # (Req 6.4, 6.5). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], 'password_change', + USER_ACCOUNT_RESOURCE_TYPE, username, + details={'permanent': permanent}, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; password change for " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + try: + cognito_client.admin_set_user_password( + UserPoolId=USER_POOL_ID, + Username=username, + Password=password, + Permanent=permanent, + ) + except ClientError as e: + error = e.response.get('Error', {}) + code = error.get('Code', '') + message = error.get('Message', str(e)) + + if code == 'InvalidPasswordException': + # Policy violation: pass the policy message through, leave + # the existing password unchanged, write no verifier (3.3). + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(400, { + 'error': 'Password policy violation', + 'message': message, + }) + if code == 'UserNotFoundException': + finalize_audit_event(audit_event_id, 'failure', + {'reason': 'user not found'}) + return create_response(404, {'error': 'User not found'}) + + # Any other Cognito failure: account untouched (3.5). + logger.error( + f"admin_set_user_password failed for {username}: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, {'error': 'password change failed'}) + + _store_verifier(username, password) + + finalize_audit_event(audit_event_id, 'success', + {'permanent': permanent}) + + return create_response(200, { + 'message': f'Password changed for {username}', + 'username': username, + 'permanent': permanent, + }) + + +def _send_temp_password_email(recipient: str, username: str, password: str): + """Deliver a temporary password to the account's registered email + address via SES from the configured sender (Req 4.1).""" + if not SES_SENDER_ADDRESS: + raise RuntimeError('SES_SENDER_ADDRESS is not configured') + ses_client.send_email( + Source=SES_SENDER_ADDRESS, + Destination={'ToAddresses': [recipient]}, + Message={ + 'Subject': { + 'Data': 'Your Edge CV Portal temporary password', + }, + 'Body': { + 'Text': { + 'Data': ( + f'A temporary password was issued for your Edge CV ' + f'Portal account "{username}".\n\n' + f'Temporary password: {password}\n\n' + f'You will be required to set a new password at ' + f'your next sign-in.' + ), + }, + }, + }, + ) + + +@require_portal_admin +def forgot_password(event): + """ + POST /api/v1/admin/users/{username}/forgot-password + + Flow (audit-before-effect, D10): verified-email check (400 before + anything is generated when email_verified != 'true', 4.4) -> + generate_temp_password -> audit-pending -> SES SendEmail from the + configured sender -> admin_set_user_password(Permanent=False) -> + verifier capture -> audit-final. + + The SES send happens before the password set so a delivery failure + leaves the account's existing credentials untouched (4.5). If the + password set fails after a successful send, the emailed password is + inert (it never became valid) and the action reports failure. The + response never contains the temporary password value (4.3). + + _Requirements: 4.1, 4.3, 4.4, 4.5, 6.1, 6.3_ + """ + username = _username_from_path(event) + if not username: + return create_response(400, {'error': 'Username is required'}) + + # Verified-email check before anything is generated (4.4). + try: + user = cognito_client.admin_get_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + if error.get('Code') == 'UserNotFoundException': + return create_response(404, {'error': 'User not found'}) + message = error.get('Message', str(e)) + logger.error(f"admin_get_user failed for {username}: {message}") + return create_response(502, {'error': 'forgot-password failed'}) + + attrs = {a['Name']: a['Value'] for a in user.get('UserAttributes', [])} + if attrs.get('email_verified') != 'true': + return create_response(400, { + 'error': 'No verified email address', + 'message': f'The account {username} has no verified email ' + f'address', + }) + email = attrs.get('email') + + temp_password = generate_temp_password() + + acting_user = get_user_from_event(event) + + # Audit-before-effect: the pending entry must be recorded before + # anything is sent or applied (Req 6.4, 6.5). Details never carry + # the temporary password value (6.3). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], 'forgot_password', + USER_ACCOUNT_RESOURCE_TYPE, username, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; forgot-password for " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + # SES send before the password set: a delivery failure leaves the + # account's existing credentials untouched (4.5). + try: + _send_temp_password_email(email, username, temp_password) + except Exception as e: + message = str(e) + if isinstance(e, ClientError): + message = e.response.get('Error', {}).get('Message', message) + logger.error( + f"Temporary password delivery failed for {username}: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': 'email delivery failed'}) + return create_response(502, { + 'error': 'temporary password was not sent', + 'message': 'The temporary password was not sent; the ' + 'account credentials are unchanged', + }) + + try: + cognito_client.admin_set_user_password( + UserPoolId=USER_POOL_ID, + Username=username, + Password=temp_password, + Permanent=False, + ) + except ClientError as e: + # The emailed password never became valid; the account's + # existing credentials remain in effect. + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error( + f"admin_set_user_password failed for {username} after the " + f"temporary password email was sent: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, { + 'error': 'forgot-password failed', + 'message': 'The emailed temporary password was not applied ' + 'and is not valid', + }) + + _store_verifier(username, temp_password) + + finalize_audit_event(audit_event_id, 'success') + + return create_response(200, { + 'message': f'Temporary password sent to the registered email ' + f'address for {username}', + 'username': username, + }) + + +def _count_enabled_portal_admins() -> int: + """Count enabled accounts whose custom:role is PortalAdmin. + + Cognito list_users cannot filter on custom attributes, so the + last-PortalAdmin guard paginates the whole pool (design, Req 5.3). + """ + count = 0 + for user in _list_all_pool_users(): + if not user.get('Enabled'): + continue + attrs = {a['Name']: a['Value'] for a in user.get('Attributes', [])} + if attrs.get('custom:role') == 'PortalAdmin': + count += 1 + return count + + +@require_portal_admin +def change_role(event): + """ + PUT /api/v1/admin/users/{username}/role + + Body {role}. Flow (design): validate against the five defined + Portal_Role values (5.2) -> last-PortalAdmin guard (5.3, 5.5) -> + audit-pending -> admin_update_user_attributes on custom:role (5.1) + -> audit-final recording the previous and new role (5.4). + + Guard: when the change would remove the PortalAdmin role from the + last remaining enabled PortalAdmin account, reject with 409 + the + reason and record the rejected attempt in the audit log. + + Error mapping: UserNotFoundException -> 404; other Cognito failures + -> 502 "role change failed" with the role unchanged and the audit + entry finalized to failure (5.6). A pending-audit write failure -> + 500 "action not applied" with Cognito untouched (6.4, 6.5). + + _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ + """ + username = _username_from_path(event) + if not username: + return create_response(400, {'error': 'Username is required'}) + + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Invalid JSON body'}) + + new_role = body.get('role') + if new_role not in PORTAL_ROLES: + return create_response(400, { + 'error': 'Invalid role', + 'message': f"role must be one of: {', '.join(PORTAL_ROLES)}", + }) + + # Current state: previous role for the audit record (5.4) and the + # enabled flag for the last-PortalAdmin guard (5.3). + try: + user = cognito_client.admin_get_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + if error.get('Code') == 'UserNotFoundException': + return create_response(404, {'error': 'User not found'}) + message = error.get('Message', str(e)) + logger.error(f"admin_get_user failed for {username}: {message}") + return create_response(502, {'error': 'role change failed'}) + + attrs = {a['Name']: a['Value'] for a in user.get('UserAttributes', [])} + previous_role = attrs.get('custom:role') or 'Viewer' + target_enabled = bool(user.get('Enabled', False)) + + acting_user = get_user_from_event(event) + + # Last-PortalAdmin guard (5.3): only a change that takes PortalAdmin + # away from an enabled PortalAdmin account can reduce the enabled- + # PortalAdmin count. + if (previous_role == 'PortalAdmin' and target_enabled + and new_role != 'PortalAdmin'): + try: + admin_count = _count_enabled_portal_admins() + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error( + f"last-PortalAdmin guard count failed for {username}: " + f"{message}") + return create_response(502, {'error': 'role change failed'}) + + if admin_count <= 1: + reason = (f'{username} is the last remaining enabled ' + f'PortalAdmin account; the portal must retain at ' + f'least one enabled PortalAdmin') + # The rejected attempt is itself audited (5.5); if it cannot + # be recorded, the action is reported as not applied (6.4). + try: + record_audit_event_strict( + acting_user['user_id'], 'role_change', + USER_ACCOUNT_RESOURCE_TYPE, username, + result='rejected', + details={ + 'reason': reason, + 'previous_role': previous_role, + 'requested_role': new_role, + }, + ) + except Exception as e: + logger.error( + f"Rejected-attempt audit write failed for " + f"{username}: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + return create_response(409, { + 'error': 'Role change rejected', + 'message': reason, + }) + + # Audit-before-effect: the pending entry must be recorded before + # Cognito is touched; if it cannot be, the action is not applied + # (Req 6.4, 6.5). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], 'role_change', + USER_ACCOUNT_RESOURCE_TYPE, username, + details={'previous_role': previous_role, 'new_role': new_role}, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; role change for " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + try: + cognito_client.admin_update_user_attributes( + UserPoolId=USER_POOL_ID, + Username=username, + UserAttributes=[{'Name': 'custom:role', 'Value': new_role}], + ) + except ClientError as e: + error = e.response.get('Error', {}) + code = error.get('Code', '') + message = error.get('Message', str(e)) + + if code == 'UserNotFoundException': + finalize_audit_event(audit_event_id, 'failure', + {'reason': 'user not found'}) + return create_response(404, {'error': 'User not found'}) + + # Any other Cognito failure: the role is unchanged (5.6). + logger.error( + f"admin_update_user_attributes failed for {username}: " + f"{message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, {'error': 'role change failed'}) + + # The role is a synchronized account attribute: refresh every + # device's staged set and mark it pending (Req 7.2). + _mark_account_change_pending(username, {'role': new_role}) + + # Audit-final records the previous and new role (5.4). + finalize_audit_event(audit_event_id, 'success', { + 'previous_role': previous_role, + 'new_role': new_role, + }) + + return create_response(200, { + 'message': f'Role changed for {username}', + 'username': username, + 'previous_role': previous_role, + 'role': new_role, + }) + + +def _set_account_enabled(event, target_enabled: bool): + """ + Shared implementation for the disable/enable endpoints (task 13.3). + + Flow (design): admin_get_user reads the current Enabled state first + - already in the requested state -> 200 no-op returning the current + state with no Cognito mutation, no audit-pending write, and no sync + staging (13.6). Disable additionally runs the last-PortalAdmin + guard (shared predicate, D14): disabling the last remaining enabled + PortalAdmin -> 409 + the reason with the rejected attempt audited + before any mutation (13.9). Otherwise: audit-pending + (account_disable / account_enable) -> admin_disable_user / + admin_enable_user (13.2, 13.3) -> mark sync staging pending with + the new enabled state (7.2; disable also satisfies 7.8's + mark-as-disabled-on-next-sync) -> audit-final. + + Error mapping: UserNotFoundException -> 404; other Cognito failures + -> 502 "action failed" with the state unchanged and the audit entry + finalized to failure (13.7). A pending-audit write failure -> 500 + "action not applied" with Cognito untouched (6.4, 6.5). + + _Requirements: 13.2, 13.3, 13.6, 13.7, 13.9, 7.2, 7.8_ + """ + action = 'account_enable' if target_enabled else 'account_disable' + verb = 'enable' if target_enabled else 'disable' + state_word = 'enabled' if target_enabled else 'disabled' + + username = _username_from_path(event) + if not username: + return create_response(400, {'error': 'Username is required'}) + + # Current state first (13.6): the enabled flag, plus the role for + # the last-PortalAdmin guard on disable. + try: + user = cognito_client.admin_get_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + if error.get('Code') == 'UserNotFoundException': + return create_response(404, {'error': 'User not found'}) + message = error.get('Message', str(e)) + logger.error(f"admin_get_user failed for {username}: {message}") + return create_response(502, {'error': 'action failed'}) + + current_enabled = bool(user.get('Enabled', False)) + + if current_enabled == target_enabled: + # Already in the requested state: 200 no-op returning the + # current state - no Cognito mutation, no audit-pending write, + # no sync staging (13.6). + return create_response(200, { + 'message': f'{username} is already {state_word}', + 'username': username, + 'enabled': current_enabled, + 'changed': False, + }) + + acting_user = get_user_from_event(event) + + # Last-PortalAdmin guard on disable (D14, 5.3, 13.9): disabling + # reduces the enabled-PortalAdmin count exactly like a role change + # away from PortalAdmin. Here current_enabled is True (the states + # differ), so the target counts toward the enabled pool iff its + # role is PortalAdmin. + if not target_enabled: + attrs = {a['Name']: a['Value'] + for a in user.get('UserAttributes', [])} + if (attrs.get('custom:role') or 'Viewer') == 'PortalAdmin': + try: + admin_count = _count_enabled_portal_admins() + except ClientError as e: + message = e.response.get('Error', {}).get( + 'Message', str(e)) + logger.error( + f"last-PortalAdmin guard count failed for " + f"{username}: {message}") + return create_response(502, {'error': 'action failed'}) + + if admin_count <= 1: + reason = (f'{username} is the last remaining enabled ' + f'PortalAdmin account; the portal must retain ' + f'at least one enabled PortalAdmin') + # The rejected attempt is audited before any mutation + # (13.9); if it cannot be recorded, the action is + # reported as not applied (6.4). + try: + record_audit_event_strict( + acting_user['user_id'], action, + USER_ACCOUNT_RESOURCE_TYPE, username, + result='rejected', + details={'reason': reason}, + ) + except Exception as e: + logger.error( + f"Rejected-attempt audit write failed for " + f"{username}: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + return create_response(409, { + 'error': 'Disable rejected', + 'message': reason, + }) + + # Audit-before-effect: the pending entry must be recorded before + # Cognito is touched; if it cannot be, the action is not applied + # (Req 6.4, 6.5). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], action, + USER_ACCOUNT_RESOURCE_TYPE, username, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; {verb} for " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + try: + if target_enabled: + cognito_client.admin_enable_user( + UserPoolId=USER_POOL_ID, Username=username) + else: + cognito_client.admin_disable_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + code = error.get('Code', '') + message = error.get('Message', str(e)) + + if code == 'UserNotFoundException': + finalize_audit_event(audit_event_id, 'failure', + {'reason': 'user not found'}) + return create_response(404, {'error': 'User not found'}) + + # Any other Cognito failure: the state is unchanged (13.7). + logger.error( + f"admin_{verb}_user failed for {username}: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, {'error': 'action failed'}) + + # The enabled/disabled state is a synchronized account attribute: + # refresh every device's staged set and mark it pending (7.2; + # disable also satisfies 7.8's mark-as-disabled-on-next-sync). + _mark_account_change_pending(username, {'enabled': target_enabled}) + + finalize_audit_event(audit_event_id, 'success', + {'enabled': target_enabled}) + + return create_response(200, { + 'message': f'{username} has been {state_word}', + 'username': username, + 'enabled': target_enabled, + 'changed': True, + }) + + +@require_portal_admin +def disable_account(event): + """ + POST /api/v1/admin/users/{username}/disable + + _Requirements: 13.2, 13.6, 13.7, 13.9, 7.2, 7.8_ + """ + return _set_account_enabled(event, target_enabled=False) + + +@require_portal_admin +def enable_account(event): + """ + POST /api/v1/admin/users/{username}/enable + + _Requirements: 13.3, 13.6, 13.7, 7.2_ + """ + return _set_account_enabled(event, target_enabled=True) + + +@require_portal_admin +def delete_account(event): + """ + DELETE /api/v1/admin/users/{username} + + Flow (D13 ordering): admin_get_user captures the username, email, + and role for the audit entry (14.8) and maps UserNotFoundException + -> 404 with nothing modified (14.11) -> last-PortalAdmin guard + (shared predicate, D14): deleting the last remaining enabled + PortalAdmin -> 409 + the reason with the rejected attempt audited + (14.3, 14.4) -> audit-pending (account_delete) -> admin_delete_user + (14.2) -> delete the edge-credentials verifier record (14.5) -> + mark sync staging pending with enabled=false, deleted=true (7.8) + -> audit-final. + + Error mapping: a Cognito failure aborts before the verifier record + is touched - account and verifier record unchanged, audit-final + failure (14.6). A verifier-delete failure after a successful + Cognito delete retains the record for a subsequent attempt, + finalizes the audit entry with a partial-cleanup detail, and + returns an error stating the account was deleted but its verifier + record was not removed (14.10). A pending-audit write failure -> + 500 "action not applied" with Cognito untouched (6.4, 6.5). + + _Requirements: 14.2, 14.3, 14.4, 14.5, 14.6, 14.8, 14.10, 14.11, 7.8_ + """ + username = _username_from_path(event) + if not username: + return create_response(400, {'error': 'Username is required'}) + + # Current state first: username/email/role for the audit entry + # (14.8), the role and enabled flag for the last-PortalAdmin guard. + # A missing account -> 404 with nothing modified (14.11). + try: + user = cognito_client.admin_get_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + if error.get('Code') == 'UserNotFoundException': + return create_response(404, { + 'error': 'User not found', + 'message': f'The account {username} was not found', + }) + message = error.get('Message', str(e)) + logger.error(f"admin_get_user failed for {username}: {message}") + return create_response(502, {'error': 'deletion failed'}) + + attrs = {a['Name']: a['Value'] for a in user.get('UserAttributes', [])} + email = attrs.get('email', '') + role = attrs.get('custom:role') or 'Viewer' + target_enabled = bool(user.get('Enabled', False)) + + acting_user = get_user_from_event(event) + + # Last-PortalAdmin guard (D14, 14.3): deleting an enabled + # PortalAdmin reduces the enabled-PortalAdmin count exactly like a + # role change away from PortalAdmin or a disable. + if role == 'PortalAdmin' and target_enabled: + try: + admin_count = _count_enabled_portal_admins() + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error( + f"last-PortalAdmin guard count failed for {username}: " + f"{message}") + return create_response(502, {'error': 'deletion failed'}) + + if admin_count <= 1: + reason = (f'{username} is the last remaining enabled ' + f'PortalAdmin account; the portal must retain at ' + f'least one enabled PortalAdmin') + # The rejected attempt is itself audited (14.4); if it + # cannot be recorded, the action is reported as not + # applied (6.4). + try: + record_audit_event_strict( + acting_user['user_id'], 'account_delete', + USER_ACCOUNT_RESOURCE_TYPE, username, + result='rejected', + details={'reason': reason}, + ) + except Exception as e: + logger.error( + f"Rejected-attempt audit write failed for " + f"{username}: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + return create_response(409, { + 'error': 'Deletion rejected', + 'message': reason, + }) + + # Audit-before-effect: the pending entry must be recorded before + # Cognito is touched; if it cannot be, the action is not applied + # (Req 6.4, 6.5). The entry carries the account's email and role + # at the time of deletion (14.8). + try: + audit_event_id = record_audit_event_strict( + acting_user['user_id'], 'account_delete', + USER_ACCOUNT_RESOURCE_TYPE, username, + details={'email': email, 'role': role}, + ) + except Exception as e: + logger.error( + f"Pending audit write failed; deletion of " + f"{username} not applied: {e}") + return create_response(500, { + 'error': 'Audit log unavailable', + 'message': 'The action was not applied', + }) + + # Cognito delete first (D13): a failure here aborts before the + # verifier record is touched, leaving the account and its + # Edge_Credential_Verifier record unchanged (14.6). + try: + cognito_client.admin_delete_user( + UserPoolId=USER_POOL_ID, Username=username) + except ClientError as e: + error = e.response.get('Error', {}) + code = error.get('Code', '') + message = error.get('Message', str(e)) + + if code == 'UserNotFoundException': + finalize_audit_event(audit_event_id, 'failure', + {'reason': 'user not found'}) + return create_response(404, { + 'error': 'User not found', + 'message': f'The account {username} was not found', + }) + + logger.error(f"admin_delete_user failed for {username}: {message}") + finalize_audit_event(audit_event_id, 'failure', + {'reason': message}) + return create_response(502, {'error': 'deletion failed'}) + + # The account is deleted from the User_Pool: mark it disabled and + # deleted in every device's staged sync set regardless of what the + # verifier cleanup does next (7.8; failures inside are logged, + # never raised). + _mark_account_change_pending(username, {'enabled': False, + 'deleted': True}) + + # Verifier record cleanup (14.5). A failure after the successful + # Cognito delete retains the record for a subsequent attempt, + # finalizes the audit entry with a partial-cleanup detail, and + # reports that the account was deleted but its verifier record was + # not removed (14.10). + try: + dynamodb.Table(EDGE_CREDENTIALS_TABLE).delete_item( + Key={'username': username.lower()}) + except Exception as e: + logger.error( + f"Edge-credentials record delete failed for {username} " + f"after a successful Cognito delete: {e}") + finalize_audit_event(audit_event_id, 'success', { + 'email': email, + 'role': role, + 'partial_cleanup': 'the account was deleted from the user ' + 'pool but its credential record was not ' + 'removed; it is retained for a ' + 'subsequent removal attempt', + }) + return create_response(502, { + 'error': 'partial deletion', + 'message': f'The account {username} was deleted but its ' + f'verifier record was not removed; it will be ' + f'removed on a subsequent attempt', + }) + + # Audit-final records the deleted account's username, email, and + # role at the time of deletion (14.8). + finalize_audit_event(audit_event_id, 'success', { + 'email': email, + 'role': role, + }) + + return create_response(200, { + 'message': f'Account {username} has been deleted', + 'username': username, + 'email': email, + 'role': role, + 'deleted': True, + }) + + +def _scan_all_items(table, **scan_kwargs) -> List[Dict[str, Any]]: + """Paginate a DynamoDB table scan fully.""" + items = [] + kwargs = dict(scan_kwargs) + while True: + page = table.scan(**kwargs) + items.extend(page.get('Items', [])) + last_key = page.get('LastEvaluatedKey') + if not last_key: + return items + kwargs['ExclusiveStartKey'] = last_key + + +@require_portal_admin +def list_sync_devices(event): + """ + GET /api/v1/admin/edge-sync/devices + + Devices table joined with the dda-portal-account-sync sync-state + table: per device the last sync status, last sync timestamp, and + whether undelivered pending changes exist (Req 7.4 display data). + Devices without a sync row report null status ("never synced"). + + _Requirements: 7.1 (device list for initiating syncs), 7.4_ + """ + if not DEVICES_TABLE: + return create_response( + 500, {'error': 'Devices table not configured'}) + + try: + device_items = _scan_all_items( + dynamodb.Table(DEVICES_TABLE), + ProjectionExpression='device_id', + ) + sync_items = _scan_all_items(dynamodb.Table(ACCOUNT_SYNC_TABLE)) + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error(f"Failed to retrieve edge-sync device list: {message}") + return create_response(502, { + 'error': 'Failed to retrieve edge-sync device list', + 'message': message, + }) + + sync_by_device = {row['device_id']: row for row in sync_items + if row.get('device_id')} + device_ids = {item['device_id'] for item in device_items + if item.get('device_id')} + # Devices with staged sync state are listed even if their devices- + # table record has gone, so pending changes stay visible. + device_ids.update(sync_by_device.keys()) + + devices = [] + for device_id in sorted(device_ids): + row = sync_by_device.get(device_id, {}) + devices.append({ + 'device_id': device_id, + 'lastSyncStatus': row.get('status'), + 'lastSyncAt': row.get('lastSyncAt'), + 'pendingChanges': bool(row.get('pendingChanges', False)), + 'failureReason': row.get('failureReason'), + }) + + return create_response(200, { + 'devices': devices, + 'count': len(devices), + }) + + +def _device_id_from_path(event) -> str: + """Extract {deviceId} for /admin/edge-sync/devices/{deviceId}.""" + params = event.get('pathParameters') or {} + for key in ('deviceId', 'device_id', 'id'): + if params.get(key): + return unquote(params[key]) + segments = [s for s in event.get('path', '').split('/') if s] + try: + return unquote(segments[segments.index('devices') + 1]) + except (ValueError, IndexError): + return '' + + +def _load_verifier(username: str) -> Optional[Dict[str, Any]]: + """The captured credential verifier for a username, or None.""" + table = dynamodb.Table(EDGE_CREDENTIALS_TABLE) + item = table.get_item( + Key={'username': username.lower()}).get('Item') or {} + return item.get('verifier') + + +def _invoke_sync_lambda(device_id: str, sync_id: str) -> bool: + """ + Ask the account_sync Lambda for an immediate sync attempt. + + Absence of the function (env var unset) or an invoke failure is + tolerated: staged pending changes are picked up by the 5-minute + schedule regardless (Req 7.7). + """ + if not ACCOUNT_SYNC_FUNCTION: + logger.info( + 'ACCOUNT_SYNC_FUNCTION not configured; staged sync for ' + f'{device_id} awaits the scheduled attempt') + return False + try: + lambda_client.invoke( + FunctionName=ACCOUNT_SYNC_FUNCTION, + InvocationType='Event', + Payload=json.dumps({ + 'action': 'sync_attempt', + 'device_id': device_id, + 'syncId': sync_id, + }), + ) + return True + except Exception as e: + logger.error( + f"Failed to invoke the account sync Lambda for {device_id}: " + f"{e}") + return False + + +@require_portal_admin +def sync_device(event): + """ + POST /api/v1/admin/edge-sync/devices/{deviceId} + + Body {usernames: [...]}. Stages the selected accounts as the + device's complete staged account set - each record carrying + username, email, Portal_Role, enabled/disabled state, and the + captured credential verifier when one exists (Req 7.1, 7.3) - with + a fresh syncId and pendingChanges=true, then invokes the sync + Lambda for an immediate attempt. + + Disabled accounts are staged marked `enabled: false`, never + dropped (Req 7.8). The rendered document is validated against the + 8 KB shadow limit before staging and the request fails with an + explicit reason when it does not fit. + + _Requirements: 7.1, 7.2, 7.3, 7.8_ + """ + device_id = _device_id_from_path(event) + if not device_id: + return create_response(400, {'error': 'Device id is required'}) + + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return create_response(400, {'error': 'Invalid JSON body'}) + + usernames = body.get('usernames') + if (not isinstance(usernames, list) or not usernames + or not all(isinstance(u, str) and u for u in usernames)): + return create_response(400, { + 'error': 'usernames must be a non-empty list of usernames'}) + + # Resolve the selected accounts' current attributes from the pool. + try: + pool_users = {u.get('Username'): u for u in _list_all_pool_users()} + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error(f"Failed to resolve accounts for sync: {message}") + return create_response(502, { + 'error': 'Failed to resolve the selected accounts', + 'message': message, + }) + + unknown = sorted(set(usernames) - set(pool_users)) + if unknown: + return create_response(400, { + 'error': 'Unknown usernames', + 'message': f"Not found in the user pool: {', '.join(unknown)}", + }) + + # Stage the complete selected account set (Req 7.1): disabled + # accounts are included marked enabled=false, never dropped (7.8); + # credential material only as the captured one-way verifier (7.3). + try: + staged_accounts = {} + for username in sorted(set(usernames)): + user = pool_users[username] + attrs = {a['Name']: a['Value'] + for a in user.get('Attributes', [])} + record: Dict[str, Any] = { + 'email': attrs.get('email', ''), + 'role': attrs.get('custom:role') or 'Viewer', + 'enabled': bool(user.get('Enabled', False)), + } + verifier = _load_verifier(username) + if verifier: + record['verifier'] = verifier + staged_accounts[username] = record + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error(f"Failed to load credential verifiers: {message}") + return create_response(502, { + 'error': 'Failed to load credential verifiers', + 'message': message, + }) + + sync_id = str(uuid.uuid4()) + + # Validate the rendered document against the 8 KB shadow limit + # before anything is staged; fail with the explicit reason. + try: + build_sync_document(staged_accounts, sync_id) + except SyncDocumentTooLarge as e: + return create_response(400, { + 'error': 'Sync document too large', + 'message': str(e), + }) + + # Stage atomically on the device's sync-state row, preserving any + # prior lastSyncAt; a stale failureReason is cleared with the new + # staging. + try: + dynamodb.Table(ACCOUNT_SYNC_TABLE).update_item( + Key={'device_id': device_id}, + UpdateExpression=( + 'SET syncId = :s, accounts = :a, #st = :pending, ' + 'pendingChanges = :p, stagedAt = :now ' + 'REMOVE failureReason'), + ExpressionAttributeNames={'#st': 'status'}, + ExpressionAttributeValues={ + ':s': sync_id, + ':a': staged_accounts, + ':pending': 'pending', + ':p': True, + ':now': int(time.time() * 1000), + }, + ) + except ClientError as e: + message = e.response.get('Error', {}).get('Message', str(e)) + logger.error( + f"Failed to stage account sync for {device_id}: {message}") + return create_response(502, { + 'error': 'Failed to stage the account sync', + 'message': message, + }) + + sync_invoked = _invoke_sync_lambda(device_id, sync_id) + + return create_response(200, { + 'message': f'Account sync to {device_id} staged for ' + f'{len(staged_accounts)} account(s)', + 'device_id': device_id, + 'syncId': sync_id, + 'accountCount': len(staged_accounts), + 'pendingChanges': True, + 'syncInvoked': sync_invoked, + }) diff --git a/edge-cv-portal/backend/functions/workflow_generator.py b/edge-cv-portal/backend/functions/workflow_generator.py new file mode 100644 index 00000000..fdfa43a6 --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_generator.py @@ -0,0 +1,679 @@ +""" +Workflow Generator API Lambda function (Workflow Manager) + +Prompt-based Workflow_Definition generation via Amazon Bedrock +(Workflow_Generator, Requirements 10.2, 10.3, 10.5, 10.7). + +Routes (API Gateway REST): + POST /workflows/generate Submit a natural-language prompt (optionally + within an existing chat session) and receive a + generated Workflow_Definition plus the complete + Workflow_Validator findings list. + +Design (design.md section 9): +- Chat sessions live in the WorkflowChatSessions DynamoDB table (TTL'd), + holding the message history and the current canvas Workflow_Definition + snapshot (stored in portal S3, referenced by ``current_definition_key``). +- Invocation uses the Bedrock Converse API with a ``create_workflow`` tool + whose input schema IS the Workflow_Definition JSON Schema; the system + prompt embeds the serialized node catalog (10.2). +- Follow-up prompts include the current canvas definition and instruct + modification rather than regeneration (10.5). +- Tool output is parsed by the Workflow_Serializer and then run through the + Workflow_Validator; the definition plus findings are returned for canvas + rendering and review - never auto-saved or deployed (10.3). +- Bedrock_Configuration (model id, region, inference params, timeout <= 60 s) + is read from the existing portal settings storage; the Lambda invokes with + a client-side timeout equal to the configured value, and invocation + failures/timeouts are returned as descriptive errors (10.6, 10.7). + +Request body: + { + "usecase_id": "...", required + "prompt": "...", required + "session_id": "...", optional; omitted on the first prompt + "current_definition": {...}, optional; canvas snapshot from the client + "temperature": 0.1 optional; number in [0, 1] overriding the + configured temperature for this invocation + (top_p is then suppressed); outside the + range -> 400 INVALID_TEMPERATURE + } + +Error envelope (design): {"error": {"code", "message", "details"}} with +403 RBAC denial (11.4) and 404s that avoid cross-tenant existence leaks. +""" +import copy +import importlib +import json +import os +import logging +import time +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ( + ClientError, + ConnectTimeoutError, + EndpointConnectionError, + ReadTimeoutError, +) + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, rbac_manager, Permission +) +from workflow_core.serializer import ( + SCHEMA_VERSION, + WORKFLOW_DEFINITION_SCHEMA, + parse as parse_definition, + serialize as serialize_graph, +) +from workflow_core.validator import ( + SEVERITY_ERROR, + SEVERITY_WARNING, + validate as run_validator, +) +from workflow_core.catalog import NODE_CATALOG + +# Node catalog wire serialization is shared with the node-catalog endpoint +# (workflow_validation.py lives in the same Lambda bundle). +from workflow_validation import descriptor_to_wire + +# Shared Bedrock_Configuration resolution, client cache, and inference +# config (custom-node-code-assist Requirement 4.1): bedrock_common.py lives +# in the same Lambda bundle, so this is a same-directory import exactly like +# `from workflow_validation import …` above. The module is reloaded so it +# rebinds its environment (SETTINGS_TABLE) and boto3 resource whenever this +# module is (re-)imported — preserving the previous behavior where these +# bindings lived here and were refreshed on every import of +# workflow_generator (the test suites re-import this module per fixture +# after repointing SETTINGS_TABLE). A single extra exec at Lambda cold +# start; a no-op behaviorally in production. +import bedrock_common +bedrock_common = importlib.reload(bedrock_common) +from bedrock_common import ( + BEDROCK_CONFIG_SETTING_KEY, + DEFAULT_BEDROCK_CONFIG, + MAX_TIMEOUT_SECONDS, + build_inference_config, + get_bedrock_client, + get_bedrock_configuration, +) + +# Code_Assist_Generator (custom-node-code-assist Requirement 2.1): +# code_assist.py lives in the same Lambda bundle and serves POST +# /code-assist, dispatched from handler() below. Reloaded AFTER the +# bedrock_common reload above so its own `from bedrock_common import ...` +# bindings always reference the freshly reloaded module (the test suites +# re-import this module per fixture after repointing SETTINGS_TABLE). +import code_assist +code_assist = importlib.reload(code_assist) + +# Merged Node_Type_Catalog resolution (custom-node-designer task 9.2): +# generation embeds the Use_Case's merged palette catalog in the system +# prompt so generated workflows may use registered Custom_Node_Types +# (same palette rules as GET /workflows/node-catalog: test/prod only, +# deprecated excluded). +from node_catalog_resolution import palette_catalog_for_usecase + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables (SETTINGS_TABLE moved to bedrock_common) +WORKFLOW_CHAT_SESSIONS_TABLE = os.environ.get('WORKFLOW_CHAT_SESSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +WORKFLOWS_S3_PREFIX = os.environ.get('WORKFLOWS_S3_PREFIX', 'workflows') + +# BEDROCK_CONFIG_SETTING_KEY, MAX_TIMEOUT_SECONDS, and +# DEFAULT_BEDROCK_CONFIG now live in bedrock_common (re-exported above, +# values unchanged - custom-node-code-assist Requirement 4.1). + +# Chat sessions expire after 24 hours (DynamoDB TTL attribute 'ttl'); +# the TTL is refreshed on every message. +SESSION_TTL_SECONDS = 24 * 60 * 60 + +# Cap of prior conversation turns replayed to the model per invocation. +MAX_HISTORY_MESSAGES = 20 + +TOOL_NAME = 'create_workflow' + +# Serialized node catalog is static; built once per container. +_catalog_json_cache: Optional[str] = None + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def has_workflow_permission(user: Dict, usecase_id: str, permission: Permission) -> bool: + """Check a workflow permission for the acting user on a Use_Case""" + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, user_info=user) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, permissions: List[Permission]) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry (11.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='workflow', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id + }) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +# -------------------------------------------------------------------------- +# Bedrock_Configuration (Requirements 10.6, 10.7) +# +# get_bedrock_configuration and get_bedrock_client now live in +# bedrock_common (imported above with unchanged semantics); node_generator +# keeps importing them from this module. +# -------------------------------------------------------------------------- + + +# -------------------------------------------------------------------------- +# Prompt and tool assembly (Requirements 10.2, 10.5) +# -------------------------------------------------------------------------- + +def serialized_catalog_json(catalog=NODE_CATALOG) -> str: + """A node type catalog in its camelCase wire form, as a JSON string. + + Defaults to the static built-in catalog (cached per container); a + merged catalog carrying a Use_Case's Custom_Node_Types is serialized + per invocation (task 9.2). + """ + global _catalog_json_cache + if catalog is NODE_CATALOG: + if _catalog_json_cache is None: + _catalog_json_cache = json.dumps( + [descriptor_to_wire(d) for d in NODE_CATALOG], + sort_keys=True + ) + return _catalog_json_cache + return json.dumps([descriptor_to_wire(d) for d in catalog], + sort_keys=True) + + +def create_workflow_tool_schema() -> Dict: + """ + The create_workflow tool input schema IS the Workflow_Definition JSON + Schema (design section 9), minus the metadata keywords the Converse API + does not need. + """ + schema = copy.deepcopy(WORKFLOW_DEFINITION_SCHEMA) + schema.pop('$schema', None) + schema.pop('$id', None) + return schema + + +def build_tool_config() -> Dict: + """Converse toolConfig forcing structured output through create_workflow""" + return { + 'tools': [{ + 'toolSpec': { + 'name': TOOL_NAME, + 'description': ( + 'Return the complete Workflow_Definition graph document ' + 'that fulfils the user request. Always call this tool ' + 'with the full definition (all nodes, parameters, ' + 'positions, and connections).' + ), + 'inputSchema': {'json': create_workflow_tool_schema()} + } + }], + 'toolChoice': {'tool': {'name': TOOL_NAME}} + } + + +def build_system_prompt(catalog=NODE_CATALOG) -> str: + """System prompt embedding the serialized node catalog (Requirement + 10.2). ``catalog`` is the effective (possibly merged) catalog for the + requesting Use_Case (custom-node-designer task 9.2).""" + return ( + 'You are the workflow generation assistant of the DDA edge computer ' + 'vision portal. Users describe video analytics pipelines in natural ' + 'language; you compose them as Workflow_Definition graph documents.\n' + '\n' + 'Rules:\n' + '- Use ONLY node types from the NODE TYPE CATALOG below; the "typeId" ' + 'field is the value for a node\'s "type".\n' + '- Every workflow needs at least one input-category node and at least ' + 'one output-category node.\n' + '- Set every required parameter to a value satisfying its declared ' + 'type and constraints; keep catalog defaults unless the user asks ' + 'otherwise.\n' + '- Connections join an output port of one node ("from") to an input ' + 'port of another node ("to"); the two port types must be compatible. ' + 'Port names and types are declared in the catalog.\n' + '- The graph must be acyclic and every node must be reachable from an ' + 'input node.\n' + '- Give nodes short unique ids (n1, n2, ...) and connections unique ' + 'ids (c1, c2, ...). Lay node positions out left to right in ' + 'processing order (roughly 250 px horizontal spacing).\n' + f'- "schemaVersion" is always {SCHEMA_VERSION}.\n' + '- Always respond by calling the create_workflow tool with the ' + 'complete definition. Do not answer with prose only.\n' + '- When a CURRENT CANVAS WORKFLOW DEFINITION is provided, apply the ' + 'requested modification to it and return the complete modified ' + 'definition; do not regenerate an unrelated workflow from scratch.\n' + '\n' + f'NODE TYPE CATALOG (JSON):\n{serialized_catalog_json(catalog)}' + ) + + +def build_user_message(prompt: str, current_definition_json: Optional[str]) -> str: + """ + The user turn sent to the model. Follow-up prompts (and first prompts + over a non-empty canvas) include the current canvas definition and + instruct modification rather than regeneration (Requirement 10.5). + """ + if not current_definition_json: + return prompt + return ( + f'{prompt}\n' + '\n' + 'CURRENT CANVAS WORKFLOW DEFINITION (JSON):\n' + f'{current_definition_json}\n' + '\n' + 'Apply the requested change to this current definition rather than ' + 'generating a new workflow from scratch, and return the complete ' + 'modified Workflow_Definition via the create_workflow tool.' + ) + + +# -------------------------------------------------------------------------- +# Chat sessions (WorkflowChatSessions, TTL'd) +# -------------------------------------------------------------------------- + +def snapshot_s3_key(usecase_id: str, session_id: str) -> str: + """S3 key of a session's current canvas definition snapshot""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/chat-sessions/{session_id}/current_definition.json" + + +def get_session(session_id: str) -> Optional[Dict]: + """Fetch a chat session item, or None (expired items may still be absent)""" + table = dynamodb.Table(WORKFLOW_CHAT_SESSIONS_TABLE) + response = table.get_item(Key={'session_id': session_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def save_session(session: Dict) -> None: + """Persist a chat session with a refreshed TTL""" + session['ttl'] = int(time.time()) + SESSION_TTL_SECONDS + session['updated_at'] = now_ms() + dynamodb.Table(WORKFLOW_CHAT_SESSIONS_TABLE).put_item(Item=session) + + +def load_snapshot(s3_key: str) -> Optional[Dict]: + """Load a stored canvas definition snapshot from portal S3, or None""" + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + except ClientError as e: + logger.warning(f"Could not load session snapshot {s3_key}: {str(e)}") + return None + + +def put_snapshot(s3_key: str, canonical_json: str) -> None: + """Store the current canvas definition snapshot in portal S3""" + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=s3_key, + Body=canonical_json.encode('utf-8'), + ContentType='application/json' + ) + + +# -------------------------------------------------------------------------- +# Bedrock invocation (Requirements 10.2, 10.7) +# -------------------------------------------------------------------------- + +def converse_messages(history: List[Dict], user_text: str) -> List[Dict]: + """Converse-format message list: replayed history plus the new user turn""" + messages = [ + {'role': m['role'], 'content': [{'text': m['text']}]} + for m in history[-MAX_HISTORY_MESSAGES:] + ] + messages.append({'role': 'user', 'content': [{'text': user_text}]}) + return messages + + +def invoke_generation(config: Dict, messages: List[Dict], + catalog=NODE_CATALOG) -> Tuple[Optional[Dict], Optional[str], Optional[Dict]]: + """ + Invoke the configured Bedrock model via the Converse API with the + effective (possibly merged) node type catalog in the system prompt. + + Returns (tool_input, assistant_text, None) on success or + (None, None, error_response). Timeouts and invocation failures are + returned as descriptive errors with the prompt preserved client-side + (Requirement 10.7). + """ + client = get_bedrock_client(config['region'], config['timeout_seconds']) + # Never send temperature and top_p together: recent Anthropic models + # (e.g. claude-sonnet-4-5) reject requests specifying both. Temperature + # wins when set; top_p is sent only when temperature is absent/None + # (bedrock_common.build_inference_config, Requirements 4.2, 4.3). + inference_config = build_inference_config(config) + try: + response = client.converse( + modelId=config['model_id'], + system=[{'text': build_system_prompt(catalog)}], + messages=messages, + inferenceConfig=inference_config, + toolConfig=build_tool_config() + ) + except (ReadTimeoutError, ConnectTimeoutError): + logger.error(f"Bedrock invocation exceeded the configured timeout " + f"({config['timeout_seconds']}s, model {config['model_id']})") + return None, None, error_response( + 504, 'GENERATION_TIMEOUT', + f"Workflow generation timed out after {config['timeout_seconds']} seconds. " + 'Your prompt was not lost - please retry.', + {'timeout_seconds': config['timeout_seconds'], 'model_id': config['model_id']} + ) + except EndpointConnectionError as e: + logger.error(f"Bedrock endpoint unreachable: {str(e)}") + return None, None, error_response( + 502, 'BEDROCK_UNREACHABLE', + f"The Bedrock endpoint in region {config['region']} could not be reached. " + 'Check the Bedrock configuration.', + {'region': config['region']} + ) + except ClientError as e: + error = e.response.get('Error', {}) + logger.error(f"Bedrock invocation failed: {error.get('Code')}: {error.get('Message')}") + return None, None, error_response( + 502, 'BEDROCK_INVOCATION_FAILED', + f"The Bedrock model invocation failed: {error.get('Message', 'unknown error')}", + {'bedrock_error_code': error.get('Code'), 'model_id': config['model_id']} + ) + + content = (response.get('output', {}).get('message', {}) or {}).get('content', []) + tool_input = None + text_parts: List[str] = [] + for block in content: + if 'toolUse' in block and block['toolUse'].get('name') == TOOL_NAME: + tool_input = block['toolUse'].get('input') + elif 'text' in block: + text_parts.append(block['text']) + assistant_text = '\n'.join(text_parts).strip() + + if not isinstance(tool_input, dict): + logger.error(f"Model returned no {TOOL_NAME} tool call " + f"(stopReason={response.get('stopReason')})") + return None, None, error_response( + 502, 'NO_WORKFLOW_RETURNED', + 'The model did not return a workflow definition. Please retry or rephrase the prompt.', + {'stop_reason': response.get('stopReason'), 'model_text': assistant_text[:500]} + ) + return tool_input, assistant_text, None + + +# -------------------------------------------------------------------------- +# Generate endpoint +# -------------------------------------------------------------------------- + +def generate_workflow(event: Dict, user: Dict) -> Dict: + """ + POST /workflows/generate + Body: {usecase_id, prompt, session_id?, current_definition?} + + Invokes the configured Bedrock model with the prompt and the node type + catalog and returns a Workflow_Definition (10.2) together with the + complete Workflow_Validator findings list for user review on the canvas. + The result is never auto-saved or deployed (10.3). Follow-up prompts in + the same session modify the current canvas definition (10.5). Parse and + invocation failures return descriptive errors and persist no session + changes, so the canvas stays untouched and the prompt can be retried + (10.4, 10.7). + """ + body, err = parse_body(event) + if err: + return err + + usecase_id = body.get('usecase_id') + prompt = body.get('prompt') + missing = [f for f in ('usecase_id', 'prompt') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + if not isinstance(prompt, str) or not prompt.strip(): + return error_response(400, 'INVALID_PROMPT', 'prompt must be a non-empty string') + + # Optional per-invocation sampling override: a number in [0, 1]. When + # present it replaces the configured/settings temperature for this + # invocation only (and, per the existing rule, suppresses top_p - + # temperature and top_p are never sent together). + temperature_override = body.get('temperature') + if temperature_override is not None: + if isinstance(temperature_override, bool) \ + or not isinstance(temperature_override, (int, float)) \ + or not (0 <= temperature_override <= 1): + return error_response( + 400, 'INVALID_TEMPERATURE', + 'temperature must be a number between 0 and 1', + {'temperature': temperature_override}) + + # Generation drafts workflow content, so it requires the create/edit + # permission (DataScientist or UseCaseAdmin per the design RBAC matrix). + if not (has_workflow_permission(user, usecase_id, Permission.WORKFLOW_CREATE) + or has_workflow_permission(user, usecase_id, Permission.WORKFLOW_EDIT)): + return forbidden_response(user, event, usecase_id, + [Permission.WORKFLOW_CREATE, Permission.WORKFLOW_EDIT]) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + # ---------------------------------------------------------------- session + session_id = body.get('session_id') + session: Optional[Dict] = None + if session_id: + session = get_session(str(session_id)) + # Expired/unknown sessions and sessions of other users or Use_Cases + # all yield the same 404, so existence is never leaked. + if not session or session.get('user_id') != user['user_id'] \ + or session.get('usecase_id') != usecase_id: + return error_response(404, 'SESSION_NOT_FOUND', + 'Chat session not found or expired') + else: + session_id = str(uuid.uuid4()) + session = { + 'session_id': session_id, + 'usecase_id': usecase_id, + 'user_id': user['user_id'], + 'messages': [], + 'current_definition_key': None, + 'created_at': now_ms(), + } + + # ------------------------------------------- current canvas definition + # The client-provided snapshot is authoritative (the user may have edited + # the canvas since the last turn); otherwise fall back to the session's + # stored snapshot (10.5). + current_definition_json: Optional[str] = None + provided = body.get('current_definition') + if provided is not None: + try: + raw = provided if isinstance(provided, str) else json.dumps(provided) + except (TypeError, ValueError) as exc: + return error_response(400, 'INVALID_CURRENT_DEFINITION', + f'current_definition is not JSON-serializable: {exc}') + result = parse_definition(raw) + if not result.ok: + return error_response(400, 'INVALID_CURRENT_DEFINITION', + f'current_definition is not a valid Workflow_Definition: ' + f'{result.error.message}', + {'code': result.error.code, 'path': result.error.path}) + current_definition_json = serialize_graph(result.graph) + elif session.get('current_definition_key'): + snapshot = load_snapshot(session['current_definition_key']) + if snapshot is not None: + current_definition_json = json.dumps(snapshot, sort_keys=True) + + # ------------------------------------------------------------ invocation + config = get_bedrock_configuration() + if temperature_override is not None: + # Request-scoped override of the configured temperature; setting a + # non-null temperature makes invoke_generation send temperature + # only, suppressing top_p (Anthropic models reject both together). + config['temperature'] = float(temperature_override) + user_text = build_user_message(prompt.strip(), current_definition_json) + history = session.get('messages') or [] + messages = converse_messages(history, user_text) + + # The Use_Case's merged palette catalog (built-ins + registered + # Custom_Node_Types in test/prod, deprecated excluded) drives both the + # system prompt and the validation of the generated output (task 9.2). + catalog, _markers = palette_catalog_for_usecase(usecase_id) + + tool_input, assistant_text, err = invoke_generation(config, messages, + catalog=catalog) + if err: + # No session mutation on failure: the canvas stays untouched and the + # client retries with the preserved prompt (10.4, 10.7). + return err + + # ------------------------------------------------- parse + validate (10.3) + result = parse_definition(json.dumps(tool_input)) + if not result.ok: + logger.error(f"Generated definition failed to parse: " + f"{result.error.code} at {result.error.path}: {result.error.message}") + return error_response( + 422, 'GENERATED_DEFINITION_INVALID', + 'The generated output could not be parsed into a valid ' + f'Workflow_Definition: {result.error.message}. The canvas was left ' + 'unchanged - please retry or rephrase the prompt.', + {'code': result.error.code, 'path': result.error.path} + ) + + canonical_json = serialize_graph(result.graph) + definition = json.loads(canonical_json) + + # Full Workflow_Validator run against the same merged catalog; the + # complete findings list accompanies the definition so the user + # reviews it on the canvas before any save (10.3). + findings = run_validator(result.graph, catalog=catalog) + wire_findings = [f.to_dict() for f in findings] + error_count = sum(1 for f in findings if f.severity == SEVERITY_ERROR) + warning_count = sum(1 for f in findings if f.severity == SEVERITY_WARNING) + + # ------------------------------------------------------- persist session + # The generated definition becomes the session's canvas snapshot for + # follow-up modification prompts (10.5). + snapshot_key = snapshot_s3_key(usecase_id, session_id) + put_snapshot(snapshot_key, canonical_json) + + timestamp = now_ms() + session['messages'] = (history + [ + {'role': 'user', 'text': user_text, 'at': timestamp}, + {'role': 'assistant', + 'text': assistant_text or 'Produced a workflow definition (see the current canvas definition).', + 'at': timestamp}, + ])[-MAX_HISTORY_MESSAGES * 2:] + session['current_definition_key'] = snapshot_key + save_session(session) + + return create_response(200, { + 'session_id': session_id, + 'usecase_id': usecase_id, + 'definition': definition, + 'findings': wire_findings, + 'error_count': error_count, + 'warning_count': warning_count, + 'validation_passed': error_count == 0, + 'assistant_text': assistant_text, + 'model_id': config['model_id'], + }) + + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + + if resource == '/workflows/generate' and http_method == 'POST': + return generate_workflow(event, user) + + # Code assistance for custom Python node modules + # (custom-node-code-assist Requirement 2.1). Unexpected exceptions + # fall through to the 500 INTERNAL_ERROR guard below. + if resource == '/code-assist' and http_method == 'POST': + return code_assist.handle_code_assist(event, user) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/workflow_guards.py b/edge-cv-portal/backend/functions/workflow_guards.py new file mode 100644 index 00000000..7ebf805e --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_guards.py @@ -0,0 +1,171 @@ +""" +Shared workflow validation guard (Workflow Manager) + +Guard helper used by the packaging, publishing, and deployment endpoints +(workflow_packaging.py, deployments.py): a workflow version may only be +packaged, published, or deployed when it has a recorded passed-validation +run with zero error-severity findings (Requirements 4.7, 4.10). + +This module deliberately does NOT import the workflow_core layer: it only +reads the recorded validation status from the WorkflowVersions table and +the stored findings document from portal S3, so it is importable by +Lambdas that do not attach the workflow_core layer (e.g. deployments.py). + +Usage: + import workflow_guards + + failure = workflow_guards.check_workflow_version_validated(workflow_id, version) + if failure: + return error_response(failure['status_code'], failure['code'], + failure['message'], failure['details']) +""" +import json +import os +import logging +from decimal import Decimal +from typing import Any, Dict, List, Optional + +import boto3 +from botocore.exceptions import ClientError + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients (module-level, mocked by moto in tests) +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables (shared with workflows.py / workflow_validation.py) +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') + +# Wire-form severity written by workflow_core.validator ValidationFinding.to_dict() +SEVERITY_ERROR = 'error' + +# Validation status values recorded on WorkflowVersions items +VALIDATION_STATUS_PASSED = 'passed' +VALIDATION_STATUS_FAILED = 'failed' +VALIDATION_STATUS_NONE = 'none' + +# Guard failure codes (error envelope "code" values) +GUARD_CODE_VERSION_NOT_FOUND = 'WORKFLOW_VERSION_NOT_FOUND' +GUARD_CODE_NOT_VALIDATED = 'WORKFLOW_VERSION_NOT_VALIDATED' +GUARD_CODE_VALIDATION_ERRORS = 'WORKFLOW_VALIDATION_ERRORS' + + +def _decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: _decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [_decimal_to_native(i) for i in obj] + return obj + + +def _guard_failure(status_code: int, code: str, message: str, + details: Optional[Dict] = None) -> Dict: + """Shape a guard rejection for the caller's error envelope""" + return { + 'status_code': status_code, + 'code': code, + 'message': message, + 'details': details or {} + } + + +def get_version_item(workflow_id: str, version: int) -> Optional[Dict]: + """Fetch a WorkflowVersions item, or None""" + table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id, 'version': int(version)}) + item = response.get('Item') + return _decimal_to_native(item) if item else None + + +def load_recorded_findings(validation_status: Dict) -> List[Dict]: + """ + Load the findings recorded by the last validation run: inline under + 'findings', or from portal S3 under 'findings_key'. Returns [] when + no findings are recorded or the document cannot be loaded (the + passed-status check in the guard still applies in that case). + """ + if not isinstance(validation_status, dict): + return [] + + inline = validation_status.get('findings') + if isinstance(inline, list): + return inline + + findings_key = validation_status.get('findings_key') + if not findings_key: + return [] + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=findings_key) + document = json.loads(response['Body'].read().decode('utf-8')) + except (ClientError, ValueError) as e: + logger.error(f"Error loading findings document {findings_key}: {str(e)}") + return [] + findings = document.get('findings') if isinstance(document, dict) else document + return findings if isinstance(findings, list) else [] + + +def error_findings(findings: List[Dict]) -> List[Dict]: + """The error-severity subset of a findings list""" + return [f for f in findings if isinstance(f, dict) and f.get('severity') == SEVERITY_ERROR] + + +def check_workflow_version_validated(workflow_id: str, version: int) -> Optional[Dict]: + """ + Guard for packaging/publishing/deployment of a workflow version. + + Returns None when the version passed a Workflow_Validator run with + zero validation errors (Requirement 4.10). Otherwise returns a guard + failure dict {status_code, code, message, details}: + + - the recorded findings contain error-severity findings: rejected + with the validation errors so the caller can display them + (Requirement 4.7) + - the version has no recorded passed-validation run: rejected + (Requirement 4.10) + - the version does not exist: rejected with 404 + """ + version_item = get_version_item(workflow_id, version) + if not version_item: + return _guard_failure( + 404, GUARD_CODE_VERSION_NOT_FOUND, + f'Version {version} not found for workflow', + {'workflow_id': workflow_id, 'version': version} + ) + + validation_status = version_item.get('validation_status') or {} + status = validation_status.get('status', VALIDATION_STATUS_NONE) + + errors = error_findings(load_recorded_findings(validation_status)) + if errors: + return _guard_failure( + 409, GUARD_CODE_VALIDATION_ERRORS, + 'Workflow version has validation errors and cannot be ' + 'packaged, published, or deployed', + { + 'workflow_id': workflow_id, + 'version': version, + 'validation_status': status, + 'validated_at': validation_status.get('validated_at'), + 'errors': errors + } + ) + + if status != VALIDATION_STATUS_PASSED: + return _guard_failure( + 409, GUARD_CODE_NOT_VALIDATED, + 'Workflow version has not passed validation; run validation ' + 'with zero errors before packaging, publishing, or deploying', + { + 'workflow_id': workflow_id, + 'version': version, + 'validation_status': status + } + ) + + return None diff --git a/edge-cv-portal/backend/functions/workflow_model_staging.py b/edge-cv-portal/backend/functions/workflow_model_staging.py new file mode 100644 index 00000000..f7342dce --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_model_staging.py @@ -0,0 +1,347 @@ +""" +Triton model staging for workflow test runs (Workflow Manager). + +Cloud test runs execute the compiled pipeline in the Fargate sandbox, +whose Triton model repository (/aws_dda/dda_triton/triton_model_repo) +starts empty. For every model_inference node in the workflow under +test, this module resolves the node's ``modelName`` against the portal +model registry (MODELS_TABLE, scoped to the Use_Case), picks a +CPU-runnable Greengrass component variant, locates the component's S3 +model artifact from its recipe, and copies the artifact zip into the +portal artifacts bucket under the test run's prefix +(``.../test-runs/{test_run_id}/models/{modelName}.zip``). The resulting +staging manifest ``[{nodeId, modelName, s3Key}, ...]`` travels through +the state-machine input into the sandbox container (STAGED_MODELS env), +where the harness unpacks each artifact into the Triton model +repository before launching the pipeline. + +Artifact layout (inspected against a live registry, documented for the +harness-side conversion): + +- Registry items (MODELS_TABLE) carry ``name``, ``version``, + ``usecase_id`` and ``component_arns`` keyed by device type, e.g.:: + + component_arns: { + "jetson-xavier-jp5": "arn:aws:greengrass:...:components: + model-cookies-binary-jetson-xavier-jp5:versions:3.0.0", + "x86_64-cpu": "arn:aws:greengrass:...:components: + model-cookies-binary-x86-64-cpu:versions:3.0.0" + } + +- The component recipe (greengrass:GetComponent) contains one artifact:: + + Manifests[0].Artifacts[0].Uri = + s3:///model_artifacts/model-/ + _greengrass_model_component.zip + + and a Startup lifecycle that runs ``model_convertor.py`` on-device. + +- The zip is NOT a ready Triton model repository: it contains the raw + runtime artifact plus its manifest (e.g. ``model.onnx`` + + ``manifest.json`` with ``runtime: "onnx"``). The device-side + ``src/backend/dda_triton/model_convertor.py`` converts it into a + three-entry python-backend Triton repository (``base_``, + ``marshal_``, ensemble ````); the sandbox harness + replicates that conversion (test-sandbox/harness/model_staging.py). + +Only CPU-executable variants can run in the sandbox (no GPU on +Fargate; the CPU Triton binary lives at /opt/tritonserver). Variant +selection therefore prefers component names ending ``-x86-64-cpu``, +then ``-onnx``; a model with neither fails the run with a clear +per-node error (Requirement 12.10 semantics) before any execution +starts. + +IAM: the WorkflowTesting Lambda role (compute-stack createLambdaRole) +already carries greengrass:GetComponent on ``components:*``, read +access to MODELS_TABLE, and s3:GetObject on the portal-managed data +buckets, so no additional grant is needed for the copy. Cross-account +Use_Cases go through the same shared_utils assume-role path +(get_usecase_client) every other portal Lambda uses for use-case data. +The sandbox task role stays untouched: it only ever reads the staged +copy from the portal artifacts bucket (Requirement 12.9). +""" +import json +import logging +from typing import Any, Dict, List, Optional, Tuple + +from botocore.exceptions import ClientError + +logger = logging.getLogger() + +#: Node type whose modelName parameter references the model registry. +MODEL_INFERENCE_TYPE = 'model_inference' + +#: CPU-runnable component-name suffixes, in preference order: the +#: x86_64 CPU build first, then the architecture-neutral ONNX package +#: (the sandbox runs the ONNX runtime on CPU). +CPU_VARIANT_SUFFIXES = ('-x86-64-cpu', '-onnx') + +#: Error codes recorded on per-node error records. +CODE_MODEL_NOT_REGISTERED = 'MODEL_NOT_REGISTERED' +CODE_NO_CPU_VARIANT = 'MODEL_NO_CPU_VARIANT' +CODE_MODEL_STAGING_FAILED = 'MODEL_STAGING_FAILED' + + +def no_cpu_variant_message(model_name: str) -> str: + """The per-node error for a model without a CPU-runnable variant.""" + return ('Model {0} has no CPU-compatible (x86_64/ONNX) variant for ' + 'cloud testing'.format(model_name)) + + +def model_inference_nodes(definition: Any) -> List[Dict[str, Optional[str]]]: + """The model_inference nodes of a raw Workflow_Definition document. + + Returns ``[{nodeId, modelName}, ...]`` in document order. Parses the + stored JSON shape directly (nodes[].type / nodes[].parameters) so no + workflow_core import is needed; a missing/blank modelName yields + ``None`` (the validator would have failed the run anyway, but the + caller degrades to a per-node error rather than crashing). + """ + if not isinstance(definition, dict): + return [] + nodes = definition.get('nodes') + if not isinstance(nodes, list): + return [] + found: List[Dict[str, Optional[str]]] = [] + for node in nodes: + if not isinstance(node, dict) or node.get('type') != MODEL_INFERENCE_TYPE: + continue + parameters = node.get('parameters') + model_name = None + if isinstance(parameters, dict): + value = parameters.get('modelName') + if isinstance(value, str) and value.strip(): + model_name = value + found.append({'nodeId': node.get('id'), 'modelName': model_name}) + return found + + +def component_name_from_arn(arn: Any) -> Optional[str]: + """The component name of a Greengrass component-version ARN + (``arn:aws:greengrass:::components::versions:``).""" + if not isinstance(arn, str): + return None + parts = arn.split(':') + if len(parts) >= 7 and parts[5] == 'components': + return parts[6] or None + return None + + +def select_cpu_component_arn(component_arns: Any) -> Optional[str]: + """The ARN of the best CPU-runnable component variant, or None. + + Preference: component names ending ``-x86-64-cpu`` first, then + ``-onnx`` (CPU_VARIANT_SUFFIXES). GPU/Jetson-compiled variants + (e.g. ``-jetson-xavier-jp5``) can never run on the Fargate CPU + sandbox and are ignored. + """ + if not isinstance(component_arns, dict): + return None + for suffix in CPU_VARIANT_SUFFIXES: + for arn in component_arns.values(): + name = component_name_from_arn(arn) + if name and name.endswith(suffix): + return arn + return None + + +def registry_name_candidates(model_name: str) -> List[str]: + """Registry ``name`` values that may denote ``model_name``. + + The Workflow_Builder model dropdown stores the training-job + ``model_name`` (e.g. ``yolo_test``), while the packaging pipeline + registers the model in MODELS_TABLE under its component base name: + ``model-`` prefix with underscores normalized to hyphens (e.g. + ``model-yolo-test``). Both spellings resolve so workflows built from + either source stage correctly. + """ + candidates = [model_name] + normalized = model_name.replace('_', '-') + if normalized not in candidates: + candidates.append(normalized) + for base in (model_name, normalized): + prefixed = 'model-' + base + if not base.startswith('model-') and prefixed not in candidates: + candidates.append(prefixed) + return candidates + + +def resolve_model_item(model_items: List[Dict], model_name: str) -> Tuple[Optional[Dict], Optional[str]]: + """Resolve the registry item to stage for ``model_name``. + + ``model_items`` are the Use_Case's MODELS_TABLE items. Among items + whose ``name`` matches (exactly, or via the component base-name + convention - see registry_name_candidates), the newest (created_at) + one carrying a CPU-runnable variant wins. Returns + ``(item, cpu_component_arn)``; ``(None, None)`` when the name is not + registered at all, and ``(item, None)`` when it is registered but no + variant can run on CPU. + """ + matching: List[Dict] = [] + for candidate in registry_name_candidates(model_name): + matching = [item for item in model_items + if isinstance(item, dict) and item.get('name') == candidate] + if matching: + break + if not matching: + return None, None + matching.sort(key=lambda item: item.get('created_at') or 0, reverse=True) + for item in matching: + arn = select_cpu_component_arn(item.get('component_arns')) + if arn: + return item, arn + return matching[0], None + + +def artifact_location_from_recipe(recipe: Any) -> Optional[Tuple[str, str]]: + """The (bucket, key) of the component's zip artifact. + + Recipes list the model zip as ``Manifests[].Artifacts[].Uri`` in + ``s3://bucket/key`` form (see the module docstring for the observed + layout). The first ``.zip`` S3 artifact wins; None when the recipe + carries no such artifact. + """ + if not isinstance(recipe, dict): + return None + fallback: Optional[Tuple[str, str]] = None + for manifest in recipe.get('Manifests') or []: + if not isinstance(manifest, dict): + continue + for artifact in manifest.get('Artifacts') or []: + if not isinstance(artifact, dict): + continue + uri = artifact.get('Uri') + if not isinstance(uri, str) or not uri.startswith('s3://'): + continue + bucket, _, key = uri[len('s3://'):].partition('/') + if not bucket or not key: + continue + if key.lower().endswith('.zip'): + return bucket, key + fallback = fallback or (bucket, key) + return fallback + + +def load_component_recipe(greengrass_client, component_arn: str) -> Dict: + """Fetch and parse a component recipe (JSON) via greengrass:GetComponent.""" + response = greengrass_client.get_component(arn=component_arn, + recipeOutputFormat='JSON') + recipe = response.get('recipe') + if hasattr(recipe, 'read'): + recipe = recipe.read() + if isinstance(recipe, (bytes, bytearray)): + recipe = recipe.decode('utf-8') + return json.loads(recipe) + + +def staged_model_key(results_s3_key: str, model_name: str) -> str: + """S3 key of a staged model zip: ``models/{modelName}.zip`` under the + test run's prefix (next to results.json / compiled_pipeline.json).""" + prefix = results_s3_key.rsplit('/', 1)[0] if '/' in results_s3_key else '' + return '{0}models/{1}.zip'.format(prefix + '/' if prefix else '', model_name) + + +def stage_models_for_run( + nodes: List[Dict], + model_items: List[Dict], + greengrass_client, + source_s3, + portal_s3, + artifacts_bucket: str, + results_s3_key: str, +) -> Tuple[List[Dict], List[Dict]]: + """Stage the model artifact of every model_inference node. + + Returns ``(staged, errors)``: + + - ``staged``: the staging manifest ``[{nodeId, modelName, s3Key}]`` + recorded in the state-machine input (one entry per node; nodes + sharing a model share the same staged object). + - ``errors``: per-node error records ``{nodeId, status, outputs, + stubActivity, error:{code, message}}`` (the results-document shape + of workflow_test_steps.error_records). Any error fails the run + before the state machine starts. + """ + staged: List[Dict] = [] + errors: List[Dict] = [] + copied_keys: Dict[str, str] = {} # modelName -> staged s3 key + + def record_error(node_id, code, message): + errors.append({ + 'nodeId': node_id, + 'status': 'error', + 'outputs': [], + 'stubActivity': [], + 'error': {'code': code, 'message': message}, + }) + + for node in nodes: + node_id = node.get('nodeId') + model_name = node.get('modelName') + if not model_name: + record_error(node_id, CODE_MODEL_NOT_REGISTERED, + 'Model inference node has no model selected') + continue + + if model_name in copied_keys: + staged.append({'nodeId': node_id, 'modelName': model_name, + 's3Key': copied_keys[model_name]}) + continue + + item, component_arn = resolve_model_item(model_items, model_name) + if item is None: + record_error(node_id, CODE_MODEL_NOT_REGISTERED, + 'Model {0} is not registered for this use ' + 'case'.format(model_name)) + continue + if not component_arn: + record_error(node_id, CODE_NO_CPU_VARIANT, + no_cpu_variant_message(model_name)) + continue + + try: + recipe = load_component_recipe(greengrass_client, component_arn) + except (ClientError, ValueError, KeyError) as error: + logger.error('Could not load recipe for %s: %s', + component_arn, str(error)) + record_error(node_id, CODE_MODEL_STAGING_FAILED, + 'Model {0}: the component recipe could not be ' + 'read ({1})'.format( + model_name, + component_name_from_arn(component_arn))) + continue + + location = artifact_location_from_recipe(recipe) + if not location: + record_error(node_id, CODE_MODEL_STAGING_FAILED, + 'Model {0}: the component recipe declares no S3 ' + 'model artifact'.format(model_name)) + continue + + source_bucket, source_key = location + destination_key = staged_model_key(results_s3_key, model_name) + try: + # Stream the artifact through the Lambda: the source is a + # use-case data bucket (possibly cross-account, read via the + # per-usecase client), the destination the portal artifacts + # bucket the sandbox task role can read (12.9). + body = source_s3.get_object(Bucket=source_bucket, + Key=source_key)['Body'] + portal_s3.upload_fileobj(body, artifacts_bucket, destination_key) + except ClientError as error: + logger.error('Could not stage model artifact s3://%s/%s: %s', + source_bucket, source_key, str(error)) + record_error(node_id, CODE_MODEL_STAGING_FAILED, + 'Model {0}: the model artifact could not be ' + 'copied from s3://{1}/{2}'.format( + model_name, source_bucket, source_key)) + continue + + copied_keys[model_name] = destination_key + staged.append({'nodeId': node_id, 'modelName': model_name, + 's3Key': destination_key}) + logger.info('Staged model %s (component %s) at s3://%s/%s', + model_name, component_name_from_arn(component_arn), + artifacts_bucket, destination_key) + + return staged, errors diff --git a/edge-cv-portal/backend/functions/workflow_packaging.py b/edge-cv-portal/backend/functions/workflow_packaging.py new file mode 100644 index 00000000..6602e732 --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_packaging.py @@ -0,0 +1,1526 @@ +""" +Component_Packager Lambda function (Workflow Manager) + +Compiles a validated workflow version for the user-selected device +architectures, assembles per-arch Workflow_Component artifacts, uploads +them to the Use_Case account S3 bucket via the assumed cross-account +role, and registers a Greengrass component named +``dda.workflow.{workflowId}`` with version ``{workflowVersion}.0.0`` +(Requirements 7.1-7.5, 11.5, 13.3). + +Routes (API Gateway REST): + POST /workflows/{id}/package + Body: {"architectures": ["x86_64", ...], "version": N?} + +Per-arch artifact zip layout (discovered by LocalServer under +/aws_dda/workflows/{workflowId}/{version}/ — Requirement 13.3): + manifest.json component + workflow metadata + workflow.json the Workflow_Definition + compiled_pipeline.json Workflow_Compiler output for the arch + plugins/{arch}/{plugin}.so curated plugin library artifacts (7.1) + python/{nodeId}/handler.py Custom_Python_Node code (7.3) + python/{nodeId}/requirements.txt Custom_Python_Node dependencies (7.3) + +Custom_Node_Type plugins (custom-node-designer Requirements 10.4, 11.1, +11.2, 11.3, 16.4): compilation runs against the merged Node_Type_Catalog +resolving the Custom_Node_Type versions pinned at workflow save (14.2). +Compiled ``custom:{usecase}/{name}`` plugin dependencies are NEVER +bundled inline; each resolves to its backing Plugin_Record, which is +gated (dev lifecycle state, missing per-arch Plugin_Artifact, or missing +Plugin_Component version reject the request identifying the +Custom_Node_Type and arch/state), verified (streamed SHA-256 recompute + +KMS signature verification, failing via the PackagingError path on +either mismatch), recorded per arch in manifest.json ``pluginChecksums`` +/ ``pluginComponents``, and delivered by a Greengrass +``ComponentDependencies`` entry on ``dda.plugin.{pluginId}`` pinned to +the recorded Plugin_Record version. Built-in/curated plugins keep the +inline ``plugins/{arch}/*.so`` bundling unchanged. + +All-or-nothing staging (Requirement 7.5): every artifact is uploaded to a +temporary staging prefix first; only after every artifact for every +selected architecture uploads successfully are the objects promoted to +the final prefix and the component registered. On any failure the stage +(and any promoted objects) are deleted, the failing artifact is reported, +and no component version is registered. + +The recipe is install-only — no Run lifecycle — so deploying or removing +a Workflow_Component never restarts LocalServer or any other component +(Requirement 13.3). +""" +import base64 +import binascii +import hashlib +import json +import os +import logging +import posixpath +import re +import shutil +import tempfile +import time +import uuid +import zipfile +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, get_usecase_client, rbac_manager, Permission +) +from workflow_core.serializer import parse as parse_definition +from workflow_core.compiler import compile as compile_workflow, CompileContext +from workflow_core.catalog import ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + DEVICE_ARCHITECTURES, +) +from workflow_core.catalog.custom import resolve_catalog + +# Merged-catalog resolution + Plugin_Record persistence (same bundle) +from node_catalog_resolution import ( + descriptors_from_items, + load_registered_node_types, + resolution_items, +) +from plugin_records import get_version_item as get_plugin_record_version + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients (portal account) +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') +kms = boto3.client('kms') + +# Environment variables +WORKFLOWS_TABLE = os.environ.get('WORKFLOWS_TABLE') +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +WORKFLOWS_S3_PREFIX = os.environ.get('WORKFLOWS_S3_PREFIX', 'workflows') +# Curated GStreamer plugin artifact library in portal S3: +# {WORKFLOW_PLUGIN_LIBRARY_PREFIX}/{arch}/{plugin}.so +WORKFLOW_PLUGIN_LIBRARY_PREFIX = os.environ.get( + 'WORKFLOW_PLUGIN_LIBRARY_PREFIX', 'workflow-plugins') +# Minimum LocalServer component version a Workflow_Component requires +# (surfaced in manifest.json for the deployment compatibility check, 8.4) +MIN_LOCAL_SERVER_VERSION = os.environ.get( + 'WORKFLOW_MIN_LOCAL_SERVER_VERSION', + os.environ.get('DDA_LOCAL_SERVER_VERSION', '1.0.0')) + +# Greengrass component naming (design section 6) +WORKFLOW_COMPONENT_PREFIX = 'dda.workflow.' +COMPONENT_PUBLISHER = 'DDA Portal Workflow Manager' + +# Plugin_Component naming (custom-node-designer, Requirement 16.4). Kept as +# a local constant: plugin_components.py imports THIS module, so importing +# it back would be circular. +PLUGIN_COMPONENT_PREFIX = 'dda.plugin.' + +# Use_Case account S3 prefixes for Workflow_Component artifacts +COMPONENT_S3_PREFIX = 'workflows/components' +STAGING_S3_PREFIX = 'workflows/staging' + +# Compiler pluginDependencies prefixes (split_plugin_dependencies) +PYTHON_DEP_PREFIX = 'python:' +#: A Custom_Node_Type plugin dependency: custom:{usecase_id}/{plugin_name} +#: (recorded by custom_node_types.py, Requirement 8.6). Routed to the +#: plugin's Plugin_Component instead of inline bundling (16.4). +CUSTOM_DEP_PREFIX = 'custom:' + +# The portal Plugin_Artifact signing key (custom-node-designer 10.4): +# packaging KMS-Verifies each custom plugin artifact's recorded signature. +PLUGIN_SIGNING_KEY_ARN = os.environ.get('PLUGIN_SIGNING_KEY_ARN') +SIGNING_ALGORITHM = 'ECDSA_SHA_256' + +# Backing Plugin_Record lifecycle states allowed to package (11.3: dev is +# rejected; anything unknown fails closed). +PACKAGEABLE_LIFECYCLE_STATES = ('test', 'prod') + +# Custom-plugin packaging gate codes (11.2, 11.3, 16.4) +GATE_RECORD_MISSING = 'PLUGIN_RECORD_NOT_FOUND' +GATE_LIFECYCLE = 'PLUGIN_LIFECYCLE_VIOLATION' +GATE_ARTIFACT_MISSING = 'PLUGIN_ARTIFACT_MISSING' +GATE_COMPONENT_MISSING = 'PLUGIN_COMPONENT_MISSING' + +ARCH_X86_64 = 'x86_64' +ARCH_X86_64_NVIDIA = 'x86_64_nvidia' + +# arch id (workflow_core) -> Greengrass platform architecture. Both x86_64 +# flavors map to Greengrass amd64; recipes disambiguate with the +# 'runtime: nvidia' platform attribute on x86_64_nvidia manifests plus the +# manifest ordering from recipe_manifest_order (design: x86_64_nvidia). +ARCH_TO_GG_PLATFORM = { + 'x86_64': 'amd64', + 'x86_64_nvidia': 'amd64', + 'arm64_jp4': 'aarch64', + 'arm64_jp5': 'aarch64', + 'arm64_jp6': 'aarch64', +} + +# Where the LocalServer workflow engine discovers artifacts (13.3) +DEVICE_WORKFLOWS_ROOT = '/aws_dda/workflows' + +# Polling for the registered component to become DEPLOYABLE +COMPONENT_STATUS_MAX_ATTEMPTS = 30 +COMPONENT_STATUS_POLL_SECONDS = 2 + + +class PackagingError(Exception): + """A packaging failure attributable to one artifact (Requirement 7.5).""" + + def __init__(self, artifact: str, message: str): + super().__init__(message) + self.artifact = artifact + self.message = message + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def has_workflow_permission(user: Dict, usecase_id: str, permission: Permission) -> bool: + """Check a workflow permission for the acting user on a Use_Case""" + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, user_info=user) + + +def not_found_response() -> Dict: + """Uniform 404 that never confirms whether a workflow exists (5.8)""" + return error_response(404, 'WORKFLOW_NOT_FOUND', 'Workflow not found') + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, permissions: List[Permission]) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry (11.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='workflow', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id + }) + + +def authorize_workflow_access(user: Dict, event: Dict, item: Dict, + permission: Permission) -> Optional[Dict]: + """ + Authorize an operation on an existing workflow. + + Returns an error response, or None when authorized. Mirrors + workflows.py: a user without read access to the owning Use_Case gets + the same 404 as for a missing workflow (no existence leak); a user + who can read but lacks the operation permission gets a 403. + """ + usecase_id = item['usecase_id'] + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return not_found_response() + if permission != Permission.WORKFLOW_READ and not has_workflow_permission(user, usecase_id, permission): + return forbidden_response(user, event, usecase_id, [permission]) + return None + + +def get_workflow_item(workflow_id: str) -> Optional[Dict]: + """Fetch a workflow metadata item, or None""" + table = dynamodb.Table(WORKFLOWS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_version_item(workflow_id: str, version: int) -> Optional[Dict]: + """Fetch a workflow version item, or None""" + table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id, 'version': version}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def validation_guard(version_item: Dict) -> Optional[Dict]: + """ + Reject packaging unless the workflow version has a recorded passed + Workflow_Validator run with zero errors (Requirements 4.7, 4.10). + + A version that was validated and failed gets 400 with the findings + reference; a version never validated (or with a stale record) gets + 409 asking the user to validate first. + """ + validation_status = version_item.get('validation_status') or {} + status = validation_status.get('status') + if status == 'passed': + return None + if status == 'failed': + return error_response( + 400, 'VALIDATION_FAILED', + 'Workflow version has validation errors and cannot be packaged', + { + 'version': version_item.get('version'), + 'findings_key': validation_status.get('findings_key'), + 'validated_at': validation_status.get('validated_at') + } + ) + return error_response( + 409, 'VALIDATION_REQUIRED', + 'Workflow version has no passed validation record; run validation before packaging', + {'version': version_item.get('version')} + ) + + +def load_definition(s3_key: str) -> str: + """Load a stored Workflow_Definition document (raw JSON) from portal S3""" + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return response['Body'].read().decode('utf-8') + + +def compiled_doc_portal_key(usecase_id: str, workflow_id: str, version: int, arch: str) -> str: + """Portal S3 key of a compiled pipeline document (data model: compiled docs)""" + return (f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/versions/" + f"{version}/compiled/{arch}.json") + + +def plugin_library_key(arch: str, plugin: str) -> str: + """Portal S3 key of a curated plugin library artifact""" + return f"{WORKFLOW_PLUGIN_LIBRARY_PREFIX}/{arch}/{plugin}.so" + + +def component_name_for(workflow_id: str) -> str: + return f"{WORKFLOW_COMPONENT_PREFIX}{workflow_id}" + + +def component_version_for(workflow_version: int) -> str: + """Component version derived from the workflow version (Requirement 7.2)""" + return f"{int(workflow_version)}.0.0" + + +def zip_artifact_name(arch: str) -> str: + return f"workflow-{arch}.zip" + + +# -------------------------------------------------------------------------- +# Artifact assembly +# -------------------------------------------------------------------------- + +#: Node types whose per-node code and declared pip dependencies ship as +#: python/{nodeId}/handler.py + requirements.txt in every architecture +#: artifact zip and are listed together in the manifest's +#: customPythonNodeIds (custom-python-frames Requirements 2.4, 2.5). +CUSTOM_PYTHON_NODE_TYPES = ('custom_python', 'custom_python_preprocess') + + +def gather_custom_python_nodes(graph) -> List[Dict]: + """Custom_Python_Nodes whose code + declared dependencies ship in the + Workflow_Component artifacts (Requirement 7.3; both Custom Python node + types — custom-python-frames Requirements 2.4, 2.5)""" + nodes = [] + for node in graph.nodes: + if node.type in CUSTOM_PYTHON_NODE_TYPES: + nodes.append({ + 'node_id': node.id, + 'code': str(node.parameters.get('code') or ''), + 'requirements': str(node.parameters.get('requirements') or '') + }) + return nodes + + +def split_plugin_dependencies(plugin_dependencies: List[str] + ) -> Tuple[List[str], List[str], List[str]]: + """Split compiler pluginDependencies into curated GStreamer plugin + names (packaged inline as plugins/{arch}/*.so), custom:{usecase}/{name} + Custom_Node_Type plugin dependencies (delivered by Plugin_Component + dependency, never bundled inline — Requirement 16.4), and python: + runtime packages (surfaced in manifest.json for the edge executor).""" + gst_plugins, custom_plugins, python_packages = [], [], [] + for dep in plugin_dependencies: + if dep.startswith(PYTHON_DEP_PREFIX): + python_packages.append(dep[len(PYTHON_DEP_PREFIX):]) + elif dep.startswith(CUSTOM_DEP_PREFIX): + custom_plugins.append(dep) + else: + gst_plugins.append(dep) + return sorted(gst_plugins), sorted(custom_plugins), sorted(python_packages) + + +# -------------------------------------------------------------------------- +# Camera_Input_Node binding points +# (camera-registry-sync Requirements 8.6, 11.5) +# +# For each Camera_Input_Node the packager appends a ``bindingPoints`` entry +# to compiled_pipeline.json mapping the node's logical parameters to the +# rendered element arguments, and records the ``has_binding_points`` / +# ``camera_input_nodes`` discriminator on the workflow version item. The +# compiled elements keep their fully rendered default values, so an unbound +# document behaves byte-identically to pre-feature output, and workflows +# without Camera_Input_Nodes produce byte-identical documents (11.5). +# -------------------------------------------------------------------------- + +#: The built-in Camera_Input_Node type. +CAMERA_SOURCE_TYPE_ID = 'camera_source' + +#: The Aravis (GenICam) Camera_Input_Node type (aravis-camera-input +#: Requirements 4.1, 4.2). Aravis acquisition happens in the LocalServer +#: process through the camera manager, so the binding never lands in an +#: element argument: the binding point carries ``aravisBinding: true`` +#: with empty slots on every physical device architecture. +ARAVIS_CAMERA_SOURCE_TYPE_ID = 'aravis_camera_source' + +#: Optional Custom_Node_Type descriptor flag declaring the type +#: camera-backed. Both the snake_case spelling from the design and the +#: camelCase convention of custom declaration wire shapes are honored. +CAMERA_BACKED_FLAGS = ('camera_backed', 'cameraBacked') + +#: Architectures where camera_source renders as an appsrc fed by the +#: LocalServer camera adapter: binding resolution selects which local +#: Image_Source/cameraId the executor's adapter connects, not an element +#: argument, so the binding point carries ``adapterBinding: true`` with +#: empty slots. +ADAPTER_BINDING_ARCHS = (ARCH_ARM64_JP4, ARCH_ARM64_JP5) + +#: An argument template value that is exactly one ``{placeholder}`` token — +#: the only shape that lands a node parameter verbatim in an element arg. +_SLOT_PLACEHOLDER = re.compile(r'^\{(\w+)\}$') + + +def camera_backed_type_ids(node_type_items: List[Dict]) -> set: + """Type ids of resolved Custom_Node_Types declared camera-backed via + the optional ``camera_backed: true`` descriptor flag.""" + type_ids = set() + for item in node_type_items or []: + declaration = item.get('declaration') + if not isinstance(declaration, dict): + continue + if any(declaration.get(flag) is True for flag in CAMERA_BACKED_FLAGS): + type_id = declaration.get('typeId') + if isinstance(type_id, str): + type_ids.add(type_id) + return type_ids + + +def gather_camera_input_nodes(graph, camera_backed_types: set) -> List: + """The graph's Camera_Input_Nodes: camera_source and + aravis_camera_source nodes plus nodes of any Custom_Node_Type declared + camera-backed, in graph node order.""" + return [node for node in graph.nodes + if node.type == CAMERA_SOURCE_TYPE_ID + or node.type == ARAVIS_CAMERA_SOURCE_TYPE_ID + or node.type in camera_backed_types] + + +def binding_hints_from_definition(definition: Dict) -> Dict[str, Dict]: + """Per-node ``cameraBindingHint`` advisory data recorded by the + Workflow_Builder in the definition document (``nodes[].data``), keyed + by node id. Tolerant of definitions carrying no node data at all — + every pre-feature definition — so packaging them is unchanged (11.5).""" + hints: Dict[str, Dict] = {} + for node in definition.get('nodes') or []: + if not isinstance(node, dict): + continue + data = node.get('data') + hint = data.get('cameraBindingHint') if isinstance(data, dict) else None + node_id = node.get('id') + if isinstance(hint, dict) and hint and isinstance(node_id, str): + hints[node_id] = hint + return hints + + +def rendered_default_parameters(node, descriptor) -> Dict[str, Any]: + """The parameter values rendered into the compiled document: declared + defaults overlaid with the node's explicit values (the compiler's + effective-value rule).""" + values = {parameter.name: parameter.default + for parameter in descriptor.parameters} + values.update(node.parameters) + return values + + +def binding_point_slots(compiled_doc: Dict, node_id: str, mapping, + parameter_names: set) -> List[Dict]: + """Where each of the node's parameters lands in THIS compiled document: + one slot per element argument whose catalog template is exactly a + single ``{parameter}`` placeholder (e.g. the v4l2src ``device`` arg on + x86_64 / x86_64_nvidia). The node's element chain appears contiguously + in exactly one segment (compiler Requirement 6.6), so template index k + addresses the k-th element of that run.""" + if mapping is None or not mapping.element_chain: + return [] + slots: List[Dict] = [] + for segment_index, segment in enumerate(compiled_doc.get('segments') or []): + elements = segment.get('elements') or [] + run_start = next((index for index, element in enumerate(elements) + if element.get('nodeId') == node_id), None) + if run_start is None: + continue + for offset, template in enumerate(mapping.element_chain): + for arg in sorted(template.get('args_template') or {}): + value = template['args_template'][arg] + if not isinstance(value, str): + continue + match = _SLOT_PLACEHOLDER.match(value) + if match and match.group(1) in parameter_names: + slots.append({ + 'param': match.group(1), + 'segment': segment_index, + 'element': run_start + offset, + 'arg': arg, + }) + break # each node's chain lives in exactly one segment + return slots + + +def build_binding_points(camera_nodes: List, compiled_doc: Dict, arch: str, + hints: Dict[str, Dict], + descriptors_by_id: Dict) -> List[Dict]: + """The ``bindingPoints`` section of one architecture's compiled + document: nodeId, nodeType, bindingHint from the definition, rendered + default parameters, and arch-specific slots. On JP4/JP5 camera_source + is adapter-fed (``adapterBinding: true``, empty slots); on JP6 the + binding selects the CSI sensor the capture host service stages from + (``csiSensorBinding: true``, empty slots). aravis_camera_source is + executor-feed-bound on every physical device architecture + (``aravisBinding: true``, empty slots) with the rendered + camera_id/gain/exposure values in ``parameters`` (aravis-camera-input + Requirement 4.2).""" + binding_points: List[Dict] = [] + for node in camera_nodes: + descriptor = descriptors_by_id[node.type] + entry: Dict[str, Any] = { + 'nodeId': node.id, + 'nodeType': node.type, + 'parameters': rendered_default_parameters(node, descriptor), + 'slots': [], + } + hint = hints.get(node.id) + if hint: + entry['bindingHint'] = hint + if node.type == ARAVIS_CAMERA_SOURCE_TYPE_ID: + entry['aravisBinding'] = True + elif node.type == CAMERA_SOURCE_TYPE_ID and arch in ADAPTER_BINDING_ARCHS: + entry['adapterBinding'] = True + elif node.type == CAMERA_SOURCE_TYPE_ID and arch == ARCH_ARM64_JP6: + entry['csiSensorBinding'] = True + else: + entry['slots'] = binding_point_slots( + compiled_doc, node.id, descriptor.mapping_for(arch), + {parameter.name for parameter in descriptor.parameters}) + binding_points.append(entry) + return binding_points + + +def compiled_document_json(compiled, binding_points: List[Dict]) -> str: + """compiled_pipeline.json content: the canonical compiler output plus + the ``bindingPoints`` section when the workflow has Camera_Input_Nodes. + With no camera nodes the output is byte-identical to the compiler's + own serialization (Requirement 11.5).""" + if not binding_points: + return compiled.to_json() + document = compiled.to_dict() + document['bindingPoints'] = binding_points + return json.dumps(document, sort_keys=True, indent=2, ensure_ascii=True) + + +def _rendered_slot_value(compiled_doc: Dict, slot: Dict) -> Any: + """The rendered element-argument value a slot points at.""" + try: + segment = compiled_doc['segments'][slot['segment']] + return segment['elements'][slot['element']]['args'][slot['arg']] + except (KeyError, IndexError, TypeError): + return None + + +def _dynamo_safe(obj: Any) -> Any: + """Floats are not storable in DynamoDB; convert them to Decimal.""" + if isinstance(obj, float): + return Decimal(str(obj)) + if isinstance(obj, dict): + return {key: _dynamo_safe(value) for key, value in obj.items()} + if isinstance(obj, list): + return [_dynamo_safe(item) for item in obj] + return obj + + +def camera_input_nodes_record(camera_nodes: List, hints: Dict[str, Dict], + arch_binding_points: Dict[str, List[Dict]], + arch_compiled_docs: Dict[str, Dict]) -> List[Dict]: + """The ``camera_input_nodes`` version-item attribute: node id, node + type, binding hint, and the per-arch compiled device paths (the + rendered ``device`` slot values) the Deployment_Service's legacy path + check and binding matrix read without re-fetching compiled documents + from S3 (camera-registry-sync 8.6, 9.5).""" + records: List[Dict] = [] + for node in camera_nodes: + record: Dict[str, Any] = { + 'node_id': node.id, + 'node_type': node.type, + 'compiled_device_paths': {}, + } + hint = hints.get(node.id) + if hint: + record['binding_hint'] = hint + for arch in sorted(arch_binding_points): + entry = next((point for point in arch_binding_points[arch] + if point['nodeId'] == node.id), None) + if not entry: + continue + for slot in entry['slots']: + if slot['param'] != 'device': + continue + value = _rendered_slot_value(arch_compiled_docs[arch], slot) + if isinstance(value, str): + record['compiled_device_paths'][arch] = value + records.append(record) + return records + + +# -------------------------------------------------------------------------- +# Custom_Node_Type plugin resolution, gates, and verification +# (custom-node-designer Requirements 10.4, 11.1, 11.2, 11.3, 16.4) +# +# Everything up to load_custom_plugin_records is pure over plain dicts so +# the gate/dependency decision logic is property-testable without AWS +# (tasks 10.2-10.4). +# -------------------------------------------------------------------------- + +def plugin_component_name(plugin_id: str) -> str: + """The Greengrass Plugin_Component name of a Plugin_Record (16.4)""" + return f"{PLUGIN_COMPONENT_PREFIX}{plugin_id}" + + +def plugin_version_requirement(plugin_version: int) -> str: + """Greengrass VersionRequirement pinning the Plugin_Record version + recorded by the workflow's pinned Custom_Node_Type version (16.4).""" + version = int(plugin_version) + return f">={version}.0.0 <{version + 1}.0.0" + + +def custom_dependency_index(node_type_items: List[Dict]) -> Dict[str, Dict]: + """Map each ``custom:`` plugin dependency recorded in the resolved + Custom_Node_Type declarations to its CustomNodeTypes item, so a + compiled dependency resolves to the pinned backing Plugin_Record and + gate rejections can identify the Custom_Node_Type (11.2, 11.3). + Deterministic: items visited sorted by (node_type_id, version).""" + index: Dict[str, Dict] = {} + ordered = sorted(node_type_items, + key=lambda i: (str(i.get('node_type_id')), + int(i.get('version', 0)))) + for item in ordered: + declaration = item.get('declaration') + if not isinstance(declaration, dict): + continue + mappings = declaration.get('mappings') + if not isinstance(mappings, list): + continue + for mapping in mappings: + if not isinstance(mapping, dict): + continue + for dep in mapping.get('pluginDependencies') or []: + if isinstance(dep, str) and dep.startswith(CUSTOM_DEP_PREFIX): + index[dep] = item + return index + + +def artifact_entry_complete(entry: Optional[Dict]) -> bool: + """A per-arch Plugin_Record artifact entry usable for packaging: a + succeeded build with the recorded key, checksum, and signature that + verification (10.4) needs.""" + return bool(entry + and entry.get('buildStatus') == 'succeeded' + and entry.get('s3Key') + and entry.get('checksum') + and entry.get('signature')) + + +def custom_plugin_gate_findings(arch_custom_deps: Dict[str, List[str]], + dep_index: Dict[str, Dict], + dep_records: Dict[str, Optional[Dict]] + ) -> List[Dict]: + """ + Evaluate the custom-plugin packaging gates (11.2, 11.3, 16.4) before + any artifact is assembled. Returns [] when packaging may proceed, + otherwise the complete list of findings, each identifying the + Custom_Node_Type and the offending lifecycle state or missing + Target_Architecture / Plugin_Component. + + ``arch_custom_deps``: {arch: [custom: deps compiled for that arch]} + ``dep_index``: custom_dependency_index over the resolved items + ``dep_records``: {dep: backing Plugin_Record item or None} + """ + findings: List[Dict] = [] + dep_archs: Dict[str, List[str]] = {} + for arch in sorted(arch_custom_deps): + for dep in arch_custom_deps[arch]: + dep_archs.setdefault(dep, []).append(arch) + + for dep in sorted(dep_archs): + item = dep_index.get(dep) + node_type_id = item.get('node_type_id') if item else None + record = dep_records.get(dep) + if not item or not record: + findings.append({ + 'code': GATE_RECORD_MISSING, + 'message': (f"Custom plugin dependency '{dep}' of " + f"Custom_Node_Type '{node_type_id or 'unknown'}' has no " + f"resolvable backing Plugin_Record"), + 'node_type_id': node_type_id, + 'dependency': dep, + }) + continue + + plugin_id = record.get('plugin_id') + plugin_version = record.get('version') + state = record.get('lifecycle_state') + if state not in PACKAGEABLE_LIFECYCLE_STATES: + # 11.3: dev (or unknown — fail closed) lifecycle state rejects, + # identifying the Custom_Node_Type and its Lifecycle_State. + findings.append({ + 'code': GATE_LIFECYCLE, + 'message': (f"Custom_Node_Type '{node_type_id}' is backed by " + f"plugin '{plugin_id}' v{plugin_version} in lifecycle " + f"state '{state}'; packaging requires test or prod"), + 'node_type_id': node_type_id, + 'dependency': dep, + 'plugin_id': plugin_id, + 'plugin_version': plugin_version, + 'lifecycle_state': state, + }) + continue + + artifacts = record.get('artifacts') or {} + for arch in dep_archs[dep]: + if not artifact_entry_complete(artifacts.get(arch)): + # 11.2: missing per-arch Plugin_Artifact rejects, identifying + # the Custom_Node_Type and the missing Target_Architecture. + findings.append({ + 'code': GATE_ARTIFACT_MISSING, + 'message': (f"Custom_Node_Type '{node_type_id}' has no built " + f"Plugin_Artifact for architecture '{arch}' " + f"(plugin '{plugin_id}' v{plugin_version})"), + 'node_type_id': node_type_id, + 'dependency': dep, + 'plugin_id': plugin_id, + 'plugin_version': plugin_version, + 'arch': arch, + }) + + component = record.get('component') or {} + if component.get('status') != 'registered': + # 16.4: the Workflow_Component depends on the Plugin_Component, + # so a missing registered version rejects packaging. + findings.append({ + 'code': GATE_COMPONENT_MISSING, + 'message': (f"Custom_Node_Type '{node_type_id}' has no registered " + f"Plugin_Component version for plugin '{plugin_id}' " + f"v{plugin_version} " + f"({plugin_component_name(str(plugin_id))})"), + 'node_type_id': node_type_id, + 'dependency': dep, + 'plugin_id': plugin_id, + 'plugin_version': plugin_version, + }) + return findings + + +def plugin_component_dependencies(dep_records: Dict[str, Optional[Dict]]) -> Dict: + """The Greengrass ComponentDependencies block of the Workflow_Component + recipe (16.4): one HARD dependency on dda.plugin.{pluginId} per distinct + custom plugin, pinned to the recorded Plugin_Record version.""" + dependencies: Dict[str, Dict] = {} + for dep in sorted(dep_records): + record = dep_records[dep] + if not record: + continue + dependencies[plugin_component_name(str(record['plugin_id']))] = { + 'VersionRequirement': plugin_version_requirement(record['version']), + 'DependencyType': 'HARD', + } + return dependencies + + +def recipe_manifest_order(archs) -> List[str]: + """Deterministic platform-manifest order: sorted, except the plain + x86_64 manifest is listed after the x86_64_nvidia one — both map to + Greengrass amd64, so attribute-less amd64 devices match plain x86_64 + while devices declaring 'runtime: nvidia' match the more specific + manifest first (design: x86_64_nvidia).""" + ordered = sorted(archs) + if ARCH_X86_64 in ordered and ARCH_X86_64_NVIDIA in ordered: + ordered.remove(ARCH_X86_64) + ordered.insert(ordered.index(ARCH_X86_64_NVIDIA) + 1, ARCH_X86_64) + return ordered + + +def load_custom_plugin_records(arch_custom_deps: Dict[str, List[str]], + dep_index: Dict[str, Dict] + ) -> Dict[str, Optional[Dict]]: + """The backing Plugin_Record of every distinct compiled custom plugin + dependency, resolved through the pinned Custom_Node_Type version + (14.2). Unresolvable dependencies map to None (gates fail closed).""" + records: Dict[str, Optional[Dict]] = {} + for deps in arch_custom_deps.values(): + for dep in deps: + if dep in records: + continue + item = dep_index.get(dep) + if not item or item.get('plugin_id') is None \ + or item.get('plugin_version') is None: + records[dep] = None + continue + records[dep] = get_plugin_record_version( + item['plugin_id'], int(item['plugin_version'])) + return records + + +def verify_custom_plugin_artifact(dependency: str, node_type_id: Optional[str], + record: Dict, arch: str) -> Tuple[str, str]: + """ + Stream one custom Plugin_Artifact's bytes for ``arch`` from the + Plugin_Library, recompute the SHA-256, and KMS-Verify the recorded + signature against the portal signing key (Requirement 10.4). Returns + ``(manifest_key, checksum)`` for the arch manifest's pluginChecksums + ({/: }). Raises PackagingError — + the existing all-or-nothing path (stage cleanup, no partial + component) — on a missing artifact, checksum mismatch, or signature + verification failure. + """ + entry = (record.get('artifacts') or {}).get(arch) or {} + so_key = entry['s3Key'] + so_name = posixpath.basename(so_key) + component_name = plugin_component_name(str(record['plugin_id'])) + manifest_key = f"{component_name}/{so_name}" + label = f"custom-plugins/{arch}/{so_name}" + identity = (f"Custom plugin artifact '{so_name}' for architecture '{arch}' " + f"(Custom_Node_Type '{node_type_id}', plugin " + f"'{record.get('plugin_id')}' v{record.get('version')})") + + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=so_key) + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + raise PackagingError( + label, + f"{identity} could not be read from the Plugin_Library " + f"(s3://{PORTAL_ARTIFACTS_BUCKET}/{so_key}): {code or str(e)}") + + digest = hashlib.sha256() + body = response['Body'] + while True: + chunk = body.read(1024 * 1024) + if not chunk: + break + digest.update(chunk) + + if digest.hexdigest() != entry['checksum']: + raise PackagingError( + label, + f"{identity} failed checksum verification against the " + f"Plugin_Record (Requirement 10.4)") + + try: + signature = base64.b64decode(entry['signature']) + except (TypeError, ValueError, binascii.Error): + signature = None + signature_valid = False + if signature: + try: + verified = kms.verify( + KeyId=PLUGIN_SIGNING_KEY_ARN, + Message=digest.digest(), + MessageType='DIGEST', + SigningAlgorithm=SIGNING_ALGORITHM, + Signature=signature, + ) + signature_valid = bool(verified.get('SignatureValid')) + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code != 'KMSInvalidSignatureException': + raise PackagingError( + label, + f"{identity} signature could not be verified: {code or str(e)}") + if not signature_valid: + raise PackagingError( + label, + f"{identity} failed signature verification against the portal " + f"signing key (Requirement 10.4)") + + return manifest_key, entry['checksum'] + + +def build_manifest(workflow_id: str, workflow_version: int, arch: str, + gst_plugins: List[str], python_packages: List[str], + custom_python_nodes: List[Dict], user: Dict, + plugin_checksums: Optional[Dict[str, str]] = None, + plugin_components: Optional[Dict[str, str]] = None) -> Dict: + """manifest.json content: what WorkflowWatcher needs to register the + workflow and what the deployment compatibility check reads (8.4). + + ``plugin_checksums`` ({/: }) and + ``plugin_components`` ({: }) + let the LocalServer plugin loader verify each Plugin_Component- + delivered custom plugin file and derive its install root + (custom-node-designer Requirements 10.4, 10.6, 11.1).""" + return { + 'componentName': component_name_for(workflow_id), + 'componentVersion': component_version_for(workflow_version), + 'workflowId': workflow_id, + 'workflowVersion': int(workflow_version), + 'targetArch': arch, + 'minLocalServerVersion': MIN_LOCAL_SERVER_VERSION, + 'pluginDependencies': gst_plugins, + 'pythonDependencies': python_packages, + 'pluginChecksums': dict(plugin_checksums or {}), + 'pluginComponents': dict(plugin_components or {}), + 'customPythonNodeIds': [n['node_id'] for n in custom_python_nodes], + 'packagedAt': now_ms(), + 'packagedBy': user['user_id'] + } + + +def build_arch_zip(zip_path: str, arch: str, manifest: Dict, definition_json: str, + compiled_json: str, gst_plugins: List[str], + custom_python_nodes: List[Dict]) -> None: + """ + Assemble one architecture's artifact zip on local disk (Requirement 7.1). + + Plugin binaries are resolved from the curated plugin library in portal + S3; a missing or unreadable plugin artifact fails packaging with that + artifact identified (Requirement 7.5). + """ + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + zf.writestr('manifest.json', json.dumps(manifest, sort_keys=True, indent=2)) + zf.writestr('workflow.json', definition_json) + zf.writestr('compiled_pipeline.json', compiled_json) + + for plugin in gst_plugins: + library_key = plugin_library_key(arch, plugin) + artifact_label = f"plugins/{arch}/{plugin}.so" + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=library_key) + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code in ('NoSuchKey', '404'): + raise PackagingError( + artifact_label, + f"Plugin artifact '{plugin}' for architecture '{arch}' was not " + f"found in the plugin library (s3://{PORTAL_ARTIFACTS_BUCKET}/{library_key})") + raise PackagingError( + artifact_label, + f"Plugin artifact '{plugin}' for architecture '{arch}' could not be " + f"read from the plugin library: {code or str(e)}") + # Stream the .so into the zip without holding it all in memory + with zf.open(artifact_label, 'w') as target: + body = response['Body'] + while True: + chunk = body.read(1024 * 1024) + if not chunk: + break + target.write(chunk) + + for node in custom_python_nodes: + zf.writestr(f"python/{node['node_id']}/handler.py", node['code']) + zf.writestr(f"python/{node['node_id']}/requirements.txt", node['requirements']) + + +# -------------------------------------------------------------------------- +# Use_Case account S3 staging (all-or-nothing, Requirement 7.5) +# -------------------------------------------------------------------------- + +def delete_prefix(s3_client, bucket: str, prefix: str) -> None: + """Best-effort delete of every object under a prefix (stage cleanup)""" + try: + continuation_token = None + while True: + kwargs = {'Bucket': bucket, 'Prefix': prefix} + if continuation_token: + kwargs['ContinuationToken'] = continuation_token + listed = s3_client.list_objects_v2(**kwargs) + objects = [{'Key': o['Key']} for o in listed.get('Contents', [])] + if objects: + s3_client.delete_objects(Bucket=bucket, Delete={'Objects': objects}) + if not listed.get('IsTruncated'): + break + continuation_token = listed.get('NextContinuationToken') + except ClientError as e: + # Cleanup must never mask the original failure + logger.error(f"Error cleaning s3://{bucket}/{prefix}: {str(e)}") + + +def stage_and_promote_artifacts(usecase_s3, bucket: str, workflow_id: str, + workflow_version: int, arch_zip_paths: Dict[str, str] + ) -> Tuple[str, Dict[str, str]]: + """ + Upload every arch zip to a temporary staging prefix, then promote all + of them to the final component prefix (Requirement 7.5). + + Returns (final_prefix, {arch: final_key}). Raises PackagingError with + the failing artifact identified; the caller cleans up. + """ + stage_id = uuid.uuid4().hex + stage_prefix = f"{STAGING_S3_PREFIX}/{workflow_id}/{workflow_version}/{stage_id}" + final_prefix = f"{COMPONENT_S3_PREFIX}/{workflow_id}/{workflow_version}" + + staged_keys: Dict[str, str] = {} + for arch, zip_path in arch_zip_paths.items(): + artifact_label = f"{arch}/{zip_artifact_name(arch)}" + stage_key = f"{stage_prefix}/{artifact_label}" + try: + with open(zip_path, 'rb') as fh: + usecase_s3.put_object(Bucket=bucket, Key=stage_key, Body=fh, + ContentType='application/zip') + except (ClientError, OSError) as e: + raise PackagingError( + artifact_label, + f"Failed to upload artifact '{artifact_label}' to the staging area: {str(e)}") + staged_keys[arch] = stage_key + + # Every artifact staged successfully -> promote to the final prefix. + final_keys: Dict[str, str] = {} + for arch, stage_key in staged_keys.items(): + artifact_label = f"{arch}/{zip_artifact_name(arch)}" + final_key = f"{final_prefix}/{artifact_label}" + try: + usecase_s3.copy_object(Bucket=bucket, Key=final_key, + CopySource={'Bucket': bucket, 'Key': stage_key}) + except ClientError as e: + raise PackagingError( + artifact_label, + f"Failed to promote artifact '{artifact_label}' from the staging area: {str(e)}") + final_keys[arch] = final_key + + return final_prefix, final_keys + + +# -------------------------------------------------------------------------- +# Greengrass component registration (Requirements 7.2, 13.3) +# -------------------------------------------------------------------------- + +def build_recipe(workflow_id: str, workflow_version: int, bucket: str, + final_keys: Dict[str, str], + component_dependencies: Optional[Dict] = None) -> Dict: + """ + Install-only Greengrass recipe with one platform manifest per selected + architecture (Requirements 7.2, 7.4). There is deliberately no Run + lifecycle: the component installs its artifacts under + /aws_dda/workflows/ and finishes, so deploying or removing it never + disturbs LocalServer or any other component (Requirement 13.3) — the + LocalServer workflow engine discovers the files at runtime. + + ``component_dependencies`` is the ComponentDependencies block + declaring the Plugin_Components of the workflow's Custom_Node_Types + (custom-node-designer Requirement 16.4); omitted when the workflow + uses none. + """ + component_name = component_name_for(workflow_id) + component_version = component_version_for(workflow_version) + install_dir = f"{DEVICE_WORKFLOWS_ROOT}/{workflow_id}/{workflow_version}" + + # Greengrass matches manifests on platform attributes. amd64 vs aarch64 + # separates the x86 flavors from the Jetson builds; when more than one + # arm64 JetPack variant is packaged, a custom 'variant' attribute + # (declared in the device's Nucleus platform overrides) disambiguates + # them, and x86_64_nvidia always carries 'runtime: nvidia' (with the + # plain x86_64 manifest ordered after it — recipe_manifest_order). + arm_archs = [a for a in final_keys if ARCH_TO_GG_PLATFORM.get(a) == 'aarch64'] + disambiguate_arm = len(arm_archs) > 1 + + manifests = [] + for arch in recipe_manifest_order(final_keys): + platform = {'os': 'linux', 'architecture': ARCH_TO_GG_PLATFORM[arch]} + if disambiguate_arm and ARCH_TO_GG_PLATFORM[arch] == 'aarch64': + platform['variant'] = arch + elif arch == ARCH_X86_64_NVIDIA: + platform['runtime'] = 'nvidia' + unarchived_dir = zip_artifact_name(arch)[:-len('.zip')] + install_script = ( + f"mkdir -p {install_dir} && " + f"cp -r {{artifacts:decompressedPath}}/{unarchived_dir}/. {install_dir}/" + ) + manifests.append({ + 'Platform': platform, + 'Lifecycle': { + 'Install': { + 'Script': install_script, + 'Timeout': 300, + 'requiresPrivilege': True + } + }, + 'Artifacts': [ + { + 'Uri': f"s3://{bucket}/{final_keys[arch]}", + 'Unarchive': 'ZIP', + 'Permission': { + 'Read': 'ALL' + } + } + ] + }) + + recipe = { + 'RecipeFormatVersion': '2020-01-25', + 'ComponentName': component_name, + 'ComponentVersion': component_version, + 'ComponentType': 'aws.greengrass.generic', + 'ComponentPublisher': COMPONENT_PUBLISHER, + 'ComponentConfiguration': { + 'DefaultConfiguration': { + 'WorkflowId': workflow_id, + 'WorkflowVersion': str(workflow_version) + } + }, + 'Manifests': manifests, + 'Lifecycle': {} + } + if component_dependencies: + recipe['ComponentDependencies'] = component_dependencies + return recipe + + +def register_component(greengrass, recipe: Dict, usecase_id: str, + workflow_id: str, workflow_version: int, user: Dict) -> str: + """ + Register the component version in the Use_Case account Greengrass + registry and wait until it is DEPLOYABLE (Requirement 7.2). A version + that fails to become DEPLOYABLE is deleted so no partial or broken + component version remains (Requirement 7.5). + """ + component_label = (f"component {recipe['ComponentName']} " + f"v{recipe['ComponentVersion']}") + try: + response = greengrass.create_component_version( + inlineRecipe=json.dumps(recipe), + tags={ + 'dda-portal:managed': 'true', + 'dda-portal:usecase-id': usecase_id, + 'dda-portal:workflow-id': workflow_id, + 'dda-portal:workflow-version': str(workflow_version), + 'dda-portal:created-by': user['user_id'] + } + ) + except ClientError as e: + code = e.response.get('Error', {}).get('Code', '') + if code == 'ConflictException': + raise PackagingError( + component_label, + f"Component version {recipe['ComponentVersion']} already exists for " + f"{recipe['ComponentName']}") + raise PackagingError(component_label, + f"Component registration failed: {str(e)}") + + component_arn = response['arn'] + component_status = 'REQUESTED' + status_message = '' + for _ in range(COMPONENT_STATUS_MAX_ATTEMPTS): + if component_status not in ('REQUESTED', 'IN_PROGRESS'): + break + time.sleep(COMPONENT_STATUS_POLL_SECONDS) + status_response = greengrass.describe_component(arn=component_arn) + component_status = status_response['status']['componentState'] + status_message = status_response['status'].get('message', '') + + if component_status != 'DEPLOYABLE': + # Remove the failed registration so nothing partial exists (7.5) + try: + greengrass.delete_component(arn=component_arn) + except ClientError as e: + logger.error(f"Error deleting failed component {component_arn}: {str(e)}") + raise PackagingError( + component_label, + f"Component did not become DEPLOYABLE (state {component_status}): " + f"{status_message or 'no status message'}") + + return component_arn + + +# -------------------------------------------------------------------------- +# POST /workflows/{id}/package +# -------------------------------------------------------------------------- + +def package_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + Compile, assemble, upload, and register a Workflow_Component for the + user-selected architectures (Requirements 7.1-7.5, 11.5, 13.3). + + Body: {"architectures": ["x86_64", "arm64_jp5", ...], "version": N?} + version defaults to the workflow's latest version. + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_PACKAGE) + if err: + return err + + body, err = parse_body(event) + if err: + return err + + architectures = body.get('architectures') + if not architectures or not isinstance(architectures, list): + return error_response(400, 'MISSING_FIELDS', + 'architectures must be a non-empty list of target architectures', + {'supported_architectures': list(DEVICE_ARCHITECTURES)}) + architectures = list(dict.fromkeys(architectures)) # dedupe, keep order + unsupported = [a for a in architectures if a not in DEVICE_ARCHITECTURES] + if unsupported: + return error_response(400, 'UNSUPPORTED_ARCHITECTURE', + f"Unsupported architectures: {', '.join(map(str, unsupported))}", + {'supported_architectures': list(DEVICE_ARCHITECTURES)}) + + version_param = body.get('version', item.get('latest_version', 1)) + try: + version = int(version_param) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', 'version must be an integer') + + version_item = get_version_item(workflow_id, version) + if not version_item: + return error_response(404, 'VERSION_NOT_FOUND', + f'Version {version} not found for workflow') + + # Packaging requires a recorded passed validation (Requirements 4.7, 4.10) + err = validation_guard(version_item) + if err: + return err + + usecase_id = item['usecase_id'] + try: + usecase = get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + usecase_bucket = usecase.get('s3_bucket') + if not usecase_bucket: + return error_response(500, 'USECASE_MISCONFIGURED', + 'Use case has no S3 bucket configured for component artifacts') + + # Load and parse the stored Workflow_Definition + try: + definition_json = load_definition(version_item['s3_definition_key']) + except ClientError as e: + logger.error(f"Error loading definition for {workflow_id} v{version}: {str(e)}") + return error_response(500, 'DEFINITION_LOAD_FAILED', + 'Stored workflow definition could not be loaded') + parse_result = parse_definition(definition_json) + if not parse_result.ok: + return error_response(400, parse_result.error.code, parse_result.error.message, + {'path': parse_result.error.path}) + graph = parse_result.graph + + # Merged Node_Type_Catalog resolving the Custom_Node_Type versions + # pinned at workflow save (custom-node-designer 14.2; built-in-only + # workflows resolve to the built-in catalog unchanged). + pinned_versions = version_item.get('custom_node_types') or {} + node_type_items = load_registered_node_types(usecase_id) + resolved_items = resolution_items(node_type_items, pinned_versions) + catalog = resolve_catalog(descriptors_from_items(resolved_items)) + + # Compile once per user-selected architecture (Requirement 7.4) + compile_context = CompileContext(workflow_id=workflow_id, workflow_version=str(version)) + compiled_docs: Dict[str, Any] = {} + for arch in architectures: + result = compile_workflow(graph, arch, compile_context, simulation=False, + catalog=catalog) + if isinstance(result, list): + return error_response(400, 'COMPILATION_FAILED', + f"Workflow failed to compile for architecture '{arch}'", + {'arch': arch, + 'errors': [e.to_dict() for e in result]}) + compiled_docs[arch] = result + + custom_python_nodes = gather_custom_python_nodes(graph) + + # Camera_Input_Node binding points (camera-registry-sync 8.6, 11.5): + # one bindingPoints entry per camera node in each arch's compiled + # document, plus the version-item discriminator recorded on success. + # Workflows without camera nodes serialize byte-identically to the + # plain compiler output. + camera_nodes = gather_camera_input_nodes( + graph, camera_backed_type_ids(resolved_items)) + binding_hints = binding_hints_from_definition(json.loads(definition_json)) + descriptors_by_id = {descriptor.type_id: descriptor for descriptor in catalog} + arch_compiled_dicts: Dict[str, Dict] = {} + arch_binding_points: Dict[str, List[Dict]] = {} + arch_compiled_json: Dict[str, str] = {} + for arch, compiled in compiled_docs.items(): + compiled_dict = compiled.to_dict() + binding_points = build_binding_points( + camera_nodes, compiled_dict, arch, binding_hints, descriptors_by_id) + arch_compiled_dicts[arch] = compiled_dict + arch_binding_points[arch] = binding_points + arch_compiled_json[arch] = compiled_document_json(compiled, binding_points) + + # Split each arch's compiled plugin dependencies: curated plugins stay + # bundled inline; custom: dependencies resolve to Plugin_Components. + arch_gst_plugins: Dict[str, List[str]] = {} + arch_python_packages: Dict[str, List[str]] = {} + arch_custom_deps: Dict[str, List[str]] = {} + for arch, compiled in compiled_docs.items(): + gst_plugins, custom_plugins, python_packages = split_plugin_dependencies( + compiled.plugin_dependencies) + arch_gst_plugins[arch] = gst_plugins + arch_python_packages[arch] = python_packages + arch_custom_deps[arch] = custom_plugins + + # Custom-plugin packaging gates before any assembly (11.2, 11.3, 16.4): + # dev lifecycle state, a missing per-arch Plugin_Artifact, or a missing + # Plugin_Component version rejects with the Custom_Node_Type and the + # arch/state identified. + dep_records: Dict[str, Optional[Dict]] = {} + if any(arch_custom_deps.values()): + dep_index = custom_dependency_index(resolved_items) + dep_records = load_custom_plugin_records(arch_custom_deps, dep_index) + findings = custom_plugin_gate_findings(arch_custom_deps, dep_index, + dep_records) + if findings: + return error_response( + 409, findings[0]['code'], findings[0]['message'], + {'findings': findings, 'version': version, + 'architectures': architectures}) + else: + dep_index = {} + component_dependencies = plugin_component_dependencies(dep_records) + + # Assemble the per-arch artifact zips locally, then run the + # all-or-nothing stage -> promote -> register sequence (7.1, 7.3, 7.5) + work_dir = tempfile.mkdtemp(prefix='workflow-packaging-') + usecase_s3 = None + final_keys: Dict[str, str] = {} + component_arn = None + staging_root = f"{STAGING_S3_PREFIX}/{workflow_id}/{version}/" + # Computed up front so failure cleanup can always delete any promoted + # objects, even when promotion itself failed partway (Requirement 7.5). + final_prefix = f"{COMPONENT_S3_PREFIX}/{workflow_id}/{version}" + try: + # Verify every custom Plugin_Artifact per selected architecture + # against its Plugin_Record — streamed SHA-256 recompute + KMS + # signature verification (10.4) — collecting the per-arch + # pluginChecksums for the manifests. Custom .so files are never + # bundled inline (16.4). + arch_plugin_checksums: Dict[str, Dict[str, str]] = {} + arch_plugin_components: Dict[str, Dict[str, str]] = {} + verified_cache: Dict[Tuple[str, str], Tuple[str, str]] = {} + for arch in compiled_docs: + arch_plugin_checksums[arch] = {} + arch_plugin_components[arch] = {} + for dep in arch_custom_deps[arch]: + record = dep_records[dep] + cache_key = (dep, arch) + if cache_key not in verified_cache: + node_type_item = dep_index.get(dep) or {} + verified_cache[cache_key] = verify_custom_plugin_artifact( + dep, node_type_item.get('node_type_id'), record, arch) + manifest_key, checksum = verified_cache[cache_key] + arch_plugin_checksums[arch][manifest_key] = checksum + arch_plugin_components[arch][ + plugin_component_name(str(record['plugin_id']))] = \ + component_version_for(record['version']) + + arch_zip_paths: Dict[str, str] = {} + for arch, compiled in compiled_docs.items(): + gst_plugins = arch_gst_plugins[arch] + manifest = build_manifest( + workflow_id, version, arch, gst_plugins, + arch_python_packages[arch], custom_python_nodes, user, + plugin_checksums=arch_plugin_checksums[arch], + plugin_components=arch_plugin_components[arch]) + zip_path = os.path.join(work_dir, zip_artifact_name(arch)) + build_arch_zip(zip_path, arch, manifest, definition_json, + arch_compiled_json[arch], gst_plugins, + custom_python_nodes) + arch_zip_paths[arch] = zip_path + + # Use_Case account clients via the assumed cross-account role (7.2) + session_name = f"wf-pkg-{user['user_id'][:20]}-{int(datetime.utcnow().timestamp())}"[:64] + usecase_s3 = get_usecase_client('s3', usecase, session_name=session_name) + greengrass = get_usecase_client('greengrassv2', usecase, session_name=session_name) + + final_prefix, final_keys = stage_and_promote_artifacts( + usecase_s3, usecase_bucket, workflow_id, version, arch_zip_paths) + + # Register only after every artifact uploaded successfully (7.5); + # the recipe pins each custom plugin's Plugin_Component (16.4). + recipe = build_recipe(workflow_id, version, usecase_bucket, final_keys, + component_dependencies=component_dependencies) + component_arn = register_component(greengrass, recipe, usecase_id, + workflow_id, version, user) + except PackagingError as e: + # All-or-nothing: delete the stage and any promoted artifacts, + # report the failing artifact, register nothing (Requirement 7.5) + if usecase_s3 is not None: + delete_prefix(usecase_s3, usecase_bucket, staging_root) + if final_prefix: + delete_prefix(usecase_s3, usecase_bucket, final_prefix) + log_audit_event( + user_id=user['user_id'], + action='package_workflow', + resource_type='workflow', + resource_id=workflow_id, + result='failure', + details={'usecase_id': usecase_id, 'version': version, + 'architectures': architectures, + 'failing_artifact': e.artifact, 'error': e.message} + ) + logger.error(f"Packaging failed for {workflow_id} v{version} " + f"at artifact {e.artifact}: {e.message}") + return error_response(502, 'PACKAGING_FAILED', e.message, + {'failing_artifact': e.artifact, + 'version': version, + 'architectures': architectures}) + finally: + shutil.rmtree(work_dir, ignore_errors=True) + # The stage is temporary in every outcome; on success the promoted + # copies under the final prefix are the component's artifacts. + if usecase_s3 is not None and component_arn is not None: + delete_prefix(usecase_s3, usecase_bucket, staging_root) + + # Success bookkeeping: compiled documents to portal S3 + version record + compiled_arch_keys: Dict[str, str] = {} + for arch in compiled_docs: + portal_key = compiled_doc_portal_key(usecase_id, workflow_id, version, arch) + s3.put_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=portal_key, + Body=arch_compiled_json[arch].encode('utf-8'), + ContentType='application/json') + compiled_arch_keys[arch] = portal_key + + # The dependency closure of the packaged Workflow_Component: every + # depended-on Plugin_Component (dda.plugin.* name -> component version) + # across all packaged architectures. Recorded on the version item so + # the Deployment_Service's pre-submit lifecycle/architecture gates can + # evaluate the closure without re-resolving the recipe + # (custom-node-designer task 10.5, Requirements 9.7, 9.8, 16.3, 16.6). + workflow_plugin_components: Dict[str, str] = {} + for arch_map in arch_plugin_components.values(): + workflow_plugin_components.update(arch_map) + + # The version-item binding discriminator (camera-registry-sync 8.6, + # 11.5): has_binding_points separates the strict deploy-time binding + # rule from legacy leniency, and camera_input_nodes feeds the binding + # matrix and the legacy compiled-path check (9.5) without re-reading + # compiled documents from S3. + camera_input_nodes = camera_input_nodes_record( + camera_nodes, binding_hints, arch_binding_points, arch_compiled_dicts) + + dynamodb.Table(WORKFLOW_VERSIONS_TABLE).update_item( + Key={'workflow_id': workflow_id, 'version': version}, + UpdateExpression=('SET component_arn = :arn, compiled_arch_keys = :keys, ' + 'plugin_components = :pc, ' + 'has_binding_points = :hbp, ' + 'camera_input_nodes = :cin, ' + 'packaged_at = :at, packaged_by = :by'), + ExpressionAttributeValues={ + ':arn': component_arn, + ':keys': compiled_arch_keys, + ':pc': workflow_plugin_components, + ':hbp': bool(camera_nodes), + ':cin': _dynamo_safe(camera_input_nodes), + ':at': now_ms(), + ':by': user['user_id'] + } + ) + + # Audit log entry for packaging (Requirement 11.5) + log_audit_event( + user_id=user['user_id'], + action='package_workflow', + resource_type='workflow', + resource_id=workflow_id, + result='success', + details={ + 'usecase_id': usecase_id, + 'version': version, + 'architectures': architectures, + 'component_name': component_name_for(workflow_id), + 'component_version': component_version_for(version), + 'component_arn': component_arn + } + ) + + return create_response(201, { + 'workflow_id': workflow_id, + 'version': version, + 'component_name': component_name_for(workflow_id), + 'component_version': component_version_for(version), + 'component_arn': component_arn, + 'architectures': architectures, + 'artifacts': { + arch: f"s3://{usecase_bucket}/{key}" for arch, key in final_keys.items() + } + }) + + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + workflow_id = path_params.get('id') + + if resource == '/workflows/{id}/package' and workflow_id: + if http_method == 'POST': + return package_workflow(event, user, workflow_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/workflow_test_steps.py b/edge-cv-portal/backend/functions/workflow_test_steps.py new file mode 100644 index 00000000..a2bd57ef --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_test_steps.py @@ -0,0 +1,746 @@ +""" +Workflow_Test_Runner Step Functions task handlers (Workflow Manager) + +Lambda steps of the test-run state machine +Validate -> Compile (x86_64, simulation=true) -> RunSandbox (Fargate) -> +CollectResults (design section 10, task 11.2). + +Each state invokes this handler with {"step": , "input": } where the input is the execution input started by +workflow_testing.py: + + {test_run_id, workflow_id, workflow_version, usecase_id, dataset_id, + dataset_s3_prefix, definition_s3_key, results_s3_key, + artifacts_bucket, target_arch: "x86_64", simulation: true} + +Steps: + validate Parse the stored Workflow_Definition and run all + Workflow_Validator checks (12.4). Errors short-circuit: + each is recorded with its node/connection identifier in + the results document, the TestRuns item is marked + failed, and the pipeline is never executed (12.12). + compile Workflow_Compiler for the target architecture with + simulation=true (12.4, 12.6); uploads the Compiled + Pipeline Document next to the results for the sandbox + task. Compile errors short-circuit exactly like + validation errors (12.12). + + Compiles against the merged Node_Type_Catalog of the + run's Use_Case (custom-node-designer 12.1): registered + Custom_Node_Types resolve with the versions pinned at + workflow save (custom_node_type_pins in the execution + input). Custom_Node_Types whose backing Plugin_Record + has a successful x86_64 Plugin_Artifact get that + artifact staged under the run's prefix + (.../test-runs/{id}/plugins/) for the sandbox task to + download into its plugin scan path; Custom_Node_Types + lacking one are substituted with a pass-through + recording stub (identity element named + custom_stub_, in addition to the + hardware-dependent stubbing rules) that the harness + identifies as stubbed in the test run report + (custom-node-designer 12.2). The staged plugins and + stubbed type ids are written to the + custom_plugins.json manifest next to the compiled + document. + collect Read the per-node results the sandbox flushed + incrementally to S3 and mark the run completed, or + failed with the failing node identified (12.10). + record_timeout The 10-minute execution timeout stopped the sandbox + task: mark the run failed with a timeout indication; + partial per-node results already in S3 are retained + untouched (12.13). + record_failure The sandbox task (or an internal step) failed: mark the + run failed; partial results are retained (12.10). + +This module is standalone (workflow_core layer + node_catalog_resolution +only, no shared_utils) so the test-runner stack can deploy it with a +minimal role: TestRuns table read/write, portal-artifacts S3 read/write, +and read-only access to the node-designer CustomNodeTypes/PluginRecords +tables for the merged-catalog resolution (degrading to the built-in +catalog when the node-designer stack is not deployed). It has no +Greengrass or device permissions (12.9). +""" +import json +import logging +import os +import sys +from datetime import datetime +from typing import Any, Dict, FrozenSet, Iterable, List, Optional, Sequence + +import boto3 +from botocore.exceptions import ClientError + +# workflow_core ships as a Lambda layer under /opt/python +sys.path.append('/opt/python') +from workflow_core.catalog.custom import resolve_catalog +from workflow_core.catalog.models import ARCH_SIM, GstMapping, NodeTypeDescriptor +from workflow_core.compiler import CompileContext, compile as compile_workflow +from workflow_core.serializer import parse as parse_definition +from workflow_core.validator import SEVERITY_ERROR, validate as run_validator + +# Merged-catalog helpers shared with the other catalog consumers (task 9.2); +# bundled in the same functions asset the test-runner stack deploys. Imports +# workflow_core + boto3 only, keeping this handler shared_utils-free. +from node_catalog_resolution import ( + descriptors_from_items, + load_registered_node_types, + resolution_items, +) + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +TEST_RUNS_TABLE = os.environ.get('TEST_RUNS_TABLE') +#: PluginRecords table of the node-designer stack; unset when that stack +#: is not deployed (every custom node then counts as artifact-less, which +#: cannot be reached in practice because the catalog merge also degrades +#: and validation rejects the unknown type first). +PLUGIN_RECORDS_TABLE = os.environ.get('PLUGIN_RECORDS_TABLE') + +#: Message recorded when the 10-minute limit terminates the run (12.13). +TIMEOUT_MESSAGE = 'Test run exceeded the 10 minute execution limit' + +# Human-readable progress entries appended to the TestRuns item's +# `progress` list attribute as the run advances through the state machine, +# so the portal can show what the run is currently doing while the user +# waits (polled via GET /test-runs/{id}). Each entry is +# {at: , message: }, most recent last. +PROGRESS_VALIDATING = 'Validating the workflow definition' +PROGRESS_VALIDATION_PASSED = 'Validation passed' +PROGRESS_COMPILE_SUCCEEDED = 'Compilation succeeded' +# The sandbox container start has no Lambda step of its own (the Fargate +# RunSandbox state follows the compile step directly), so the successful +# compile step appends this entry last. +PROGRESS_STARTING_SANDBOX = 'Starting the sandbox container...' +PROGRESS_COLLECTING = 'Collecting per-node results' +PROGRESS_COMPLETED = 'Test run completed' + + +def compile_progress_message(target_arch: str, simulation: bool) -> str: + """Progress entry recorded while the Workflow_Compiler runs.""" + return 'Compiling for {0} ({1} mode)'.format( + target_arch, 'simulation' if simulation else 'hardware') + + +def failure_progress_message(message: str) -> str: + """Progress entry recorded when the run is marked failed.""" + return 'Test run failed: {0}'.format(message) + + +def custom_plugins_progress_message(count: int) -> str: + """Progress entry recorded when custom x86_64 Plugin_Artifacts are + staged for the sandbox task (custom-node-designer 12.1).""" + return ('Staged {0} custom plugin artifact(s) for the sandbox' + .format(count)) + + +def custom_stub_progress_message(stubbed_type_ids: List[str]) -> str: + """Progress entry recorded when Custom_Node_Types without an x86_64 + build are substituted with pass-through stubs (12.2).""" + return ('Substituting pass-through stubs for custom node type(s) ' + 'without an x86_64 build: {0}'.format(', '.join(stubbed_type_ids))) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def run_prefix(results_s3_key: str) -> str: + """The run's S3 prefix (everything up to the results document name).""" + prefix = results_s3_key.rsplit('/', 1)[0] if '/' in results_s3_key else '' + return prefix + '/' if prefix else '' + + +def compiled_document_key(results_s3_key: str) -> str: + """The Compiled Pipeline Document lives next to the results document: + .../test-runs/{test_run_id}/compiled_pipeline.json""" + return run_prefix(results_s3_key) + 'compiled_pipeline.json' + + +# --------------------------------------------------------------------------- +# Custom_Node_Type support (custom-node-designer Requirements 12.1, 12.2) +# +# The "pure decision logic" section is pure over plain dicts/descriptors so +# task 13.2 can property-test that stubbing is exactly the unavailable +# custom nodes without AWS. Loaders and staging live below it. +# --------------------------------------------------------------------------- + +#: Identity-element name prefix of the custom-node pass-through recording +#: stub; the sandbox harness identifies stubbed nodes by it (12.2). +CUSTOM_STUB_ELEMENT_PREFIX = 'custom_stub_' + +#: Manifest written next to the Compiled Pipeline Document listing the +#: staged custom x86_64 Plugin_Artifacts the sandbox task downloads into +#: its plugin scan path, plus the stubbed Custom_Node_Type ids (12.1, 12.2). +CUSTOM_PLUGINS_MANIFEST_NAME = 'custom_plugins.json' + +#: buildStatus value of a usable per-arch Plugin_Record artifact entry. +BUILD_SUCCEEDED = 'succeeded' + + +# ------------------------------------------------------ pure decision logic + +def x86_64_artifact_available(entry: Optional[Dict]) -> bool: + """Whether a Plugin_Record's x86_64 artifact entry is a successfully + built Plugin_Artifact the sandbox can execute (12.1).""" + return bool(entry + and entry.get('buildStatus') == BUILD_SUCCEEDED + and entry.get('s3Key')) + + +def stubbed_custom_type_ids(custom_type_ids: Iterable[str], + artifact_entries: Dict[str, Optional[Dict]] + ) -> FrozenSet[str]: + """The Custom_Node_Types that get the pass-through recording stub: + exactly those lacking a successful x86_64 Plugin_Artifact (12.2) — + everything else executes its real x86_64 build (12.1).""" + return frozenset( + type_id for type_id in custom_type_ids + if not x86_64_artifact_available(artifact_entries.get(type_id)) + ) + + +def custom_stub_mapping(arch: str) -> GstMapping: + """The pass-through recording stub mapping: an identity element named + custom_stub_ (the compiler resolves {custom_stub_name} per + node) that passes input frames through unchanged; the harness records + the substitution as stub activity in the test run report (12.2).""" + return GstMapping( + arch=arch, + element_chain=[{ + 'factory': 'identity', + 'args_template': {'name': '{custom_stub_name}'}, + }], + plugin_dependencies=[], + ) + + +def stub_descriptor(descriptor: NodeTypeDescriptor, + target_arch: str) -> NodeTypeDescriptor: + """The descriptor of a stubbed Custom_Node_Type: identical declaration + but every realization replaced by the pass-through recording stub. + Both the target architecture and the ``sim`` architecture are mapped, + so hardware-dependent custom types (which the simulation compiler + resolves via the existing sim-stub rule) stub identically (12.2: + "in addition to the hardware-dependent stubbing rules").""" + archs = [target_arch] if target_arch == ARCH_SIM else [target_arch, ARCH_SIM] + return NodeTypeDescriptor( + type_id=descriptor.type_id, + category=descriptor.category, + display_name=descriptor.display_name, + inputs=descriptor.inputs, + outputs=descriptor.outputs, + parameters=descriptor.parameters, + mappings=[custom_stub_mapping(arch) for arch in archs], + hardware_dependent=descriptor.hardware_dependent, + ) + + +def apply_custom_stubs(custom_descriptors: Sequence[NodeTypeDescriptor], + stub_type_ids: FrozenSet[str], + target_arch: str) -> List[NodeTypeDescriptor]: + """Replace exactly the stubbed descriptors with their pass-through + recording stubs; every other descriptor is untouched (12.2).""" + return [ + stub_descriptor(descriptor, target_arch) + if descriptor.type_id in stub_type_ids else descriptor + for descriptor in custom_descriptors + ] + + +def plugin_file_name(artifact_s3_key: str) -> str: + """The staged .so file name of a Plugin_Library artifact key.""" + name = artifact_s3_key.rsplit('/', 1)[-1] or 'plugin.so' + return name if name.endswith('.so') else name + '.so' + + +def staged_plugin_key(results_s3_key: str, file_name: str) -> str: + """Run-prefix key a custom x86_64 Plugin_Artifact is staged to for + the sandbox task: .../test-runs/{id}/plugins/{plugin}.so (12.1).""" + return run_prefix(results_s3_key) + 'plugins/' + file_name + + +def custom_plugins_manifest_key(results_s3_key: str) -> str: + """.../test-runs/{test_run_id}/custom_plugins.json""" + return run_prefix(results_s3_key) + CUSTOM_PLUGINS_MANIFEST_NAME + + +# ------------------------------------------------------------------ loaders + +def load_custom_catalog_items(inp: Dict) -> List[Dict]: + """The resolved CustomNodeTypes version items of the run's Use_Case, + honoring the Custom_Node_Type versions pinned at workflow save + (custom_node_type_pins from the execution input, 14.2). Returns [] + when the run has no Use_Case or the node-designer stack is absent, + so the built-in catalog is used unchanged.""" + usecase_id = inp.get('usecase_id') + if not usecase_id: + return [] + items = load_registered_node_types(usecase_id) + if not items: + return [] + return resolution_items(items, inp.get('custom_node_type_pins') or {}) + + +def merged_catalog(custom_descriptors: Sequence[NodeTypeDescriptor]) -> tuple: + """The built-in catalog merged with the resolved custom descriptors + (built-ins win on type-id collision).""" + return resolve_catalog(custom_descriptors) + + +def load_x86_64_artifact_entries(items_by_type: Dict[str, Dict], + type_ids: Iterable[str] + ) -> Dict[str, Optional[Dict]]: + """The x86_64 artifact entry of each used Custom_Node_Type's pinned + backing Plugin_Record version, or None when the record/entry/table is + missing (fails closed: the type is then stubbed, 12.2).""" + entries: Dict[str, Optional[Dict]] = {} + table = dynamodb.Table(PLUGIN_RECORDS_TABLE) if PLUGIN_RECORDS_TABLE else None + for type_id in type_ids: + entry = None + item = items_by_type.get(type_id) + if (table is not None and item + and item.get('plugin_id') is not None + and item.get('plugin_version') is not None): + try: + response = table.get_item(Key={ + 'plugin_id': item['plugin_id'], + 'version': int(item['plugin_version']), + }) + record = response.get('Item') + except ClientError as e: + logger.warning('Could not load plugin record %s v%s: %s', + item.get('plugin_id'), + item.get('plugin_version'), str(e)) + record = None + if record: + artifacts = record.get('artifacts') or {} + candidate = artifacts.get('x86_64') + entry = candidate if isinstance(candidate, dict) else None + entries[type_id] = entry + return entries + + +def stage_custom_plugins(bucket: str, results_s3_key: str, + real_type_ids: Iterable[str], + artifact_entries: Dict[str, Optional[Dict]] + ) -> List[Dict]: + """Copy each executable custom x86_64 Plugin_Artifact from the + Plugin_Library into the run's plugins/ prefix so the sandbox task + (whose access is the portal artifacts bucket) downloads it into its + plugin scan path (12.1). Returns the manifest plugin entries.""" + staged: List[Dict] = [] + for type_id in sorted(real_type_ids): + entry = artifact_entries[type_id] + source_key = str(entry['s3Key']) + file_name = plugin_file_name(source_key) + target_key = staged_plugin_key(results_s3_key, file_name) + s3.copy_object( + Bucket=bucket, + CopySource={'Bucket': bucket, 'Key': source_key}, + Key=target_key, + ) + staged.append({ + 'nodeTypeId': type_id, + 'fileName': file_name, + 's3Key': target_key, + }) + return staged + + +def write_custom_plugins_manifest(bucket: str, results_s3_key: str, + plugins: List[Dict], + stubbed_type_ids: Iterable[str]) -> str: + """Write the custom_plugins.json manifest next to the compiled + document; the sandbox harness stages the listed plugins and the + report identifies the stubbed Custom_Node_Types (12.1, 12.2).""" + key = custom_plugins_manifest_key(results_s3_key) + document = { + 'plugins': plugins, + 'stubbedNodeTypeIds': sorted(stubbed_type_ids), + } + s3.put_object( + Bucket=bucket, + Key=key, + Body=json.dumps(document, indent=2).encode('utf-8'), + ContentType='application/json', + ) + return key + + +def load_definition(bucket: str, key: str) -> str: + """Fetch the stored Workflow_Definition JSON text""" + response = s3.get_object(Bucket=bucket, Key=key) + return response['Body'].read().decode('utf-8') + + +def write_results_document(bucket: str, key: str, records: List[Dict]) -> None: + """Write the per-node results document ({"nodes": [...]}, the shape + workflow_testing.load_node_results consumes).""" + s3.put_object( + Bucket=bucket, + Key=key, + Body=json.dumps({'nodes': records}, indent=2).encode('utf-8'), + ContentType='application/json', + ) + + +def error_records(errors: List[Dict]) -> List[Dict]: + """Per-node/connection error records in the per-node result shape + {nodeId, status, outputs, stubActivity, error} (12.7, 12.12).""" + return [ + { + 'nodeId': error.get('nodeId'), + 'connectionId': error.get('connectionId'), + 'status': 'error', + 'outputs': [], + 'stubActivity': [], + 'error': { + 'code': error.get('code'), + 'message': error.get('message'), + }, + } + for error in errors + ] + + +def append_run_progress(test_run_id: str, *messages: str) -> None: + """Append {at, message} entries to the TestRuns item's `progress` + list (created on first use). Purely informational and additive: the + run's status semantics are untouched, and a progress write failure + never fails the step itself.""" + entries = [{'at': now_ms(), 'message': message} for message in messages] + if not entries: + return + try: + dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET progress = ' + 'list_append(if_not_exists(progress, :empty), :entries)', + ExpressionAttributeValues={':empty': [], ':entries': entries}, + ) + except ClientError as e: + logger.warning('Could not record progress for run %s: %s', + test_run_id, str(e)) + + +def mark_run_failed(test_run_id: str, message: str, + node_id: Optional[str] = None, + timeout: bool = False) -> None: + """Mark the TestRuns item failed with the failure record + {nodeId, message, timeout} (design data model).""" + dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished, failure = :failure', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': 'failed', + ':finished': now_ms(), + ':failure': {'nodeId': node_id, 'message': message, 'timeout': timeout}, + }, + ) + append_run_progress(test_run_id, failure_progress_message(message)) + + +def mark_run_completed(test_run_id: str) -> None: + """Mark the TestRuns item completed""" + dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished, failure = :failure', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': 'completed', + ':finished': now_ms(), + ':failure': None, + }, + ) + append_run_progress(test_run_id, PROGRESS_COMPLETED) + + +def record_short_circuit(inp: Dict, errors: List[Dict], summary: str) -> None: + """Validation/compilation errors short-circuit the run: write the + per-node/connection error records and mark the run failed without + executing the pipeline (12.12).""" + records = error_records(errors) + write_results_document(inp['artifacts_bucket'], inp['results_s3_key'], records) + first_node_id = next((e.get('nodeId') for e in errors if e.get('nodeId')), None) + mark_run_failed(inp['test_run_id'], summary, node_id=first_node_id) + + +# --------------------------------------------------------------------------- +# Steps +# --------------------------------------------------------------------------- + +def step_validate(inp: Dict) -> Dict: + """Validate: parse the definition and run all validator checks against + the merged catalog of the run's Use_Case (12.4; custom-node-designer + 12.1 — custom nodes are known types here exactly as in + workflow_validation.py). Errors are recorded with node/connection ids + and fail the run (12.12).""" + append_run_progress(inp['test_run_id'], PROGRESS_VALIDATING) + document = load_definition(inp['artifacts_bucket'], inp['definition_s3_key']) + + result = parse_definition(document) + if not result.ok: + error = result.error + errors = [{'code': error.code, 'message': str(error), + 'nodeId': None, 'connectionId': None}] + record_short_circuit(inp, errors, + 'Workflow definition could not be parsed: ' + str(error)) + return {'ok': False, 'stage': 'parse', 'errors': errors} + + catalog = merged_catalog( + descriptors_from_items(load_custom_catalog_items(inp))) + findings = run_validator(result.graph, catalog) + errors = [f.to_dict() for f in findings if f.severity == SEVERITY_ERROR] + if errors: + record_short_circuit( + inp, errors, + 'Workflow validation reported {0} error(s); the pipeline was not ' + 'executed'.format(len(errors))) + return {'ok': False, 'stage': 'validate', 'errors': errors} + + append_run_progress(inp['test_run_id'], PROGRESS_VALIDATION_PASSED) + warnings = sum(1 for f in findings if f.severity != SEVERITY_ERROR) + return {'ok': True, 'warnings': warnings} + + +def step_compile(inp: Dict) -> Dict: + """Compile for the target architecture (x86_64) with simulation=true + against the merged catalog of the run's Use_Case (12.4, 12.6; + custom-node-designer 12.1) and stage the Compiled Pipeline Document + for the sandbox. Custom_Node_Types with a successful x86_64 + Plugin_Artifact get the artifact staged under the run's prefix; those + without one are substituted with the pass-through recording stub + (12.2). Compile errors short-circuit the run (12.12).""" + target_arch = inp.get('target_arch') or 'x86_64' + simulation = bool(inp.get('simulation', True)) + append_run_progress(inp['test_run_id'], + compile_progress_message(target_arch, simulation)) + bucket = inp['artifacts_bucket'] + document = load_definition(bucket, inp['definition_s3_key']) + + result = parse_definition(document) + if not result.ok: + # Validate ran first; a parse failure here means the stored + # definition changed between steps. Treat it like a compile error. + error = result.error + errors = [{'code': error.code, 'message': str(error), + 'nodeId': None, 'connectionId': None}] + record_short_circuit(inp, errors, + 'Workflow definition could not be parsed: ' + str(error)) + return {'ok': False, 'stage': 'parse', 'errors': errors} + + # Merged catalog with the pinned Custom_Node_Type versions (12.1). + custom_items = load_custom_catalog_items(inp) + custom_descriptors = descriptors_from_items(custom_items) + items_by_type = { + item['node_type_id']: item + for item in custom_items if item.get('node_type_id') + } + + # The stub-vs-real decision per used custom node keys purely on the + # presence of a successful x86_64 Plugin_Artifact (12.1, 12.2). + custom_type_ids = {d.type_id for d in custom_descriptors} + used_custom_ids = sorted( + {node.type for node in result.graph.nodes} & custom_type_ids) + artifact_entries = load_x86_64_artifact_entries(items_by_type, + used_custom_ids) + stub_ids = stubbed_custom_type_ids(used_custom_ids, artifact_entries) + + catalog = merged_catalog( + apply_custom_stubs(custom_descriptors, stub_ids, target_arch)) + + context = CompileContext( + workflow_id=str(inp.get('workflow_id') or ''), + workflow_version=str(inp.get('workflow_version') or ''), + ) + outcome = compile_workflow( + result.graph, + target_arch, + context=context, + simulation=simulation, + catalog=catalog, + ) + + if isinstance(outcome, list): + errors = [e.to_dict() for e in outcome] + record_short_circuit( + inp, errors, + 'Workflow compilation reported {0} error(s); the pipeline was not ' + 'executed'.format(len(errors))) + return {'ok': False, 'stage': 'compile', 'errors': errors} + + key = compiled_document_key(inp['results_s3_key']) + s3.put_object( + Bucket=bucket, + Key=key, + Body=outcome.to_json().encode('utf-8'), + ContentType='application/json', + ) + + # Custom plugin staging + manifest (only when custom nodes are used, + # keeping runs without them byte-identical to the pre-existing flow). + stubbed = sorted(stub_ids) + if used_custom_ids: + real_ids = [t for t in used_custom_ids if t not in stub_ids] + plugins = stage_custom_plugins(bucket, inp['results_s3_key'], + real_ids, artifact_entries) + write_custom_plugins_manifest(bucket, inp['results_s3_key'], + plugins, stubbed) + progress = [] + if plugins: + progress.append(custom_plugins_progress_message(len(plugins))) + if stubbed: + progress.append(custom_stub_progress_message(stubbed)) + if progress: + append_run_progress(inp['test_run_id'], *progress) + + # The next state is the Fargate sandbox task (no Lambda runs there), + # so the sandbox-start entry is appended here on compile success. + append_run_progress(inp['test_run_id'], + PROGRESS_COMPILE_SUCCEEDED, PROGRESS_STARTING_SANDBOX) + return {'ok': True, 'compiled_s3_key': key, + 'stubbed_custom_node_types': stubbed} + + +def step_collect(inp: Dict) -> Dict: + """CollectResults: read the per-node results the sandbox flushed to S3 + and finalize the run status (12.7, 12.10).""" + append_run_progress(inp['test_run_id'], PROGRESS_COLLECTING) + records: List[Dict] = [] + try: + response = s3.get_object(Bucket=inp['artifacts_bucket'], + Key=inp['results_s3_key']) + document = json.loads(response['Body'].read().decode('utf-8')) + if isinstance(document, dict) and isinstance(document.get('nodes'), list): + records = document['nodes'] + elif isinstance(document, list): + records = document + except ClientError as e: + if e.response.get('Error', {}).get('Code') not in ('NoSuchKey', '404'): + raise + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error('Malformed results document %s: %s', + inp['results_s3_key'], str(e)) + + failing = next( + (r for r in records + if isinstance(r, dict) and (r.get('status') in ('failed', 'error') or r.get('error'))), + None, + ) + if failing is not None: + error = failing.get('error') + message = (error.get('message') if isinstance(error, dict) else error) \ + or 'Pipeline execution failed' + mark_run_failed(inp['test_run_id'], str(message), + node_id=failing.get('nodeId')) + return {'status': 'failed', 'node_count': len(records), + 'failing_node_id': failing.get('nodeId')} + + mark_run_completed(inp['test_run_id']) + return {'status': 'completed', 'node_count': len(records)} + + +def step_record_timeout(inp: Dict) -> Dict: + """The 10-minute execution timeout stopped the sandbox task: mark the + run failed-with-timeout. Partial per-node results the harness already + flushed to S3 are retained untouched (12.13).""" + mark_run_failed(inp['test_run_id'], TIMEOUT_MESSAGE, timeout=True) + return {'ok': True, 'status': 'failed', 'timeout': True} + + +def summarize_ecs_task_failure(cause: Any) -> Optional[str]: + """Concise human-readable summary of an ECS RunTask failure Cause. + + When the Fargate sandbox task fails, Step Functions delivers the whole + ECS task description (Attachments, network interfaces, container + states, ...) as the Cause JSON string - unreadable in the portal's + failure banner. Extract just what a user needs, in preference order: + + 1. Containers[].Reason (e.g. image pull / OOM failures), with the + exit code appended when known, + 2. the container exit code ("The sandbox container exited with + code N"), + 3. the task-level StoppedReason, then StopCode. + + Returns None when ``cause`` is not JSON or not an ECS task shape, so + the caller keeps its plain-text fallback. + """ + try: + task = json.loads(cause) + except (TypeError, ValueError): + return None + if not isinstance(task, dict): + return None + + containers = [c for c in (task.get('Containers') or []) + if isinstance(c, dict)] + is_ecs_task = bool(containers) or any( + key in task for key in ('StoppedReason', 'StopCode', 'TaskArn')) + if not is_ecs_task: + return None + + exit_code = next((c.get('ExitCode') for c in containers + if isinstance(c.get('ExitCode'), int) + and not isinstance(c.get('ExitCode'), bool)), None) + reason = next((str(c['Reason']) for c in containers if c.get('Reason')), + None) + + if reason: + if exit_code is not None: + return '{0} (exit code {1})'.format(reason, exit_code) + return reason + if exit_code is not None: + return 'The sandbox container exited with code {0}'.format(exit_code) + stopped = task.get('StoppedReason') or task.get('StopCode') + if stopped: + return str(stopped) + return 'The sandbox task failed' + + +def step_record_failure(inp: Dict) -> Dict: + """The sandbox task (or an internal step) failed: mark the run failed. + Partial results already flushed to S3 are retained (12.10).""" + error_info = inp.get('errorInfo') or {} + cause = error_info.get('Cause') + # For ECS task failures the Cause is the raw task JSON blob; extract a + # concise, readable message instead of storing the blob. + message = summarize_ecs_task_failure(cause) if cause else None + if message is None: + message = cause or error_info.get('Error') \ + or 'Test run execution failed' + # Step Functions delivers Cause as a JSON string for service + # errors; keep the failure record readable. + if isinstance(message, str) and len(message) > 512: + message = message[:512] + mark_run_failed(inp['test_run_id'], str(message)) + return {'ok': True, 'status': 'failed', 'timeout': False} + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +STEPS = { + 'validate': step_validate, + 'compile': step_compile, + 'collect': step_collect, + 'record_timeout': step_record_timeout, + 'record_failure': step_record_failure, +} + + +def handler(event: Dict, context: Any) -> Dict: + """Dispatch on event['step'] with event['input'] as the state input""" + step = event.get('step') + inp = event.get('input') or {} + logger.info('Test run step %s for run %s', step, inp.get('test_run_id')) + step_fn = STEPS.get(step) + if step_fn is None: + raise ValueError('Unknown test run step: {0!r}'.format(step)) + return step_fn(inp) diff --git a/edge-cv-portal/backend/functions/workflow_testing.py b/edge-cv-portal/backend/functions/workflow_testing.py new file mode 100644 index 00000000..edf76d23 --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_testing.py @@ -0,0 +1,1333 @@ +""" +Workflow_Test_Runner API Lambda function (Workflow Manager) + +Test dataset upload/list/delete and test run start/status/results +(Requirements 12.2, 12.3, 12.11). Fronts the Workflow_Test_Runner +Step Functions state machine (built with the test-runner infrastructure); +this handler reads the state machine ARN from configuration and returns a +clear error when the runner is not yet configured. + +Routes (API Gateway REST): + GET /test-datasets List Test_Datasets scoped to Use_Case (12.2) + POST /test-datasets Initiate / finalize a dataset upload (12.3, 12.11) + GET /test-datasets/{id} Get one Test_Dataset + DELETE /test-datasets/{id} Delete a Test_Dataset + POST /workflows/{id}/test-runs Start a test run (12.3) + GET /workflows/{id}/test-runs List test runs of a workflow + GET /test-runs/{id} Test run status + per-node results (12.3) + +Dataset upload flow (design section 10): + 1. POST /test-datasets (initiate) declares the file set; the declared + total size and file formats are pre-checked, then S3 multipart + uploads are created with presigned part URLs for the client. + No DynamoDB record is written at this stage. + 2. POST /test-datasets (action=finalize) completes the multipart + uploads and performs server-side verification: actual total size + <= 500 MB and JPEG/PNG content (magic bytes). Only when + verification passes is the TestDatasets record committed; + violations reject with the reason and persist nothing - all + uploaded objects are removed (12.3, 12.11). + +Storage layout: + TestDatasets table (TEST_DATASETS_TABLE) PK dataset_id, GSI usecase-datasets-index + TestRuns table (TEST_RUNS_TABLE) PK test_run_id, GSI workflow-runs-index + Objects in portal S3 under + {WORKFLOWS_S3_PREFIX}/{usecase_id}/test-datasets/{dataset_id}/... + {WORKFLOWS_S3_PREFIX}/{usecase_id}/test-runs/{test_run_id}/results.json + +Error envelope (design): {"error": {"code", "message", "details"}} with +400 parse/validation, 403 RBAC denial, 404 scoped to avoid cross-tenant +existence leaks, 503 when the test runner is not configured. +""" +import json +import os +import logging +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, get_usecase_client, get_usecase_region, + rbac_manager, Permission +) + +# Triton model staging for test runs (same functions/ Lambda asset). +import workflow_model_staging as model_staging + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') +stepfunctions = boto3.client('stepfunctions') + +# Environment variables +TEST_DATASETS_TABLE = os.environ.get('TEST_DATASETS_TABLE') +TEST_RUNS_TABLE = os.environ.get('TEST_RUNS_TABLE') +MODELS_TABLE = os.environ.get('MODELS_TABLE') +WORKFLOWS_TABLE = os.environ.get('WORKFLOWS_TABLE') +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +SETTINGS_TABLE = os.environ.get('SETTINGS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +WORKFLOWS_S3_PREFIX = os.environ.get('WORKFLOWS_S3_PREFIX', 'workflows') +# The Step Functions state machine is provisioned with the test-runner +# infrastructure; until then the ARN may instead be supplied through the +# portal settings table (setting_key below). +TEST_RUN_STATE_MACHINE_ARN = os.environ.get('TEST_RUN_STATE_MACHINE_ARN') +TEST_RUN_STATE_MACHINE_SETTING_KEY = 'workflow-test-runner.state-machine-arn' + +# Upload constraints (Requirements 12.3, 12.11) +MAX_DATASET_BYTES = 500 * 1024 * 1024 # 500 MB total +SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png'} +SUPPORTED_CONTENT_TYPES = {'image/jpeg', 'image/png'} +JPEG_MAGIC = b'\xff\xd8\xff' +PNG_MAGIC = b'\x89PNG\r\n\x1a\n' +PART_SIZE = 100 * 1024 * 1024 # multipart part size +PRESIGN_EXPIRY_SECONDS = 3600 + +# Default simulated inference outcome injected for stubbed model +# inference nodes when the request does not configure one (12.6): the +# model is not executed in the cloud sandbox, so the user chooses the +# outcome per run in the Test panel. +DEFAULT_SIMULATED_INFERENCE = {'is_anomalous': False, 'confidence': 0.9} + +# Step Functions execution status -> test run status +SFN_STATUS_MAP = { + 'RUNNING': 'running', + 'SUCCEEDED': 'completed', + 'FAILED': 'failed', + 'TIMED_OUT': 'failed', + 'ABORTED': 'failed', +} + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def dataset_s3_prefix(usecase_id: str, dataset_id: str) -> str: + """S3 prefix holding all sample-input objects of one Test_Dataset""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/test-datasets/{dataset_id}/" + + +def test_run_results_key(usecase_id: str, test_run_id: str) -> str: + """S3 key of the per-node results document of a test run""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/test-runs/{test_run_id}/results.json" + + +def has_workflow_permission(user: Dict, usecase_id: str, permission: Permission) -> bool: + """Check a workflow permission for the acting user on a Use_Case""" + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, user_info=user) + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, permissions: List[Permission]) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry (11.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='workflow_test', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id + }) + + +def authorize_usecase_access(user: Dict, event: Dict, usecase_id: str, + permission: Permission, + not_found: Dict) -> Optional[Dict]: + """ + Authorize an operation on an existing Use_Case-scoped resource. + + Returns an error response, or None when authorized. + + Cross-tenant handling (design error section): a user without even read + access to the owning Use_Case receives the same 404 as for a missing + resource, so existence is never leaked across tenants. A user who can + read but lacks the operation permission receives a 403. + """ + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return not_found + if permission != Permission.WORKFLOW_READ and not has_workflow_permission(user, usecase_id, permission): + return forbidden_response(user, event, usecase_id, [permission]) + return None + + +def dataset_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a dataset exists""" + return error_response(404, 'TEST_DATASET_NOT_FOUND', 'Test dataset not found') + + +def test_run_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a test run exists""" + return error_response(404, 'TEST_RUN_NOT_FOUND', 'Test run not found') + + +def workflow_not_found_response() -> Dict: + """Uniform 404 that never confirms whether a workflow exists""" + return error_response(404, 'WORKFLOW_NOT_FOUND', 'Workflow not found') + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def get_dataset_item(dataset_id: str) -> Optional[Dict]: + """Fetch a Test_Dataset record, or None""" + table = dynamodb.Table(TEST_DATASETS_TABLE) + response = table.get_item(Key={'dataset_id': dataset_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_test_run_item(test_run_id: str) -> Optional[Dict]: + """Fetch a TestRuns record, or None""" + table = dynamodb.Table(TEST_RUNS_TABLE) + response = table.get_item(Key={'test_run_id': test_run_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_workflow_item(workflow_id: str) -> Optional[Dict]: + """Fetch a workflow metadata item, or None""" + table = dynamodb.Table(WORKFLOWS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def dataset_summary(item: Dict) -> Dict: + """Public shape of a Test_Dataset record""" + return { + 'dataset_id': item['dataset_id'], + 'usecase_id': item['usecase_id'], + 'account_id': item.get('account_id'), + 'name': item.get('name'), + 'description': item.get('description', ''), + 's3_prefix': item.get('s3_prefix'), + 'total_bytes': item.get('total_bytes'), + 'file_count': item.get('file_count'), + 'format': item.get('format'), + 'created_at': item.get('created_at'), + 'created_by': item.get('created_by') + } + + +def test_run_summary(item: Dict) -> Dict: + """Public shape of a TestRuns record""" + return { + 'test_run_id': item['test_run_id'], + 'workflow_id': item.get('workflow_id'), + 'version': item.get('version'), + 'usecase_id': item.get('usecase_id'), + 'dataset_id': item.get('dataset_id'), + 'status': item.get('status'), + 'progress': item.get('progress'), + 'failure': item.get('failure'), + 'started_at': item.get('started_at'), + 'finished_at': item.get('finished_at'), + 'created_by': item.get('created_by') + } + + +def validate_declared_files(files: Any) -> Optional[Dict]: + """ + Pre-check the declared file set of an upload initiation (12.11). + + Returns an error response on violation, or None. Rejections identify + the reason; nothing has been uploaded or persisted at this point. + """ + if not isinstance(files, list) or not files: + return error_response(400, 'MISSING_FIELDS', + 'files must be a non-empty list of {name, size} entries') + total = 0 + for index, entry in enumerate(files): + if not isinstance(entry, dict) or not entry.get('name'): + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}] must be an object with a name') + name = str(entry['name']) + if '/' in name or '\\' in name or name in ('.', '..'): + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}].name must be a plain file name', + {'name': name}) + extension = os.path.splitext(name)[1].lower() + if extension not in SUPPORTED_EXTENSIONS: + return error_response( + 400, 'UNSUPPORTED_FORMAT', + f"Unsupported file format '{extension or name}': only JPEG and PNG " + f"images are supported", + {'file': name, 'supported_extensions': sorted(SUPPORTED_EXTENSIONS)} + ) + content_type = entry.get('content_type') + if content_type and content_type not in SUPPORTED_CONTENT_TYPES: + return error_response( + 400, 'UNSUPPORTED_FORMAT', + f"Unsupported content type '{content_type}': only JPEG and PNG " + f"images are supported", + {'file': name, 'supported_content_types': sorted(SUPPORTED_CONTENT_TYPES)} + ) + size = entry.get('size') + if not isinstance(size, (int, float)) or isinstance(size, bool) or size <= 0: + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}].size must be a positive number of bytes', + {'file': name}) + total += int(size) + if total > MAX_DATASET_BYTES: + return error_response( + 400, 'DATASET_TOO_LARGE', + f'Total upload size {total} bytes exceeds the {MAX_DATASET_BYTES} byte ' + f'(500 MB) limit', + {'total_bytes': total, 'max_bytes': MAX_DATASET_BYTES} + ) + return None + + +def object_content_format(key: str) -> Optional[str]: + """ + Identify an uploaded object as 'jpeg' or 'png' by its magic bytes, + or None when it is neither (server-side format verification, 12.11). + """ + try: + response = s3.get_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key, + Range=f'bytes=0-{len(PNG_MAGIC) - 1}' + ) + head = response['Body'].read() + except ClientError as e: + logger.error(f"Error reading object head for {key}: {str(e)}") + return None + if head.startswith(JPEG_MAGIC): + return 'jpeg' + if head.startswith(PNG_MAGIC): + return 'png' + return None + + +def list_dataset_objects(prefix: str) -> List[Dict]: + """List all objects under a dataset staging prefix""" + objects: List[Dict] = [] + continuation_token = None + while True: + kwargs = {'Bucket': PORTAL_ARTIFACTS_BUCKET, 'Prefix': prefix} + if continuation_token: + kwargs['ContinuationToken'] = continuation_token + listed = s3.list_objects_v2(**kwargs) + objects.extend(listed.get('Contents', [])) + if not listed.get('IsTruncated'): + break + continuation_token = listed.get('NextContinuationToken') + return objects + + +def delete_prefix_objects(prefix: str) -> int: + """Delete every object under a prefix; returns the count removed""" + deleted = 0 + while True: + listed = s3.list_objects_v2(Bucket=PORTAL_ARTIFACTS_BUCKET, Prefix=prefix) + objects = [{'Key': o['Key']} for o in listed.get('Contents', [])] + if objects: + s3.delete_objects(Bucket=PORTAL_ARTIFACTS_BUCKET, Delete={'Objects': objects}) + deleted += len(objects) + if not listed.get('IsTruncated'): + break + return deleted + + +def abort_pending_multipart_uploads(prefix: str) -> None: + """Abort any in-progress multipart uploads under a prefix (cleanup)""" + try: + listed = s3.list_multipart_uploads(Bucket=PORTAL_ARTIFACTS_BUCKET, Prefix=prefix) + for upload in listed.get('Uploads', []) or []: + try: + s3.abort_multipart_upload( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=upload['Key'], + UploadId=upload['UploadId'] + ) + except ClientError as e: + logger.warning(f"Could not abort multipart upload {upload.get('Key')}: {str(e)}") + except ClientError as e: + logger.warning(f"Could not list multipart uploads under {prefix}: {str(e)}") + + +def cleanup_rejected_upload(prefix: str) -> None: + """Remove everything an aborted/rejected upload left behind (12.11)""" + abort_pending_multipart_uploads(prefix) + delete_prefix_objects(prefix) + + +def get_test_run_state_machine_arn() -> Optional[str]: + """ + Resolve the Workflow_Test_Runner state machine ARN. + + Prefers the TEST_RUN_STATE_MACHINE_ARN environment variable (set once + the test-runner CDK infrastructure exists); falls back to runtime + configuration in the portal settings table. + """ + if TEST_RUN_STATE_MACHINE_ARN: + return TEST_RUN_STATE_MACHINE_ARN + if not SETTINGS_TABLE: + return None + try: + response = dynamodb.Table(SETTINGS_TABLE).get_item( + Key={'setting_key': TEST_RUN_STATE_MACHINE_SETTING_KEY} + ) + item = response.get('Item') + if item and item.get('value'): + return str(item['value']) + except ClientError as e: + logger.error(f"Error reading test runner setting: {str(e)}") + return None + + +def test_runner_unconfigured_response() -> Dict: + """503 returned while the test-runner state machine is not yet provisioned""" + return error_response( + 503, 'TEST_RUNNER_NOT_CONFIGURED', + 'The workflow test runner is not configured: no Step Functions state ' + 'machine ARN is available. Deploy the test-runner infrastructure or set ' + f"the '{TEST_RUN_STATE_MACHINE_SETTING_KEY}' portal setting." + ) + + +# --------------------------------------------------------------------------- +# Test dataset endpoints (12.2, 12.3, 12.11) +# --------------------------------------------------------------------------- + +def initiate_dataset_upload(event: Dict, user: Dict, body: Dict) -> Dict: + """ + POST /test-datasets (default action: initiate) + Body: {usecase_id, name, files: [{name, size, content_type?}], description?} + + Pre-checks the declared file set (formats, 500 MB total), then creates + S3 multipart uploads with presigned part URLs. No dataset record is + written until the finalize step verifies the uploaded content (12.3). + """ + usecase_id = body.get('usecase_id') + name = body.get('name') + files = body.get('files') + + missing = [f for f in ('usecase_id', 'name', 'files') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_TEST): + return forbidden_response(user, event, usecase_id, [Permission.WORKFLOW_TEST]) + + try: + get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + err = validate_declared_files(files) + if err: + return err + + dataset_id = str(uuid.uuid4()) + prefix = dataset_s3_prefix(usecase_id, dataset_id) + + upload_files: List[Dict] = [] + try: + for entry in files: + file_name = str(entry['name']) + size = int(entry['size']) + extension = os.path.splitext(file_name)[1].lower() + content_type = entry.get('content_type') or ( + 'image/png' if extension == '.png' else 'image/jpeg' + ) + key = f"{prefix}{file_name}" + multipart = s3.create_multipart_upload( + Bucket=PORTAL_ARTIFACTS_BUCKET, Key=key, ContentType=content_type + ) + upload_id = multipart['UploadId'] + part_count = max(1, (size + PART_SIZE - 1) // PART_SIZE) + parts = [ + { + 'part_number': part_number, + 'url': s3.generate_presigned_url( + 'upload_part', + Params={ + 'Bucket': PORTAL_ARTIFACTS_BUCKET, + 'Key': key, + 'UploadId': upload_id, + 'PartNumber': part_number + }, + ExpiresIn=PRESIGN_EXPIRY_SECONDS + ) + } + for part_number in range(1, part_count + 1) + ] + upload_files.append({ + 'name': file_name, + 'key': key, + 'upload_id': upload_id, + 'part_size': PART_SIZE, + 'parts': parts + }) + except ClientError as e: + logger.error(f"Error creating multipart uploads for dataset {dataset_id}: {str(e)}") + cleanup_rejected_upload(prefix) + return error_response(500, 'UPLOAD_INIT_FAILED', + 'Could not initiate the dataset upload') + + return create_response(201, { + 'dataset_id': dataset_id, + 'usecase_id': usecase_id, + 'name': name, + 's3_prefix': prefix, + 'upload': { + 'files': upload_files, + 'expires_in': PRESIGN_EXPIRY_SECONDS + }, + 'message': 'Upload the parts, then finalize with action=finalize. ' + 'The dataset is committed only after server-side verification.' + }) + + +def finalize_dataset_upload(event: Dict, user: Dict, body: Dict) -> Dict: + """ + POST /test-datasets (action: finalize) + Body: {usecase_id, dataset_id, name, description?, + files: [{key, upload_id, parts: [{part_number, etag}]}]} + + Completes the multipart uploads and performs server-side verification + (total size <= 500 MB, JPEG/PNG magic bytes) before committing the + TestDatasets record. Violations reject with the reason and persist + nothing - uploaded objects are removed (12.3, 12.11). + """ + usecase_id = body.get('usecase_id') + dataset_id = body.get('dataset_id') + name = body.get('name') + files = body.get('files') + + missing = [f for f in ('usecase_id', 'dataset_id', 'name', 'files') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_TEST): + return forbidden_response(user, event, usecase_id, [Permission.WORKFLOW_TEST]) + + try: + usecase = get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + if get_dataset_item(dataset_id): + return error_response(409, 'DATASET_ALREADY_EXISTS', + 'This dataset has already been finalized') + + prefix = dataset_s3_prefix(usecase_id, dataset_id) + if not isinstance(files, list) or not files: + return error_response(400, 'MISSING_FIELDS', + 'files must be a non-empty list of completed uploads') + + # Complete the multipart uploads. Keys outside the dataset prefix are + # rejected outright - a client cannot commit foreign objects. + for index, entry in enumerate(files): + if not isinstance(entry, dict) or not entry.get('key') or not entry.get('upload_id'): + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}] must carry key, upload_id and parts') + if not str(entry['key']).startswith(prefix): + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}].key does not belong to this dataset', + {'key': entry['key']}) + parts = entry.get('parts') + if not isinstance(parts, list) or not parts: + return error_response(400, 'INVALID_FILE_ENTRY', + f'files[{index}].parts must be a non-empty list') + try: + s3.complete_multipart_upload( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=entry['key'], + UploadId=entry['upload_id'], + MultipartUpload={ + 'Parts': sorted( + ( + {'PartNumber': int(p['part_number']), 'ETag': str(p['etag'])} + for p in parts + ), + key=lambda p: p['PartNumber'] + ) + } + ) + except (ClientError, KeyError, TypeError, ValueError) as e: + logger.error(f"Error completing upload for {entry.get('key')}: {str(e)}") + cleanup_rejected_upload(prefix) + return error_response(400, 'UPLOAD_INCOMPLETE', + 'A file upload could not be completed; the upload ' + 'was discarded and no dataset was persisted', + {'key': entry.get('key')}) + + # Server-side verification against what actually landed in S3 (12.11) + objects = list_dataset_objects(prefix) + if not objects: + return error_response(400, 'UPLOAD_INCOMPLETE', + 'No uploaded files were found; no dataset was persisted') + + total_bytes = sum(int(o.get('Size', 0)) for o in objects) + if total_bytes > MAX_DATASET_BYTES: + cleanup_rejected_upload(prefix) + return error_response( + 400, 'DATASET_TOO_LARGE', + f'Uploaded total size {total_bytes} bytes exceeds the ' + f'{MAX_DATASET_BYTES} byte (500 MB) limit; the upload was discarded ' + f'and no dataset was persisted', + {'total_bytes': total_bytes, 'max_bytes': MAX_DATASET_BYTES} + ) + + formats = set() + for obj in objects: + detected = object_content_format(obj['Key']) + if detected is None: + cleanup_rejected_upload(prefix) + file_name = obj['Key'][len(prefix):] + return error_response( + 400, 'UNSUPPORTED_FORMAT', + f"File '{file_name}' is not a JPEG or PNG image; the upload was " + f"discarded and no dataset was persisted", + {'file': file_name} + ) + formats.add(detected) + + timestamp = now_ms() + item = { + 'dataset_id': dataset_id, + 'usecase_id': usecase_id, + 'account_id': usecase.get('account_id'), + 'name': name, + 'description': body.get('description', ''), + 's3_prefix': prefix, + 'total_bytes': total_bytes, + 'file_count': len(objects), + 'format': '+'.join(sorted(formats)), + 'created_at': timestamp, + 'created_by': user['user_id'] + } + dynamodb.Table(TEST_DATASETS_TABLE).put_item( + Item=item, + ConditionExpression='attribute_not_exists(dataset_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='create_test_dataset', + resource_type='test_dataset', + resource_id=dataset_id, + result='success', + details={'usecase_id': usecase_id, 'name': name, + 'total_bytes': total_bytes, 'file_count': len(objects)} + ) + + return create_response(201, {'dataset': dataset_summary(item)}) + + +def create_test_dataset(event: Dict, user: Dict) -> Dict: + """POST /test-datasets - dispatch initiate vs finalize on body.action""" + body, err = parse_body(event) + if err: + return err + action = body.get('action', 'initiate') + if action == 'initiate': + return initiate_dataset_upload(event, user, body) + if action == 'finalize': + return finalize_dataset_upload(event, user, body) + return error_response(400, 'INVALID_ACTION', + "action must be 'initiate' or 'finalize'") + + +def list_test_datasets(event: Dict, user: Dict) -> Dict: + """ + GET /test-datasets[?usecase_id=...] + Test_Datasets scoped to Use_Cases the user is authorized to access (12.2). + """ + params = event.get('queryStringParameters') or {} + usecase_id = params.get('usecase_id') + + if usecase_id: + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return forbidden_response(user, event, usecase_id, [Permission.WORKFLOW_READ]) + usecase_ids = [usecase_id] + else: + usecase_ids = [ + uc for uc in rbac_manager.get_accessible_usecases(user['user_id'], user_info=user) + if has_workflow_permission(user, uc, Permission.WORKFLOW_READ) + ] + + table = dynamodb.Table(TEST_DATASETS_TABLE) + datasets: List[Dict] = [] + for uc in usecase_ids: + kwargs = { + 'IndexName': 'usecase-datasets-index', + 'KeyConditionExpression': 'usecase_id = :uid', + 'ExpressionAttributeValues': {':uid': uc} + } + while True: + response = table.query(**kwargs) + datasets.extend(dataset_summary(decimal_to_native(i)) + for i in response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + + datasets.sort(key=lambda d: d.get('created_at') or 0, reverse=True) + return create_response(200, {'datasets': datasets, 'count': len(datasets)}) + + +def get_test_dataset(event: Dict, user: Dict, dataset_id: str) -> Dict: + """GET /test-datasets/{id}""" + item = get_dataset_item(dataset_id) + if not item: + return dataset_not_found_response() + err = authorize_usecase_access(user, event, item['usecase_id'], + Permission.WORKFLOW_READ, + dataset_not_found_response()) + if err: + return err + return create_response(200, {'dataset': dataset_summary(item)}) + + +def delete_test_dataset(event: Dict, user: Dict, dataset_id: str) -> Dict: + """DELETE /test-datasets/{id} - removes the record and the S3 objects""" + item = get_dataset_item(dataset_id) + if not item: + return dataset_not_found_response() + err = authorize_usecase_access(user, event, item['usecase_id'], + Permission.WORKFLOW_TEST, + dataset_not_found_response()) + if err: + return err + + prefix = item.get('s3_prefix') or dataset_s3_prefix(item['usecase_id'], dataset_id) + delete_prefix_objects(prefix) + dynamodb.Table(TEST_DATASETS_TABLE).delete_item(Key={'dataset_id': dataset_id}) + + log_audit_event( + user_id=user['user_id'], + action='delete_test_dataset', + resource_type='test_dataset', + resource_id=dataset_id, + result='success', + details={'usecase_id': item['usecase_id'], 'name': item.get('name')} + ) + + return create_response(200, { + 'dataset_id': dataset_id, + 'message': 'Test dataset deleted successfully' + }) + + +# --------------------------------------------------------------------------- +# Test run endpoints (12.3) +# --------------------------------------------------------------------------- + +def validate_simulated_inference(value: Any) -> Tuple[Optional[Dict], Optional[Dict]]: + """ + Validate the optional ``simulated_inference`` request field: + {is_anomalous: bool, confidence: number 0..1}, both fields optional + with the DEFAULT_SIMULATED_INFERENCE values. Returns + (normalized_dict, None) or (None, 400 error_response) on a bad shape. + """ + if value is None: + return dict(DEFAULT_SIMULATED_INFERENCE), None + if not isinstance(value, dict): + return None, error_response( + 400, 'INVALID_SIMULATED_INFERENCE', + 'simulated_inference must be an object with is_anomalous ' + '(boolean) and confidence (number between 0 and 1)') + unknown = sorted(set(value) - {'is_anomalous', 'confidence'}) + if unknown: + return None, error_response( + 400, 'INVALID_SIMULATED_INFERENCE', + f"simulated_inference has unknown fields: {', '.join(unknown)}", + {'unknown_fields': unknown}) + is_anomalous = value.get('is_anomalous', + DEFAULT_SIMULATED_INFERENCE['is_anomalous']) + if not isinstance(is_anomalous, bool): + return None, error_response( + 400, 'INVALID_SIMULATED_INFERENCE', + 'simulated_inference.is_anomalous must be a boolean') + confidence = value.get('confidence', + DEFAULT_SIMULATED_INFERENCE['confidence']) + if isinstance(confidence, bool) or not isinstance(confidence, (int, float, Decimal)): + return None, error_response( + 400, 'INVALID_SIMULATED_INFERENCE', + 'simulated_inference.confidence must be a number between 0 and 1') + confidence = float(confidence) + if not 0.0 <= confidence <= 1.0: + return None, error_response( + 400, 'INVALID_SIMULATED_INFERENCE', + 'simulated_inference.confidence must be between 0 and 1', + {'confidence': confidence}) + return {'is_anomalous': is_anomalous, 'confidence': confidence}, None + + +# --------------------------------------------------------------------------- +# Triton model staging for test runs (see workflow_model_staging.py) +# --------------------------------------------------------------------------- + +def load_stored_definition(definition_s3_key: Optional[str]) -> Optional[Dict]: + """The stored Workflow_Definition JSON document, or None when it + cannot be read (the validate step would fail the run anyway).""" + if not definition_s3_key: + return None + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=definition_s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + except (ClientError, ValueError, UnicodeDecodeError) as e: + logger.error(f"Could not read definition {definition_s3_key}: {str(e)}") + return None + + +def query_usecase_model_items(usecase_id: str) -> List[Dict]: + """Registry items of one Use_Case from the models table (the + component_arns source for CPU variant selection).""" + if not MODELS_TABLE: + return [] + table = dynamodb.Table(MODELS_TABLE) + items: List[Dict] = [] + kwargs = { + 'IndexName': 'usecase-models-index', + 'KeyConditionExpression': 'usecase_id = :uid', + 'ExpressionAttributeValues': {':uid': usecase_id}, + } + while True: + response = table.query(**kwargs) + items.extend(decimal_to_native(i) for i in response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + return items + + +def model_staging_clients(usecase: Dict) -> Tuple[Any, Any]: + """(greengrass, source-S3) clients for reading the Use_Case's model + components and artifact bucket. Follows the shared_utils per-usecase + client pattern (assume-role for cross-account Use_Cases, the Lambda + role directly otherwise).""" + region = get_usecase_region(usecase) + greengrass = get_usecase_client('greengrassv2', usecase, region=region) + source_s3 = get_usecase_client('s3', usecase, region=region) + return greengrass, source_s3 + + +def append_run_progress(test_run_id: str, message: str) -> None: + """Append one {at, message} progress entry to the TestRuns item (the + same additive list workflow_test_steps.append_run_progress feeds); + best-effort only.""" + try: + dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET progress = ' + 'list_append(if_not_exists(progress, :empty), :entries)', + ExpressionAttributeValues={ + ':empty': [], + ':entries': [{'at': now_ms(), 'message': message}], + }, + ) + except ClientError as e: + logger.warning(f"Could not record progress for run {test_run_id}: {str(e)}") + + +def fail_run_before_start(run_item: Dict, error_records: List[Dict]) -> Dict: + """Fail a test run before its state-machine execution starts. + + Writes the per-node error records as the run's results document + (the shape GET /test-runs/{id} serves) and marks the TestRuns item + failed with the first failing node identified — the Requirement + 12.10/12.12 semantics, applied to model staging errors. Returns the + 202 response carrying the failed run. + """ + test_run_id = run_item['test_run_id'] + first = error_records[0] + message = (first.get('error') or {}).get('message') or 'Model staging failed' + node_id = next((r.get('nodeId') for r in error_records if r.get('nodeId')), None) + try: + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=run_item['results_s3_key'], + Body=json.dumps({'nodes': error_records}, indent=2).encode('utf-8'), + ContentType='application/json', + ) + except ClientError as e: + logger.error(f"Could not write staging error results for run " + f"{test_run_id}: {str(e)}") + dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished, failure = :failure', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': 'failed', + ':finished': now_ms(), + ':failure': {'nodeId': node_id, 'message': message, 'timeout': False}, + }, + ) + append_run_progress(test_run_id, 'Test run failed: {0}'.format(message)) + run_item = dict(run_item) + run_item['status'] = 'failed' + run_item['failure'] = {'nodeId': node_id, 'message': message, + 'timeout': False} + run_item['finished_at'] = now_ms() + return create_response(202, {'test_run': test_run_summary(run_item)}) + + +def stage_test_run_models(run_item: Dict, definition: Optional[Dict]) -> Tuple[List[Dict], List[Dict]]: + """Stage the Triton model artifacts of every model_inference node. + + Resolves each node's modelName against the Use_Case's model + registry, prefers CPU-runnable component variants (-x86-64-cpu then + -onnx), copies each component's S3 artifact zip into the portal + artifacts bucket under the run's prefix, and returns the staging + manifest plus any per-node error records (see + workflow_model_staging.stage_models_for_run). + """ + nodes = model_staging.model_inference_nodes(definition) + if not nodes: + return [], [] + + usecase_id = run_item['usecase_id'] + append_run_progress( + run_item['test_run_id'], + 'Staging {0} model(s) for cloud inference'.format(len(nodes))) + try: + usecase = get_usecase(usecase_id) + greengrass, source_s3 = model_staging_clients(usecase) + except Exception as e: # per-usecase client setup (assume-role) failed + logger.error(f"Model staging clients unavailable for usecase " + f"{usecase_id}: {str(e)}") + return [], [{ + 'nodeId': node.get('nodeId'), + 'status': 'error', + 'outputs': [], + 'stubActivity': [], + 'error': { + 'code': model_staging.CODE_MODEL_STAGING_FAILED, + 'message': 'Model {0} could not be staged: the use-case ' + 'model registry is not reachable'.format( + node.get('modelName') or '(unselected)'), + }, + } for node in nodes] + + return model_staging.stage_models_for_run( + nodes, + query_usecase_model_items(usecase_id), + greengrass, + source_s3, + s3, + PORTAL_ARTIFACTS_BUCKET, + run_item['results_s3_key'], + ) + + +def start_test_run(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + POST /workflows/{id}/test-runs + Body: {dataset_id, version?, simulated_inference?} + + ``simulated_inference`` ({is_anomalous, confidence}) configures the + outcome injected for simulation-stubbed model inference nodes — the + model itself is never executed in the cloud sandbox (12.6). It is + forwarded to the sandbox container through the state machine input + (pre-serialized as simulated_inference_json for the env override). + + Creates the TestRuns record and starts the Workflow_Test_Runner Step + Functions execution (Validate -> Compile -> RunSandbox -> CollectResults, + task 11.2). Requires workflow:test on the owning Use_Case. + """ + workflow = get_workflow_item(workflow_id) + if not workflow: + return workflow_not_found_response() + usecase_id = workflow['usecase_id'] + err = authorize_usecase_access(user, event, usecase_id, + Permission.WORKFLOW_TEST, + workflow_not_found_response()) + if err: + return err + + body, err = parse_body(event) + if err: + return err + dataset_id = body.get('dataset_id') + if not dataset_id: + return error_response(400, 'MISSING_FIELDS', + 'Missing required fields: dataset_id') + + simulated_inference, err = validate_simulated_inference( + body.get('simulated_inference')) + if err: + return err + + # The Test_Dataset must exist in the same Use_Case; a dataset of another + # tenant is indistinguishable from a missing one (no existence leak). + dataset = get_dataset_item(dataset_id) + if not dataset or dataset.get('usecase_id') != usecase_id: + return dataset_not_found_response() + + version_param = body.get('version') + if version_param is not None: + try: + version = int(version_param) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', 'version must be an integer') + else: + version = int(workflow.get('latest_version', 1)) + + versions_table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = versions_table.get_item(Key={'workflow_id': workflow_id, 'version': version}) + version_item = response.get('Item') + if not version_item: + return error_response(404, 'VERSION_NOT_FOUND', + f'Version {version} not found for workflow') + version_item = decimal_to_native(version_item) + + state_machine_arn = get_test_run_state_machine_arn() + if not state_machine_arn: + return test_runner_unconfigured_response() + + test_run_id = str(uuid.uuid4()) + timestamp = now_ms() + results_key = test_run_results_key(usecase_id, test_run_id) + + run_item = { + 'test_run_id': test_run_id, + 'workflow_id': workflow_id, + 'version': version, + 'usecase_id': usecase_id, + 'dataset_id': dataset_id, + 'status': 'pending', + 'started_at': timestamp, + 'finished_at': None, + 'results_s3_key': results_key, + 'failure': None, + 'created_by': user['user_id'] + } + runs_table = dynamodb.Table(TEST_RUNS_TABLE) + runs_table.put_item(Item=run_item, + ConditionExpression='attribute_not_exists(test_run_id)') + + # Triton model staging: every model_inference node's model artifact + # is copied into the portal artifacts bucket before the execution + # starts, so the sandbox harness can populate the (initially empty) + # Triton model repository and actually run inference on CPU. A model + # without a CPU-compatible variant (or one that cannot be staged) + # fails the run with a per-node error and never starts the pipeline. + definition = load_stored_definition(version_item.get('s3_definition_key')) + staged_models, staging_errors = stage_test_run_models(run_item, definition) + if staging_errors: + log_audit_event( + user_id=user['user_id'], + action='start_test_run', + resource_type='test_run', + resource_id=test_run_id, + result='failed', + details={'usecase_id': usecase_id, 'workflow_id': workflow_id, + 'version': version, 'dataset_id': dataset_id, + 'reason': 'model_staging_failed'} + ) + return fail_run_before_start(run_item, staging_errors) + + # State machine input per design section 10: the sandbox compiles for + # x86_64 with simulation=true and feeds sources from the Test_Dataset. + execution_input = { + 'test_run_id': test_run_id, + 'workflow_id': workflow_id, + 'workflow_version': version, + 'usecase_id': usecase_id, + 'dataset_id': dataset_id, + 'dataset_s3_prefix': dataset.get('s3_prefix'), + 'definition_s3_key': version_item.get('s3_definition_key'), + 'results_s3_key': results_key, + 'artifacts_bucket': PORTAL_ARTIFACTS_BUCKET, + 'target_arch': 'x86_64', + 'simulation': True, + # Custom_Node_Type versions pinned at workflow save ({typeId: + # typeVersion} from the WorkflowVersions item, custom-node-designer + # 14.2): the validate/compile steps resolve the merged catalog + # against exactly these versions (12.1). + 'custom_node_type_pins': version_item.get('custom_node_types') or {}, + # Simulated inference outcome for stubbed model inference nodes + # (12.6): the object for readability, plus the pre-serialized + # JSON string the RunSandbox containerOverrides pass through as + # the SIMULATED_INFERENCE env value. + 'simulated_inference': simulated_inference, + 'simulated_inference_json': json.dumps(simulated_inference), + # Model staging manifest: the models copied under the run's + # prefix for the sandbox to unpack into the Triton model + # repository. The list for readability, plus the pre-serialized + # JSON string the RunSandbox containerOverrides pass through as + # the STAGED_MODELS env value. + 'staged_models': staged_models, + 'staged_models_json': json.dumps(staged_models) + } + try: + execution = stepfunctions.start_execution( + stateMachineArn=state_machine_arn, + name=test_run_id, + input=json.dumps(execution_input) + ) + except ClientError as e: + logger.error(f"Error starting test run execution {test_run_id}: {str(e)}") + runs_table.update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET #s = :status, finished_at = :finished, failure = :failure', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={ + ':status': 'failed', + ':finished': now_ms(), + ':failure': {'nodeId': None, 'message': 'Test run could not be started', + 'timeout': False} + } + ) + return error_response(502, 'TEST_RUN_START_FAILED', + 'The test run execution could not be started') + + runs_table.update_item( + Key={'test_run_id': test_run_id}, + UpdateExpression='SET #s = :status, execution_arn = :arn', + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues={':status': 'running', + ':arn': execution['executionArn']} + ) + run_item['status'] = 'running' + run_item['execution_arn'] = execution['executionArn'] + + log_audit_event( + user_id=user['user_id'], + action='start_test_run', + resource_type='test_run', + resource_id=test_run_id, + result='success', + details={'usecase_id': usecase_id, 'workflow_id': workflow_id, + 'version': version, 'dataset_id': dataset_id} + ) + + return create_response(202, {'test_run': test_run_summary(run_item)}) + + +def list_test_runs(event: Dict, user: Dict, workflow_id: str) -> Dict: + """GET /workflows/{id}/test-runs - runs of one workflow, newest first""" + workflow = get_workflow_item(workflow_id) + if not workflow: + return workflow_not_found_response() + err = authorize_usecase_access(user, event, workflow['usecase_id'], + Permission.WORKFLOW_READ, + workflow_not_found_response()) + if err: + return err + + table = dynamodb.Table(TEST_RUNS_TABLE) + runs: List[Dict] = [] + kwargs = { + 'IndexName': 'workflow-runs-index', + 'KeyConditionExpression': 'workflow_id = :wid', + 'ExpressionAttributeValues': {':wid': workflow_id}, + 'ScanIndexForward': False + } + while True: + response = table.query(**kwargs) + runs.extend(test_run_summary(decimal_to_native(i)) + for i in response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + + return create_response(200, {'test_runs': runs, 'count': len(runs)}) + + +def sync_run_status_from_execution(item: Dict) -> Dict: + """ + Refresh a non-terminal run's status from its Step Functions execution. + Best-effort: on describe failure the stored status is returned unchanged. + """ + if item.get('status') not in ('pending', 'running') or not item.get('execution_arn'): + return item + try: + execution = stepfunctions.describe_execution(executionArn=item['execution_arn']) + except ClientError as e: + logger.warning(f"Could not describe execution for run {item['test_run_id']}: {str(e)}") + return item + + sfn_status = execution.get('status') + mapped = SFN_STATUS_MAP.get(sfn_status) + if not mapped or mapped == item.get('status'): + return item + + update_expr = 'SET #s = :status' + expr_values: Dict[str, Any] = {':status': mapped} + if mapped in ('completed', 'failed'): + update_expr += ', finished_at = :finished' + expr_values[':finished'] = now_ms() + if sfn_status == 'TIMED_OUT': + # 10-minute limit exceeded: failed with a timeout indication (12.13) + update_expr += ', failure = :failure' + expr_values[':failure'] = { + 'nodeId': item.get('failure', {}).get('nodeId') if isinstance(item.get('failure'), dict) else None, + 'message': 'Test run exceeded the 10 minute execution limit', + 'timeout': True + } + try: + updated = dynamodb.Table(TEST_RUNS_TABLE).update_item( + Key={'test_run_id': item['test_run_id']}, + UpdateExpression=update_expr, + ExpressionAttributeNames={'#s': 'status'}, + ExpressionAttributeValues=expr_values, + ReturnValues='ALL_NEW' + ) + return decimal_to_native(updated['Attributes']) + except ClientError as e: + logger.warning(f"Could not update run status for {item['test_run_id']}: {str(e)}") + item['status'] = mapped + return item + + +def load_node_results(results_s3_key: Optional[str]) -> List[Dict]: + """ + Load per-node results from S3. The sandbox harness flushes them + incrementally, so partial results are available for in-progress and + mid-run-failed executions (12.10). Missing document -> empty list. + """ + if not results_s3_key: + return [] + try: + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=results_s3_key) + document = json.loads(response['Body'].read().decode('utf-8')) + except ClientError as e: + if e.response.get('Error', {}).get('Code') in ('NoSuchKey', '404'): + return [] + logger.error(f"Error loading test results {results_s3_key}: {str(e)}") + return [] + except (json.JSONDecodeError, UnicodeDecodeError) as e: + logger.error(f"Malformed test results document {results_s3_key}: {str(e)}") + return [] + if isinstance(document, dict): + nodes = document.get('nodes') + return nodes if isinstance(nodes, list) else [] + return document if isinstance(document, list) else [] + + +def get_test_run(event: Dict, user: Dict, test_run_id: str) -> Dict: + """ + GET /test-runs/{id} + Status plus per-node results {nodeId, status, outputs, stubActivity, + error} produced so far (12.3, 12.7, 12.10). + """ + item = get_test_run_item(test_run_id) + if not item: + return test_run_not_found_response() + err = authorize_usecase_access(user, event, item['usecase_id'], + Permission.WORKFLOW_READ, + test_run_not_found_response()) + if err: + return err + + item = sync_run_status_from_execution(item) + node_results = load_node_results(item.get('results_s3_key')) + + return create_response(200, { + 'test_run': test_run_summary(item), + 'node_results': node_results + }) + + +# --------------------------------------------------------------------------- +# Handler +# --------------------------------------------------------------------------- + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + resource_id = path_params.get('id') + + if resource == '/test-datasets': + if http_method == 'GET': + return list_test_datasets(event, user) + if http_method == 'POST': + return create_test_dataset(event, user) + elif resource == '/test-datasets/{id}' and resource_id: + if http_method == 'GET': + return get_test_dataset(event, user, resource_id) + if http_method == 'DELETE': + return delete_test_dataset(event, user, resource_id) + elif resource == '/workflows/{id}/test-runs' and resource_id: + if http_method == 'POST': + return start_test_run(event, user, resource_id) + if http_method == 'GET': + return list_test_runs(event, user, resource_id) + elif resource == '/test-runs/{id}' and resource_id: + if http_method == 'GET': + return get_test_run(event, user, resource_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/workflow_validation.py b/edge-cv-portal/backend/functions/workflow_validation.py new file mode 100644 index 00000000..2d8bfa47 --- /dev/null +++ b/edge-cv-portal/backend/functions/workflow_validation.py @@ -0,0 +1,447 @@ +""" +Workflow Validation API Lambda function (Workflow Manager) + +Backend Workflow_Validator endpoint plus the node catalog endpoint for +the frontend Node_Palette (Requirements 2.8, 4.6, 4.7, 4.10). + +Routes (API Gateway REST): + POST /workflows/{id}/validate Run all Workflow_Validator checks on a + stored workflow version, return the + complete findings list, and record the + validation status (passed/failed, + findings key, validated_at) on the + version item (4.6) + GET /workflows/node-catalog Serialized node type catalog in the + camelCase wire form the frontend + palette consumes (2.8) + +The shared guard used by packaging/publishing/deployment endpoints to +reject versions with error-severity findings or without a recorded +passed-validation run (4.7, 4.10) lives in workflow_guards.py, which +does not import the workflow_core layer so deployments.py can use it. + +Storage layout (shared with workflows.py): + WorkflowVersions table (WORKFLOW_VERSIONS_TABLE) PK workflow_id, SK version + Findings documents in portal S3 under + {WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/versions/{version}/findings.json + +Error envelope (design): {"error": {"code", "message", "details"}} with +403 RBAC denial (11.4) and 404 scoped to avoid cross-tenant existence +leaks (5.8). +""" +import json +import os +import logging +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + rbac_manager, Permission +) +from workflow_core.catalog import ( + NODE_CATALOG, GstMapping, NodeTypeDescriptor, + ParameterDescriptor, PortDescriptor +) +from workflow_core.serializer import parse as parse_definition +from workflow_core.validator import validate as run_validator, SEVERITY_ERROR, SEVERITY_WARNING + +# Merged Node_Type_Catalog resolution for Custom_Node_Types (task 9.2): +# the palette merge (test/prod only, dev excluded, deprecated excluded, +# test markers) and the resolution merge for validating existing +# workflows (pinned versions honored, deprecated resolvable). Same +# deployment bundle (functions/). +from node_catalog_resolution import ( + palette_catalog_for_usecase, + resolution_catalog_for_usecase, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables +WORKFLOWS_TABLE = os.environ.get('WORKFLOWS_TABLE') +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +WORKFLOWS_S3_PREFIX = os.environ.get('WORKFLOWS_S3_PREFIX', 'workflows') + +# Validation status values recorded on WorkflowVersions items (design data model) +VALIDATION_STATUS_PASSED = 'passed' +VALIDATION_STATUS_FAILED = 'failed' + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def findings_s3_key(usecase_id: str, workflow_id: str, version: int) -> str: + """S3 key of the stored findings document of one validation run""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/versions/{version}/findings.json" + + +def has_workflow_permission(user: Dict, usecase_id: str, permission: Permission) -> bool: + """Check a workflow permission for the acting user on a Use_Case""" + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, user_info=user) + + +def not_found_response() -> Dict: + """Uniform 404 that never confirms whether a workflow exists (5.8)""" + return error_response(404, 'WORKFLOW_NOT_FOUND', 'Workflow not found') + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, permissions: List[Permission]) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry (11.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='workflow', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id + }) + + +def authorize_workflow_access(user: Dict, event: Dict, item: Dict, + permission: Permission) -> Optional[Dict]: + """ + Authorize an operation on an existing workflow. + + Returns an error response, or None when authorized. + + Cross-tenant handling (5.8, design error section): a user without even + read access to the owning Use_Case receives the same 404 as for a + missing workflow, so existence is never leaked across tenants. A user + who can read but lacks the operation permission receives a 403. + """ + usecase_id = item['usecase_id'] + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return not_found_response() + if permission != Permission.WORKFLOW_READ and not has_workflow_permission(user, usecase_id, permission): + return forbidden_response(user, event, usecase_id, [permission]) + return None + + +def get_workflow_item(workflow_id: str) -> Optional[Dict]: + """Fetch a workflow metadata item, or None""" + table = dynamodb.Table(WORKFLOWS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def get_version_item(workflow_id: str, version: int) -> Optional[Dict]: + """Fetch a WorkflowVersions item, or None""" + table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id, 'version': version}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def load_definition(s3_key: str) -> Dict: + """Load a stored Workflow_Definition document from portal S3""" + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +# -------------------------------------------------------------------------- +# Node catalog serialization (camelCase wire form, Requirement 2.8) +# Mirrors edge-cv-portal/frontend/src/pages/workflows/types.ts. +# -------------------------------------------------------------------------- + +# Python constraint keys -> wire keys (min/max/regex/values pass through) +_CONSTRAINT_KEY_MAP = { + 'min_length': 'minLength', + 'max_length': 'maxLength', +} + + +def constraints_to_wire(constraints: Dict) -> Dict: + """ParameterDescriptor.constraints in wire form""" + return {_CONSTRAINT_KEY_MAP.get(k, k): v for k, v in (constraints or {}).items()} + + +def port_to_wire(port: PortDescriptor) -> Dict: + return {'name': port.name, 'portType': port.port_type} + + +def parameter_to_wire(parameter: ParameterDescriptor) -> Dict: + return { + 'name': parameter.name, + 'paramType': parameter.param_type, + 'required': parameter.required, + 'default': parameter.default, + 'constraints': constraints_to_wire(parameter.constraints), + 'dependsOn': parameter.depends_on, + 'description': parameter.description, + 'examples': list(parameter.examples) if parameter.examples is not None else None + } + + +def mapping_to_wire(mapping: GstMapping) -> Dict: + return { + 'arch': mapping.arch, + 'elementChain': [ + {'factory': e['factory'], 'argsTemplate': e.get('args_template', {})} + for e in mapping.element_chain + ], + 'executorBinding': mapping.executor_binding, + 'pluginDependencies': list(mapping.plugin_dependencies) + } + + +def descriptor_to_wire(descriptor: NodeTypeDescriptor) -> Dict: + """One NodeTypeDescriptor in the camelCase wire form (Requirement 2.8)""" + return { + 'typeId': descriptor.type_id, + 'category': descriptor.category, + 'displayName': descriptor.display_name, + 'inputs': [port_to_wire(p) for p in descriptor.inputs], + 'outputs': [port_to_wire(p) for p in descriptor.outputs], + 'parameters': [parameter_to_wire(p) for p in descriptor.parameters], + 'mappings': [mapping_to_wire(m) for m in descriptor.mappings], + 'hardwareDependent': descriptor.hardware_dependent + } + + +def get_node_catalog(event: Dict, user: Dict) -> Dict: + """ + GET /workflows/node-catalog[?usecase_id=...] + Serves the node type catalog for the frontend Node_Palette: every + node type's ports, port types, parameters with types, defaults, and + constraints, per-arch mappings, and hardware-dependence flag + (workflow-manager Requirement 2.8). + + Without ``usecase_id`` the built-in catalog is served unchanged + (global, static data any authenticated user may read). With + ``usecase_id`` the Use_Case's registered Custom_Node_Types are merged + in (custom-node-designer 8.2, 8.3): only types backed by a test- or + prod-state Plugin_Record version (dev excluded, 9.2), deprecated + types excluded from new placement (14.3), and test-state entries + carrying a ``lifecycleState: "test"`` palette marker (9.6). + """ + params = event.get('queryStringParameters') or {} + usecase_id = params.get('usecase_id') + if usecase_id and not has_workflow_permission(user, usecase_id, + Permission.WORKFLOW_READ): + return forbidden_response(user, event, usecase_id, + [Permission.WORKFLOW_READ]) + + catalog, markers = palette_catalog_for_usecase(usecase_id) + node_types = [] + for descriptor in catalog: + wire = descriptor_to_wire(descriptor) + marker = markers.get(descriptor.type_id) + if marker: + wire['lifecycleState'] = marker + node_types.append(wire) + return create_response(200, { + 'nodeTypes': node_types, + 'count': len(node_types) + }) + + +# -------------------------------------------------------------------------- +# Validate endpoint (Requirements 4.6, 4.10) +# -------------------------------------------------------------------------- + +def validate_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + POST /workflows/{id}/validate + Body: {version?} (defaults to the latest version) + + Runs all Workflow_Validator checks on the stored Workflow_Definition + of the requested version, returns the complete findings list (4.6), + stores the findings document in portal S3, and records the validation + status (passed/failed, findings key, validated_at) on the + WorkflowVersions item so packaging/publishing/deployment can verify a + passed run (4.10). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_EDIT) + if err: + return err + + body, err = parse_body(event) + if err: + return err + + version_param = body.get('version') + if version_param is not None: + try: + version = int(version_param) + except (TypeError, ValueError): + return error_response(400, 'INVALID_VERSION', 'version must be an integer') + else: + version = int(item.get('latest_version', 1)) + + version_item = get_version_item(workflow_id, version) + if not version_item: + return error_response(404, 'VERSION_NOT_FOUND', + f'Version {version} not found for workflow') + + try: + definition = load_definition(version_item['s3_definition_key']) + except ClientError as e: + logger.error(f"Error loading definition for {workflow_id} v{version}: {str(e)}") + return error_response(500, 'DEFINITION_LOAD_FAILED', + 'Stored workflow definition could not be loaded') + + # Stored definitions were canonicalized through the serializer on save, + # so a parse failure here indicates a corrupted document. + result = parse_definition(json.dumps(definition)) + if not result.ok: + logger.error( + f"Stored definition for {workflow_id} v{version} failed to parse: " + f"{result.error.code}: {result.error.message}" + ) + return error_response(500, 'STORED_DEFINITION_INVALID', + 'Stored workflow definition could not be parsed', + {'code': result.error.code, 'path': result.error.path}) + + # All checks always run; the complete findings list is returned (4.6). + # Validation runs against the merged catalog for the workflow's + # Use_Case (custom-node-designer task 9.2): Custom_Node_Type versions + # recorded at save are honored (14.2) and deprecated types remain + # resolvable so existing workflows stay validatable (14.3). + pinned_versions = version_item.get('custom_node_types') or {} + catalog = resolution_catalog_for_usecase(item['usecase_id'], + pinned_versions) + findings = run_validator(result.graph, catalog=catalog) + wire_findings = [f.to_dict() for f in findings] + error_count = sum(1 for f in findings if f.severity == SEVERITY_ERROR) + warning_count = sum(1 for f in findings if f.severity == SEVERITY_WARNING) + passed = error_count == 0 + + usecase_id = item['usecase_id'] + validated_at = now_ms() + findings_key = findings_s3_key(usecase_id, workflow_id, version) + + # Store the findings document, then record the validation status on the + # version item (design: validation_status {passed/failed, findings_key, + # validated_at}); the packaging/deployment guard reads both (4.7, 4.10). + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=findings_key, + Body=json.dumps({ + 'workflow_id': workflow_id, + 'version': version, + 'validated_at': validated_at, + 'passed': passed, + 'findings': wire_findings + }).encode('utf-8'), + ContentType='application/json' + ) + + validation_status = { + 'status': VALIDATION_STATUS_PASSED if passed else VALIDATION_STATUS_FAILED, + 'findings_key': findings_key, + 'validated_at': validated_at + } + dynamodb.Table(WORKFLOW_VERSIONS_TABLE).update_item( + Key={'workflow_id': workflow_id, 'version': version}, + UpdateExpression='SET validation_status = :vs', + ExpressionAttributeValues={':vs': validation_status}, + ConditionExpression='attribute_exists(workflow_id)' + ) + + return create_response(200, { + 'workflow_id': workflow_id, + 'version': version, + 'passed': passed, + 'validation_status': validation_status, + 'findings': wire_findings, + 'error_count': error_count, + 'warning_count': warning_count + }) + + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + workflow_id = path_params.get('id') + + if resource == '/workflows/node-catalog': + if http_method == 'GET': + return get_node_catalog(event, user) + elif resource == '/workflows/{id}/validate' and workflow_id: + if http_method == 'POST': + return validate_workflow(event, user, workflow_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/functions/workflows.py b/edge-cv-portal/backend/functions/workflows.py new file mode 100644 index 00000000..963df97c --- /dev/null +++ b/edge-cv-portal/backend/functions/workflows.py @@ -0,0 +1,811 @@ +""" +Workflow_Store API Lambda function (Workflow Manager) + +CRUD, versioning, duplication, and deletion of Workflow_Definitions, +scoped per account and Use_Case (Requirements 5.1-5.8, 11.4, 11.5). + +Routes (API Gateway REST): + GET /workflows List workflows for authorized Use_Cases (5.3) + POST /workflows Create a workflow (version 1) (5.1) + GET /workflows/{id} Open/load a stored definition (5.4) + PUT /workflows/{id} Save changes as a new version (5.2) + DELETE /workflows/{id} Delete workflow + versions (5.5, 5.6) + POST /workflows/{id}/duplicate Duplicate under a new name (5.7) + GET /workflows/{id}/versions List version history (5.2) + +Storage layout: + Workflows table (WORKFLOWS_TABLE) PK workflow_id, GSI usecase-workflows-index + WorkflowVersions table (WORKFLOW_VERSIONS_TABLE) PK workflow_id, SK version (NUMBER) + Definitions in portal S3 under + {WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/versions/{version}/workflow.json + +Error envelope (design): {"error": {"code", "message", "details"}} with +400 parse/validation, 403 RBAC denial (5.8, 11.4), 404 scoped to avoid +cross-tenant existence leaks, 409 delete-with-active-deployments (5.6). +""" +import json +import os +import logging +import uuid +from typing import Any, Dict, List, Optional, Tuple +from datetime import datetime +from decimal import Decimal +import boto3 +from botocore.exceptions import ClientError + +# Import shared utilities (Lambda layer) +import sys +sys.path.append('/opt/python') +from shared_utils import ( + create_response, get_user_from_event, log_audit_event, + get_usecase, rbac_manager, Permission +) +from workflow_core.serializer import parse as parse_definition, serialize as serialize_graph + +# Custom_Node_Type reference recording at save (custom-node-designer task +# 9.2, Requirement 14.2): the version items carry a `custom_node_types` +# map {typeId: typeVersion} pinning the Custom_Node_Type versions in use. +# Design note: no inverted-index GSI (node-type-refs-index) is created — +# a DynamoDB GSI indexes one scalar attribute value per item, but a +# workflow version may reference several Custom_Node_Types, so a scalar +# ref_node_type_id cannot represent the reference set. The removal scan +# in custom_node_types.py already honors the map attribute in its +# fallback path without loading definition documents from S3. +from node_catalog_resolution import ( + load_registered_node_types, + referenced_node_type_versions, +) + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# AWS clients +dynamodb = boto3.resource('dynamodb') +s3 = boto3.client('s3') + +# Environment variables +WORKFLOWS_TABLE = os.environ.get('WORKFLOWS_TABLE') +WORKFLOW_VERSIONS_TABLE = os.environ.get('WORKFLOW_VERSIONS_TABLE') +DEPLOYMENTS_TABLE = os.environ.get('DEPLOYMENTS_TABLE') +PORTAL_ARTIFACTS_BUCKET = os.environ.get('PORTAL_ARTIFACTS_BUCKET') +WORKFLOWS_S3_PREFIX = os.environ.get('WORKFLOWS_S3_PREFIX', 'workflows') + +# Deployment statuses that count as "active" for delete rejection (5.6). +# Follows the convention used by models.py plus in-flight states. +ACTIVE_DEPLOYMENT_STATUSES = {'ACTIVE', 'COMPLETED', 'IN_PROGRESS', 'PENDING', 'QUEUED', 'DEPLOYED'} + +# Greengrass component name assigned by the Component_Packager (design section 6) +WORKFLOW_COMPONENT_PREFIX = 'dda.workflow.' + + +def decimal_to_native(obj): + """Convert Decimal objects from DynamoDB to native Python types""" + if isinstance(obj, Decimal): + return float(obj) if obj % 1 else int(obj) + elif isinstance(obj, dict): + return {k: decimal_to_native(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [decimal_to_native(i) for i in obj] + return obj + + +def error_response(status_code: int, code: str, message: str, details: Optional[Dict] = None) -> Dict: + """Build the Workflow Manager error envelope: {error: {code, message, details}}""" + return create_response(status_code, { + 'error': { + 'code': code, + 'message': message, + 'details': details or {} + } + }) + + +def now_ms() -> int: + return int(datetime.utcnow().timestamp() * 1000) + + +def definition_s3_key(usecase_id: str, workflow_id: str, version: int) -> str: + """S3 key of a stored Workflow_Definition document""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/versions/{version}/workflow.json" + + +def workflow_s3_prefix(usecase_id: str, workflow_id: str) -> str: + """S3 prefix holding all documents of a workflow""" + return f"{WORKFLOWS_S3_PREFIX}/{usecase_id}/{workflow_id}/" + + +def has_workflow_permission(user: Dict, usecase_id: str, permission: Permission) -> bool: + """Check a workflow permission for the acting user on a Use_Case""" + return rbac_manager.has_permission(user['user_id'], usecase_id, permission, user_info=user) + + +def not_found_response() -> Dict: + """Uniform 404 that never confirms whether a workflow exists (5.8)""" + return error_response(404, 'WORKFLOW_NOT_FOUND', 'Workflow not found') + + +def forbidden_response(user: Dict, event: Dict, usecase_id: str, permissions: List[Permission]) -> Dict: + """Uniform 403 authorization error with a denied-access audit entry (11.4)""" + log_audit_event( + user_id=user['user_id'], + action='unauthorized_access', + resource_type='workflow', + resource_id=event.get('resource', 'unknown'), + result='denied', + details={ + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id, + 'method': event.get('httpMethod'), + 'path': event.get('path') + } + ) + return error_response(403, 'FORBIDDEN', 'Insufficient permissions', { + 'required_permissions': [p.value for p in permissions], + 'usecase_id': usecase_id + }) + + +def authorize_workflow_access(user: Dict, event: Dict, item: Dict, + permission: Permission) -> Optional[Dict]: + """ + Authorize an operation on an existing workflow. + + Returns an error response, or None when authorized. + + Cross-tenant handling (5.8, design error section): a user without even + read access to the owning Use_Case receives the same 404 as for a + missing workflow, so existence is never leaked across tenants. A user + who can read but lacks the operation permission receives a 403. + """ + usecase_id = item['usecase_id'] + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return not_found_response() + if permission != Permission.WORKFLOW_READ and not has_workflow_permission(user, usecase_id, permission): + return forbidden_response(user, event, usecase_id, [permission]) + return None + + +def get_workflow_item(workflow_id: str) -> Optional[Dict]: + """Fetch a workflow metadata item, or None""" + table = dynamodb.Table(WORKFLOWS_TABLE) + response = table.get_item(Key={'workflow_id': workflow_id}) + item = response.get('Item') + return decimal_to_native(item) if item else None + + +def canonicalize_definition(definition: Any) -> Tuple[Optional[str], Optional[Dict]]: + """ + Validate a submitted Workflow_Definition with the Workflow_Serializer + and return its canonical JSON document. + + Returns (canonical_json, None) on success or (None, error_response). + """ + if isinstance(definition, str): + raw = definition + else: + try: + raw = json.dumps(definition) + except (TypeError, ValueError) as exc: + return None, error_response(400, 'INVALID_DEFINITION', + f'definition is not JSON-serializable: {exc}') + result = parse_definition(raw) + if not result.ok: + return None, error_response(400, result.error.code, result.error.message, + {'path': result.error.path}) + return serialize_graph(result.graph), None + + +def put_definition(usecase_id: str, workflow_id: str, version: int, canonical_json: str) -> str: + """Store a canonical definition document in portal S3; returns the key""" + key = definition_s3_key(usecase_id, workflow_id, version) + s3.put_object( + Bucket=PORTAL_ARTIFACTS_BUCKET, + Key=key, + Body=canonical_json.encode('utf-8'), + ContentType='application/json' + ) + return key + + +def load_definition(s3_key: str) -> Dict: + """Load a stored Workflow_Definition document from portal S3""" + response = s3.get_object(Bucket=PORTAL_ARTIFACTS_BUCKET, Key=s3_key) + return json.loads(response['Body'].read().decode('utf-8')) + + +def custom_node_type_references(usecase_id: str, canonical_json: str) -> Dict: + """ + The Custom_Node_Type versions a definition being saved uses + (custom-node-designer 14.2): {typeId: typeVersion} recording the + latest registered version of each referenced custom type at save + time. Empty when the definition uses only built-in node types or the + node-designer stack is not deployed. + """ + definition = json.loads(canonical_json) + registered = load_registered_node_types(usecase_id) + if not registered: + return {} + return referenced_node_type_versions(definition, registered) + + +def put_version_item(workflow_id: str, version: int, s3_key: str, user: Dict, + custom_node_types: Optional[Dict] = None) -> Dict: + """Record an immutable workflow version (design: WorkflowVersions table). + + ``custom_node_types`` pins the Custom_Node_Type versions the saved + definition uses ({typeId: typeVersion}, custom-node-designer 14.2); + it is always recorded (empty map for built-in-only workflows) so the + reference scan in custom_node_types.py never needs the S3 document + for versions saved after task 9.2. + """ + item = { + 'workflow_id': workflow_id, + 'version': version, + 's3_definition_key': s3_key, + 'validation_status': {'status': 'none'}, + 'compiled_arch_keys': {}, + 'component_arn': None, + 'custom_node_types': custom_node_types or {}, + 'created_by': user['user_id'], + 'created_at': now_ms() + } + dynamodb.Table(WORKFLOW_VERSIONS_TABLE).put_item(Item=item) + return item + + +def query_workflows_by_usecase(usecase_id: str) -> List[Dict]: + """All workflow metadata items of one Use_Case via the GSI""" + table = dynamodb.Table(WORKFLOWS_TABLE) + items: List[Dict] = [] + kwargs = { + 'IndexName': 'usecase-workflows-index', + 'KeyConditionExpression': 'usecase_id = :uid', + 'ExpressionAttributeValues': {':uid': usecase_id} + } + while True: + response = table.query(**kwargs) + items.extend(response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + return [decimal_to_native(i) for i in items] + + +def deployment_references_workflow(deployment: Dict, workflow_id: str) -> bool: + """ + True when a Deployments-table record references the workflow, either via + the component_type/workflow_id association (design data model) or via a + packaged Workflow_Component name in its components list. + """ + if deployment.get('component_type') == 'workflow' and \ + str(deployment.get('workflow_id', '')) == workflow_id: + return True + component_name = f"{WORKFLOW_COMPONENT_PREFIX}{workflow_id}" + for comp in deployment.get('components', []) or []: + if isinstance(comp, dict) and comp.get('component_name') == component_name: + return True + if isinstance(comp, str) and comp == component_name: + return True + return False + + +def find_active_workflow_deployments(usecase_id: str, workflow_id: str) -> List[str]: + """Deployment ids of active deployments referencing the workflow (5.6)""" + deployment_ids: List[str] = [] + try: + table = dynamodb.Table(DEPLOYMENTS_TABLE) + kwargs = { + 'IndexName': 'usecase-deployments-index', + 'KeyConditionExpression': 'usecase_id = :uid', + 'ExpressionAttributeValues': {':uid': usecase_id} + } + while True: + response = table.query(**kwargs) + for deployment in response.get('Items', []): + status = str(deployment.get('deployment_status', '')).upper() + if status not in ACTIVE_DEPLOYMENT_STATUSES: + continue + if deployment_references_workflow(deployment, workflow_id): + dep_id = deployment.get('deployment_id') or deployment.get('deploymentId') + if dep_id: + deployment_ids.append(str(dep_id)) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + except ClientError as e: + # Fail closed: if the deployment check cannot run we must not delete + logger.error(f"Error checking deployments for workflow {workflow_id}: {str(e)}") + raise + return sorted(set(deployment_ids)) + + +def workflow_summary(item: Dict) -> Dict: + """Public shape of a workflow metadata item""" + return { + 'workflow_id': item['workflow_id'], + 'usecase_id': item['usecase_id'], + 'account_id': item.get('account_id'), + 'name': item.get('name'), + 'description': item.get('description', ''), + 'created_at': item.get('created_at'), + 'updated_at': item.get('updated_at'), + 'latest_version': item.get('latest_version'), + 'created_by': item.get('created_by') + } + + +def parse_body(event: Dict) -> Tuple[Optional[Dict], Optional[Dict]]: + """Parse the request body; returns (body, None) or (None, error_response)""" + try: + body = json.loads(event.get('body') or '{}') + except (json.JSONDecodeError, TypeError): + return None, error_response(400, 'INVALID_JSON', 'Request body is not valid JSON') + if not isinstance(body, dict): + return None, error_response(400, 'INVALID_JSON', 'Request body must be a JSON object') + return body, None + + +def create_workflow(event: Dict, user: Dict) -> Dict: + """ + POST /workflows + Body: {usecase_id, name, definition, description?} + Persists the definition scoped to account + Use_Case as version 1 (5.1). + """ + body, err = parse_body(event) + if err: + return err + + usecase_id = body.get('usecase_id') + name = body.get('name') + definition = body.get('definition') + + missing = [f for f in ('usecase_id', 'name', 'definition') if not body.get(f)] + if missing: + return error_response(400, 'MISSING_FIELDS', + f"Missing required fields: {', '.join(missing)}") + + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_CREATE): + return forbidden_response(user, event, usecase_id, [Permission.WORKFLOW_CREATE]) + + # The Use_Case must exist; its account scopes the workflow (5.1) + try: + usecase = get_usecase(usecase_id) + except ValueError: + return error_response(404, 'USECASE_NOT_FOUND', 'Use case not found') + + canonical_json, err = canonicalize_definition(definition) + if err: + return err + + workflow_id = str(uuid.uuid4()) + timestamp = now_ms() + + s3_key = put_definition(usecase_id, workflow_id, 1, canonical_json) + put_version_item(workflow_id, 1, s3_key, user, + custom_node_type_references(usecase_id, canonical_json)) + + item = { + 'workflow_id': workflow_id, + 'usecase_id': usecase_id, + 'account_id': usecase.get('account_id'), + 'name': name, + 'description': body.get('description', ''), + 'created_at': timestamp, + 'updated_at': timestamp, + 'latest_version': 1, + 'created_by': user['user_id'] + } + dynamodb.Table(WORKFLOWS_TABLE).put_item( + Item=item, + ConditionExpression='attribute_not_exists(workflow_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='create_workflow', + resource_type='workflow', + resource_id=workflow_id, + result='success', + details={'usecase_id': usecase_id, 'name': name, 'version': 1} + ) + + return create_response(201, {'workflow': workflow_summary(item), 'version': 1}) + + +def list_workflows(event: Dict, user: Dict) -> Dict: + """ + GET /workflows[?usecase_id=...] + Returns workflows belonging to Use_Cases the user is authorized to + access (5.3). With usecase_id the list is scoped to that Use_Case. + """ + params = event.get('queryStringParameters') or {} + usecase_id = params.get('usecase_id') + + if usecase_id: + if not has_workflow_permission(user, usecase_id, Permission.WORKFLOW_READ): + return forbidden_response(user, event, usecase_id, [Permission.WORKFLOW_READ]) + usecase_ids = [usecase_id] + else: + usecase_ids = [ + uc for uc in rbac_manager.get_accessible_usecases(user['user_id'], user_info=user) + if has_workflow_permission(user, uc, Permission.WORKFLOW_READ) + ] + + workflows: List[Dict] = [] + for uc in usecase_ids: + workflows.extend(workflow_summary(i) for i in query_workflows_by_usecase(uc)) + + workflows.sort(key=lambda w: w.get('updated_at') or 0, reverse=True) + return create_response(200, {'workflows': workflows, 'count': len(workflows)}) + + +def get_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + GET /workflows/{id}[?version=N] + Open/load: returns metadata plus the stored Workflow_Definition of the + requested (default latest) version exactly as saved (5.4). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_READ) + if err: + return err + + params = event.get('queryStringParameters') or {} + version_param = params.get('version') + if version_param is not None: + try: + version = int(version_param) + except ValueError: + return error_response(400, 'INVALID_VERSION', 'version must be an integer') + else: + version = int(item.get('latest_version', 1)) + + versions_table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = versions_table.get_item(Key={'workflow_id': workflow_id, 'version': version}) + version_item = response.get('Item') + if not version_item: + return error_response(404, 'VERSION_NOT_FOUND', + f'Version {version} not found for workflow') + version_item = decimal_to_native(version_item) + + try: + definition = load_definition(version_item['s3_definition_key']) + except ClientError as e: + logger.error(f"Error loading definition for {workflow_id} v{version}: {str(e)}") + return error_response(500, 'DEFINITION_LOAD_FAILED', + 'Stored workflow definition could not be loaded') + + return create_response(200, { + 'workflow': workflow_summary(item), + 'version': version, + 'validation_status': version_item.get('validation_status'), + 'definition': definition + }) + + +def update_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + PUT /workflows/{id} + Body: {definition, name?, description?} + Saves changes as a new workflow version; prior versions are never + modified or removed (5.2). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_SAVE) + if err: + return err + + body, err = parse_body(event) + if err: + return err + definition = body.get('definition') + if not definition: + return error_response(400, 'MISSING_FIELDS', 'Missing required fields: definition') + + canonical_json, err = canonicalize_definition(definition) + if err: + return err + + usecase_id = item['usecase_id'] + timestamp = now_ms() + + # Atomically allocate the next version number and refresh metadata + update_expr = 'SET latest_version = latest_version + :one, updated_at = :updated' + expr_values: Dict[str, Any] = {':one': 1, ':updated': timestamp} + expr_names: Dict[str, str] = {} + if 'name' in body and body['name']: + update_expr += ', #name = :name' + expr_names['#name'] = 'name' + expr_values[':name'] = body['name'] + if 'description' in body: + update_expr += ', description = :description' + expr_values[':description'] = body.get('description', '') + + kwargs = { + 'Key': {'workflow_id': workflow_id}, + 'UpdateExpression': update_expr, + 'ExpressionAttributeValues': expr_values, + 'ConditionExpression': 'attribute_exists(workflow_id)', + 'ReturnValues': 'ALL_NEW' + } + if expr_names: + kwargs['ExpressionAttributeNames'] = expr_names + updated = dynamodb.Table(WORKFLOWS_TABLE).update_item(**kwargs) + new_item = decimal_to_native(updated['Attributes']) + new_version = int(new_item['latest_version']) + + s3_key = put_definition(usecase_id, workflow_id, new_version, canonical_json) + put_version_item(workflow_id, new_version, s3_key, user, + custom_node_type_references(usecase_id, canonical_json)) + + log_audit_event( + user_id=user['user_id'], + action='update_workflow', + resource_type='workflow', + resource_id=workflow_id, + result='success', + details={'usecase_id': usecase_id, 'name': new_item.get('name'), + 'version': new_version} + ) + + return create_response(200, {'workflow': workflow_summary(new_item), 'version': new_version}) + + +def delete_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + DELETE /workflows/{id} + Removes the workflow and all its versions when no active deployment + references it (5.5); otherwise rejects with 409 and the referencing + deployment ids (5.6). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_DELETE) + if err: + return err + + usecase_id = item['usecase_id'] + + deployment_ids = find_active_workflow_deployments(usecase_id, workflow_id) + if deployment_ids: + return error_response( + 409, 'WORKFLOW_HAS_ACTIVE_DEPLOYMENTS', + 'Workflow cannot be deleted while active deployments reference it', + {'deployment_ids': deployment_ids} + ) + + # Delete version records + versions_table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + version_keys: List[Dict] = [] + kwargs = { + 'KeyConditionExpression': 'workflow_id = :wid', + 'ExpressionAttributeValues': {':wid': workflow_id}, + # 'version' is a DynamoDB reserved word + 'ProjectionExpression': 'workflow_id, #v', + 'ExpressionAttributeNames': {'#v': 'version'} + } + while True: + response = versions_table.query(**kwargs) + version_keys.extend( + {'workflow_id': v['workflow_id'], 'version': v['version']} + for v in response.get('Items', []) + ) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + with versions_table.batch_writer() as batch: + for key in version_keys: + batch.delete_item(Key=key) + + # Delete stored documents in S3 + prefix = workflow_s3_prefix(usecase_id, workflow_id) + continuation_token = None + while True: + list_kwargs = {'Bucket': PORTAL_ARTIFACTS_BUCKET, 'Prefix': prefix} + if continuation_token: + list_kwargs['ContinuationToken'] = continuation_token + listed = s3.list_objects_v2(**list_kwargs) + objects = [{'Key': o['Key']} for o in listed.get('Contents', [])] + if objects: + s3.delete_objects(Bucket=PORTAL_ARTIFACTS_BUCKET, Delete={'Objects': objects}) + if not listed.get('IsTruncated'): + break + continuation_token = listed.get('NextContinuationToken') + + # Delete workflow metadata + dynamodb.Table(WORKFLOWS_TABLE).delete_item(Key={'workflow_id': workflow_id}) + + log_audit_event( + user_id=user['user_id'], + action='delete_workflow', + resource_type='workflow', + resource_id=workflow_id, + result='success', + details={'usecase_id': usecase_id, 'name': item.get('name'), + 'versions_deleted': len(version_keys)} + ) + + return create_response(200, { + 'workflow_id': workflow_id, + 'message': 'Workflow deleted successfully' + }) + + +def duplicate_workflow(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + POST /workflows/{id}/duplicate + Body: {name?, description?} + Creates a new workflow with a copy of the source's latest + Workflow_Definition under a new name (5.7). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_CREATE) + if err: + return err + + body, err = parse_body(event) + if err: + return err + new_name = body.get('name') or f"{item.get('name', 'Workflow')} (copy)" + + usecase_id = item['usecase_id'] + latest_version = int(item.get('latest_version', 1)) + + versions_table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + response = versions_table.get_item( + Key={'workflow_id': workflow_id, 'version': latest_version} + ) + source_version = response.get('Item') + if not source_version: + return error_response(404, 'VERSION_NOT_FOUND', + f'Version {latest_version} not found for workflow') + + try: + definition = load_definition(source_version['s3_definition_key']) + except ClientError as e: + logger.error(f"Error loading definition for {workflow_id} v{latest_version}: {str(e)}") + return error_response(500, 'DEFINITION_LOAD_FAILED', + 'Stored workflow definition could not be loaded') + + canonical_json, err = canonicalize_definition(definition) + if err: + return err + + new_workflow_id = str(uuid.uuid4()) + timestamp = now_ms() + + s3_key = put_definition(usecase_id, new_workflow_id, 1, canonical_json) + put_version_item(new_workflow_id, 1, s3_key, user, + custom_node_type_references(usecase_id, canonical_json)) + + new_item = { + 'workflow_id': new_workflow_id, + 'usecase_id': usecase_id, + 'account_id': item.get('account_id'), + 'name': new_name, + 'description': body.get('description', item.get('description', '')), + 'created_at': timestamp, + 'updated_at': timestamp, + 'latest_version': 1, + 'created_by': user['user_id'] + } + dynamodb.Table(WORKFLOWS_TABLE).put_item( + Item=new_item, + ConditionExpression='attribute_not_exists(workflow_id)' + ) + + log_audit_event( + user_id=user['user_id'], + action='duplicate_workflow', + resource_type='workflow', + resource_id=new_workflow_id, + result='success', + details={'usecase_id': usecase_id, 'name': new_name, + 'source_workflow_id': workflow_id, 'source_version': latest_version} + ) + + return create_response(201, {'workflow': workflow_summary(new_item), 'version': 1}) + + +def list_versions(event: Dict, user: Dict, workflow_id: str) -> Dict: + """ + GET /workflows/{id}/versions + Version history, newest first; prior versions are retained (5.2). + """ + item = get_workflow_item(workflow_id) + if not item: + return not_found_response() + err = authorize_workflow_access(user, event, item, Permission.WORKFLOW_READ) + if err: + return err + + versions_table = dynamodb.Table(WORKFLOW_VERSIONS_TABLE) + versions: List[Dict] = [] + kwargs = { + 'KeyConditionExpression': 'workflow_id = :wid', + 'ExpressionAttributeValues': {':wid': workflow_id}, + 'ScanIndexForward': False + } + while True: + response = versions_table.query(**kwargs) + versions.extend(decimal_to_native(v) for v in response.get('Items', [])) + last_key = response.get('LastEvaluatedKey') + if not last_key: + break + kwargs['ExclusiveStartKey'] = last_key + + return create_response(200, { + 'workflow_id': workflow_id, + 'latest_version': item.get('latest_version'), + 'versions': [ + { + 'version': v['version'], + 'created_at': v.get('created_at'), + 'created_by': v.get('created_by'), + 'validation_status': v.get('validation_status'), + 'component_arn': v.get('component_arn') + } + for v in versions + ], + 'count': len(versions) + }) + + +def handler(event: Dict, context: Any) -> Dict: + """Main Lambda handler - routes to the appropriate operation""" + try: + http_method = event.get('httpMethod') + + # Handle CORS preflight requests + if http_method == 'OPTIONS': + return { + 'statusCode': 200, + 'headers': { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token', + 'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS', + 'Access-Control-Max-Age': '86400' + }, + 'body': '' + } + + user = get_user_from_event(event) + resource = event.get('resource', '') + path_params = event.get('pathParameters') or {} + workflow_id = path_params.get('id') + + if resource == '/workflows': + if http_method == 'GET': + return list_workflows(event, user) + if http_method == 'POST': + return create_workflow(event, user) + elif resource == '/workflows/{id}' and workflow_id: + if http_method == 'GET': + return get_workflow(event, user, workflow_id) + if http_method == 'PUT': + return update_workflow(event, user, workflow_id) + if http_method == 'DELETE': + return delete_workflow(event, user, workflow_id) + elif resource == '/workflows/{id}/duplicate' and workflow_id: + if http_method == 'POST': + return duplicate_workflow(event, user, workflow_id) + elif resource == '/workflows/{id}/versions' and workflow_id: + if http_method == 'GET': + return list_versions(event, user, workflow_id) + + return error_response(404, 'NOT_FOUND', 'Not found') + + except Exception as e: + logger.error(f"Handler error: {str(e)}", exc_info=True) + return error_response(500, 'INTERNAL_ERROR', 'Internal server error') diff --git a/edge-cv-portal/backend/layers/shared/python/shared_utils.py b/edge-cv-portal/backend/layers/shared/python/shared_utils.py index ec802680..15a516ed 100644 --- a/edge-cv-portal/backend/layers/shared/python/shared_utils.py +++ b/edge-cv-portal/backend/layers/shared/python/shared_utils.py @@ -4,10 +4,12 @@ import json import os import logging +import uuid from typing import Dict, Any, Optional, List, Set from datetime import datetime from decimal import Decimal import boto3 +from boto3.dynamodb.conditions import Key from botocore.exceptions import ClientError from enum import Enum from functools import wraps @@ -148,6 +150,143 @@ def log_audit_event(user_id: str, action: str, resource_type: str, logger.error(f"Error logging audit event: {str(e)}") +# Strict (raising) audit helpers — portal-user-manager +# +# Unlike log_audit_event above (which swallows errors), these helpers +# implement the two-phase audit-before-effect protocol: a 'pending' entry +# is written (and any failure RAISED) before the guarded operation runs, +# then the entry is finalized to its terminal result afterwards. + +# Action types recorded against user accounts by the User Manager. +USER_ACCOUNT_AUDIT_ACTIONS = ('password_change', 'forgot_password', 'role_change', + 'account_create', 'account_disable', + 'account_enable', 'account_delete') + +# Resource type used for user-account audit entries. +USER_ACCOUNT_RESOURCE_TYPE = 'user_account' + +# Valid audit results for the two-phase protocol. +AUDIT_RESULT_PENDING = 'pending' +AUDIT_RESULTS = ('pending', 'success', 'failure', 'rejected') +AUDIT_FINAL_RESULTS = ('success', 'failure', 'rejected') + +# Denylist for audit details: 'temp*' is a prefix match (temporary +# passwords), the others match anywhere in the key (password_hash, +# new_password, credential_verifier, ...). +_AUDIT_DETAILS_DENY_SUBSTRINGS = ('password', 'verifier', 'hash') +_AUDIT_DETAILS_DENY_PREFIXES = ('temp',) + + +def _is_denied_audit_detail_key(key: Any) -> bool: + """True when a details key must never reach the audit log.""" + normalized = str(key).lower() + return ( + any(term in normalized for term in _AUDIT_DETAILS_DENY_SUBSTRINGS) + or normalized.startswith(_AUDIT_DETAILS_DENY_PREFIXES) + ) + + +def sanitize_audit_details(details: Optional[Dict]) -> Dict: + """Drop denylisted keys (password / verifier / hash / temp*) from + audit details, recursively through nested dicts and lists. + + Passwords, password hashes, credential verifiers, and temporary + password values must never be written to the audit log (Req 6.3). + """ + def _sanitize(value): + if isinstance(value, dict): + return { + key: _sanitize(nested) + for key, nested in value.items() + if not _is_denied_audit_detail_key(key) + } + if isinstance(value, (list, tuple)): + return [_sanitize(nested) for nested in value] + return value + + return _sanitize(details or {}) + + +def record_audit_event_strict(user_id: str, action: str, resource_type: str, + resource_id: str, result: str = AUDIT_RESULT_PENDING, + details: Optional[Dict] = None) -> str: + """Write an audit entry and RAISE on failure (strict variant of + log_audit_event). + + First phase of the audit-before-effect protocol: call with + result='pending' BEFORE performing the guarded operation; if this + raises, the caller must abort the operation (Req 6.4, 6.5). + + Returns the event_id used to finalize_audit_event() afterwards. + """ + if result not in AUDIT_RESULTS: + raise ValueError(f"Invalid audit result: {result!r} " + f"(expected one of {AUDIT_RESULTS})") + + table = dynamodb.Table(AUDIT_LOG_TABLE) + timestamp = int(datetime.utcnow().timestamp() * 1000) + event_id = f"{user_id}_{timestamp}_{uuid.uuid4().hex[:8]}" + + item = { + 'event_id': event_id, + 'timestamp': timestamp, + 'user_id': user_id, + 'action': action, + 'resource_type': resource_type, + 'resource_id': resource_id, + 'result': result, + 'details': sanitize_audit_details(details), + 'ttl': timestamp + (90 * 24 * 60 * 60 * 1000) # 90 days retention + } + + table.put_item(Item=item) + logger.info(f"Strict audit event recorded ({result}): " + f"{action} on {resource_type}/{resource_id}") + return event_id + + +def finalize_audit_event(event_id: str, result: str, + details: Optional[Dict] = None): + """Second phase of the audit-before-effect protocol: move a pending + entry to its terminal result ('success' | 'failure' | 'rejected'), + merging sanitized details and stamping the completion time (Req 6.1). + + Raises on lookup/update failure so callers can surface the problem. + """ + if result not in AUDIT_FINAL_RESULTS: + raise ValueError(f"Invalid final audit result: {result!r} " + f"(expected one of {AUDIT_FINAL_RESULTS})") + + table = dynamodb.Table(AUDIT_LOG_TABLE) + + # The table key is (event_id, timestamp); recover the range key from + # the partition so callers only need the event_id handle. + response = table.query( + KeyConditionExpression=Key('event_id').eq(event_id) + ) + items = response.get('Items', []) + if not items: + raise ValueError(f"Audit event not found: {event_id}") + entry = items[0] + + completed_at = int(datetime.utcnow().timestamp() * 1000) + merged_details = dict(entry.get('details') or {}) + merged_details.update(sanitize_audit_details(details)) + + table.update_item( + Key={'event_id': event_id, 'timestamp': entry['timestamp']}, + UpdateExpression='SET #result = :result, #details = :details, ' + 'completed_at = :completed_at', + ExpressionAttributeNames={'#result': 'result', '#details': 'details'}, + ExpressionAttributeValues={ + ':result': result, + ':details': merged_details, + ':completed_at': completed_at, + }, + ) + logger.info(f"Audit event finalized ({result}): {event_id}") + + # RBAC Authorization System class Role(Enum): """User roles with hierarchical permissions""" @@ -202,6 +341,28 @@ class Permission(Enum): VIEW_DEVICE_LOGS = "view_device_logs" UPDATE_DEVICE_CONFIG = "update_device_config" + # Workflow Manager + WORKFLOW_READ = "workflow:read" + WORKFLOW_CREATE = "workflow:create" + WORKFLOW_EDIT = "workflow:edit" + WORKFLOW_SAVE = "workflow:save" + WORKFLOW_DELETE = "workflow:delete" + WORKFLOW_TEST = "workflow:test" + WORKFLOW_PACKAGE = "workflow:package" + WORKFLOW_DEPLOY = "workflow:deploy" + BEDROCK_CONFIG_WRITE = "bedrock-config:write" + + # Node Designer (custom-node-designer, Requirement 13) + NODE_DESIGNER_READ = "node-designer:read" + NODE_DESIGNER_CREATE = "node-designer:create" + NODE_DESIGNER_GENERATE = "node-designer:generate" + NODE_DESIGNER_IMPORT = "node-designer:import" + NODE_DESIGNER_SIMULATE = "node-designer:simulate" + NODE_DESIGNER_REGISTER = "node-designer:register" + NODE_DESIGNER_PROMOTE_DEMOTE = "node-designer:promote-demote" + NODE_DESIGNER_MANAGE = "node-designer:manage" + NODE_DESIGNER_SECURITY_REVIEW = "node-designer:security-review" + # System Administration VIEW_AUDIT_LOGS = "view_audit_logs" MANAGE_SETTINGS = "manage_settings" @@ -239,6 +400,11 @@ def _initialize_role_permissions(self) -> Dict[Role, Set[Permission]]: Permission.VIEW_DEPLOYMENTS, Permission.VIEW_DEVICES, Permission.VIEW_DEVICE_LOGS, + # Workflow Manager: read-only view of workflows and deployment status + Permission.WORKFLOW_READ, + # Node Designer: read-only view of Custom_Node_Types and + # Plugin_Records (13.3) + Permission.NODE_DESIGNER_READ, }, Role.OPERATOR: { @@ -260,6 +426,13 @@ def _initialize_role_permissions(self) -> Dict[Role, Set[Permission]]: Permission.REBOOT_DEVICES, Permission.BROWSE_DEVICE_FILES, Permission.UPDATE_DEVICE_CONFIG, + # Workflow Manager: Operators package and deploy workflows + Permission.WORKFLOW_READ, + Permission.WORKFLOW_PACKAGE, + Permission.WORKFLOW_DEPLOY, + # Node Designer: read-only view of Custom_Node_Types and + # Plugin_Records (13.3) + Permission.NODE_DESIGNER_READ, }, Role.DATA_SCIENTIST: { @@ -281,6 +454,16 @@ def _initialize_role_permissions(self) -> Dict[Role, Set[Permission]]: Permission.MANAGE_TRAINING_JOBS, Permission.PROMOTE_MODELS, Permission.DELETE_MODELS, + # Workflow Manager: DataScientists create, edit, save, delete, and test workflows + Permission.WORKFLOW_READ, + Permission.WORKFLOW_CREATE, + Permission.WORKFLOW_EDIT, + Permission.WORKFLOW_SAVE, + Permission.WORKFLOW_DELETE, + Permission.WORKFLOW_TEST, + # Node Designer: read-only view of Custom_Node_Types and + # Plugin_Records (13.3) + Permission.NODE_DESIGNER_READ, }, Role.USECASE_ADMIN: { @@ -309,6 +492,32 @@ def _initialize_role_permissions(self) -> Dict[Role, Set[Permission]]: Permission.REBOOT_DEVICES, Permission.BROWSE_DEVICE_FILES, Permission.UPDATE_DEVICE_CONFIG, + # Workflow Manager: UseCaseAdmins hold all workflow permissions + # (create/edit/save/delete/test like DataScientist, plus + # package/deploy like Operator). bedrock-config:write remains + # PortalAdmin-only. + Permission.WORKFLOW_READ, + Permission.WORKFLOW_CREATE, + Permission.WORKFLOW_EDIT, + Permission.WORKFLOW_SAVE, + Permission.WORKFLOW_DELETE, + Permission.WORKFLOW_TEST, + Permission.WORKFLOW_PACKAGE, + Permission.WORKFLOW_DEPLOY, + # Node Designer: UseCaseAdmins create scaffolds, use the + # Node_Generator, import plugins, run the Plugin_Simulator, + # register Custom_Node_Types, promote/demote between dev and + # test, and manage Plugin_Records within their own Use_Case + # (13.1). node-designer:security-review remains + # PortalAdmin-only (13.2). + Permission.NODE_DESIGNER_READ, + Permission.NODE_DESIGNER_CREATE, + Permission.NODE_DESIGNER_GENERATE, + Permission.NODE_DESIGNER_IMPORT, + Permission.NODE_DESIGNER_SIMULATE, + Permission.NODE_DESIGNER_REGISTER, + Permission.NODE_DESIGNER_PROMOTE_DEMOTE, + Permission.NODE_DESIGNER_MANAGE, # Add UseCaseAdmin-specific permissions Permission.UPDATE_USECASES, Permission.DELETE_USECASES, diff --git a/edge-cv-portal/backend/layers/workflow_core/README.md b/edge-cv-portal/backend/layers/workflow_core/README.md new file mode 100644 index 00000000..7c8f6105 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/README.md @@ -0,0 +1,44 @@ +# workflow_core Lambda Layer + +Shared Python package for the Workflow Manager feature. Contains the node +catalog, workflow serializer, validator, and compiler used by the portal +Lambda functions, the cloud test sandbox, and the LocalServer workflow +engine (vendored). + +## Layout + +``` +workflow_core/ +├── build.sh # Layer build script (produces layer.zip) +├── pyproject.toml # Package metadata + pytest config +├── requirements.txt # Runtime layer dependencies +├── python/ +│ └── workflow_core/ # The package (Lambda layer python/ convention) +│ ├── catalog/ # Node type descriptors and port compatibility +│ ├── serializer/ # Canonical JSON serialize/parse + migration +│ ├── validator/ # Graph validation checks (V1–V5, W1) +│ └── compiler/ # Compiled Pipeline Document generation +└── tests/ # pytest + hypothesis test suite +``` + +## Testing + +Property-based tests use [hypothesis](https://hypothesis.readthedocs.io/) +configured for a minimum of 100 examples per property (see +`tests/conftest.py`). Run the suite from this directory: + +``` +pip install pytest hypothesis +python -m pytest +``` + +Use `HYPOTHESIS_PROFILE=ci` for a larger 500-example run. + +## Building the layer + +``` +./build.sh +``` + +Produces `layer.zip` with the package and dependencies under `python/`, +following the same conventions as the `shared` and `jwt` layers. diff --git a/edge-cv-portal/backend/layers/workflow_core/build.sh b/edge-cv-portal/backend/layers/workflow_core/build.sh new file mode 100755 index 00000000..155e572c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/build.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Build workflow_core Lambda layer +# Packages the workflow_core package (node catalog, serializer, validator, +# compiler) and its dependencies for Lambda functions + +set -e + +LAYER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$LAYER_DIR/build" +PYTHON_DIR="$BUILD_DIR/python" + +echo "Building workflow_core Lambda layer..." + +# Clean and create build directory +rm -rf "$BUILD_DIR" "$LAYER_DIR/layer.zip" +mkdir -p "$PYTHON_DIR" + +# Copy the workflow_core package (exclude caches) +cp -r "$LAYER_DIR/python/workflow_core" "$PYTHON_DIR/" +find "$PYTHON_DIR" -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + +# Install dependencies targeting the Lambda runtime platform (x86_64, +# Python 3.11) so native wheels are correct regardless of the build host. +if [ -f "$LAYER_DIR/requirements.txt" ]; then + python3 -m pip install -r "$LAYER_DIR/requirements.txt" -t "$PYTHON_DIR/" \ + --platform manylinux2014_x86_64 \ + --implementation cp \ + --python-version 3.11 \ + --only-binary=:all: \ + --upgrade +fi + +# Create zip file +cd "$BUILD_DIR" +zip -r ../layer.zip . > /dev/null + +echo "Layer built successfully: $LAYER_DIR/layer.zip" diff --git a/edge-cv-portal/backend/layers/workflow_core/pyproject.toml b/edge-cv-portal/backend/layers/workflow_core/pyproject.toml new file mode 100644 index 00000000..2d2892a7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "workflow-core" +version = "0.1.0" +description = "Shared workflow core: node catalog, serializer, validator, and compiler for the Workflow Manager" +requires-python = ">=3.11" +dependencies = [ + "jsonschema>=3.2,<5", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", + "hypothesis>=6.100", +] + +[tool.setuptools] +package-dir = { "" = "python" } +packages = ["workflow_core"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.py new file mode 100644 index 00000000..5c6e0650 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: MIT + +""" +Classes Without Boilerplate +""" + +from functools import partial +from typing import Callable, Literal, Protocol + +from . import converters, exceptions, filters, setters, validators +from ._cmp import cmp_using +from ._config import get_run_validators, set_run_validators +from ._funcs import asdict, assoc, astuple, has, resolve_types +from ._make import ( + NOTHING, + Attribute, + Converter, + Factory, + _Nothing, + attrib, + attrs, + evolve, + fields, + fields_dict, + make_class, + validate, +) +from ._next_gen import define, field, frozen, mutable +from ._version_info import VersionInfo + + +s = attributes = attrs +ib = attr = attrib +dataclass = partial(attrs, auto_attribs=True) # happy Easter ;) + + +class AttrsInstance(Protocol): + pass + + +NothingType = Literal[_Nothing.NOTHING] + +__all__ = [ + "NOTHING", + "Attribute", + "AttrsInstance", + "Converter", + "Factory", + "NothingType", + "asdict", + "assoc", + "astuple", + "attr", + "attrib", + "attributes", + "attrs", + "cmp_using", + "converters", + "define", + "evolve", + "exceptions", + "field", + "fields", + "fields_dict", + "filters", + "frozen", + "get_run_validators", + "has", + "ib", + "make_class", + "mutable", + "resolve_types", + "s", + "set_run_validators", + "setters", + "validate", + "validators", +] + + +def _make_getattr(mod_name: str) -> Callable: + """ + Create a metadata proxy for packaging information that uses *mod_name* in + its warnings and errors. + """ + + def __getattr__(name: str) -> str: + if name not in ("__version__", "__version_info__"): + msg = f"module {mod_name} has no attribute {name}" + raise AttributeError(msg) + + from importlib.metadata import metadata + + meta = metadata("attrs") + + if name == "__version_info__": + return VersionInfo._from_version_string(meta["version"]) + + return meta["version"] + + return __getattr__ + + +__getattr__ = _make_getattr(__name__) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.pyi new file mode 100644 index 00000000..758decf8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/__init__.pyi @@ -0,0 +1,389 @@ +import enum +import sys + +from typing import ( + Any, + Callable, + Generic, + Literal, + Mapping, + Protocol, + Sequence, + TypeVar, + overload, +) + +# `import X as X` is required to make these public +from . import converters as converters +from . import exceptions as exceptions +from . import filters as filters +from . import setters as setters +from . import validators as validators +from ._cmp import cmp_using as cmp_using +from ._typing_compat import AttrsInstance_ +from ._version_info import VersionInfo +from attrs import ( + define as define, + field as field, + mutable as mutable, + frozen as frozen, + _EqOrderType, + _ValidatorType, + _ConverterType, + _ReprArgType, + _OnSetAttrType, + _OnSetAttrArgType, + _FieldTransformer, + _ValidatorArgType, +) + +if sys.version_info >= (3, 10): + from typing import TypeGuard, TypeAlias +else: + from typing_extensions import TypeGuard, TypeAlias + +if sys.version_info >= (3, 11): + from typing import dataclass_transform +else: + from typing_extensions import dataclass_transform + +__version__: str +__version_info__: VersionInfo +__title__: str +__description__: str +__url__: str +__uri__: str +__author__: str +__email__: str +__license__: str +__copyright__: str + +_T = TypeVar("_T") +_C = TypeVar("_C", bound=type) + +_FilterType = Callable[["Attribute[_T]", _T], bool] + +# We subclass this here to keep the protocol's qualified name clean. +class AttrsInstance(AttrsInstance_, Protocol): + pass + +_A = TypeVar("_A", bound=type[AttrsInstance]) + +class _Nothing(enum.Enum): + NOTHING = enum.auto() + +NOTHING = _Nothing.NOTHING +NothingType: TypeAlias = Literal[_Nothing.NOTHING] + +# NOTE: Factory lies about its return type to make this possible: +# `x: List[int] # = Factory(list)` +# Work around mypy issue #4554 in the common case by using an overload. + +@overload +def Factory(factory: Callable[[], _T]) -> _T: ... +@overload +def Factory( + factory: Callable[[Any], _T], + takes_self: Literal[True], +) -> _T: ... +@overload +def Factory( + factory: Callable[[], _T], + takes_self: Literal[False], +) -> _T: ... + +In = TypeVar("In") +Out = TypeVar("Out") + +class Converter(Generic[In, Out]): + @overload + def __init__(self, converter: Callable[[In], Out]) -> None: ... + @overload + def __init__( + self, + converter: Callable[[In, AttrsInstance, Attribute], Out], + *, + takes_self: Literal[True], + takes_field: Literal[True], + ) -> None: ... + @overload + def __init__( + self, + converter: Callable[[In, Attribute], Out], + *, + takes_field: Literal[True], + ) -> None: ... + @overload + def __init__( + self, + converter: Callable[[In, AttrsInstance], Out], + *, + takes_self: Literal[True], + ) -> None: ... + +class Attribute(Generic[_T]): + name: str + default: _T | None + validator: _ValidatorType[_T] | None + repr: _ReprArgType + cmp: _EqOrderType + eq: _EqOrderType + order: _EqOrderType + hash: bool | None + init: bool + converter: Converter | None + metadata: dict[Any, Any] + type: type[_T] | None + kw_only: bool + on_setattr: _OnSetAttrType + alias: str | None + + def evolve(self, **changes: Any) -> "Attribute[Any]": ... + +# NOTE: We had several choices for the annotation to use for type arg: +# 1) Type[_T] +# - Pros: Handles simple cases correctly +# - Cons: Might produce less informative errors in the case of conflicting +# TypeVars e.g. `attr.ib(default='bad', type=int)` +# 2) Callable[..., _T] +# - Pros: Better error messages than #1 for conflicting TypeVars +# - Cons: Terrible error messages for validator checks. +# e.g. attr.ib(type=int, validator=validate_str) +# -> error: Cannot infer function type argument +# 3) type (and do all of the work in the mypy plugin) +# - Pros: Simple here, and we could customize the plugin with our own errors. +# - Cons: Would need to write mypy plugin code to handle all the cases. +# We chose option #1. + +# `attr` lies about its return type to make the following possible: +# attr() -> Any +# attr(8) -> int +# attr(validator=) -> Whatever the callable expects. +# This makes this type of assignments possible: +# x: int = attr(8) +# +# This form catches explicit None or no default but with no other arguments +# returns Any. +@overload +def attrib( + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + type: None = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def attrib( + default: None = ..., + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + type: type[_T] | None = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def attrib( + default: _T, + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + type: type[_T] | None = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def attrib( + default: _T | None = ..., + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + type: object = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., +) -> Any: ... +@overload +@dataclass_transform(order_default=True, field_specifiers=(attrib, field)) +def attrs( + maybe_cls: _C, + these: dict[str, Any] | None = ..., + repr_ns: str | None = ..., + repr: bool = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., + unsafe_hash: bool | None = ..., +) -> _C: ... +@overload +@dataclass_transform(order_default=True, field_specifiers=(attrib, field)) +def attrs( + maybe_cls: None = ..., + these: dict[str, Any] | None = ..., + repr_ns: str | None = ..., + repr: bool = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + auto_detect: bool = ..., + collect_by_mro: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., + unsafe_hash: bool | None = ..., +) -> Callable[[_C], _C]: ... +def fields(cls: type[AttrsInstance] | AttrsInstance) -> Any: ... +def fields_dict(cls: type[AttrsInstance]) -> dict[str, Attribute[Any]]: ... +def validate(inst: AttrsInstance) -> None: ... +def resolve_types( + cls: _A, + globalns: dict[str, Any] | None = ..., + localns: dict[str, Any] | None = ..., + attribs: list[Attribute[Any]] | None = ..., + include_extras: bool = ..., +) -> _A: ... + +# TODO: add support for returning a proper attrs class from the mypy plugin +# we use Any instead of _CountingAttr so that e.g. `make_class('Foo', +# [attr.ib()])` is valid +def make_class( + name: str, + attrs: list[str] | tuple[str, ...] | dict[str, Any], + bases: tuple[type, ...] = ..., + class_body: dict[str, Any] | None = ..., + repr_ns: str | None = ..., + repr: bool = ..., + cmp: _EqOrderType | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + collect_by_mro: bool = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., +) -> type: ... + +# _funcs -- + +# TODO: add support for returning TypedDict from the mypy plugin +# FIXME: asdict/astuple do not honor their factory args. Waiting on one of +# these: +# https://github.com/python/mypy/issues/4236 +# https://github.com/python/typing/issues/253 +# XXX: remember to fix attrs.asdict/astuple too! +def asdict( + inst: AttrsInstance, + recurse: bool = ..., + filter: _FilterType[Any] | None = ..., + dict_factory: type[Mapping[Any, Any]] = ..., + retain_collection_types: bool = ..., + value_serializer: Callable[[type, Attribute[Any], Any], Any] | None = ..., + tuple_keys: bool | None = ..., +) -> dict[str, Any]: ... + +# TODO: add support for returning NamedTuple from the mypy plugin +def astuple( + inst: AttrsInstance, + recurse: bool = ..., + filter: _FilterType[Any] | None = ..., + tuple_factory: type[Sequence[Any]] = ..., + retain_collection_types: bool = ..., +) -> tuple[Any, ...]: ... +def has(cls: type) -> TypeGuard[type[AttrsInstance]]: ... +def assoc(inst: _T, **changes: Any) -> _T: ... +def evolve(inst: _T, **changes: Any) -> _T: ... + +# _config -- + +def set_run_validators(run: bool) -> None: ... +def get_run_validators() -> bool: ... + +# aliases -- + +s = attributes = attrs +ib = attr = attrib +dataclass = attrs # Technically, partial(attrs, auto_attribs=True) ;) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.py new file mode 100644 index 00000000..09bab491 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.py @@ -0,0 +1,160 @@ +# SPDX-License-Identifier: MIT + + +import functools +import types + +from ._make import __ne__ + + +_operation_names = {"eq": "==", "lt": "<", "le": "<=", "gt": ">", "ge": ">="} + + +def cmp_using( + eq=None, + lt=None, + le=None, + gt=None, + ge=None, + require_same_type=True, + class_name="Comparable", +): + """ + Create a class that can be passed into `attrs.field`'s ``eq``, ``order``, + and ``cmp`` arguments to customize field comparison. + + The resulting class will have a full set of ordering methods if at least + one of ``{lt, le, gt, ge}`` and ``eq`` are provided. + + Args: + eq (typing.Callable | None): + Callable used to evaluate equality of two objects. + + lt (typing.Callable | None): + Callable used to evaluate whether one object is less than another + object. + + le (typing.Callable | None): + Callable used to evaluate whether one object is less than or equal + to another object. + + gt (typing.Callable | None): + Callable used to evaluate whether one object is greater than + another object. + + ge (typing.Callable | None): + Callable used to evaluate whether one object is greater than or + equal to another object. + + require_same_type (bool): + When `True`, equality and ordering methods will return + `NotImplemented` if objects are not of the same type. + + class_name (str | None): Name of class. Defaults to "Comparable". + + See `comparison` for more details. + + .. versionadded:: 21.1.0 + """ + + body = { + "__slots__": ["value"], + "__init__": _make_init(), + "_requirements": [], + "_is_comparable_to": _is_comparable_to, + } + + # Add operations. + num_order_functions = 0 + has_eq_function = False + + if eq is not None: + has_eq_function = True + body["__eq__"] = _make_operator("eq", eq) + body["__ne__"] = __ne__ + + if lt is not None: + num_order_functions += 1 + body["__lt__"] = _make_operator("lt", lt) + + if le is not None: + num_order_functions += 1 + body["__le__"] = _make_operator("le", le) + + if gt is not None: + num_order_functions += 1 + body["__gt__"] = _make_operator("gt", gt) + + if ge is not None: + num_order_functions += 1 + body["__ge__"] = _make_operator("ge", ge) + + type_ = types.new_class( + class_name, (object,), {}, lambda ns: ns.update(body) + ) + + # Add same type requirement. + if require_same_type: + type_._requirements.append(_check_same_type) + + # Add total ordering if at least one operation was defined. + if 0 < num_order_functions < 4: + if not has_eq_function: + # functools.total_ordering requires __eq__ to be defined, + # so raise early error here to keep a nice stack. + msg = "eq must be define is order to complete ordering from lt, le, gt, ge." + raise ValueError(msg) + type_ = functools.total_ordering(type_) + + return type_ + + +def _make_init(): + """ + Create __init__ method. + """ + + def __init__(self, value): + """ + Initialize object with *value*. + """ + self.value = value + + return __init__ + + +def _make_operator(name, func): + """ + Create operator method. + """ + + def method(self, other): + if not self._is_comparable_to(other): + return NotImplemented + + result = func(self.value, other.value) + if result is NotImplemented: + return NotImplemented + + return result + + method.__name__ = f"__{name}__" + method.__doc__ = ( + f"Return a {_operation_names[name]} b. Computed by attrs." + ) + + return method + + +def _is_comparable_to(self, other): + """ + Check whether `other` is comparable to `self`. + """ + return all(func(self, other) for func in self._requirements) + + +def _check_same_type(self, other): + """ + Return True if *self* and *other* are of the same type, False otherwise. + """ + return other.value.__class__ is self.value.__class__ diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.pyi new file mode 100644 index 00000000..cc7893b0 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_cmp.pyi @@ -0,0 +1,13 @@ +from typing import Any, Callable + +_CompareWithType = Callable[[Any, Any], bool] + +def cmp_using( + eq: _CompareWithType | None = ..., + lt: _CompareWithType | None = ..., + le: _CompareWithType | None = ..., + gt: _CompareWithType | None = ..., + ge: _CompareWithType | None = ..., + require_same_type: bool = ..., + class_name: str = ..., +) -> type: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_compat.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_compat.py new file mode 100644 index 00000000..bc68ed9e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_compat.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: MIT + +import inspect +import platform +import sys +import threading + +from collections.abc import Mapping, Sequence # noqa: F401 +from typing import _GenericAlias + + +PYPY = platform.python_implementation() == "PyPy" +PY_3_10_PLUS = sys.version_info[:2] >= (3, 10) +PY_3_11_PLUS = sys.version_info[:2] >= (3, 11) +PY_3_12_PLUS = sys.version_info[:2] >= (3, 12) +PY_3_13_PLUS = sys.version_info[:2] >= (3, 13) +PY_3_14_PLUS = sys.version_info[:2] >= (3, 14) + + +if PY_3_14_PLUS: + import annotationlib + + # We request forward-ref annotations to not break in the presence of + # forward references. + + def _get_annotations(cls): + return annotationlib.get_annotations( + cls, format=annotationlib.Format.FORWARDREF + ) + +else: + + def _get_annotations(cls): + """ + Get annotations for *cls*. + """ + return cls.__dict__.get("__annotations__", {}) + + +class _AnnotationExtractor: + """ + Extract type annotations from a callable, returning None whenever there + is none. + """ + + __slots__ = ["sig"] + + def __init__(self, callable): + try: + self.sig = inspect.signature(callable) + except (ValueError, TypeError): # inspect failed + self.sig = None + + def get_first_param_type(self): + """ + Return the type annotation of the first argument if it's not empty. + """ + if not self.sig: + return None + + params = list(self.sig.parameters.values()) + if params and params[0].annotation is not inspect.Parameter.empty: + return params[0].annotation + + return None + + def get_return_type(self): + """ + Return the return type if it's not empty. + """ + if ( + self.sig + and self.sig.return_annotation is not inspect.Signature.empty + ): + return self.sig.return_annotation + + return None + + +# Thread-local global to track attrs instances which are already being repr'd. +# This is needed because there is no other (thread-safe) way to pass info +# about the instances that are already being repr'd through the call stack +# in order to ensure we don't perform infinite recursion. +# +# For instance, if an instance contains a dict which contains that instance, +# we need to know that we're already repr'ing the outside instance from within +# the dict's repr() call. +# +# This lives here rather than in _make.py so that the functions in _make.py +# don't have a direct reference to the thread-local in their globals dict. +# If they have such a reference, it breaks cloudpickle. +repr_context = threading.local() + + +def get_generic_base(cl): + """If this is a generic class (A[str]), return the generic base for it.""" + if cl.__class__ is _GenericAlias: + return cl.__origin__ + return None diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_config.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_config.py new file mode 100644 index 00000000..4b257726 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_config.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: MIT + +__all__ = ["get_run_validators", "set_run_validators"] + +_run_validators = True + + +def set_run_validators(run): + """ + Set whether or not validators are run. By default, they are run. + + .. deprecated:: 21.3.0 It will not be removed, but it also will not be + moved to new ``attrs`` namespace. Use `attrs.validators.set_disabled()` + instead. + """ + if not isinstance(run, bool): + msg = "'run' must be bool." + raise TypeError(msg) + global _run_validators + _run_validators = run + + +def get_run_validators(): + """ + Return whether or not validators are run. + + .. deprecated:: 21.3.0 It will not be removed, but it also will not be + moved to new ``attrs`` namespace. Use `attrs.validators.get_disabled()` + instead. + """ + return _run_validators diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_funcs.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_funcs.py new file mode 100644 index 00000000..1adb5002 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_funcs.py @@ -0,0 +1,497 @@ +# SPDX-License-Identifier: MIT + + +import copy + +from ._compat import get_generic_base +from ._make import _OBJ_SETATTR, NOTHING, fields +from .exceptions import AttrsAttributeNotFoundError + + +_ATOMIC_TYPES = frozenset( + { + type(None), + bool, + int, + float, + str, + complex, + bytes, + type(...), + type, + range, + property, + } +) + + +def asdict( + inst, + recurse=True, + filter=None, + dict_factory=dict, + retain_collection_types=False, + value_serializer=None, +): + """ + Return the *attrs* attribute values of *inst* as a dict. + + Optionally recurse into other *attrs*-decorated classes. + + Args: + inst: Instance of an *attrs*-decorated class. + + recurse (bool): Recurse into classes that are also *attrs*-decorated. + + filter (~typing.Callable): + A callable whose return code determines whether an attribute or + element is included (`True`) or dropped (`False`). Is called with + the `attrs.Attribute` as the first argument and the value as the + second argument. + + dict_factory (~typing.Callable): + A callable to produce dictionaries from. For example, to produce + ordered dictionaries instead of normal Python dictionaries, pass in + ``collections.OrderedDict``. + + retain_collection_types (bool): + Do not convert to `list` when encountering an attribute whose type + is `tuple` or `set`. Only meaningful if *recurse* is `True`. + + value_serializer (typing.Callable | None): + A hook that is called for every attribute or dict key/value. It + receives the current instance, field and value and must return the + (updated) value. The hook is run *after* the optional *filter* has + been applied. + + Returns: + Return type of *dict_factory*. + + Raises: + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + .. versionadded:: 16.0.0 *dict_factory* + .. versionadded:: 16.1.0 *retain_collection_types* + .. versionadded:: 20.3.0 *value_serializer* + .. versionadded:: 21.3.0 + If a dict has a collection for a key, it is serialized as a tuple. + """ + attrs = fields(inst.__class__) + rv = dict_factory() + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + + if value_serializer is not None: + v = value_serializer(inst, a, v) + + if recurse is True: + value_type = type(v) + if value_type in _ATOMIC_TYPES: + rv[a.name] = v + elif has(value_type): + rv[a.name] = asdict( + v, + recurse=True, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ) + elif issubclass(value_type, (tuple, list, set, frozenset)): + cf = value_type if retain_collection_types is True else list + items = [ + _asdict_anything( + i, + is_key=False, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ) + for i in v + ] + try: + rv[a.name] = cf(items) + except TypeError: + if not issubclass(cf, tuple): + raise + # Workaround for TypeError: cf.__new__() missing 1 required + # positional argument (which appears, for a namedturle) + rv[a.name] = cf(*items) + elif issubclass(value_type, dict): + df = dict_factory + rv[a.name] = df( + ( + _asdict_anything( + kk, + is_key=True, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ), + _asdict_anything( + vv, + is_key=False, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ), + ) + for kk, vv in v.items() + ) + else: + rv[a.name] = v + else: + rv[a.name] = v + return rv + + +def _asdict_anything( + val, + is_key, + filter, + dict_factory, + retain_collection_types, + value_serializer, +): + """ + ``asdict`` only works on attrs instances, this works on anything. + """ + val_type = type(val) + if val_type in _ATOMIC_TYPES: + rv = val + if value_serializer is not None: + rv = value_serializer(None, None, rv) + elif getattr(val_type, "__attrs_attrs__", None) is not None: + # Attrs class. + rv = asdict( + val, + recurse=True, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ) + elif issubclass(val_type, (tuple, list, set, frozenset)): + if retain_collection_types is True: + cf = val.__class__ + elif is_key: + cf = tuple + else: + cf = list + + rv = cf( + [ + _asdict_anything( + i, + is_key=False, + filter=filter, + dict_factory=dict_factory, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ) + for i in val + ] + ) + elif issubclass(val_type, dict): + df = dict_factory + rv = df( + ( + _asdict_anything( + kk, + is_key=True, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ), + _asdict_anything( + vv, + is_key=False, + filter=filter, + dict_factory=df, + retain_collection_types=retain_collection_types, + value_serializer=value_serializer, + ), + ) + for kk, vv in val.items() + ) + else: + rv = val + if value_serializer is not None: + rv = value_serializer(None, None, rv) + + return rv + + +def astuple( + inst, + recurse=True, + filter=None, + tuple_factory=tuple, + retain_collection_types=False, +): + """ + Return the *attrs* attribute values of *inst* as a tuple. + + Optionally recurse into other *attrs*-decorated classes. + + Args: + inst: Instance of an *attrs*-decorated class. + + recurse (bool): + Recurse into classes that are also *attrs*-decorated. + + filter (~typing.Callable): + A callable whose return code determines whether an attribute or + element is included (`True`) or dropped (`False`). Is called with + the `attrs.Attribute` as the first argument and the value as the + second argument. + + tuple_factory (~typing.Callable): + A callable to produce tuples from. For example, to produce lists + instead of tuples. + + retain_collection_types (bool): + Do not convert to `list` or `dict` when encountering an attribute + which type is `tuple`, `dict` or `set`. Only meaningful if + *recurse* is `True`. + + Returns: + Return type of *tuple_factory* + + Raises: + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + .. versionadded:: 16.2.0 + """ + attrs = fields(inst.__class__) + rv = [] + retain = retain_collection_types # Very long. :/ + for a in attrs: + v = getattr(inst, a.name) + if filter is not None and not filter(a, v): + continue + value_type = type(v) + if recurse is True: + if value_type in _ATOMIC_TYPES: + rv.append(v) + elif has(value_type): + rv.append( + astuple( + v, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + ) + elif issubclass(value_type, (tuple, list, set, frozenset)): + cf = v.__class__ if retain is True else list + items = [ + ( + astuple( + j, + recurse=True, + filter=filter, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(j.__class__) + else j + ) + for j in v + ] + try: + rv.append(cf(items)) + except TypeError: + if not issubclass(cf, tuple): + raise + # Workaround for TypeError: cf.__new__() missing 1 required + # positional argument (which appears, for a namedturle) + rv.append(cf(*items)) + elif issubclass(value_type, dict): + df = value_type if retain is True else dict + rv.append( + df( + ( + ( + astuple( + kk, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(kk.__class__) + else kk + ), + ( + astuple( + vv, + tuple_factory=tuple_factory, + retain_collection_types=retain, + ) + if has(vv.__class__) + else vv + ), + ) + for kk, vv in v.items() + ) + ) + else: + rv.append(v) + else: + rv.append(v) + + return rv if tuple_factory is list else tuple_factory(rv) + + +def has(cls): + """ + Check whether *cls* is a class with *attrs* attributes. + + Args: + cls (type): Class to introspect. + + Raises: + TypeError: If *cls* is not a class. + + Returns: + bool: + """ + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is not None: + return True + + # No attrs, maybe it's a specialized generic (A[str])? + generic_base = get_generic_base(cls) + if generic_base is not None: + generic_attrs = getattr(generic_base, "__attrs_attrs__", None) + if generic_attrs is not None: + # Stick it on here for speed next time. + cls.__attrs_attrs__ = generic_attrs + return generic_attrs is not None + return False + + +def assoc(inst, **changes): + """ + Copy *inst* and apply *changes*. + + This is different from `evolve` that applies the changes to the arguments + that create the new instance. + + `evolve`'s behavior is preferable, but there are `edge cases`_ where it + doesn't work. Therefore `assoc` is deprecated, but will not be removed. + + .. _`edge cases`: https://github.com/python-attrs/attrs/issues/251 + + Args: + inst: Instance of a class with *attrs* attributes. + + changes: Keyword changes in the new copy. + + Returns: + A copy of inst with *changes* incorporated. + + Raises: + attrs.exceptions.AttrsAttributeNotFoundError: + If *attr_name* couldn't be found on *cls*. + + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + .. deprecated:: 17.1.0 + Use `attrs.evolve` instead if you can. This function will not be + removed du to the slightly different approach compared to + `attrs.evolve`, though. + """ + new = copy.copy(inst) + attrs = fields(inst.__class__) + for k, v in changes.items(): + a = getattr(attrs, k, NOTHING) + if a is NOTHING: + msg = f"{k} is not an attrs attribute on {new.__class__}." + raise AttrsAttributeNotFoundError(msg) + _OBJ_SETATTR(new, k, v) + return new + + +def resolve_types( + cls, globalns=None, localns=None, attribs=None, include_extras=True +): + """ + Resolve any strings and forward annotations in type annotations. + + This is only required if you need concrete types in :class:`Attribute`'s + *type* field. In other words, you don't need to resolve your types if you + only use them for static type checking. + + With no arguments, names will be looked up in the module in which the class + was created. If this is not what you want, for example, if the name only + exists inside a method, you may pass *globalns* or *localns* to specify + other dictionaries in which to look up these names. See the docs of + `typing.get_type_hints` for more details. + + Args: + cls (type): Class to resolve. + + globalns (dict | None): Dictionary containing global variables. + + localns (dict | None): Dictionary containing local variables. + + attribs (list | None): + List of attribs for the given class. This is necessary when calling + from inside a ``field_transformer`` since *cls* is not an *attrs* + class yet. + + include_extras (bool): + Resolve more accurately, if possible. Pass ``include_extras`` to + ``typing.get_hints``, if supported by the typing module. On + supported Python versions (3.9+), this resolves the types more + accurately. + + Raises: + TypeError: If *cls* is not a class. + + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class and you didn't pass any attribs. + + NameError: If types cannot be resolved because of missing variables. + + Returns: + *cls* so you can use this function also as a class decorator. Please + note that you have to apply it **after** `attrs.define`. That means the + decorator has to come in the line **before** `attrs.define`. + + .. versionadded:: 20.1.0 + .. versionadded:: 21.1.0 *attribs* + .. versionadded:: 23.1.0 *include_extras* + """ + # Since calling get_type_hints is expensive we cache whether we've + # done it already. + if getattr(cls, "__attrs_types_resolved__", None) != cls: + import typing + + kwargs = { + "globalns": globalns, + "localns": localns, + "include_extras": include_extras, + } + + hints = typing.get_type_hints(cls, **kwargs) + for field in fields(cls) if attribs is None else attribs: + if field.name in hints: + # Since fields have been frozen we must work around it. + _OBJ_SETATTR(field, "type", hints[field.name]) + # We store the class we resolved so that subclasses know they haven't + # been resolved. + cls.__attrs_types_resolved__ = cls + + # Return the class so you can use it as a decorator too. + return cls diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_make.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_make.py new file mode 100644 index 00000000..4b32d6a7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_make.py @@ -0,0 +1,3406 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import abc +import contextlib +import copy +import enum +import inspect +import itertools +import linecache +import sys +import types +import unicodedata +import weakref + +from collections.abc import Callable, Mapping +from functools import cached_property +from typing import Any, NamedTuple, TypeVar + +# We need to import _compat itself in addition to the _compat members to avoid +# having the thread-local in the globals here. +from . import _compat, _config, setters +from ._compat import ( + PY_3_10_PLUS, + PY_3_11_PLUS, + PY_3_13_PLUS, + _AnnotationExtractor, + _get_annotations, + get_generic_base, +) +from .exceptions import ( + DefaultAlreadySetError, + FrozenInstanceError, + NotAnAttrsClassError, + UnannotatedAttributeError, +) + + +# This is used at least twice, so cache it here. +_OBJ_SETATTR = object.__setattr__ +_INIT_FACTORY_PAT = "__attr_factory_%s" +_CLASSVAR_PREFIXES = ( + "typing.ClassVar", + "t.ClassVar", + "ClassVar", + "typing_extensions.ClassVar", +) +# we don't use a double-underscore prefix because that triggers +# name mangling when trying to create a slot for the field +# (when slots=True) +_HASH_CACHE_FIELD = "_attrs_cached_hash" + +_EMPTY_METADATA_SINGLETON = types.MappingProxyType({}) + +# Unique object for unequivocal getattr() defaults. +_SENTINEL = object() + +_DEFAULT_ON_SETATTR = setters.pipe(setters.convert, setters.validate) + + +class _Nothing(enum.Enum): + """ + Sentinel to indicate the lack of a value when `None` is ambiguous. + + If extending attrs, you can use ``typing.Literal[NOTHING]`` to show + that a value may be ``NOTHING``. + + .. versionchanged:: 21.1.0 ``bool(NOTHING)`` is now False. + .. versionchanged:: 22.2.0 ``NOTHING`` is now an ``enum.Enum`` variant. + """ + + NOTHING = enum.auto() + + def __repr__(self): + return "NOTHING" + + def __bool__(self): + return False + + +NOTHING = _Nothing.NOTHING +""" +Sentinel to indicate the lack of a value when `None` is ambiguous. + +When using in 3rd party code, use `attrs.NothingType` for type annotations. +""" + + +class _CacheHashWrapper(int): + """ + An integer subclass that pickles / copies as None + + This is used for non-slots classes with ``cache_hash=True``, to avoid + serializing a potentially (even likely) invalid hash value. Since `None` + is the default value for uncalculated hashes, whenever this is copied, + the copy's value for the hash should automatically reset. + + See GH #613 for more details. + """ + + def __reduce__(self, _none_constructor=type(None), _args=()): # noqa: B008 + return _none_constructor, _args + + +def attrib( + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=None, + init=True, + metadata=None, + type=None, + converter=None, + factory=None, + kw_only=None, + eq=None, + order=None, + on_setattr=None, + alias=None, +): + """ + Create a new field / attribute on a class. + + Identical to `attrs.field`, except it's not keyword-only. + + Consider using `attrs.field` in new code (``attr.ib`` will *never* go away, + though). + + .. warning:: + + Does **nothing** unless the class is also decorated with + `attr.s` (or similar)! + + + .. versionadded:: 15.2.0 *convert* + .. versionadded:: 16.3.0 *metadata* + .. versionchanged:: 17.1.0 *validator* can be a ``list`` now. + .. versionchanged:: 17.1.0 + *hash* is `None` and therefore mirrors *eq* by default. + .. versionadded:: 17.3.0 *type* + .. deprecated:: 17.4.0 *convert* + .. versionadded:: 17.4.0 + *converter* as a replacement for the deprecated *convert* to achieve + consistency with other noun-based arguments. + .. versionadded:: 18.1.0 + ``factory=f`` is syntactic sugar for ``default=attr.Factory(f)``. + .. versionadded:: 18.2.0 *kw_only* + .. versionchanged:: 19.2.0 *convert* keyword argument removed. + .. versionchanged:: 19.2.0 *repr* also accepts a custom callable. + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.3.0 *kw_only* backported to Python 2 + .. versionchanged:: 21.1.0 + *eq*, *order*, and *cmp* also accept a custom callable + .. versionchanged:: 21.1.0 *cmp* undeprecated + .. versionadded:: 22.2.0 *alias* + .. versionchanged:: 25.4.0 + *kw_only* can now be None, and its default is also changed from False to + None. + """ + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq, order, True + ) + + if hash is not None and hash is not True and hash is not False: + msg = "Invalid value for hash. Must be True, False, or None." + raise TypeError(msg) + + if factory is not None: + if default is not NOTHING: + msg = ( + "The `default` and `factory` arguments are mutually exclusive." + ) + raise ValueError(msg) + if not callable(factory): + msg = "The `factory` argument must be a callable." + raise ValueError(msg) + default = Factory(factory) + + if metadata is None: + metadata = {} + + # Apply syntactic sugar by auto-wrapping. + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + if validator and isinstance(validator, (list, tuple)): + validator = and_(*validator) + + if converter and isinstance(converter, (list, tuple)): + converter = pipe(*converter) + + return _CountingAttr( + default=default, + validator=validator, + repr=repr, + cmp=None, + hash=hash, + init=init, + converter=converter, + metadata=metadata, + type=type, + kw_only=kw_only, + eq=eq, + eq_key=eq_key, + order=order, + order_key=order_key, + on_setattr=on_setattr, + alias=alias, + ) + + +def _compile_and_eval( + script: str, + globs: dict[str, Any] | None, + locs: Mapping[str, object] | None = None, + filename: str = "", +) -> None: + """ + Evaluate the script with the given global (globs) and local (locs) + variables. + """ + bytecode = compile(script, filename, "exec") + eval(bytecode, globs, locs) + + +def _linecache_and_compile( + script: str, + filename: str, + globs: dict[str, Any] | None, + locals: Mapping[str, object] | None = None, +) -> dict[str, Any]: + """ + Cache the script with _linecache_, compile it and return the _locals_. + """ + + locs = {} if locals is None else locals + + # In order of debuggers like PDB being able to step through the code, + # we add a fake linecache entry. + count = 1 + base_filename = filename + while True: + linecache_tuple = ( + len(script), + None, + script.splitlines(True), + filename, + ) + old_val = linecache.cache.setdefault(filename, linecache_tuple) + if old_val == linecache_tuple: + break + + filename = f"{base_filename[:-1]}-{count}>" + count += 1 + + _compile_and_eval(script, globs, locs, filename) + + return locs + + +def _make_attr_tuple_class(cls_name: str, attr_names: list[str]) -> type: + """ + Create a tuple subclass to hold `Attribute`s for an `attrs` class. + + The subclass is a bare tuple with properties for names. + + class MyClassAttributes(tuple): + __slots__ = () + x = property(itemgetter(0)) + """ + attr_class_name = f"{cls_name}Attributes" + body = {} + for i, attr_name in enumerate(attr_names): + + def getter(self, i=i): + return self[i] + + body[attr_name] = property(getter) + return type(attr_class_name, (tuple,), body) + + +# Tuple class for extracted attributes from a class definition. +# `base_attrs` is a subset of `attrs`. +class _Attributes(NamedTuple): + attrs: type + base_attrs: list[Attribute] + base_attrs_map: dict[str, type] + + +def _is_class_var(annot): + """ + Check whether *annot* is a typing.ClassVar. + + The string comparison hack is used to avoid evaluating all string + annotations which would put attrs-based classes at a performance + disadvantage compared to plain old classes. + """ + annot = str(annot) + + # Annotation can be quoted. + if annot.startswith(("'", '"')) and annot.endswith(("'", '"')): + annot = annot[1:-1] + + return annot.startswith(_CLASSVAR_PREFIXES) + + +def _has_own_attribute(cls, attrib_name): + """ + Check whether *cls* defines *attrib_name* (and doesn't just inherit it). + """ + return attrib_name in cls.__dict__ + + +def _collect_base_attrs( + cls, taken_attr_names +) -> tuple[list[Attribute], dict[str, type]]: + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in reversed(cls.__mro__[1:-1]): + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.inherited or a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) # noqa: PLW2901 + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + # For each name, only keep the freshest definition i.e. the furthest at the + # back. base_attr_map is fine because it gets overwritten with every new + # instance. + filtered = [] + seen = set() + for a in reversed(base_attrs): + if a.name in seen: + continue + filtered.insert(0, a) + seen.add(a.name) + + return filtered, base_attr_map + + +def _collect_base_attrs_broken(cls, taken_attr_names): + """ + Collect attr.ibs from base classes of *cls*, except *taken_attr_names*. + + N.B. *taken_attr_names* will be mutated. + + Adhere to the old incorrect behavior. + + Notably it collects from the front and considers inherited attributes which + leads to the buggy behavior reported in #428. + """ + base_attrs = [] + base_attr_map = {} # A dictionary of base attrs to their classes. + + # Traverse the MRO and collect attributes. + for base_cls in cls.__mro__[1:-1]: + for a in getattr(base_cls, "__attrs_attrs__", []): + if a.name in taken_attr_names: + continue + + a = a.evolve(inherited=True) # noqa: PLW2901 + taken_attr_names.add(a.name) + base_attrs.append(a) + base_attr_map[a.name] = base_cls + + return base_attrs, base_attr_map + + +def _transform_attrs( + cls, + these, + auto_attribs, + kw_only, + collect_by_mro, + field_transformer, +) -> _Attributes: + """ + Transform all `_CountingAttr`s on a class into `Attribute`s. + + If *these* is passed, use that and don't look for them on the class. + + If *collect_by_mro* is True, collect them in the correct MRO order, + otherwise use the old -- incorrect -- order. See #428. + + Return an `_Attributes`. + """ + cd = cls.__dict__ + anns = _get_annotations(cls) + + if these is not None: + ca_list = list(these.items()) + elif auto_attribs is True: + ca_names = { + name + for name, attr in cd.items() + if attr.__class__ is _CountingAttr + } + ca_list = [] + annot_names = set() + for attr_name, type in anns.items(): + if _is_class_var(type): + continue + annot_names.add(attr_name) + a = cd.get(attr_name, NOTHING) + + if a.__class__ is not _CountingAttr: + a = attrib(a) + ca_list.append((attr_name, a)) + + unannotated = ca_names - annot_names + if unannotated: + raise UnannotatedAttributeError( + "The following `attr.ib`s lack a type annotation: " + + ", ".join( + sorted(unannotated, key=lambda n: cd.get(n).counter) + ) + + "." + ) + else: + ca_list = sorted( + ( + (name, attr) + for name, attr in cd.items() + if attr.__class__ is _CountingAttr + ), + key=lambda e: e[1].counter, + ) + + fca = Attribute.from_counting_attr + no = ClassProps.KeywordOnly.NO + own_attrs = [ + fca( + attr_name, + ca, + kw_only is not no, + anns.get(attr_name), + ) + for attr_name, ca in ca_list + ] + + if collect_by_mro: + base_attrs, base_attr_map = _collect_base_attrs( + cls, {a.name for a in own_attrs} + ) + else: + base_attrs, base_attr_map = _collect_base_attrs_broken( + cls, {a.name for a in own_attrs} + ) + + if kw_only is ClassProps.KeywordOnly.FORCE: + own_attrs = [a.evolve(kw_only=True) for a in own_attrs] + base_attrs = [a.evolve(kw_only=True) for a in base_attrs] + + attrs = base_attrs + own_attrs + + # Resolve default field alias before executing field_transformer, so that + # the transformer receives fully populated Attribute objects with usable + # alias values. + for a in attrs: + if not a.alias: + # Evolve is very slow, so we hold our nose and do it dirty. + _OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name)) + _OBJ_SETATTR.__get__(a)("alias_is_default", True) + + if field_transformer is not None: + attrs = tuple(field_transformer(cls, attrs)) + + # Check attr order after executing the field_transformer. + # Mandatory vs non-mandatory attr order only matters when they are part of + # the __init__ signature and when they aren't kw_only (which are moved to + # the end and can be mandatory or non-mandatory in any order, as they will + # be specified as keyword args anyway). Check the order of those attrs: + had_default = False + for a in (a for a in attrs if a.init is not False and a.kw_only is False): + if had_default is True and a.default is NOTHING: + msg = f"No mandatory attributes allowed after an attribute with a default value or factory. Attribute in question: {a!r}" + raise ValueError(msg) + + if had_default is False and a.default is not NOTHING: + had_default = True + + # Resolve default field alias for any new attributes that the + # field_transformer may have added without setting an alias. + for a in attrs: + if not a.alias: + _OBJ_SETATTR.__get__(a)("alias", _default_init_alias_for(a.name)) + _OBJ_SETATTR.__get__(a)("alias_is_default", True) + + # Create AttrsClass *after* applying the field_transformer since it may + # add or remove attributes! + attr_names = [a.name for a in attrs] + AttrsClass = _make_attr_tuple_class(cls.__name__, attr_names) + + return _Attributes(AttrsClass(attrs), base_attrs, base_attr_map) + + +def _make_cached_property_getattr(cached_properties, original_getattr, cls): + lines = [ + # Wrapped to get `__class__` into closure cell for super() + # (It will be replaced with the newly constructed class after construction). + "def wrapper(_cls):", + " __class__ = _cls", + " def __getattr__(self, item, cached_properties=cached_properties, original_getattr=original_getattr, _cached_setattr_get=_cached_setattr_get):", + " func = cached_properties.get(item)", + " if func is not None:", + " result = func(self)", + " _setter = _cached_setattr_get(self)", + " _setter(item, result)", + " return result", + ] + if original_getattr is not None: + lines.append( + " return original_getattr(self, item)", + ) + else: + lines.extend( + [ + " try:", + " return super().__getattribute__(item)", + " except AttributeError:", + " if not hasattr(super(), '__getattr__'):", + " raise", + " return super().__getattr__(item)", + " original_error = f\"'{self.__class__.__name__}' object has no attribute '{item}'\"", + " raise AttributeError(original_error)", + ] + ) + + lines.extend( + [ + " return __getattr__", + "__getattr__ = wrapper(_cls)", + ] + ) + + unique_filename = _generate_unique_filename(cls, "getattr") + + glob = { + "cached_properties": cached_properties, + "_cached_setattr_get": _OBJ_SETATTR.__get__, + "original_getattr": original_getattr, + } + + return _linecache_and_compile( + "\n".join(lines), unique_filename, glob, locals={"_cls": cls} + )["__getattr__"] + + +def _frozen_setattrs(self, name, value): + """ + Attached to frozen classes as __setattr__. + """ + if isinstance(self, BaseException) and name in ( + "__cause__", + "__context__", + "__traceback__", + "__suppress_context__", + "__notes__", + ): + BaseException.__setattr__(self, name, value) + return + + raise FrozenInstanceError + + +def _frozen_delattrs(self, name): + """ + Attached to frozen classes as __delattr__. + """ + if isinstance(self, BaseException) and name == "__notes__": + BaseException.__delattr__(self, name) + return + + raise FrozenInstanceError + + +def evolve(*args, **changes): + """ + Create a new instance, based on the first positional argument with + *changes* applied. + + .. tip:: + + On Python 3.13 and later, you can also use `copy.replace` instead. + + Args: + + inst: + Instance of a class with *attrs* attributes. *inst* must be passed + as a positional argument. + + changes: + Keyword changes in the new copy. + + Returns: + A copy of inst with *changes* incorporated. + + Raises: + TypeError: + If *attr_name* couldn't be found in the class ``__init__``. + + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + .. versionadded:: 17.1.0 + .. deprecated:: 23.1.0 + It is now deprecated to pass the instance using the keyword argument + *inst*. It will raise a warning until at least April 2024, after which + it will become an error. Always pass the instance as a positional + argument. + .. versionchanged:: 24.1.0 + *inst* can't be passed as a keyword argument anymore. + """ + try: + (inst,) = args + except ValueError: + msg = ( + f"evolve() takes 1 positional argument, but {len(args)} were given" + ) + raise TypeError(msg) from None + + cls = inst.__class__ + attrs = fields(cls) + for a in attrs: + if not a.init: + continue + attr_name = a.name # To deal with private attributes. + init_name = a.alias + if init_name not in changes: + changes[init_name] = getattr(inst, attr_name) + + return cls(**changes) + + +class _ClassBuilder: + """ + Iteratively build *one* class. + """ + + __slots__ = ( + "_add_method_dunders", + "_attr_names", + "_attrs", + "_base_attr_map", + "_base_names", + "_cache_hash", + "_cls", + "_cls_dict", + "_delete_attribs", + "_frozen", + "_has_custom_setattr", + "_has_post_init", + "_has_pre_init", + "_is_exc", + "_on_setattr", + "_pre_init_has_args", + "_repr_added", + "_script_snippets", + "_slots", + "_weakref_slot", + "_wrote_own_setattr", + ) + + def __init__( + self, + cls: type, + these, + auto_attribs: bool, + props: ClassProps, + has_custom_setattr: bool, + ): + attrs, base_attrs, base_map = _transform_attrs( + cls, + these, + auto_attribs, + props.kw_only, + props.collected_fields_by_mro, + props.field_transformer, + ) + + self._cls = cls + self._cls_dict = dict(cls.__dict__) if props.is_slotted else {} + self._attrs = attrs + self._base_names = {a.name for a in base_attrs} + self._base_attr_map = base_map + self._attr_names = tuple(a.name for a in attrs) + self._slots = props.is_slotted + self._frozen = props.is_frozen + self._weakref_slot = props.has_weakref_slot + self._cache_hash = ( + props.hashability is ClassProps.Hashability.HASHABLE_CACHED + ) + self._has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) + self._pre_init_has_args = False + if self._has_pre_init: + # Check if the pre init method has more arguments than just `self` + # We want to pass arguments if pre init expects arguments + pre_init_func = cls.__attrs_pre_init__ + pre_init_signature = inspect.signature(pre_init_func) + self._pre_init_has_args = len(pre_init_signature.parameters) > 1 + self._has_post_init = bool(getattr(cls, "__attrs_post_init__", False)) + self._delete_attribs = not bool(these) + self._is_exc = props.is_exception + self._on_setattr = props.on_setattr_hook + + self._has_custom_setattr = has_custom_setattr + self._wrote_own_setattr = False + + self._cls_dict["__attrs_attrs__"] = self._attrs + self._cls_dict["__attrs_props__"] = props + + if props.is_frozen: + self._cls_dict["__setattr__"] = _frozen_setattrs + self._cls_dict["__delattr__"] = _frozen_delattrs + + self._wrote_own_setattr = True + elif self._on_setattr in ( + _DEFAULT_ON_SETATTR, + setters.validate, + setters.convert, + ): + has_validator = has_converter = False + for a in attrs: + if a.validator is not None: + has_validator = True + if a.converter is not None: + has_converter = True + + if has_validator and has_converter: + break + if ( + ( + self._on_setattr == _DEFAULT_ON_SETATTR + and not (has_validator or has_converter) + ) + or (self._on_setattr == setters.validate and not has_validator) + or (self._on_setattr == setters.convert and not has_converter) + ): + # If class-level on_setattr is set to convert + validate, but + # there's no field to convert or validate, pretend like there's + # no on_setattr. + self._on_setattr = None + + if props.added_pickling: + ( + self._cls_dict["__getstate__"], + self._cls_dict["__setstate__"], + ) = self._make_getstate_setstate() + + # tuples of script, globs, hook + self._script_snippets: list[ + tuple[str, dict, Callable[[dict, dict], Any]] + ] = [] + self._repr_added = False + + # We want to only do this check once; in 99.9% of cases these + # exist. + if not hasattr(self._cls, "__module__") or not hasattr( + self._cls, "__qualname__" + ): + self._add_method_dunders = self._add_method_dunders_safe + else: + self._add_method_dunders = self._add_method_dunders_unsafe + + def __repr__(self): + return f"<_ClassBuilder(cls={self._cls.__name__})>" + + def _eval_snippets(self) -> None: + """ + Evaluate any registered snippets in one go. + """ + script = "\n".join([snippet[0] for snippet in self._script_snippets]) + globs = {} + for _, snippet_globs, _ in self._script_snippets: + globs.update(snippet_globs) + + locs = _linecache_and_compile( + script, + _generate_unique_filename(self._cls, "methods"), + globs, + ) + + for _, _, hook in self._script_snippets: + hook(self._cls_dict, locs) + + def build_class(self): + """ + Finalize class based on the accumulated configuration. + + Builder cannot be used after calling this method. + """ + self._eval_snippets() + if self._slots is True: + cls = self._create_slots_class() + self._cls.__attrs_base_of_slotted__ = weakref.ref(cls) + else: + cls = self._patch_original_class() + if PY_3_10_PLUS: + cls = abc.update_abstractmethods(cls) + + # The method gets only called if it's not inherited from a base class. + # _has_own_attribute does NOT work properly for classmethods. + if ( + getattr(cls, "__attrs_init_subclass__", None) + and "__attrs_init_subclass__" not in cls.__dict__ + ): + cls.__attrs_init_subclass__() + + return cls + + def _patch_original_class(self): + """ + Apply accumulated methods and return the class. + """ + cls = self._cls + base_names = self._base_names + + # Clean class of attribute definitions (`attr.ib()`s). + if self._delete_attribs: + for name in self._attr_names: + if ( + name not in base_names + and getattr(cls, name, _SENTINEL) is not _SENTINEL + ): + # An AttributeError can happen if a base class defines a + # class variable and we want to set an attribute with the + # same name by using only a type annotation. + with contextlib.suppress(AttributeError): + delattr(cls, name) + + # Attach our dunder methods. + for name, value in self._cls_dict.items(): + setattr(cls, name, value) + + # If we've inherited an attrs __setattr__ and don't write our own, + # reset it to object's. + if not self._wrote_own_setattr and getattr( + cls, "__attrs_own_setattr__", False + ): + cls.__attrs_own_setattr__ = False + + if not self._has_custom_setattr: + cls.__setattr__ = _OBJ_SETATTR + + return cls + + def _create_slots_class(self): + """ + Build and return a new class with a `__slots__` attribute. + """ + cd = { + k: v + for k, v in self._cls_dict.items() + if k not in (*tuple(self._attr_names), "__dict__", "__weakref__") + } + + # 3.14.0rc2+ + if hasattr(sys, "_clear_type_descriptors"): + sys._clear_type_descriptors(self._cls) + + # If our class doesn't have its own implementation of __setattr__ + # (either from the user or by us), check the bases, if one of them has + # an attrs-made __setattr__, that needs to be reset. We don't walk the + # MRO because we only care about our immediate base classes. + # XXX: This can be confused by subclassing a slotted attrs class with + # XXX: a non-attrs class and subclass the resulting class with an attrs + # XXX: class. See `test_slotted_confused` for details. For now that's + # XXX: OK with us. + if not self._wrote_own_setattr: + cd["__attrs_own_setattr__"] = False + + if not self._has_custom_setattr: + for base_cls in self._cls.__bases__: + if base_cls.__dict__.get("__attrs_own_setattr__", False): + cd["__setattr__"] = _OBJ_SETATTR + break + + # Traverse the MRO to collect existing slots + # and check for an existing __weakref__. + existing_slots = {} + weakref_inherited = False + for base_cls in self._cls.__mro__[1:-1]: + if base_cls.__dict__.get("__weakref__", None) is not None: + weakref_inherited = True + existing_slots.update( + { + name: getattr(base_cls, name) + for name in getattr(base_cls, "__slots__", []) + } + ) + + base_names = set(self._base_names) + + names = self._attr_names + if ( + self._weakref_slot + and "__weakref__" not in getattr(self._cls, "__slots__", ()) + and "__weakref__" not in names + and not weakref_inherited + ): + names += ("__weakref__",) + + cached_properties = { + name: cached_prop.func + for name, cached_prop in cd.items() + if isinstance(cached_prop, cached_property) + } + + # Collect methods with a `__class__` reference that are shadowed in the new class. + # To know to update them. + additional_closure_functions_to_update = [] + if cached_properties: + class_annotations = _get_annotations(self._cls) + for name, func in cached_properties.items(): + # Add cached properties to names for slotting. + names += (name,) + # Clear out function from class to avoid clashing. + del cd[name] + additional_closure_functions_to_update.append(func) + annotation = inspect.signature(func).return_annotation + if annotation is not inspect.Parameter.empty: + class_annotations[name] = annotation + + original_getattr = cd.get("__getattr__") + if original_getattr is not None: + additional_closure_functions_to_update.append(original_getattr) + + cd["__getattr__"] = _make_cached_property_getattr( + cached_properties, original_getattr, self._cls + ) + + # We only add the names of attributes that aren't inherited. + # Setting __slots__ to inherited attributes wastes memory. + slot_names = [name for name in names if name not in base_names] + + # There are slots for attributes from current class + # that are defined in parent classes. + # As their descriptors may be overridden by a child class, + # we collect them here and update the class dict + reused_slots = { + slot: slot_descriptor + for slot, slot_descriptor in existing_slots.items() + if slot in slot_names + } + slot_names = [name for name in slot_names if name not in reused_slots] + cd.update(reused_slots) + if self._cache_hash: + slot_names.append(_HASH_CACHE_FIELD) + + cd["__slots__"] = tuple(slot_names) + + cd["__qualname__"] = self._cls.__qualname__ + + # Create new class based on old class and our methods. + cls = type(self._cls)(self._cls.__name__, self._cls.__bases__, cd) + + # The following is a fix for + # . + # If a method mentions `__class__` or uses the no-arg super(), the + # compiler will bake a reference to the class in the method itself + # as `method.__closure__`. Since we replace the class with a + # clone, we rewrite these references so it keeps working. + for item in itertools.chain( + cls.__dict__.values(), additional_closure_functions_to_update + ): + if isinstance(item, (classmethod, staticmethod)): + # Class- and staticmethods hide their functions inside. + # These might need to be rewritten as well. + closure_cells = getattr(item.__func__, "__closure__", None) + elif isinstance(item, property): + # Workaround for property `super()` shortcut (PY3-only). + # There is no universal way for other descriptors. + closure_cells = getattr(item.fget, "__closure__", None) + else: + closure_cells = getattr(item, "__closure__", None) + + if not closure_cells: # Catch None or the empty list. + continue + for cell in closure_cells: + try: + match = cell.cell_contents is self._cls + except ValueError: # noqa: PERF203 + # ValueError: Cell is empty + pass + else: + if match: + cell.cell_contents = cls + return cls + + def add_repr(self, ns): + script, globs = _make_repr_script(self._attrs, ns) + + def _attach_repr(cls_dict, globs): + cls_dict["__repr__"] = self._add_method_dunders(globs["__repr__"]) + + self._script_snippets.append((script, globs, _attach_repr)) + self._repr_added = True + return self + + def add_str(self): + if not self._repr_added: + msg = "__str__ can only be generated if a __repr__ exists." + raise ValueError(msg) + + def __str__(self): + return self.__repr__() + + self._cls_dict["__str__"] = self._add_method_dunders(__str__) + return self + + def _make_getstate_setstate(self): + """ + Create custom __setstate__ and __getstate__ methods. + """ + # __weakref__ is not writable. + state_attr_names = tuple( + an for an in self._attr_names if an != "__weakref__" + ) + + def slots_getstate(self): + """ + Automatically created by attrs. + """ + return {name: getattr(self, name) for name in state_attr_names} + + hash_caching_enabled = self._cache_hash + + def slots_setstate(self, state): + """ + Automatically created by attrs. + """ + __bound_setattr = _OBJ_SETATTR.__get__(self) + if isinstance(state, tuple): + # Backward compatibility with attrs instances pickled with + # attrs versions before v22.2.0 which stored tuples. + for name, value in zip(state_attr_names, state): + __bound_setattr(name, value) + else: + for name in state_attr_names: + if name in state: + __bound_setattr(name, state[name]) + + # The hash code cache is not included when the object is + # serialized, but it still needs to be initialized to None to + # indicate that the first call to __hash__ should be a cache + # miss. + if hash_caching_enabled: + __bound_setattr(_HASH_CACHE_FIELD, None) + + return slots_getstate, slots_setstate + + def make_unhashable(self): + self._cls_dict["__hash__"] = None + return self + + def add_hash(self): + script, globs = _make_hash_script( + self._cls, + self._attrs, + frozen=self._frozen, + cache_hash=self._cache_hash, + ) + + def attach_hash(cls_dict: dict, locs: dict) -> None: + cls_dict["__hash__"] = self._add_method_dunders(locs["__hash__"]) + + self._script_snippets.append((script, globs, attach_hash)) + + return self + + def add_init(self): + script, globs, annotations = _make_init_script( + self._cls, + self._attrs, + self._has_pre_init, + self._pre_init_has_args, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr, + attrs_init=False, + ) + + def _attach_init(cls_dict, globs): + init = globs["__init__"] + init.__annotations__ = annotations + cls_dict["__init__"] = self._add_method_dunders(init) + + self._script_snippets.append((script, globs, _attach_init)) + + return self + + def add_replace(self): + self._cls_dict["__replace__"] = self._add_method_dunders(evolve) + return self + + def add_match_args(self): + self._cls_dict["__match_args__"] = tuple( + field.name + for field in self._attrs + if field.init and not field.kw_only + ) + + def add_attrs_init(self): + script, globs, annotations = _make_init_script( + self._cls, + self._attrs, + self._has_pre_init, + self._pre_init_has_args, + self._has_post_init, + self._frozen, + self._slots, + self._cache_hash, + self._base_attr_map, + self._is_exc, + self._on_setattr, + attrs_init=True, + ) + + def _attach_attrs_init(cls_dict, globs): + init = globs["__attrs_init__"] + init.__annotations__ = annotations + cls_dict["__attrs_init__"] = self._add_method_dunders(init) + + self._script_snippets.append((script, globs, _attach_attrs_init)) + + return self + + def add_eq(self): + cd = self._cls_dict + + script, globs = _make_eq_script(self._attrs) + + def _attach_eq(cls_dict, globs): + cls_dict["__eq__"] = self._add_method_dunders(globs["__eq__"]) + + self._script_snippets.append((script, globs, _attach_eq)) + + cd["__ne__"] = __ne__ + + return self + + def add_order(self): + cd = self._cls_dict + + cd["__lt__"], cd["__le__"], cd["__gt__"], cd["__ge__"] = ( + self._add_method_dunders(meth) + for meth in _make_order(self._cls, self._attrs) + ) + + return self + + def add_setattr(self): + sa_attrs = {} + for a in self._attrs: + on_setattr = a.on_setattr or self._on_setattr + if on_setattr and on_setattr is not setters.NO_OP: + sa_attrs[a.name] = a, on_setattr + + if not sa_attrs: + return self + + if self._has_custom_setattr: + # We need to write a __setattr__ but there already is one! + msg = "Can't combine custom __setattr__ with on_setattr hooks." + raise ValueError(msg) + + # docstring comes from _add_method_dunders + def __setattr__(self, name, val): + try: + a, hook = sa_attrs[name] + except KeyError: + nval = val + else: + nval = hook(self, a, val) + + _OBJ_SETATTR(self, name, nval) + + self._cls_dict["__attrs_own_setattr__"] = True + self._cls_dict["__setattr__"] = self._add_method_dunders(__setattr__) + self._wrote_own_setattr = True + + return self + + def _add_method_dunders_unsafe(self, method: Callable) -> Callable: + """ + Add __module__ and __qualname__ to a *method*. + """ + method.__module__ = self._cls.__module__ + + method.__qualname__ = f"{self._cls.__qualname__}.{method.__name__}" + + method.__doc__ = ( + f"Method generated by attrs for class {self._cls.__qualname__}." + ) + + return method + + def _add_method_dunders_safe(self, method: Callable) -> Callable: + """ + Add __module__ and __qualname__ to a *method* if possible. + """ + with contextlib.suppress(AttributeError): + method.__module__ = self._cls.__module__ + + with contextlib.suppress(AttributeError): + method.__qualname__ = f"{self._cls.__qualname__}.{method.__name__}" + + with contextlib.suppress(AttributeError): + method.__doc__ = f"Method generated by attrs for class {self._cls.__qualname__}." + + return method + + +def _determine_attrs_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + msg = "Don't mix `cmp` with `eq' and `order`." + raise ValueError(msg) + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + return cmp, cmp + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq = default_eq + + if order is None: + order = eq + + if eq is False and order is True: + msg = "`order` can only be True if `eq` is True too." + raise ValueError(msg) + + return eq, order + + +def _determine_attrib_eq_order(cmp, eq, order, default_eq): + """ + Validate the combination of *cmp*, *eq*, and *order*. Derive the effective + values of eq and order. If *eq* is None, set it to *default_eq*. + """ + if cmp is not None and any((eq is not None, order is not None)): + msg = "Don't mix `cmp` with `eq' and `order`." + raise ValueError(msg) + + def decide_callable_or_boolean(value): + """ + Decide whether a key function is used. + """ + if callable(value): + value, key = True, value + else: + key = None + return value, key + + # cmp takes precedence due to bw-compatibility. + if cmp is not None: + cmp, cmp_key = decide_callable_or_boolean(cmp) + return cmp, cmp_key, cmp, cmp_key + + # If left None, equality is set to the specified default and ordering + # mirrors equality. + if eq is None: + eq, eq_key = default_eq, None + else: + eq, eq_key = decide_callable_or_boolean(eq) + + if order is None: + order, order_key = eq, eq_key + else: + order, order_key = decide_callable_or_boolean(order) + + if eq is False and order is True: + msg = "`order` can only be True if `eq` is True too." + raise ValueError(msg) + + return eq, eq_key, order, order_key + + +def _determine_whether_to_implement( + cls, flag, auto_detect, dunders, default=True +): + """ + Check whether we should implement a set of methods for *cls*. + + *flag* is the argument passed into @attr.s like 'init', *auto_detect* the + same as passed into @attr.s and *dunders* is a tuple of attribute names + whose presence signal that the user has implemented it themselves. + + Return *default* if no reason for either for or against is found. + """ + if flag is True or flag is False: + return flag + + if flag is None and auto_detect is False: + return default + + # Logically, flag is None and auto_detect is True here. + for dunder in dunders: + if _has_own_attribute(cls, dunder): + return False + + return default + + +def attrs( + maybe_cls=None, + these=None, + repr_ns=None, + repr=None, + cmp=None, + hash=None, + init=None, + slots=False, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=False, + kw_only=False, + cache_hash=False, + auto_exc=False, + eq=None, + order=None, + auto_detect=False, + collect_by_mro=False, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, + match_args=True, + unsafe_hash=None, + force_kw_only=True, +): + r""" + A class decorator that adds :term:`dunder methods` according to the + specified attributes using `attr.ib` or the *these* argument. + + Consider using `attrs.define` / `attrs.frozen` in new code (``attr.s`` will + *never* go away, though). + + Args: + repr_ns (str): + When using nested classes, there was no way in Python 2 to + automatically detect that. This argument allows to set a custom + name for a more meaningful ``repr`` output. This argument is + pointless in Python 3 and is therefore deprecated. + + .. caution:: + Refer to `attrs.define` for the rest of the parameters, but note that they + can have different defaults. + + Notably, leaving *on_setattr* as `None` will **not** add any hooks. + + .. versionadded:: 16.0.0 *slots* + .. versionadded:: 16.1.0 *frozen* + .. versionadded:: 16.3.0 *str* + .. versionadded:: 16.3.0 Support for ``__attrs_post_init__``. + .. versionchanged:: 17.1.0 + *hash* supports `None` as value which is also the default now. + .. versionadded:: 17.3.0 *auto_attribs* + .. versionchanged:: 18.1.0 + If *these* is passed, no attributes are deleted from the class body. + .. versionchanged:: 18.1.0 If *these* is ordered, the order is retained. + .. versionadded:: 18.2.0 *weakref_slot* + .. deprecated:: 18.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now raise a + `DeprecationWarning` if the classes compared are subclasses of + each other. ``__eq`` and ``__ne__`` never tried to compared subclasses + to each other. + .. versionchanged:: 19.2.0 + ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` now do not consider + subclasses comparable anymore. + .. versionadded:: 18.2.0 *kw_only* + .. versionadded:: 18.2.0 *cache_hash* + .. versionadded:: 19.1.0 *auto_exc* + .. deprecated:: 19.2.0 *cmp* Removal on or after 2021-06-01. + .. versionadded:: 19.2.0 *eq* and *order* + .. versionadded:: 20.1.0 *auto_detect* + .. versionadded:: 20.1.0 *collect_by_mro* + .. versionadded:: 20.1.0 *getstate_setstate* + .. versionadded:: 20.1.0 *on_setattr* + .. versionadded:: 20.3.0 *field_transformer* + .. versionchanged:: 21.1.0 + ``init=False`` injects ``__attrs_init__`` + .. versionchanged:: 21.1.0 Support for ``__attrs_pre_init__`` + .. versionchanged:: 21.1.0 *cmp* undeprecated + .. versionadded:: 21.3.0 *match_args* + .. versionadded:: 22.2.0 + *unsafe_hash* as an alias for *hash* (for :pep:`681` compliance). + .. deprecated:: 24.1.0 *repr_ns* + .. versionchanged:: 24.1.0 + Instances are not compared as tuples of attributes anymore, but using a + big ``and`` condition. This is faster and has more correct behavior for + uncomparable values like `math.nan`. + .. versionadded:: 24.1.0 + If a class has an *inherited* classmethod called + ``__attrs_init_subclass__``, it is executed after the class is created. + .. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*. + .. versionchanged:: 25.4.0 + *kw_only* now only applies to attributes defined in the current class, + and respects attribute-level ``kw_only=False`` settings. + .. versionadded:: 25.4.0 *force_kw_only* + """ + if repr_ns is not None: + import warnings + + warnings.warn( + DeprecationWarning( + "The `repr_ns` argument is deprecated and will be removed in or after August 2025." + ), + stacklevel=2, + ) + + eq_, order_ = _determine_attrs_eq_order(cmp, eq, order, None) + + # unsafe_hash takes precedence due to PEP 681. + if unsafe_hash is not None: + hash = unsafe_hash + + if isinstance(on_setattr, (list, tuple)): + on_setattr = setters.pipe(*on_setattr) + + def wrap(cls): + nonlocal hash + is_frozen = frozen or _has_frozen_base_class(cls) + is_exc = auto_exc is True and issubclass(cls, BaseException) + has_own_setattr = auto_detect and _has_own_attribute( + cls, "__setattr__" + ) + + if has_own_setattr and is_frozen: + msg = "Can't freeze a class with a custom __setattr__." + raise ValueError(msg) + + eq = not is_exc and _determine_whether_to_implement( + cls, eq_, auto_detect, ("__eq__", "__ne__") + ) + + Hashability = ClassProps.Hashability + + if is_exc: + hashability = Hashability.LEAVE_ALONE + elif hash is True: + hashability = ( + Hashability.HASHABLE_CACHED + if cache_hash + else Hashability.HASHABLE + ) + elif hash is False: + hashability = Hashability.LEAVE_ALONE + elif hash is None: + if auto_detect is True and _has_own_attribute(cls, "__hash__"): + hashability = Hashability.LEAVE_ALONE + elif eq is True and is_frozen is True: + hashability = ( + Hashability.HASHABLE_CACHED + if cache_hash + else Hashability.HASHABLE + ) + elif eq is False: + hashability = Hashability.LEAVE_ALONE + else: + hashability = Hashability.UNHASHABLE + else: + msg = "Invalid value for hash. Must be True, False, or None." + raise TypeError(msg) + + KeywordOnly = ClassProps.KeywordOnly + if kw_only: + kwo = KeywordOnly.FORCE if force_kw_only else KeywordOnly.YES + else: + kwo = KeywordOnly.NO + + props = ClassProps( + is_exception=is_exc, + is_frozen=is_frozen, + is_slotted=slots, + collected_fields_by_mro=collect_by_mro, + added_init=_determine_whether_to_implement( + cls, init, auto_detect, ("__init__",) + ), + added_repr=_determine_whether_to_implement( + cls, repr, auto_detect, ("__repr__",) + ), + added_eq=eq, + added_ordering=not is_exc + and _determine_whether_to_implement( + cls, + order_, + auto_detect, + ("__lt__", "__le__", "__gt__", "__ge__"), + ), + hashability=hashability, + added_match_args=match_args, + kw_only=kwo, + has_weakref_slot=weakref_slot, + added_str=str, + added_pickling=_determine_whether_to_implement( + cls, + getstate_setstate, + auto_detect, + ("__getstate__", "__setstate__"), + default=slots, + ), + on_setattr_hook=on_setattr, + field_transformer=field_transformer, + ) + + if not props.is_hashable and cache_hash: + msg = "Invalid value for cache_hash. To use hash caching, hashing must be either explicitly or implicitly enabled." + raise TypeError(msg) + + builder = _ClassBuilder( + cls, + these, + auto_attribs=auto_attribs, + props=props, + has_custom_setattr=has_own_setattr, + ) + + if props.added_repr: + builder.add_repr(repr_ns) + + if props.added_str: + builder.add_str() + + if props.added_eq: + builder.add_eq() + if props.added_ordering: + builder.add_order() + + if not frozen: + builder.add_setattr() + + if props.is_hashable: + builder.add_hash() + elif props.hashability is Hashability.UNHASHABLE: + builder.make_unhashable() + + if props.added_init: + builder.add_init() + else: + builder.add_attrs_init() + if cache_hash: + msg = "Invalid value for cache_hash. To use hash caching, init must be True." + raise TypeError(msg) + + if PY_3_13_PLUS and not _has_own_attribute(cls, "__replace__"): + builder.add_replace() + + if ( + PY_3_10_PLUS + and match_args + and not _has_own_attribute(cls, "__match_args__") + ): + builder.add_match_args() + + return builder.build_class() + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but `None` if used as `@attrs()`. + if maybe_cls is None: + return wrap + + return wrap(maybe_cls) + + +_attrs = attrs +""" +Internal alias so we can use it in functions that take an argument called +*attrs*. +""" + + +def _has_frozen_base_class(cls): + """ + Check whether *cls* has a frozen ancestor by looking at its + __setattr__. + """ + return cls.__setattr__ is _frozen_setattrs + + +def _generate_unique_filename(cls: type, func_name: str) -> str: + """ + Create a "filename" suitable for a function being generated. + """ + return ( + f"" + ) + + +def _make_hash_script( + cls: type, attrs: list[Attribute], frozen: bool, cache_hash: bool +) -> tuple[str, dict]: + attrs = tuple( + a for a in attrs if a.hash is True or (a.hash is None and a.eq is True) + ) + + tab = " " + + type_hash = hash(_generate_unique_filename(cls, "hash")) + # If eq is custom generated, we need to include the functions in globs + globs = {} + + hash_def = "def __hash__(self" + hash_func = "hash((" + closing_braces = "))" + if not cache_hash: + hash_def += "):" + else: + hash_def += ", *" + + hash_def += ", _cache_wrapper=__import__('attr._make')._make._CacheHashWrapper):" + hash_func = "_cache_wrapper(" + hash_func + closing_braces += ")" + + method_lines = [hash_def] + + def append_hash_computation_lines(prefix, indent): + """ + Generate the code for actually computing the hash code. + Below this will either be returned directly or used to compute + a value which is then cached, depending on the value of cache_hash + """ + + method_lines.extend( + [ + indent + prefix + hash_func, + indent + f" {type_hash},", + ] + ) + + for a in attrs: + if a.eq_key: + cmp_name = f"_{a.name}_key" + globs[cmp_name] = a.eq_key + method_lines.append( + indent + f" {cmp_name}(self.{a.name})," + ) + else: + method_lines.append(indent + f" self.{a.name},") + + method_lines.append(indent + " " + closing_braces) + + if cache_hash: + method_lines.append(tab + f"if self.{_HASH_CACHE_FIELD} is None:") + if frozen: + append_hash_computation_lines( + f"object.__setattr__(self, '{_HASH_CACHE_FIELD}', ", tab * 2 + ) + method_lines.append(tab * 2 + ")") # close __setattr__ + else: + append_hash_computation_lines( + f"self.{_HASH_CACHE_FIELD} = ", tab * 2 + ) + method_lines.append(tab + f"return self.{_HASH_CACHE_FIELD}") + else: + append_hash_computation_lines("return ", tab) + + script = "\n".join(method_lines) + return script, globs + + +def _add_hash(cls: type, attrs: list[Attribute]): + """ + Add a hash method to *cls*. + """ + script, globs = _make_hash_script( + cls, attrs, frozen=False, cache_hash=False + ) + _compile_and_eval( + script, globs, filename=_generate_unique_filename(cls, "__hash__") + ) + cls.__hash__ = globs["__hash__"] + return cls + + +def __ne__(self, other): + """ + Check equality and either forward a NotImplemented or + return the result negated. + """ + result = self.__eq__(other) + if result is NotImplemented: + return NotImplemented + + return not result + + +def _make_eq_script(attrs: list) -> tuple[str, dict]: + """ + Create __eq__ method for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.eq] + + lines = [ + "def __eq__(self, other):", + " if other.__class__ is not self.__class__:", + " return NotImplemented", + ] + + globs = {} + if attrs: + lines.append(" return (") + for a in attrs: + if a.eq_key: + cmp_name = f"_{a.name}_key" + # Add the key function to the global namespace + # of the evaluated function. + globs[cmp_name] = a.eq_key + lines.append( + f" {cmp_name}(self.{a.name}) == {cmp_name}(other.{a.name})" + ) + else: + lines.append(f" self.{a.name} == other.{a.name}") + if a is not attrs[-1]: + lines[-1] = f"{lines[-1]} and" + lines.append(" )") + else: + lines.append(" return True") + + script = "\n".join(lines) + + return script, globs + + +def _make_order(cls, attrs): + """ + Create ordering methods for *cls* with *attrs*. + """ + attrs = [a for a in attrs if a.order] + + def attrs_to_tuple(obj): + """ + Save us some typing. + """ + return tuple( + key(value) if key else value + for value, key in ( + (getattr(obj, a.name), a.order_key) for a in attrs + ) + ) + + def __lt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) < attrs_to_tuple(other) + + return NotImplemented + + def __le__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) <= attrs_to_tuple(other) + + return NotImplemented + + def __gt__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) > attrs_to_tuple(other) + + return NotImplemented + + def __ge__(self, other): + """ + Automatically created by attrs. + """ + if other.__class__ is self.__class__: + return attrs_to_tuple(self) >= attrs_to_tuple(other) + + return NotImplemented + + return __lt__, __le__, __gt__, __ge__ + + +def _add_eq(cls, attrs=None): + """ + Add equality methods to *cls* with *attrs*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + script, globs = _make_eq_script(attrs) + _compile_and_eval( + script, globs, filename=_generate_unique_filename(cls, "__eq__") + ) + cls.__eq__ = globs["__eq__"] + cls.__ne__ = __ne__ + + return cls + + +def _make_repr_script(attrs, ns) -> tuple[str, dict]: + """ + Create the source and globs for a __repr__ and return it. + """ + # Figure out which attributes to include, and which function to use to + # format them. The a.repr value can be either bool or a custom + # callable. + attr_names_with_reprs = tuple( + (a.name, (repr if a.repr is True else a.repr), a.init) + for a in attrs + if a.repr is not False + ) + globs = { + name + "_repr": r for name, r, _ in attr_names_with_reprs if r != repr + } + globs["_compat"] = _compat + globs["AttributeError"] = AttributeError + globs["NOTHING"] = NOTHING + attribute_fragments = [] + for name, r, i in attr_names_with_reprs: + accessor = ( + "self." + name if i else 'getattr(self, "' + name + '", NOTHING)' + ) + fragment = ( + "%s={%s!r}" % (name, accessor) + if r == repr + else "%s={%s_repr(%s)}" % (name, name, accessor) + ) + attribute_fragments.append(fragment) + repr_fragment = ", ".join(attribute_fragments) + + if ns is None: + cls_name_fragment = '{self.__class__.__qualname__.rsplit(">.", 1)[-1]}' + else: + cls_name_fragment = ns + ".{self.__class__.__name__}" + + lines = [ + "def __repr__(self):", + " try:", + " already_repring = _compat.repr_context.already_repring", + " except AttributeError:", + " already_repring = {id(self),}", + " _compat.repr_context.already_repring = already_repring", + " else:", + " if id(self) in already_repring:", + " return '...'", + " else:", + " already_repring.add(id(self))", + " try:", + f" return f'{cls_name_fragment}({repr_fragment})'", + " finally:", + " already_repring.remove(id(self))", + ] + + return "\n".join(lines), globs + + +def _add_repr(cls, ns=None, attrs=None): + """ + Add a repr method to *cls*. + """ + if attrs is None: + attrs = cls.__attrs_attrs__ + + script, globs = _make_repr_script(attrs, ns) + _compile_and_eval( + script, globs, filename=_generate_unique_filename(cls, "__repr__") + ) + cls.__repr__ = globs["__repr__"] + return cls + + +def fields(cls): + """ + Return the tuple of *attrs* attributes for a class or instance. + + The tuple also allows accessing the fields by their names (see below for + examples). + + Args: + cls (type): Class or instance to introspect. + + Raises: + TypeError: If *cls* is neither a class nor an *attrs* instance. + + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + Returns: + tuple (with name accessors) of `attrs.Attribute` + + .. versionchanged:: 16.2.0 Returned tuple allows accessing the fields + by name. + .. versionchanged:: 23.1.0 Add support for generic classes. + .. versionchanged:: 26.1.0 Add support for instances. + """ + generic_base = get_generic_base(cls) + + if generic_base is None and not isinstance(cls, type): + type_ = type(cls) + if getattr(type_, "__attrs_attrs__", None) is None: + msg = "Passed object must be a class or attrs instance." + raise TypeError(msg) + + return fields(type_) + + attrs = getattr(cls, "__attrs_attrs__", None) + + if attrs is None: + if generic_base is not None: + attrs = getattr(generic_base, "__attrs_attrs__", None) + if attrs is not None: + # Even though this is global state, stick it on here to speed + # it up. We rely on `cls` being cached for this to be + # efficient. + cls.__attrs_attrs__ = attrs + return attrs + msg = f"{cls!r} is not an attrs-decorated class." + raise NotAnAttrsClassError(msg) + + return attrs + + +def fields_dict(cls): + """ + Return an ordered dictionary of *attrs* attributes for a class, whose keys + are the attribute names. + + Args: + cls (type): Class to introspect. + + Raises: + TypeError: If *cls* is not a class. + + attrs.exceptions.NotAnAttrsClassError: + If *cls* is not an *attrs* class. + + Returns: + dict[str, attrs.Attribute]: Dict of attribute name to definition + + .. versionadded:: 18.1.0 + """ + if not isinstance(cls, type): + msg = "Passed object must be a class." + raise TypeError(msg) + attrs = getattr(cls, "__attrs_attrs__", None) + if attrs is None: + msg = f"{cls!r} is not an attrs-decorated class." + raise NotAnAttrsClassError(msg) + return {a.name: a for a in attrs} + + +def validate(inst): + """ + Validate all attributes on *inst* that have a validator. + + Leaves all exceptions through. + + Args: + inst: Instance of a class with *attrs* attributes. + """ + if _config._run_validators is False: + return + + for a in fields(inst.__class__): + v = a.validator + if v is not None: + v(inst, a, getattr(inst, a.name)) + + +def _is_slot_attr(a_name, base_attr_map): + """ + Check if the attribute name comes from a slot class. + """ + cls = base_attr_map.get(a_name) + return cls and "__slots__" in cls.__dict__ + + +def _make_init_script( + cls, + attrs, + pre_init, + pre_init_has_args, + post_init, + frozen, + slots, + cache_hash, + base_attr_map, + is_exc, + cls_on_setattr, + attrs_init, +) -> tuple[str, dict, dict]: + has_cls_on_setattr = ( + cls_on_setattr is not None and cls_on_setattr is not setters.NO_OP + ) + + if frozen and has_cls_on_setattr: + msg = "Frozen classes can't use on_setattr." + raise ValueError(msg) + + needs_cached_setattr = cache_hash or frozen + filtered_attrs = [] + attr_dict = {} + for a in attrs: + if not a.init and a.default is NOTHING: + continue + + filtered_attrs.append(a) + attr_dict[a.name] = a + + if a.on_setattr is not None: + if frozen is True and a.on_setattr is not setters.NO_OP: + msg = "Frozen classes can't use on_setattr." + raise ValueError(msg) + + needs_cached_setattr = True + elif has_cls_on_setattr and a.on_setattr is not setters.NO_OP: + needs_cached_setattr = True + + script, globs, annotations = _attrs_to_init_script( + filtered_attrs, + frozen, + slots, + pre_init, + pre_init_has_args, + post_init, + cache_hash, + base_attr_map, + is_exc, + needs_cached_setattr, + has_cls_on_setattr, + "__attrs_init__" if attrs_init else "__init__", + ) + if cls.__module__ in sys.modules: + # This makes typing.get_type_hints(CLS.__init__) resolve string types. + globs.update(sys.modules[cls.__module__].__dict__) + + globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) + + if needs_cached_setattr: + # Save the lookup overhead in __init__ if we need to circumvent + # setattr hooks. + globs["_cached_setattr_get"] = _OBJ_SETATTR.__get__ + + return script, globs, annotations + + +def _setattr(attr_name: str, value_var: str, has_on_setattr: bool) -> str: + """ + Use the cached object.setattr to set *attr_name* to *value_var*. + """ + return f"_setattr('{attr_name}', {value_var})" + + +def _setattr_with_converter( + attr_name: str, value_var: str, has_on_setattr: bool, converter: Converter +) -> str: + """ + Use the cached object.setattr to set *attr_name* to *value_var*, but run + its converter first. + """ + return f"_setattr('{attr_name}', {converter._fmt_converter_call(attr_name, value_var)})" + + +def _assign(attr_name: str, value: str, has_on_setattr: bool) -> str: + """ + Unless *attr_name* has an on_setattr hook, use normal assignment. Otherwise + relegate to _setattr. + """ + if has_on_setattr: + return _setattr(attr_name, value, True) + + return f"self.{attr_name} = {value}" + + +def _assign_with_converter( + attr_name: str, value_var: str, has_on_setattr: bool, converter: Converter +) -> str: + """ + Unless *attr_name* has an on_setattr hook, use normal assignment after + conversion. Otherwise relegate to _setattr_with_converter. + """ + if has_on_setattr: + return _setattr_with_converter(attr_name, value_var, True, converter) + + return f"self.{attr_name} = {converter._fmt_converter_call(attr_name, value_var)}" + + +def _determine_setters( + frozen: bool, slots: bool, base_attr_map: dict[str, type] +): + """ + Determine the correct setter functions based on whether a class is frozen + and/or slotted. + """ + if frozen is True: + if slots is True: + return (), _setattr, _setattr_with_converter + + # Dict frozen classes assign directly to __dict__. + # But only if the attribute doesn't come from an ancestor slot + # class. + # Note _inst_dict will be used again below if cache_hash is True + + def fmt_setter( + attr_name: str, value_var: str, has_on_setattr: bool + ) -> str: + if _is_slot_attr(attr_name, base_attr_map): + return _setattr(attr_name, value_var, has_on_setattr) + + return f"_inst_dict['{attr_name}'] = {value_var}" + + def fmt_setter_with_converter( + attr_name: str, + value_var: str, + has_on_setattr: bool, + converter: Converter, + ) -> str: + if has_on_setattr or _is_slot_attr(attr_name, base_attr_map): + return _setattr_with_converter( + attr_name, value_var, has_on_setattr, converter + ) + + return f"_inst_dict['{attr_name}'] = {converter._fmt_converter_call(attr_name, value_var)}" + + return ( + ("_inst_dict = self.__dict__",), + fmt_setter, + fmt_setter_with_converter, + ) + + # Not frozen -- we can just assign directly. + return (), _assign, _assign_with_converter + + +def _attrs_to_init_script( + attrs: list[Attribute], + is_frozen: bool, + is_slotted: bool, + call_pre_init: bool, + pre_init_has_args: bool, + call_post_init: bool, + does_cache_hash: bool, + base_attr_map: dict[str, type], + is_exc: bool, + needs_cached_setattr: bool, + has_cls_on_setattr: bool, + method_name: str, +) -> tuple[str, dict, dict]: + """ + Return a script of an initializer for *attrs*, a dict of globals, and + annotations for the initializer. + + The globals are required by the generated script. + """ + lines = ["self.__attrs_pre_init__()"] if call_pre_init else [] + + if needs_cached_setattr: + lines.append( + # Circumvent the __setattr__ descriptor to save one lookup per + # assignment. Note _setattr will be used again below if + # does_cache_hash is True. + "_setattr = _cached_setattr_get(self)" + ) + + extra_lines, fmt_setter, fmt_setter_with_converter = _determine_setters( + is_frozen, is_slotted, base_attr_map + ) + lines.extend(extra_lines) + + args = [] # Parameters in the definition of __init__ + pre_init_args = [] # Parameters in the call to __attrs_pre_init__ + kw_only_args = [] # Used for both 'args' and 'pre_init_args' above + attrs_to_validate = [] + + # This is a dictionary of names to validator and converter callables. + # Injecting this into __init__ globals lets us avoid lookups. + names_for_globals = {} + annotations = {"return": None} + + for a in attrs: + if a.validator: + attrs_to_validate.append(a) + + attr_name = a.name + has_on_setattr = a.on_setattr is not None or ( + a.on_setattr is not setters.NO_OP and has_cls_on_setattr + ) + # a.alias is set to maybe-mangled attr_name in _ClassBuilder if not + # explicitly provided + arg_name = a.alias + + has_factory = isinstance(a.default, Factory) + maybe_self = "self" if has_factory and a.default.takes_self else "" + + if a.converter is not None and not isinstance(a.converter, Converter): + converter = Converter(a.converter) + else: + converter = a.converter + + if a.init is False: + if has_factory: + init_factory_name = _INIT_FACTORY_PAT % (a.name,) + if converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + init_factory_name + f"({maybe_self})", + has_on_setattr, + converter, + ) + ) + names_for_globals[converter._get_global_name(a.name)] = ( + converter.converter + ) + else: + lines.append( + fmt_setter( + attr_name, + init_factory_name + f"({maybe_self})", + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + elif converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, + f"attr_dict['{attr_name}'].default", + has_on_setattr, + converter, + ) + ) + names_for_globals[converter._get_global_name(a.name)] = ( + converter.converter + ) + else: + lines.append( + fmt_setter( + attr_name, + f"attr_dict['{attr_name}'].default", + has_on_setattr, + ) + ) + elif a.default is not NOTHING and not has_factory: + arg = f"{arg_name}=attr_dict['{attr_name}'].default" + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + pre_init_args.append(arg_name) + + if converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr, converter + ) + ) + names_for_globals[converter._get_global_name(a.name)] = ( + converter.converter + ) + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + elif has_factory: + arg = f"{arg_name}=NOTHING" + if a.kw_only: + kw_only_args.append(arg) + else: + args.append(arg) + pre_init_args.append(arg_name) + lines.append(f"if {arg_name} is not NOTHING:") + + init_factory_name = _INIT_FACTORY_PAT % (a.name,) + if converter is not None: + lines.append( + " " + + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr, converter + ) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter_with_converter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + converter, + ) + ) + names_for_globals[converter._get_global_name(a.name)] = ( + converter.converter + ) + else: + lines.append( + " " + fmt_setter(attr_name, arg_name, has_on_setattr) + ) + lines.append("else:") + lines.append( + " " + + fmt_setter( + attr_name, + init_factory_name + "(" + maybe_self + ")", + has_on_setattr, + ) + ) + names_for_globals[init_factory_name] = a.default.factory + else: + if a.kw_only: + kw_only_args.append(arg_name) + else: + args.append(arg_name) + pre_init_args.append(arg_name) + + if converter is not None: + lines.append( + fmt_setter_with_converter( + attr_name, arg_name, has_on_setattr, converter + ) + ) + names_for_globals[converter._get_global_name(a.name)] = ( + converter.converter + ) + else: + lines.append(fmt_setter(attr_name, arg_name, has_on_setattr)) + + if a.init is True: + if a.type is not None and converter is None: + annotations[arg_name] = a.type + elif converter is not None and converter._first_param_type: + # Use the type from the converter if present. + annotations[arg_name] = converter._first_param_type + + if attrs_to_validate: # we can skip this if there are no validators. + names_for_globals["_config"] = _config + lines.append("if _config._run_validators is True:") + for a in attrs_to_validate: + val_name = "__attr_validator_" + a.name + attr_name = "__attr_" + a.name + lines.append(f" {val_name}(self, {attr_name}, self.{a.name})") + names_for_globals[val_name] = a.validator + names_for_globals[attr_name] = a + + if call_post_init: + lines.append("self.__attrs_post_init__()") + + # Because this is set only after __attrs_post_init__ is called, a crash + # will result if post-init tries to access the hash code. This seemed + # preferable to setting this beforehand, in which case alteration to field + # values during post-init combined with post-init accessing the hash code + # would result in silent bugs. + if does_cache_hash: + if is_frozen: + if is_slotted: + init_hash_cache = f"_setattr('{_HASH_CACHE_FIELD}', None)" + else: + init_hash_cache = f"_inst_dict['{_HASH_CACHE_FIELD}'] = None" + else: + init_hash_cache = f"self.{_HASH_CACHE_FIELD} = None" + lines.append(init_hash_cache) + + # For exceptions we rely on BaseException.__init__ for proper + # initialization. + if is_exc: + vals = ",".join(f"self.{a.name}" for a in attrs if a.init) + + lines.append(f"BaseException.__init__(self, {vals})") + + args = ", ".join(args) + pre_init_args = ", ".join(pre_init_args) + if kw_only_args: + # leading comma & kw_only args + args += f"{', ' if args else ''}*, {', '.join(kw_only_args)}" + pre_init_kw_only_args = ", ".join( + [ + f"{kw_arg_name}={kw_arg_name}" + # We need to remove the defaults from the kw_only_args. + for kw_arg_name in (kwa.split("=")[0] for kwa in kw_only_args) + ] + ) + pre_init_args += ", " if pre_init_args else "" + pre_init_args += pre_init_kw_only_args + + if call_pre_init and pre_init_has_args: + # If pre init method has arguments, pass the values given to __init__. + lines[0] = f"self.__attrs_pre_init__({pre_init_args})" + + # Python <3.12 doesn't allow backslashes in f-strings. + NL = "\n " + return ( + f"""def {method_name}(self, {args}): + {NL.join(lines) if lines else "pass"} +""", + names_for_globals, + annotations, + ) + + +def _default_init_alias_for(name: str) -> str: + """ + The default __init__ parameter name for a field. + + This performs private-name adjustment via leading-unscore stripping, + and is the default value of Attribute.alias if not provided. + """ + + return name.lstrip("_") + + +class Attribute: + """ + *Read-only* representation of an attribute. + + .. warning:: + + You should never instantiate this class yourself. + + The class has *all* arguments of `attr.ib` (except for ``factory`` which is + only syntactic sugar for ``default=Factory(...)`` plus the following: + + - ``name`` (`str`): The name of the attribute. + - ``alias`` (`str`): The __init__ parameter name of the attribute, after + any explicit overrides and default private-attribute-name handling. + - ``alias_is_default`` (`bool`): Whether the ``alias`` was automatically + generated (``True``) or explicitly provided by the user (``False``). + - ``inherited`` (`bool`): Whether or not that attribute has been inherited + from a base class. + - ``eq_key`` and ``order_key`` (`typing.Callable` or `None`): The + callables that are used for comparing and ordering objects by this + attribute, respectively. These are set by passing a callable to + `attr.ib`'s ``eq``, ``order``, or ``cmp`` arguments. See also + :ref:`comparison customization `. + + Instances of this class are frequently used for introspection purposes + like: + + - `fields` returns a tuple of them. + - Validators get them passed as the first argument. + - The :ref:`field transformer ` hook receives a list of + them. + - The ``alias`` property exposes the __init__ parameter name of the field, + with any overrides and default private-attribute handling applied. + + + .. versionadded:: 20.1.0 *inherited* + .. versionadded:: 20.1.0 *on_setattr* + .. versionchanged:: 20.2.0 *inherited* is not taken into account for + equality checks and hashing anymore. + .. versionadded:: 21.1.0 *eq_key* and *order_key* + .. versionadded:: 22.2.0 *alias* + .. versionadded:: 26.1.0 *alias_is_default* + + For the full version history of the fields, see `attr.ib`. + """ + + # These slots must NOT be reordered because we use them later for + # instantiation. + __slots__ = ( # noqa: RUF023 + "name", + "default", + "validator", + "repr", + "eq", + "eq_key", + "order", + "order_key", + "hash", + "init", + "metadata", + "type", + "converter", + "kw_only", + "inherited", + "on_setattr", + "alias", + "alias_is_default", + ) + + def __init__( + self, + name, + default, + validator, + repr, + cmp, # XXX: unused, remove along with other cmp code. + hash, + init, + inherited, + metadata=None, + type=None, + converter=None, + kw_only=False, + eq=None, + eq_key=None, + order=None, + order_key=None, + on_setattr=None, + alias=None, + alias_is_default=None, + ): + eq, eq_key, order, order_key = _determine_attrib_eq_order( + cmp, eq_key or eq, order_key or order, True + ) + + # Cache this descriptor here to speed things up later. + bound_setattr = _OBJ_SETATTR.__get__(self) + + # Despite the big red warning, people *do* instantiate `Attribute` + # themselves. + bound_setattr("name", name) + bound_setattr("default", default) + bound_setattr("validator", validator) + bound_setattr("repr", repr) + bound_setattr("eq", eq) + bound_setattr("eq_key", eq_key) + bound_setattr("order", order) + bound_setattr("order_key", order_key) + bound_setattr("hash", hash) + bound_setattr("init", init) + bound_setattr("converter", converter) + bound_setattr( + "metadata", + ( + types.MappingProxyType(dict(metadata)) # Shallow copy + if metadata + else _EMPTY_METADATA_SINGLETON + ), + ) + bound_setattr("type", type) + bound_setattr("kw_only", kw_only) + bound_setattr("inherited", inherited) + bound_setattr("on_setattr", on_setattr) + bound_setattr("alias", alias) + bound_setattr( + "alias_is_default", + alias is None if alias_is_default is None else alias_is_default, + ) + + def __setattr__(self, name, value): + raise FrozenInstanceError + + @classmethod + def from_counting_attr( + cls, name: str, ca: _CountingAttr, kw_only: bool, type=None + ): + # The 'kw_only' argument is the class-level setting, and is used if the + # attribute itself does not explicitly set 'kw_only'. + # type holds the annotated value. deal with conflicts: + if type is None: + type = ca.type + elif ca.type is not None: + msg = f"Type annotation and type argument cannot both be present for '{name}'." + raise ValueError(msg) + return cls( + name, + ca._default, + ca._validator, + ca.repr, + None, + ca.hash, + ca.init, + False, + ca.metadata, + type, + ca.converter, + kw_only if ca.kw_only is None else ca.kw_only, + ca.eq, + ca.eq_key, + ca.order, + ca.order_key, + ca.on_setattr, + ca.alias, + ca.alias is None, + ) + + # Don't use attrs.evolve since fields(Attribute) doesn't work + def evolve(self, **changes): + """ + Copy *self* and apply *changes*. + + This works similarly to `attrs.evolve` but that function does not work + with :class:`attrs.Attribute`. + + It is mainly meant to be used for `transform-fields`. + + .. versionadded:: 20.3.0 + """ + new = copy.copy(self) + + new._setattrs(changes.items()) + + if "alias" in changes and "alias_is_default" not in changes: + # Explicit alias provided -- no longer the default. + _OBJ_SETATTR.__get__(new)("alias_is_default", False) + elif ( + "name" in changes + and "alias" not in changes + # Don't auto-generate alias if the user picked picked the old one. + and self.alias_is_default + ): + # Name changed, alias was auto-generated -- update it. + _OBJ_SETATTR.__get__(new)( + "alias", _default_init_alias_for(new.name) + ) + + return new + + # Don't use _add_pickle since fields(Attribute) doesn't work + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple( + getattr(self, name) if name != "metadata" else dict(self.metadata) + for name in self.__slots__ + ) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + if len(state) < len(self.__slots__): + # Pre-26.1.0 pickle without alias_is_default -- infer it + # heuristically. + state_dict = dict(zip(self.__slots__, state)) + alias_is_default = state_dict.get( + "alias" + ) is None or state_dict.get("alias") == _default_init_alias_for( + state_dict["name"] + ) + state = (*state, alias_is_default) + + self._setattrs(zip(self.__slots__, state)) + + def _setattrs(self, name_values_pairs): + bound_setattr = _OBJ_SETATTR.__get__(self) + for name, value in name_values_pairs: + if name != "metadata": + bound_setattr(name, value) + else: + bound_setattr( + name, + ( + types.MappingProxyType(dict(value)) + if value + else _EMPTY_METADATA_SINGLETON + ), + ) + + +_a = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=(name != "alias_is_default"), + cmp=None, + eq=True, + order=False, + hash=(name != "metadata"), + init=True, + inherited=False, + alias=_default_init_alias_for(name), + ) + for name in Attribute.__slots__ +] + +Attribute = _add_hash( + _add_eq( + _add_repr(Attribute, attrs=_a), + attrs=[a for a in _a if a.name != "inherited"], + ), + attrs=[a for a in _a if a.hash and a.name != "inherited"], +) + + +class _CountingAttr: + """ + Intermediate representation of attributes that uses a counter to preserve + the order in which the attributes have been defined. + + *Internal* data structure of the attrs library. Running into is most + likely the result of a bug like a forgotten `@attr.s` decorator. + """ + + __slots__ = ( + "_default", + "_validator", + "alias", + "converter", + "counter", + "eq", + "eq_key", + "hash", + "init", + "kw_only", + "metadata", + "on_setattr", + "order", + "order_key", + "repr", + "type", + ) + __attrs_attrs__ = ( + *tuple( + Attribute( + name=name, + alias=_default_init_alias_for(name), + default=NOTHING, + validator=None, + repr=True, + cmp=None, + hash=True, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ) + for name in ( + "counter", + "_default", + "repr", + "eq", + "order", + "hash", + "init", + "on_setattr", + "alias", + ) + ), + Attribute( + name="metadata", + alias="metadata", + default=None, + validator=None, + repr=True, + cmp=None, + hash=False, + init=True, + kw_only=False, + eq=True, + eq_key=None, + order=False, + order_key=None, + inherited=False, + on_setattr=None, + ), + ) + cls_counter = 0 + + def __init__( + self, + default, + validator, + repr, + cmp, + hash, + init, + converter, + metadata, + type, + kw_only, + eq, + eq_key, + order, + order_key, + on_setattr, + alias, + ): + _CountingAttr.cls_counter += 1 + self.counter = _CountingAttr.cls_counter + self._default = default + self._validator = validator + self.converter = converter + self.repr = repr + self.eq = eq + self.eq_key = eq_key + self.order = order + self.order_key = order_key + self.hash = hash + self.init = init + self.metadata = metadata + self.type = type + self.kw_only = kw_only + self.on_setattr = on_setattr + self.alias = alias + + def validator(self, meth): + """ + Decorator that adds *meth* to the list of validators. + + Returns *meth* unchanged. + + .. versionadded:: 17.1.0 + """ + if self._validator is None: + self._validator = meth + else: + self._validator = and_(self._validator, meth) + return meth + + def default(self, meth): + """ + Decorator that allows to set the default for an attribute. + + Returns *meth* unchanged. + + Raises: + DefaultAlreadySetError: If default has been set before. + + .. versionadded:: 17.1.0 + """ + if self._default is not NOTHING: + raise DefaultAlreadySetError + + self._default = Factory(meth, takes_self=True) + + return meth + + +_CountingAttr = _add_eq(_add_repr(_CountingAttr)) + + +class ClassProps: + """ + Effective class properties as derived from parameters to `attr.s()` or + `define()` decorators. + + This is the same data structure that *attrs* uses internally to decide how + to construct the final class. + + Warning: + + This feature is currently **experimental** and is not covered by our + strict backwards-compatibility guarantees. + + + Attributes: + is_exception (bool): + Whether the class is treated as an exception class. + + is_slotted (bool): + Whether the class is `slotted `. + + has_weakref_slot (bool): + Whether the class has a slot for weak references. + + is_frozen (bool): + Whether the class is frozen. + + kw_only (KeywordOnly): + Whether / how the class enforces keyword-only arguments on the + ``__init__`` method. + + collected_fields_by_mro (bool): + Whether the class fields were collected by method resolution order. + That is, correctly but unlike `dataclasses`. + + added_init (bool): + Whether the class has an *attrs*-generated ``__init__`` method. + + added_repr (bool): + Whether the class has an *attrs*-generated ``__repr__`` method. + + added_eq (bool): + Whether the class has *attrs*-generated equality methods. + + added_ordering (bool): + Whether the class has *attrs*-generated ordering methods. + + hashability (Hashability): How `hashable ` the class is. + + added_match_args (bool): + Whether the class supports positional `match ` over its + fields. + + added_str (bool): + Whether the class has an *attrs*-generated ``__str__`` method. + + added_pickling (bool): + Whether the class has *attrs*-generated ``__getstate__`` and + ``__setstate__`` methods for `pickle`. + + on_setattr_hook (Callable[[Any, Attribute[Any], Any], Any] | None): + The class's ``__setattr__`` hook. + + field_transformer (Callable[[Attribute[Any]], Attribute[Any]] | None): + The class's `field transformers `. + + .. versionadded:: 25.4.0 + """ + + class Hashability(enum.Enum): + """ + The hashability of a class. + + .. versionadded:: 25.4.0 + """ + + HASHABLE = "hashable" + """Write a ``__hash__``.""" + HASHABLE_CACHED = "hashable_cache" + """Write a ``__hash__`` and cache the hash.""" + UNHASHABLE = "unhashable" + """Set ``__hash__`` to ``None``.""" + LEAVE_ALONE = "leave_alone" + """Don't touch ``__hash__``.""" + + class KeywordOnly(enum.Enum): + """ + How attributes should be treated regarding keyword-only parameters. + + .. versionadded:: 25.4.0 + """ + + NO = "no" + """Attributes are not keyword-only.""" + YES = "yes" + """Attributes in current class without kw_only=False are keyword-only.""" + FORCE = "force" + """All attributes are keyword-only.""" + + __slots__ = ( # noqa: RUF023 -- order matters for __init__ + "is_exception", + "is_slotted", + "has_weakref_slot", + "is_frozen", + "kw_only", + "collected_fields_by_mro", + "added_init", + "added_repr", + "added_eq", + "added_ordering", + "hashability", + "added_match_args", + "added_str", + "added_pickling", + "on_setattr_hook", + "field_transformer", + ) + + def __init__( + self, + is_exception, + is_slotted, + has_weakref_slot, + is_frozen, + kw_only, + collected_fields_by_mro, + added_init, + added_repr, + added_eq, + added_ordering, + hashability, + added_match_args, + added_str, + added_pickling, + on_setattr_hook, + field_transformer, + ): + self.is_exception = is_exception + self.is_slotted = is_slotted + self.has_weakref_slot = has_weakref_slot + self.is_frozen = is_frozen + self.kw_only = kw_only + self.collected_fields_by_mro = collected_fields_by_mro + self.added_init = added_init + self.added_repr = added_repr + self.added_eq = added_eq + self.added_ordering = added_ordering + self.hashability = hashability + self.added_match_args = added_match_args + self.added_str = added_str + self.added_pickling = added_pickling + self.on_setattr_hook = on_setattr_hook + self.field_transformer = field_transformer + + @property + def is_hashable(self): + return ( + self.hashability is ClassProps.Hashability.HASHABLE + or self.hashability is ClassProps.Hashability.HASHABLE_CACHED + ) + + +_cas = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + alias=_default_init_alias_for(name), + ) + for name in ClassProps.__slots__ +] + +ClassProps = _add_eq(_add_repr(ClassProps, attrs=_cas), attrs=_cas) + + +class Factory: + """ + Stores a factory callable. + + If passed as the default value to `attrs.field`, the factory is used to + generate a new value. + + Args: + factory (typing.Callable): + A callable that takes either none or exactly one mandatory + positional argument depending on *takes_self*. + + takes_self (bool): + Pass the partially initialized instance that is being initialized + as a positional argument. + + .. versionadded:: 17.1.0 *takes_self* + """ + + __slots__ = ("factory", "takes_self") + + def __init__(self, factory, takes_self=False): + self.factory = factory + self.takes_self = takes_self + + def __getstate__(self): + """ + Play nice with pickle. + """ + return tuple(getattr(self, name) for name in self.__slots__) + + def __setstate__(self, state): + """ + Play nice with pickle. + """ + for name, value in zip(self.__slots__, state): + setattr(self, name, value) + + +_f = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + ) + for name in Factory.__slots__ +] + +Factory = _add_hash(_add_eq(_add_repr(Factory, attrs=_f), attrs=_f), attrs=_f) + + +class Converter: + """ + Stores a converter callable. + + Allows for the wrapped converter to take additional arguments. The + arguments are passed in the order they are documented. + + Args: + converter (Callable): A callable that converts the passed value. + + takes_self (bool): + Pass the partially initialized instance that is being initialized + as a positional argument. (default: `False`) + + takes_field (bool): + Pass the field definition (an :class:`Attribute`) into the + converter as a positional argument. (default: `False`) + + .. versionadded:: 24.1.0 + """ + + __slots__ = ( + "__call__", + "_first_param_type", + "_global_name", + "converter", + "takes_field", + "takes_self", + ) + + def __init__(self, converter, *, takes_self=False, takes_field=False): + self.converter = converter + self.takes_self = takes_self + self.takes_field = takes_field + + ex = _AnnotationExtractor(converter) + self._first_param_type = ex.get_first_param_type() + + if not (self.takes_self or self.takes_field): + self.__call__ = lambda value, _, __: self.converter(value) + elif self.takes_self and not self.takes_field: + self.__call__ = lambda value, instance, __: self.converter( + value, instance + ) + elif not self.takes_self and self.takes_field: + self.__call__ = lambda value, __, field: self.converter( + value, field + ) + else: + self.__call__ = self.converter + + rt = ex.get_return_type() + if rt is not None: + self.__call__.__annotations__["return"] = rt + + @staticmethod + def _get_global_name(attr_name: str) -> str: + """ + Return the name that a converter for an attribute name *attr_name* + would have. + """ + return f"__attr_converter_{attr_name}" + + def _fmt_converter_call(self, attr_name: str, value_var: str) -> str: + """ + Return a string that calls the converter for an attribute name + *attr_name* and the value in variable named *value_var* according to + `self.takes_self` and `self.takes_field`. + """ + if not (self.takes_self or self.takes_field): + return f"{self._get_global_name(attr_name)}({value_var})" + + if self.takes_self and self.takes_field: + return f"{self._get_global_name(attr_name)}({value_var}, self, attr_dict['{attr_name}'])" + + if self.takes_self: + return f"{self._get_global_name(attr_name)}({value_var}, self)" + + return f"{self._get_global_name(attr_name)}({value_var}, attr_dict['{attr_name}'])" + + def __getstate__(self): + """ + Return a dict containing only converter and takes_self -- the rest gets + computed when loading. + """ + return { + "converter": self.converter, + "takes_self": self.takes_self, + "takes_field": self.takes_field, + } + + def __setstate__(self, state): + """ + Load instance from state. + """ + self.__init__(**state) + + +_f = [ + Attribute( + name=name, + default=NOTHING, + validator=None, + repr=True, + cmp=None, + eq=True, + order=False, + hash=True, + init=True, + inherited=False, + ) + for name in ("converter", "takes_self", "takes_field") +] + +Converter = _add_hash( + _add_eq(_add_repr(Converter, attrs=_f), attrs=_f), attrs=_f +) + + +def make_class( + name, attrs, bases=(object,), class_body=None, **attributes_arguments +): + r""" + A quick way to create a new class called *name* with *attrs*. + + .. note:: + + ``make_class()`` is a thin wrapper around `attr.s`, not `attrs.define` + which means that it doesn't come with some of the improved defaults. + + For example, if you want the same ``on_setattr`` behavior as in + `attrs.define`, you have to pass the hooks yourself: ``make_class(..., + on_setattr=setters.pipe(setters.convert, setters.validate)`` + + .. warning:: + + It is *your* duty to ensure that the class name and the attribute names + are valid identifiers. ``make_class()`` will *not* validate them for + you. + + Args: + name (str): The name for the new class. + + attrs (list | dict): + A list of names or a dictionary of mappings of names to `attr.ib`\ + s / `attrs.field`\ s. + + The order is deduced from the order of the names or attributes + inside *attrs*. Otherwise the order of the definition of the + attributes is used. + + bases (tuple[type, ...]): Classes that the new class will subclass. + + class_body (dict): + An optional dictionary of class attributes for the new class. + + attributes_arguments: Passed unmodified to `attr.s`. + + Returns: + type: A new class with *attrs*. + + .. versionadded:: 17.1.0 *bases* + .. versionchanged:: 18.1.0 If *attrs* is ordered, the order is retained. + .. versionchanged:: 23.2.0 *class_body* + .. versionchanged:: 25.2.0 Class names can now be unicode. + """ + # Class identifiers are converted into the normal form NFKC while parsing + name = unicodedata.normalize("NFKC", name) + + if isinstance(attrs, dict): + cls_dict = attrs + elif isinstance(attrs, (list, tuple)): + cls_dict = {a: attrib() for a in attrs} + else: + msg = "attrs argument must be a dict or a list." + raise TypeError(msg) + + pre_init = cls_dict.pop("__attrs_pre_init__", None) + post_init = cls_dict.pop("__attrs_post_init__", None) + user_init = cls_dict.pop("__init__", None) + + body = {} + if class_body is not None: + body.update(class_body) + if pre_init is not None: + body["__attrs_pre_init__"] = pre_init + if post_init is not None: + body["__attrs_post_init__"] = post_init + if user_init is not None: + body["__init__"] = user_init + + type_ = types.new_class(name, bases, {}, lambda ns: ns.update(body)) + + # For pickling to work, the __module__ variable needs to be set to the + # frame where the class is created. Bypass this step in environments where + # sys._getframe is not defined (Jython for example) or sys._getframe is not + # defined for arguments greater than 0 (IronPython). + with contextlib.suppress(AttributeError, ValueError): + type_.__module__ = sys._getframe(1).f_globals.get( + "__name__", "__main__" + ) + + # We do it here for proper warnings with meaningful stacklevel. + cmp = attributes_arguments.pop("cmp", None) + ( + attributes_arguments["eq"], + attributes_arguments["order"], + ) = _determine_attrs_eq_order( + cmp, + attributes_arguments.get("eq"), + attributes_arguments.get("order"), + True, + ) + + cls = _attrs(these=cls_dict, **attributes_arguments)(type_) + # Only add type annotations now or "_attrs()" will complain: + cls.__annotations__ = { + k: v.type for k, v in cls_dict.items() if v.type is not None + } + return cls + + +# These are required by within this module so we define them here and merely +# import into .validators / .converters. + + +@attrs(slots=True, unsafe_hash=True) +class _AndValidator: + """ + Compose many validators to a single one. + """ + + _validators = attrib() + + def __call__(self, inst, attr, value): + for v in self._validators: + v(inst, attr, value) + + +def and_(*validators): + """ + A validator that composes multiple validators into one. + + When called on a value, it runs all wrapped validators. + + Args: + validators (~collections.abc.Iterable[typing.Callable]): + Arbitrary number of validators. + + .. versionadded:: 17.1.0 + """ + vals = [] + for validator in validators: + vals.extend( + validator._validators + if isinstance(validator, _AndValidator) + else [validator] + ) + + return _AndValidator(tuple(vals)) + + +def pipe(*converters): + """ + A converter that composes multiple converters into one. + + When called on a value, it runs all wrapped converters, returning the + *last* value. + + Type annotations will be inferred from the wrapped converters', if they + have any. + + converters (~collections.abc.Iterable[typing.Callable]): + Arbitrary number of converters. + + .. versionadded:: 20.1.0 + """ + + return_instance = any(isinstance(c, Converter) for c in converters) + + if return_instance: + + def pipe_converter(val, inst, field): + for c in converters: + val = ( + c(val, inst, field) if isinstance(c, Converter) else c(val) + ) + + return val + + else: + + def pipe_converter(val): + for c in converters: + val = c(val) + + return val + + if not converters: + # If the converter list is empty, pipe_converter is the identity. + A = TypeVar("A") + pipe_converter.__annotations__.update({"val": A, "return": A}) + else: + # Get parameter type from first converter. + t = _AnnotationExtractor(converters[0]).get_first_param_type() + if t: + pipe_converter.__annotations__["val"] = t + + last = converters[-1] + if not PY_3_11_PLUS and isinstance(last, Converter): + last = last.__call__ + + # Get return type from last converter. + rt = _AnnotationExtractor(last).get_return_type() + if rt: + pipe_converter.__annotations__["return"] = rt + + if return_instance: + return Converter(pipe_converter, takes_self=True, takes_field=True) + return pipe_converter diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_next_gen.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_next_gen.py new file mode 100644 index 00000000..4ccd0da2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_next_gen.py @@ -0,0 +1,674 @@ +# SPDX-License-Identifier: MIT + +""" +These are keyword-only APIs that call `attr.s` and `attr.ib` with different +default values. +""" + +from functools import partial + +from . import setters +from ._funcs import asdict as _asdict +from ._funcs import astuple as _astuple +from ._make import ( + _DEFAULT_ON_SETATTR, + NOTHING, + _frozen_setattrs, + attrib, + attrs, +) +from .exceptions import NotAnAttrsClassError, UnannotatedAttributeError + + +def define( + maybe_cls=None, + *, + these=None, + repr=None, + unsafe_hash=None, + hash=None, + init=None, + slots=True, + frozen=False, + weakref_slot=True, + str=False, + auto_attribs=None, + kw_only=False, + cache_hash=False, + auto_exc=True, + eq=None, + order=False, + auto_detect=True, + getstate_setstate=None, + on_setattr=None, + field_transformer=None, + match_args=True, + force_kw_only=False, +): + r""" + A class decorator that adds :term:`dunder methods` according to + :term:`fields ` specified using :doc:`type annotations `, + `field()` calls, or the *these* argument. + + Since *attrs* patches or replaces an existing class, you cannot use + `object.__init_subclass__` with *attrs* classes, because it runs too early. + As a replacement, you can define ``__attrs_init_subclass__`` on your class. + It will be called by *attrs* classes that subclass it after they're + created. See also :ref:`init-subclass`. + + Args: + slots (bool): + Create a :term:`slotted class ` that's more + memory-efficient. Slotted classes are generally superior to the + default dict classes, but have some gotchas you should know about, + so we encourage you to read the :term:`glossary entry `. + + auto_detect (bool): + Instead of setting the *init*, *repr*, *eq*, and *hash* arguments + explicitly, assume they are set to True **unless any** of the + involved methods for one of the arguments is implemented in the + *current* class (meaning, it is *not* inherited from some base + class). + + So, for example by implementing ``__eq__`` on a class yourself, + *attrs* will deduce ``eq=False`` and will create *neither* + ``__eq__`` *nor* ``__ne__`` (but Python classes come with a + sensible ``__ne__`` by default, so it *should* be enough to only + implement ``__eq__`` in most cases). + + Passing :data:`True` or :data:`False` to *init*, *repr*, *eq*, or *hash* + overrides whatever *auto_detect* would determine. + + auto_exc (bool): + If the class subclasses `BaseException` (which implicitly includes + any subclass of any exception), the following happens to behave + like a well-behaved Python exception class: + + - the values for *eq*, *order*, and *hash* are ignored and the + instances compare and hash by the instance's ids [#]_ , + - all attributes that are either passed into ``__init__`` or have a + default value are additionally available as a tuple in the + ``args`` attribute, + - the value of *str* is ignored leaving ``__str__`` to base + classes. + + .. [#] + Note that *attrs* will *not* remove existing implementations of + ``__hash__`` or the equality methods. It just won't add own + ones. + + on_setattr (~typing.Callable | list[~typing.Callable] | None | ~typing.Literal[attrs.setters.NO_OP]): + A callable that is run whenever the user attempts to set an + attribute (either by assignment like ``i.x = 42`` or by using + `setattr` like ``setattr(i, "x", 42)``). It receives the same + arguments as validators: the instance, the attribute that is being + modified, and the new value. + + If no exception is raised, the attribute is set to the return value + of the callable. + + If a list of callables is passed, they're automatically wrapped in + an `attrs.setters.pipe`. + + If left None, the default behavior is to run converters and + validators whenever an attribute is set. + + init (bool): + Create a ``__init__`` method that initializes the *attrs* + attributes. Leading underscores are stripped for the argument name, + unless an alias is set on the attribute. + + .. seealso:: + `init` shows advanced ways to customize the generated + ``__init__`` method, including executing code before and after. + + repr(bool): + Create a ``__repr__`` method with a human readable representation + of *attrs* attributes. + + str (bool): + Create a ``__str__`` method that is identical to ``__repr__``. This + is usually not necessary except for `Exception`\ s. + + eq (bool | None): + If True or None (default), add ``__eq__`` and ``__ne__`` methods + that check two instances for equality. + + .. seealso:: + `comparison` describes how to customize the comparison behavior + going as far comparing NumPy arrays. + + order (bool | None): + If True, add ``__lt__``, ``__le__``, ``__gt__``, and ``__ge__`` + methods that behave like *eq* above and allow instances to be + ordered. + + They compare the instances as if they were tuples of their *attrs* + attributes if and only if the types of both classes are + *identical*. + + If `None` mirror value of *eq*. + + .. seealso:: `comparison` + + unsafe_hash (bool | None): + If None (default), the ``__hash__`` method is generated according + how *eq* and *frozen* are set. + + 1. If *both* are True, *attrs* will generate a ``__hash__`` for + you. + 2. If *eq* is True and *frozen* is False, ``__hash__`` will be set + to None, marking it unhashable (which it is). + 3. If *eq* is False, ``__hash__`` will be left untouched meaning + the ``__hash__`` method of the base class will be used. If the + base class is `object`, this means it will fall back to id-based + hashing. + + Although not recommended, you can decide for yourself and force + *attrs* to create one (for example, if the class is immutable even + though you didn't freeze it programmatically) by passing True or + not. Both of these cases are rather special and should be used + carefully. + + .. seealso:: + + - Our documentation on `hashing`, + - Python's documentation on `object.__hash__`, + - and the `GitHub issue that led to the default \ behavior + `_ for more + details. + + hash (bool | None): + Deprecated alias for *unsafe_hash*. *unsafe_hash* takes precedence. + + cache_hash (bool): + Ensure that the object's hash code is computed only once and stored + on the object. If this is set to True, hashing must be either + explicitly or implicitly enabled for this class. If the hash code + is cached, avoid any reassignments of fields involved in hash code + computation or mutations of the objects those fields point to after + object creation. If such changes occur, the behavior of the + object's hash code is undefined. + + frozen (bool): + Make instances immutable after initialization. If someone attempts + to modify a frozen instance, `attrs.exceptions.FrozenInstanceError` + is raised. + + .. note:: + + 1. This is achieved by installing a custom ``__setattr__`` + method on your class, so you can't implement your own. + + 2. True immutability is impossible in Python. + + 3. This *does* have a minor a runtime performance `impact + ` when initializing new instances. In other + words: ``__init__`` is slightly slower with ``frozen=True``. + + 4. If a class is frozen, you cannot modify ``self`` in + ``__attrs_post_init__`` or a self-written ``__init__``. You + can circumvent that limitation by using + ``object.__setattr__(self, "attribute_name", value)``. + + 5. Subclasses of a frozen class are frozen too. + + kw_only (bool): + Make attributes keyword-only in the generated ``__init__`` (if + *init* is False, this parameter is ignored). Attributes that + explicitly set ``kw_only=False`` are not affected; base class + attributes are also not affected. + + Also see *force_kw_only*. + + weakref_slot (bool): + Make instances weak-referenceable. This has no effect unless + *slots* is True. + + field_transformer (~typing.Callable | None): + A function that is called with the original class object and all + fields right before *attrs* finalizes the class. You can use this, + for example, to automatically add converters or validators to + fields based on their types. + + .. seealso:: `transform-fields` + + match_args (bool): + If True (default), set ``__match_args__`` on the class to support + :pep:`634` (*Structural Pattern Matching*). It is a tuple of all + non-keyword-only ``__init__`` parameter names on Python 3.10 and + later. Ignored on older Python versions. + + collect_by_mro (bool): + If True, *attrs* collects attributes from base classes correctly + according to the `method resolution order + `_. If False, *attrs* + will mimic the (wrong) behavior of `dataclasses` and :pep:`681`. + + See also `issue #428 + `_. + + force_kw_only (bool): + A back-compat flag for restoring pre-25.4.0 behavior. If True and + ``kw_only=True``, all attributes are made keyword-only, including + base class attributes, and those set to ``kw_only=False`` at the + attribute level. Defaults to False. + + See also `issue #980 + `_. + + getstate_setstate (bool | None): + .. note:: + + This is usually only interesting for slotted classes and you + should probably just set *auto_detect* to True. + + If True, ``__getstate__`` and ``__setstate__`` are generated and + attached to the class. This is necessary for slotted classes to be + pickleable. If left None, it's True by default for slotted classes + and False for dict classes. + + If *auto_detect* is True, and *getstate_setstate* is left None, and + **either** ``__getstate__`` or ``__setstate__`` is detected + directly on the class (meaning: not inherited), it is set to False + (this is usually what you want). + + auto_attribs (bool | None): + If True, look at type annotations to determine which attributes to + use, like `dataclasses`. If False, it will only look for explicit + :func:`field` class attributes, like classic *attrs*. + + If left None, it will guess: + + 1. If any attributes are annotated and no unannotated + `attrs.field`\ s are found, it assumes *auto_attribs=True*. + 2. Otherwise it assumes *auto_attribs=False* and tries to collect + `attrs.field`\ s. + + If *attrs* decides to look at type annotations, **all** fields + **must** be annotated. If *attrs* encounters a field that is set to + a :func:`field` / `attr.ib` but lacks a type annotation, an + `attrs.exceptions.UnannotatedAttributeError` is raised. Use + ``field_name: typing.Any = field(...)`` if you don't want to set a + type. + + .. warning:: + + For features that use the attribute name to create decorators + (for example, :ref:`validators `), you still *must* + assign :func:`field` / `attr.ib` to them. Otherwise Python will + either not find the name or try to use the default value to + call, for example, ``validator`` on it. + + Attributes annotated as `typing.ClassVar`, and attributes that are + neither annotated nor set to an `field()` are **ignored**. + + these (dict[str, object]): + A dictionary of name to the (private) return value of `field()` + mappings. This is useful to avoid the definition of your attributes + within the class body because you can't (for example, if you want + to add ``__repr__`` methods to Django models) or don't want to. + + If *these* is not `None`, *attrs* will *not* search the class body + for attributes and will *not* remove any attributes from it. + + The order is deduced from the order of the attributes inside + *these*. + + Arguably, this is a rather obscure feature. + + .. versionadded:: 20.1.0 + .. versionchanged:: 21.3.0 Converters are also run ``on_setattr``. + .. versionadded:: 22.2.0 + *unsafe_hash* as an alias for *hash* (for :pep:`681` compliance). + .. versionchanged:: 24.1.0 + Instances are not compared as tuples of attributes anymore, but using a + big ``and`` condition. This is faster and has more correct behavior for + uncomparable values like `math.nan`. + .. versionadded:: 24.1.0 + If a class has an *inherited* classmethod called + ``__attrs_init_subclass__``, it is executed after the class is created. + .. deprecated:: 24.1.0 *hash* is deprecated in favor of *unsafe_hash*. + .. versionadded:: 24.3.0 + Unless already present, a ``__replace__`` method is automatically + created for `copy.replace` (Python 3.13+ only). + .. versionchanged:: 25.4.0 + *kw_only* now only applies to attributes defined in the current class, + and respects attribute-level ``kw_only=False`` settings. + .. versionadded:: 25.4.0 + Added *force_kw_only* to go back to the previous *kw_only* behavior. + + .. note:: + + The main differences to the classic `attr.s` are: + + - Automatically detect whether or not *auto_attribs* should be `True` + (c.f. *auto_attribs* parameter). + - Converters and validators run when attributes are set by default -- + if *frozen* is `False`. + - *slots=True* + + Usually, this has only upsides and few visible effects in everyday + programming. But it *can* lead to some surprising behaviors, so + please make sure to read :term:`slotted classes`. + + - *auto_exc=True* + - *auto_detect=True* + - *order=False* + - *force_kw_only=False* + - Some options that were only relevant on Python 2 or were kept around + for backwards-compatibility have been removed. + + """ + + def do_it(cls, auto_attribs): + return attrs( + maybe_cls=cls, + these=these, + repr=repr, + hash=hash, + unsafe_hash=unsafe_hash, + init=init, + slots=slots, + frozen=frozen, + weakref_slot=weakref_slot, + str=str, + auto_attribs=auto_attribs, + kw_only=kw_only, + cache_hash=cache_hash, + auto_exc=auto_exc, + eq=eq, + order=order, + auto_detect=auto_detect, + collect_by_mro=True, + getstate_setstate=getstate_setstate, + on_setattr=on_setattr, + field_transformer=field_transformer, + match_args=match_args, + force_kw_only=force_kw_only, + ) + + def wrap(cls): + """ + Making this a wrapper ensures this code runs during class creation. + + We also ensure that frozen-ness of classes is inherited. + """ + nonlocal frozen, on_setattr + + had_on_setattr = on_setattr not in (None, setters.NO_OP) + + # By default, mutable classes convert & validate on setattr. + if frozen is False and on_setattr is None: + on_setattr = _DEFAULT_ON_SETATTR + + # However, if we subclass a frozen class, we inherit the immutability + # and disable on_setattr. + for base_cls in cls.__bases__: + if base_cls.__setattr__ is _frozen_setattrs: + if had_on_setattr: + msg = "Frozen classes can't use on_setattr (frozen-ness was inherited)." + raise ValueError(msg) + + on_setattr = setters.NO_OP + break + + if auto_attribs is not None: + return do_it(cls, auto_attribs) + + try: + return do_it(cls, True) + except UnannotatedAttributeError: + return do_it(cls, False) + + # maybe_cls's type depends on the usage of the decorator. It's a class + # if it's used as `@attrs` but `None` if used as `@attrs()`. + if maybe_cls is None: + return wrap + + return wrap(maybe_cls) + + +mutable = define +frozen = partial(define, frozen=True, on_setattr=None) + + +def field( + *, + default=NOTHING, + validator=None, + repr=True, + hash=None, + init=True, + metadata=None, + type=None, + converter=None, + factory=None, + kw_only=None, + eq=None, + order=None, + on_setattr=None, + alias=None, +): + """ + Create a new :term:`field` / :term:`attribute` on a class. + + .. warning:: + + Does **nothing** unless the class is also decorated with + `attrs.define` (or similar)! + + Args: + default: + A value that is used if an *attrs*-generated ``__init__`` is used + and no value is passed while instantiating or the attribute is + excluded using ``init=False``. + + If the value is an instance of `attrs.Factory`, its callable will + be used to construct a new value (useful for mutable data types + like lists or dicts). + + If a default is not set (or set manually to `attrs.NOTHING`), a + value *must* be supplied when instantiating; otherwise a + `TypeError` will be raised. + + .. seealso:: `defaults` + + factory (~typing.Callable): + Syntactic sugar for ``default=attr.Factory(factory)``. + + validator (~typing.Callable | list[~typing.Callable]): + Callable that is called by *attrs*-generated ``__init__`` methods + after the instance has been initialized. They receive the + initialized instance, the :func:`~attrs.Attribute`, and the passed + value. + + The return value is *not* inspected so the validator has to throw + an exception itself. + + If a `list` is passed, its items are treated as validators and must + all pass. + + Validators can be globally disabled and re-enabled using + `attrs.validators.get_disabled` / `attrs.validators.set_disabled`. + + The validator can also be set using decorator notation as shown + below. + + .. seealso:: :ref:`validators` + + repr (bool | ~typing.Callable): + Include this attribute in the generated ``__repr__`` method. If + True, include the attribute; if False, omit it. By default, the + built-in ``repr()`` function is used. To override how the attribute + value is formatted, pass a ``callable`` that takes a single value + and returns a string. Note that the resulting string is used as-is, + which means it will be used directly *instead* of calling + ``repr()`` (the default). + + eq (bool | ~typing.Callable): + If True (default), include this attribute in the generated + ``__eq__`` and ``__ne__`` methods that check two instances for + equality. To override how the attribute value is compared, pass a + callable that takes a single value and returns the value to be + compared. + + .. seealso:: `comparison` + + order (bool | ~typing.Callable): + If True (default), include this attributes in the generated + ``__lt__``, ``__le__``, ``__gt__`` and ``__ge__`` methods. To + override how the attribute value is ordered, pass a callable that + takes a single value and returns the value to be ordered. + + .. seealso:: `comparison` + + hash (bool | None): + Include this attribute in the generated ``__hash__`` method. If + None (default), mirror *eq*'s value. This is the correct behavior + according the Python spec. Setting this value to anything else + than None is *discouraged*. + + .. seealso:: `hashing` + + init (bool): + Include this attribute in the generated ``__init__`` method. + + It is possible to set this to False and set a default value. In + that case this attributed is unconditionally initialized with the + specified default value or factory. + + .. seealso:: `init` + + converter (typing.Callable | Converter): + A callable that is called by *attrs*-generated ``__init__`` methods + to convert attribute's value to the desired format. + + If a vanilla callable is passed, it is given the passed-in value as + the only positional argument. It is possible to receive additional + arguments by wrapping the callable in a `Converter`. + + Either way, the returned value will be used as the new value of the + attribute. The value is converted before being passed to the + validator, if any. + + .. seealso:: :ref:`converters` + + metadata (dict | None): + An arbitrary mapping, to be used by third-party code. + + .. seealso:: `extending-metadata`. + + type (type): + The type of the attribute. Nowadays, the preferred method to + specify the type is using a variable annotation (see :pep:`526`). + This argument is provided for backwards-compatibility and for usage + with `make_class`. Regardless of the approach used, the type will + be stored on ``Attribute.type``. + + Please note that *attrs* doesn't do anything with this metadata by + itself. You can use it as part of your own code or for `static type + checking `. + + kw_only (bool | None): + Make this attribute keyword-only in the generated ``__init__`` (if + *init* is False, this parameter is ignored). If None (default), + mirror the setting from `attrs.define`. + + on_setattr (~typing.Callable | list[~typing.Callable] | None | ~typing.Literal[attrs.setters.NO_OP]): + Allows to overwrite the *on_setattr* setting from `attr.s`. If left + None, the *on_setattr* value from `attr.s` is used. Set to + `attrs.setters.NO_OP` to run **no** `setattr` hooks for this + attribute -- regardless of the setting in `define()`. + + alias (str | None): + Override this attribute's parameter name in the generated + ``__init__`` method. If left None, default to ``name`` stripped + of leading underscores. See `private-attributes`. + + .. versionadded:: 20.1.0 + .. versionchanged:: 21.1.0 + *eq*, *order*, and *cmp* also accept a custom callable + .. versionadded:: 22.2.0 *alias* + .. versionadded:: 23.1.0 + The *type* parameter has been re-added; mostly for `attrs.make_class`. + Please note that type checkers ignore this metadata. + .. versionchanged:: 25.4.0 + *kw_only* can now be None, and its default is also changed from False to + None. + + .. seealso:: + + `attr.ib` + """ + return attrib( + default=default, + validator=validator, + repr=repr, + hash=hash, + init=init, + metadata=metadata, + type=type, + converter=converter, + factory=factory, + kw_only=kw_only, + eq=eq, + order=order, + on_setattr=on_setattr, + alias=alias, + ) + + +def asdict(inst, *, recurse=True, filter=None, value_serializer=None): + """ + Same as `attr.asdict`, except that collections types are always retained + and dict is always used as *dict_factory*. + + .. versionadded:: 21.3.0 + """ + return _asdict( + inst=inst, + recurse=recurse, + filter=filter, + value_serializer=value_serializer, + retain_collection_types=True, + ) + + +def astuple(inst, *, recurse=True, filter=None): + """ + Same as `attr.astuple`, except that collections types are always retained + and `tuple` is always used as the *tuple_factory*. + + .. versionadded:: 21.3.0 + """ + return _astuple( + inst=inst, recurse=recurse, filter=filter, retain_collection_types=True + ) + + +def inspect(cls): + """ + Inspect the class and return its effective build parameters. + + Warning: + This feature is currently **experimental** and is not covered by our + strict backwards-compatibility guarantees. + + Args: + cls: The *attrs*-decorated class to inspect. + + Returns: + The effective build parameters of the class. + + Raises: + NotAnAttrsClassError: If the class is not an *attrs*-decorated class. + + .. versionadded:: 25.4.0 + """ + try: + return cls.__dict__["__attrs_props__"] + except KeyError: + msg = f"{cls!r} is not an attrs-decorated class." + raise NotAnAttrsClassError(msg) from None diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_typing_compat.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/_typing_compat.pyi new file mode 100644 index 00000000..ca7b71e9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_typing_compat.pyi @@ -0,0 +1,15 @@ +from typing import Any, ClassVar, Protocol + +# MYPY is a special constant in mypy which works the same way as `TYPE_CHECKING`. +MYPY = False + +if MYPY: + # A protocol to be able to statically accept an attrs class. + class AttrsInstance_(Protocol): + __attrs_attrs__: ClassVar[Any] + +else: + # For type checkers without plug-in support use an empty protocol that + # will (hopefully) be combined into a union. + class AttrsInstance_(Protocol): + pass diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.py new file mode 100644 index 00000000..27f18884 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: MIT + + +from functools import total_ordering + +from ._funcs import astuple +from ._make import attrib, attrs + + +@total_ordering +@attrs(eq=False, order=False, slots=True, frozen=True) +class VersionInfo: + """ + A version object that can be compared to tuple of length 1--4: + + >>> attr.VersionInfo(19, 1, 0, "final") <= (19, 2) + True + >>> attr.VersionInfo(19, 1, 0, "final") < (19, 1, 1) + True + >>> vi = attr.VersionInfo(19, 2, 0, "final") + >>> vi < (19, 1, 1) + False + >>> vi < (19,) + False + >>> vi == (19, 2,) + True + >>> vi == (19, 2, 1) + False + + .. versionadded:: 19.2 + """ + + year = attrib(type=int) + minor = attrib(type=int) + micro = attrib(type=int) + releaselevel = attrib(type=str) + + @classmethod + def _from_version_string(cls, s): + """ + Parse *s* and return a _VersionInfo. + """ + v = s.split(".") + if len(v) == 3: + v.append("final") + + return cls( + year=int(v[0]), minor=int(v[1]), micro=int(v[2]), releaselevel=v[3] + ) + + def _ensure_tuple(self, other): + """ + Ensure *other* is a tuple of a valid length. + + Returns a possibly transformed *other* and ourselves as a tuple of + the same length as *other*. + """ + + if self.__class__ is other.__class__: + other = astuple(other) + + if not isinstance(other, tuple): + raise NotImplementedError + + if not (1 <= len(other) <= 4): + raise NotImplementedError + + return astuple(self)[: len(other)], other + + def __eq__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + return us == them + + def __lt__(self, other): + try: + us, them = self._ensure_tuple(other) + except NotImplementedError: + return NotImplemented + + # Since alphabetically "dev0" < "final" < "post1" < "post2", we don't + # have to do anything special with releaselevel for now. + return us < them + + def __hash__(self): + return hash((self.year, self.minor, self.micro, self.releaselevel)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.pyi new file mode 100644 index 00000000..45ced086 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/_version_info.pyi @@ -0,0 +1,9 @@ +class VersionInfo: + @property + def year(self) -> int: ... + @property + def minor(self) -> int: ... + @property + def micro(self) -> int: ... + @property + def releaselevel(self) -> str: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.py new file mode 100644 index 00000000..0a79deef --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: MIT + +""" +Commonly useful converters. +""" + +import typing + +from ._compat import _AnnotationExtractor +from ._make import NOTHING, Converter, Factory, pipe + + +__all__ = [ + "default_if_none", + "optional", + "pipe", + "to_bool", +] + + +def optional(converter): + """ + A converter that allows an attribute to be optional. An optional attribute + is one which can be set to `None`. + + Type annotations will be inferred from the wrapped converter's, if it has + any. + + Args: + converter (typing.Callable): + the converter that is used for non-`None` values. + + .. versionadded:: 17.1.0 + """ + + if isinstance(converter, Converter): + + def optional_converter(val, inst, field): + if val is None: + return None + return converter(val, inst, field) + + else: + + def optional_converter(val): + if val is None: + return None + return converter(val) + + xtr = _AnnotationExtractor(converter) + + t = xtr.get_first_param_type() + if t: + optional_converter.__annotations__["val"] = typing.Optional[t] + + rt = xtr.get_return_type() + if rt: + optional_converter.__annotations__["return"] = typing.Optional[rt] + + if isinstance(converter, Converter): + return Converter(optional_converter, takes_self=True, takes_field=True) + + return optional_converter + + +def default_if_none(default=NOTHING, factory=None): + """ + A converter that allows to replace `None` values by *default* or the result + of *factory*. + + Args: + default: + Value to be used if `None` is passed. Passing an instance of + `attrs.Factory` is supported, however the ``takes_self`` option is + *not*. + + factory (typing.Callable): + A callable that takes no parameters whose result is used if `None` + is passed. + + Raises: + TypeError: If **neither** *default* or *factory* is passed. + + TypeError: If **both** *default* and *factory* are passed. + + ValueError: + If an instance of `attrs.Factory` is passed with + ``takes_self=True``. + + .. versionadded:: 18.2.0 + """ + if default is NOTHING and factory is None: + msg = "Must pass either `default` or `factory`." + raise TypeError(msg) + + if default is not NOTHING and factory is not None: + msg = "Must pass either `default` or `factory` but not both." + raise TypeError(msg) + + if factory is not None: + default = Factory(factory) + + if isinstance(default, Factory): + if default.takes_self: + msg = "`takes_self` is not supported by default_if_none." + raise ValueError(msg) + + def default_if_none_converter(val): + if val is not None: + return val + + return default.factory() + + else: + + def default_if_none_converter(val): + if val is not None: + return val + + return default + + return default_if_none_converter + + +def to_bool(val): + """ + Convert "boolean" strings (for example, from environment variables) to real + booleans. + + Values mapping to `True`: + + - ``True`` + - ``"true"`` / ``"t"`` + - ``"yes"`` / ``"y"`` + - ``"on"`` + - ``"1"`` + - ``1`` + + Values mapping to `False`: + + - ``False`` + - ``"false"`` / ``"f"`` + - ``"no"`` / ``"n"`` + - ``"off"`` + - ``"0"`` + - ``0`` + + Raises: + ValueError: For any other value. + + .. versionadded:: 21.3.0 + """ + if isinstance(val, str): + val = val.lower() + + if val in (True, "true", "t", "yes", "y", "on", "1", 1): + return True + if val in (False, "false", "f", "no", "n", "off", "0", 0): + return False + + msg = f"Cannot convert value to bool: {val!r}" + raise ValueError(msg) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.pyi new file mode 100644 index 00000000..12bd0c4f --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/converters.pyi @@ -0,0 +1,19 @@ +from typing import Callable, Any, overload + +from attrs import _ConverterType, _CallableConverterType + +@overload +def pipe(*validators: _CallableConverterType) -> _CallableConverterType: ... +@overload +def pipe(*validators: _ConverterType) -> _ConverterType: ... +@overload +def optional(converter: _CallableConverterType) -> _CallableConverterType: ... +@overload +def optional(converter: _ConverterType) -> _ConverterType: ... +@overload +def default_if_none(default: Any) -> _CallableConverterType: ... +@overload +def default_if_none( + *, factory: Callable[[], Any] +) -> _CallableConverterType: ... +def to_bool(val: str | int | bool) -> bool: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.py new file mode 100644 index 00000000..a207df40 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: MIT + +from __future__ import annotations + + +class FrozenError(AttributeError): + """ + A frozen/immutable instance or attribute have been attempted to be + modified. + + It mirrors the behavior of ``namedtuples`` by using the same error message + and subclassing `AttributeError`. + + .. versionadded:: 20.1.0 + """ + + def __init__(self): + msg = "can't set attribute" + super().__init__(msg) + self.msg = msg + + +class FrozenInstanceError(FrozenError): + """ + A frozen instance has been attempted to be modified. + + .. versionadded:: 16.1.0 + """ + + +class FrozenAttributeError(FrozenError): + """ + A frozen attribute has been attempted to be modified. + + .. versionadded:: 20.1.0 + """ + + +class AttrsAttributeNotFoundError(ValueError): + """ + An *attrs* function couldn't find an attribute that the user asked for. + + .. versionadded:: 16.2.0 + """ + + +class NotAnAttrsClassError(ValueError): + """ + A non-*attrs* class has been passed into an *attrs* function. + + .. versionadded:: 16.2.0 + """ + + +class DefaultAlreadySetError(RuntimeError): + """ + A default has been set when defining the field and is attempted to be reset + using the decorator. + + .. versionadded:: 17.1.0 + """ + + +class UnannotatedAttributeError(RuntimeError): + """ + A class with ``auto_attribs=True`` has a field without a type annotation. + + .. versionadded:: 17.3.0 + """ + + +class PythonTooOldError(RuntimeError): + """ + It was attempted to use an *attrs* feature that requires a newer Python + version. + + .. versionadded:: 18.2.0 + """ + + +class NotCallableError(TypeError): + """ + A field requiring a callable has been set with a value that is not + callable. + + .. versionadded:: 19.2.0 + """ + + def __init__(self, msg, value): + super(TypeError, self).__init__(msg, value) + self.msg = msg + self.value = value + + def __str__(self): + return str(self.msg) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.pyi new file mode 100644 index 00000000..f2680118 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/exceptions.pyi @@ -0,0 +1,17 @@ +from typing import Any + +class FrozenError(AttributeError): + msg: str = ... + +class FrozenInstanceError(FrozenError): ... +class FrozenAttributeError(FrozenError): ... +class AttrsAttributeNotFoundError(ValueError): ... +class NotAnAttrsClassError(ValueError): ... +class DefaultAlreadySetError(RuntimeError): ... +class UnannotatedAttributeError(RuntimeError): ... +class PythonTooOldError(RuntimeError): ... + +class NotCallableError(TypeError): + msg: str = ... + value: Any = ... + def __init__(self, msg: str, value: Any) -> None: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.py new file mode 100644 index 00000000..689b1705 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT + +""" +Commonly useful filters for `attrs.asdict` and `attrs.astuple`. +""" + +from ._make import Attribute + + +def _split_what(what): + """ + Returns a tuple of `frozenset`s of classes and attributes. + """ + return ( + frozenset(cls for cls in what if isinstance(cls, type)), + frozenset(cls for cls in what if isinstance(cls, str)), + frozenset(cls for cls in what if isinstance(cls, Attribute)), + ) + + +def include(*what): + """ + Create a filter that only allows *what*. + + Args: + what (list[type, str, attrs.Attribute]): + What to include. Can be a type, a name, or an attribute. + + Returns: + Callable: + A callable that can be passed to `attrs.asdict`'s and + `attrs.astuple`'s *filter* argument. + + .. versionchanged:: 23.1.0 Accept strings with field names. + """ + cls, names, attrs = _split_what(what) + + def include_(attribute, value): + return ( + value.__class__ in cls + or attribute.name in names + or attribute in attrs + ) + + return include_ + + +def exclude(*what): + """ + Create a filter that does **not** allow *what*. + + Args: + what (list[type, str, attrs.Attribute]): + What to exclude. Can be a type, a name, or an attribute. + + Returns: + Callable: + A callable that can be passed to `attrs.asdict`'s and + `attrs.astuple`'s *filter* argument. + + .. versionchanged:: 23.3.0 Accept field name string as input argument + """ + cls, names, attrs = _split_what(what) + + def exclude_(attribute, value): + return not ( + value.__class__ in cls + or attribute.name in names + or attribute in attrs + ) + + return exclude_ diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.pyi new file mode 100644 index 00000000..974abdcd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/filters.pyi @@ -0,0 +1,6 @@ +from typing import Any + +from . import Attribute, _FilterType + +def include(*what: type | str | Attribute[Any]) -> _FilterType[Any]: ... +def exclude(*what: type | str | Attribute[Any]) -> _FilterType[Any]: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/py.typed b/edge-cv-portal/backend/layers/workflow_core/python/attr/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.py new file mode 100644 index 00000000..78b08398 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: MIT + +""" +Commonly used hooks for on_setattr. +""" + +from . import _config +from .exceptions import FrozenAttributeError + + +def pipe(*setters): + """ + Run all *setters* and return the return value of the last one. + + .. versionadded:: 20.1.0 + """ + + def wrapped_pipe(instance, attrib, new_value): + rv = new_value + + for setter in setters: + rv = setter(instance, attrib, rv) + + return rv + + return wrapped_pipe + + +def frozen(_, __, ___): + """ + Prevent an attribute to be modified. + + .. versionadded:: 20.1.0 + """ + raise FrozenAttributeError + + +def validate(instance, attrib, new_value): + """ + Run *attrib*'s validator on *new_value* if it has one. + + .. versionadded:: 20.1.0 + """ + if _config._run_validators is False: + return new_value + + v = attrib.validator + if not v: + return new_value + + v(instance, attrib, new_value) + + return new_value + + +def convert(instance, attrib, new_value): + """ + Run *attrib*'s converter -- if it has one -- on *new_value* and return the + result. + + .. versionadded:: 20.1.0 + """ + c = attrib.converter + if c: + # This can be removed once we drop 3.8 and use attrs.Converter instead. + from ._make import Converter + + if not isinstance(c, Converter): + return c(new_value) + + return c(new_value, instance, attrib) + + return new_value + + +# Sentinel for disabling class-wide *on_setattr* hooks for certain attributes. +# Sphinx's autodata stopped working, so the docstring is inlined in the API +# docs. +NO_OP = object() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.pyi new file mode 100644 index 00000000..73abf36e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/setters.pyi @@ -0,0 +1,20 @@ +from typing import Any, NewType, NoReturn, TypeVar + +from . import Attribute +from attrs import _OnSetAttrType + +_T = TypeVar("_T") + +def frozen( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> NoReturn: ... +def pipe(*setters: _OnSetAttrType) -> _OnSetAttrType: ... +def validate(instance: Any, attribute: Attribute[_T], new_value: _T) -> _T: ... + +# convert is allowed to return Any, because they can be chained using pipe. +def convert( + instance: Any, attribute: Attribute[Any], new_value: Any +) -> Any: ... + +_NoOpType = NewType("_NoOpType", object) +NO_OP: _NoOpType diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.py b/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.py new file mode 100644 index 00000000..0b1a2944 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.py @@ -0,0 +1,750 @@ +# SPDX-License-Identifier: MIT + +""" +Commonly useful validators. +""" + +import operator +import re + +from contextlib import contextmanager +from re import Pattern + +from ._config import get_run_validators, set_run_validators +from ._make import _AndValidator, and_, attrib, attrs +from .converters import default_if_none +from .exceptions import NotCallableError + + +__all__ = [ + "and_", + "deep_iterable", + "deep_mapping", + "disabled", + "ge", + "get_disabled", + "gt", + "in_", + "instance_of", + "is_callable", + "le", + "lt", + "matches_re", + "max_len", + "min_len", + "not_", + "optional", + "or_", + "set_disabled", +] + + +def set_disabled(disabled): + """ + Globally disable or enable running validators. + + By default, they are run. + + Args: + disabled (bool): If `True`, disable running all validators. + + .. warning:: + + This function is not thread-safe! + + .. versionadded:: 21.3.0 + """ + set_run_validators(not disabled) + + +def get_disabled(): + """ + Return a bool indicating whether validators are currently disabled or not. + + Returns: + bool:`True` if validators are currently disabled. + + .. versionadded:: 21.3.0 + """ + return not get_run_validators() + + +@contextmanager +def disabled(): + """ + Context manager that disables running validators within its context. + + .. warning:: + + This context manager is not thread-safe! + + .. versionadded:: 21.3.0 + .. versionchanged:: 26.1.0 The contextmanager is nestable. + """ + prev = get_run_validators() + set_run_validators(False) + try: + yield + finally: + set_run_validators(prev) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _InstanceOfValidator: + type = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not isinstance(value, self.type): + msg = f"'{attr.name}' must be {self.type!r} (got {value!r} that is a {value.__class__!r})." + raise TypeError( + msg, + attr, + self.type, + value, + ) + + def __repr__(self): + return f"" + + +def instance_of(type): + """ + A validator that raises a `TypeError` if the initializer is called with a + wrong type for this particular attribute (checks are performed using + `isinstance` therefore it's also valid to pass a tuple of types). + + Args: + type (type | tuple[type]): The type to check for. + + Raises: + TypeError: + With a human readable error message, the attribute (of type + `attrs.Attribute`), the expected type, and the value it got. + """ + return _InstanceOfValidator(type) + + +@attrs(repr=False, frozen=True, slots=True) +class _MatchesReValidator: + pattern = attrib() + match_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.match_func(value): + msg = f"'{attr.name}' must match regex {self.pattern.pattern!r} ({value!r} doesn't)" + raise ValueError( + msg, + attr, + self.pattern, + value, + ) + + def __repr__(self): + return f"" + + +def matches_re(regex, flags=0, func=None): + r""" + A validator that raises `ValueError` if the initializer is called with a + string that doesn't match *regex*. + + Args: + regex (str, re.Pattern): + A regex string or precompiled pattern to match against + + flags (int): + Flags that will be passed to the underlying re function (default 0) + + func (typing.Callable): + Which underlying `re` function to call. Valid options are + `re.fullmatch`, `re.search`, and `re.match`; the default `None` + means `re.fullmatch`. For performance reasons, the pattern is + always precompiled using `re.compile`. + + .. versionadded:: 19.2.0 + .. versionchanged:: 21.3.0 *regex* can be a pre-compiled pattern. + """ + valid_funcs = (re.fullmatch, None, re.search, re.match) + if func not in valid_funcs: + msg = "'func' must be one of {}.".format( + ", ".join( + sorted((e and e.__name__) or "None" for e in set(valid_funcs)) + ) + ) + raise ValueError(msg) + + if isinstance(regex, Pattern): + if flags: + msg = "'flags' can only be used with a string pattern; pass flags to re.compile() instead" + raise TypeError(msg) + pattern = regex + else: + pattern = re.compile(regex, flags) + + if func is re.match: + match_func = pattern.match + elif func is re.search: + match_func = pattern.search + else: + match_func = pattern.fullmatch + + return _MatchesReValidator(pattern, match_func) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _OptionalValidator: + validator = attrib() + + def __call__(self, inst, attr, value): + if value is None: + return + + self.validator(inst, attr, value) + + def __repr__(self): + return f"" + + +def optional(validator): + """ + A validator that makes an attribute optional. An optional attribute is one + which can be set to `None` in addition to satisfying the requirements of + the sub-validator. + + Args: + validator + (typing.Callable | tuple[typing.Callable] | list[typing.Callable]): + A validator (or validators) that is used for non-`None` values. + + .. versionadded:: 15.1.0 + .. versionchanged:: 17.1.0 *validator* can be a list of validators. + .. versionchanged:: 23.1.0 *validator* can also be a tuple of validators. + """ + if isinstance(validator, (list, tuple)): + return _OptionalValidator(_AndValidator(validator)) + + return _OptionalValidator(validator) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _InValidator: + options = attrib() + _original_options = attrib(hash=False) + + def __call__(self, inst, attr, value): + try: + in_options = value in self.options + except TypeError: # e.g. `1 in "abc"` + in_options = False + + if not in_options: + msg = f"'{attr.name}' must be in {self._original_options!r} (got {value!r})" + raise ValueError( + msg, + attr, + self._original_options, + value, + ) + + def __repr__(self): + return f"" + + +def in_(options): + """ + A validator that raises a `ValueError` if the initializer is called with a + value that does not belong in the *options* provided. + + The check is performed using ``value in options``, so *options* has to + support that operation. + + To keep the validator hashable, dicts, lists, and sets are transparently + transformed into a `tuple`. + + Args: + options: Allowed options. + + Raises: + ValueError: + With a human readable error message, the attribute (of type + `attrs.Attribute`), the expected options, and the value it got. + + .. versionadded:: 17.1.0 + .. versionchanged:: 22.1.0 + The ValueError was incomplete until now and only contained the human + readable error message. Now it contains all the information that has + been promised since 17.1.0. + .. versionchanged:: 24.1.0 + *options* that are a list, dict, or a set are now transformed into a + tuple to keep the validator hashable. + """ + repr_options = options + if isinstance(options, (list, dict, set)): + options = tuple(options) + + return _InValidator(options, repr_options) + + +@attrs(repr=False, slots=False, unsafe_hash=True) +class _IsCallableValidator: + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not callable(value): + message = ( + "'{name}' must be callable " + "(got {value!r} that is a {actual!r})." + ) + raise NotCallableError( + msg=message.format( + name=attr.name, value=value, actual=value.__class__ + ), + value=value, + ) + + def __repr__(self): + return "" + + +def is_callable(): + """ + A validator that raises a `attrs.exceptions.NotCallableError` if the + initializer is called with a value for this particular attribute that is + not callable. + + .. versionadded:: 19.1.0 + + Raises: + attrs.exceptions.NotCallableError: + With a human readable error message containing the attribute + (`attrs.Attribute`) name, and the value it got. + """ + return _IsCallableValidator() + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _DeepIterable: + member_validator = attrib(validator=is_callable()) + iterable_validator = attrib( + default=None, validator=optional(is_callable()) + ) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.iterable_validator is not None: + self.iterable_validator(inst, attr, value) + + for member in value: + self.member_validator(inst, attr, member) + + def __repr__(self): + iterable_identifier = ( + "" + if self.iterable_validator is None + else f" {self.iterable_validator!r}" + ) + return ( + f"" + ) + + +def deep_iterable(member_validator, iterable_validator=None): + """ + A validator that performs deep validation of an iterable. + + Args: + member_validator: Validator(s) to apply to iterable members. + + iterable_validator: + Validator(s) to apply to iterable itself (optional). + + Raises + TypeError: if any sub-validators fail + + .. versionadded:: 19.1.0 + + .. versionchanged:: 25.4.0 + *member_validator* and *iterable_validator* can now be a list or tuple + of validators. + """ + if isinstance(member_validator, (list, tuple)): + member_validator = and_(*member_validator) + if isinstance(iterable_validator, (list, tuple)): + iterable_validator = and_(*iterable_validator) + return _DeepIterable(member_validator, iterable_validator) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _DeepMapping: + key_validator = attrib(validator=optional(is_callable())) + value_validator = attrib(validator=optional(is_callable())) + mapping_validator = attrib(validator=optional(is_callable())) + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if self.mapping_validator is not None: + self.mapping_validator(inst, attr, value) + + for key in value: + if self.key_validator is not None: + self.key_validator(inst, attr, key) + if self.value_validator is not None: + self.value_validator(inst, attr, value[key]) + + def __repr__(self): + return f"" + + +def deep_mapping( + key_validator=None, value_validator=None, mapping_validator=None +): + """ + A validator that performs deep validation of a dictionary. + + All validators are optional, but at least one of *key_validator* or + *value_validator* must be provided. + + Args: + key_validator: Validator(s) to apply to dictionary keys. + + value_validator: Validator(s) to apply to dictionary values. + + mapping_validator: + Validator(s) to apply to top-level mapping attribute. + + .. versionadded:: 19.1.0 + + .. versionchanged:: 25.4.0 + *key_validator* and *value_validator* are now optional, but at least one + of them must be provided. + + .. versionchanged:: 25.4.0 + *key_validator*, *value_validator*, and *mapping_validator* can now be a + list or tuple of validators. + + Raises: + TypeError: If any sub-validator fails on validation. + + ValueError: + If neither *key_validator* nor *value_validator* is provided on + instantiation. + """ + if key_validator is None and value_validator is None: + msg = ( + "At least one of key_validator or value_validator must be provided" + ) + raise ValueError(msg) + + if isinstance(key_validator, (list, tuple)): + key_validator = and_(*key_validator) + if isinstance(value_validator, (list, tuple)): + value_validator = and_(*value_validator) + if isinstance(mapping_validator, (list, tuple)): + mapping_validator = and_(*mapping_validator) + + return _DeepMapping(key_validator, value_validator, mapping_validator) + + +@attrs(repr=False, frozen=True, slots=True) +class _NumberValidator: + bound = attrib() + compare_op = attrib() + compare_func = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not self.compare_func(value, self.bound): + msg = f"'{attr.name}' must be {self.compare_op} {self.bound}: {value}" + raise ValueError(msg) + + def __repr__(self): + return f"" + + +def lt(val): + """ + A validator that raises `ValueError` if the initializer is called with a + number larger or equal to *val*. + + The validator uses `operator.lt` to compare the values. + + Args: + val: Exclusive upper bound for values. + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, "<", operator.lt) + + +def le(val): + """ + A validator that raises `ValueError` if the initializer is called with a + number greater than *val*. + + The validator uses `operator.le` to compare the values. + + Args: + val: Inclusive upper bound for values. + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, "<=", operator.le) + + +def ge(val): + """ + A validator that raises `ValueError` if the initializer is called with a + number smaller than *val*. + + The validator uses `operator.ge` to compare the values. + + Args: + val: Inclusive lower bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, ">=", operator.ge) + + +def gt(val): + """ + A validator that raises `ValueError` if the initializer is called with a + number smaller or equal to *val*. + + The validator uses `operator.gt` to compare the values. + + Args: + val: Exclusive lower bound for values + + .. versionadded:: 21.3.0 + """ + return _NumberValidator(val, ">", operator.gt) + + +@attrs(repr=False, frozen=True, slots=True) +class _MaxLengthValidator: + max_length = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if len(value) > self.max_length: + msg = f"Length of '{attr.name}' must be <= {self.max_length}: {len(value)}" + raise ValueError(msg) + + def __repr__(self): + return f"" + + +def max_len(length): + """ + A validator that raises `ValueError` if the initializer is called + with a string or iterable that is longer than *length*. + + Args: + length (int): Maximum length of the string or iterable + + .. versionadded:: 21.3.0 + """ + return _MaxLengthValidator(length) + + +@attrs(repr=False, frozen=True, slots=True) +class _MinLengthValidator: + min_length = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if len(value) < self.min_length: + msg = f"Length of '{attr.name}' must be >= {self.min_length}: {len(value)}" + raise ValueError(msg) + + def __repr__(self): + return f"" + + +def min_len(length): + """ + A validator that raises `ValueError` if the initializer is called + with a string or iterable that is shorter than *length*. + + Args: + length (int): Minimum length of the string or iterable + + .. versionadded:: 22.1.0 + """ + return _MinLengthValidator(length) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _SubclassOfValidator: + type = attrib() + + def __call__(self, inst, attr, value): + """ + We use a callable class to be able to change the ``__repr__``. + """ + if not issubclass(value, self.type): + msg = f"'{attr.name}' must be a subclass of {self.type!r} (got {value!r})." + raise TypeError( + msg, + attr, + self.type, + value, + ) + + def __repr__(self): + return f"" + + +def _subclass_of(type): + """ + A validator that raises a `TypeError` if the initializer is called with a + wrong type for this particular attribute (checks are performed using + `issubclass` therefore it's also valid to pass a tuple of types). + + Args: + type (type | tuple[type, ...]): The type(s) to check for. + + Raises: + TypeError: + With a human readable error message, the attribute (of type + `attrs.Attribute`), the expected type, and the value it got. + """ + return _SubclassOfValidator(type) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _NotValidator: + validator = attrib() + msg = attrib( + converter=default_if_none( + "not_ validator child '{validator!r}' " + "did not raise a captured error" + ) + ) + exc_types = attrib( + validator=deep_iterable( + member_validator=_subclass_of(Exception), + iterable_validator=instance_of(tuple), + ), + ) + + def __call__(self, inst, attr, value): + try: + self.validator(inst, attr, value) + except self.exc_types: + pass # suppress error to invert validity + else: + raise ValueError( + self.msg.format( + validator=self.validator, + exc_types=self.exc_types, + ), + attr, + self.validator, + value, + self.exc_types, + ) + + def __repr__(self): + return f"" + + +def not_(validator, *, msg=None, exc_types=(ValueError, TypeError)): + """ + A validator that wraps and logically 'inverts' the validator passed to it. + It will raise a `ValueError` if the provided validator *doesn't* raise a + `ValueError` or `TypeError` (by default), and will suppress the exception + if the provided validator *does*. + + Intended to be used with existing validators to compose logic without + needing to create inverted variants, for example, ``not_(in_(...))``. + + Args: + validator: A validator to be logically inverted. + + msg (str): + Message to raise if validator fails. Formatted with keys + ``exc_types`` and ``validator``. + + exc_types (tuple[type, ...]): + Exception type(s) to capture. Other types raised by child + validators will not be intercepted and pass through. + + Raises: + ValueError: + With a human readable error message, the attribute (of type + `attrs.Attribute`), the validator that failed to raise an + exception, the value it got, and the expected exception types. + + .. versionadded:: 22.2.0 + """ + try: + exc_types = tuple(exc_types) + except TypeError: + exc_types = (exc_types,) + return _NotValidator(validator, msg, exc_types) + + +@attrs(repr=False, slots=True, unsafe_hash=True) +class _OrValidator: + validators = attrib() + + def __call__(self, inst, attr, value): + for v in self.validators: + try: + v(inst, attr, value) + except Exception: # noqa: BLE001, PERF203, S112 + continue + else: + return + + msg = f"None of {self.validators!r} satisfied for value {value!r}" + raise ValueError(msg) + + def __repr__(self): + return f"" + + +def or_(*validators): + """ + A validator that composes multiple validators into one. + + When called on a value, it runs all wrapped validators until one of them is + satisfied. + + Args: + validators (~collections.abc.Iterable[typing.Callable]): + Arbitrary number of validators. + + Raises: + ValueError: + If no validator is satisfied. Raised with a human-readable error + message listing all the wrapped validators and the value that + failed all of them. + + .. versionadded:: 24.1.0 + """ + vals = [] + for v in validators: + vals.extend(v.validators if isinstance(v, _OrValidator) else [v]) + + return _OrValidator(tuple(vals)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.pyi new file mode 100644 index 00000000..18fb112c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attr/validators.pyi @@ -0,0 +1,140 @@ +from types import UnionType +from typing import ( + Any, + AnyStr, + Callable, + Container, + ContextManager, + Iterable, + Mapping, + Match, + Pattern, + TypeVar, + overload, +) + +from attrs import _ValidatorType +from attrs import _ValidatorArgType + +_T = TypeVar("_T") +_T1 = TypeVar("_T1") +_T2 = TypeVar("_T2") +_T3 = TypeVar("_T3") +_T4 = TypeVar("_T4") +_T5 = TypeVar("_T5") +_T6 = TypeVar("_T6") +_I = TypeVar("_I", bound=Iterable) +_K = TypeVar("_K") +_V = TypeVar("_V") +_M = TypeVar("_M", bound=Mapping) + +def set_disabled(run: bool) -> None: ... +def get_disabled() -> bool: ... +def disabled() -> ContextManager[None]: ... + +# To be more precise on instance_of use some overloads. +# If there are more than 3 items in the tuple then we fall back to Any +@overload +def instance_of(type: type[_T]) -> _ValidatorType[_T]: ... +@overload +def instance_of(type: tuple[type[_T]]) -> _ValidatorType[_T]: ... +@overload +def instance_of( + type: tuple[type[_T1], type[_T2]], +) -> _ValidatorType[_T1 | _T2]: ... +@overload +def instance_of( + type: tuple[type[_T1], type[_T2], type[_T3]], +) -> _ValidatorType[_T1 | _T2 | _T3]: ... +@overload +def instance_of(type: tuple[type, ...]) -> _ValidatorType[Any]: ... +@overload +def instance_of(type: UnionType) -> _ValidatorType[Any]: ... +def optional( + validator: ( + _ValidatorType[_T] + | list[_ValidatorType[_T]] + | tuple[_ValidatorType[_T], ...] + ), +) -> _ValidatorType[_T | None]: ... +def in_(options: Container[_T]) -> _ValidatorType[_T]: ... +def and_(*validators: _ValidatorType[_T]) -> _ValidatorType[_T]: ... +def matches_re( + regex: Pattern[AnyStr] | AnyStr, + flags: int = ..., + func: Callable[[AnyStr, AnyStr, int], Match[AnyStr] | None] | None = ..., +) -> _ValidatorType[AnyStr]: ... +def deep_iterable( + member_validator: _ValidatorArgType[_T], + iterable_validator: _ValidatorArgType[_I] | None = ..., +) -> _ValidatorType[_I]: ... +@overload +def deep_mapping( + key_validator: _ValidatorArgType[_K], + value_validator: _ValidatorArgType[_V] | None = ..., + mapping_validator: _ValidatorArgType[_M] | None = ..., +) -> _ValidatorType[_M]: ... +@overload +def deep_mapping( + key_validator: _ValidatorArgType[_K] | None = ..., + value_validator: _ValidatorArgType[_V] = ..., + mapping_validator: _ValidatorArgType[_M] | None = ..., +) -> _ValidatorType[_M]: ... +def is_callable() -> _ValidatorType[_T]: ... +def lt(val: _T) -> _ValidatorType[_T]: ... +def le(val: _T) -> _ValidatorType[_T]: ... +def ge(val: _T) -> _ValidatorType[_T]: ... +def gt(val: _T) -> _ValidatorType[_T]: ... +def max_len(length: int) -> _ValidatorType[_T]: ... +def min_len(length: int) -> _ValidatorType[_T]: ... +def not_( + validator: _ValidatorType[_T], + *, + msg: str | None = None, + exc_types: type[Exception] | Iterable[type[Exception]] = ..., +) -> _ValidatorType[_T]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], +) -> _ValidatorType[_T1 | _T2]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], +) -> _ValidatorType[_T1 | _T2 | _T3]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], + __v5: _ValidatorType[_T5], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4 | _T5]: ... +@overload +def or_( + __v1: _ValidatorType[_T1], + __v2: _ValidatorType[_T2], + __v3: _ValidatorType[_T3], + __v4: _ValidatorType[_T4], + __v5: _ValidatorType[_T5], + __v6: _ValidatorType[_T6], +) -> _ValidatorType[_T1 | _T2 | _T3 | _T4 | _T5 | _T6]: ... +@overload +def or_( + __v1: _ValidatorType[Any], + __v2: _ValidatorType[Any], + __v3: _ValidatorType[Any], + __v4: _ValidatorType[Any], + __v5: _ValidatorType[Any], + __v6: _ValidatorType[Any], + *validators: _ValidatorType[Any], +) -> _ValidatorType[Any]: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/METADATA new file mode 100644 index 00000000..5cf16a04 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/METADATA @@ -0,0 +1,199 @@ +Metadata-Version: 2.4 +Name: attrs +Version: 26.1.0 +Summary: Classes Without Boilerplate +Project-URL: Documentation, https://www.attrs.org/ +Project-URL: Changelog, https://www.attrs.org/en/stable/changelog.html +Project-URL: GitHub, https://github.com/python-attrs/attrs +Project-URL: Funding, https://github.com/sponsors/hynek +Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-attrs?utm_source=pypi-attrs&utm_medium=pypi +Author-email: Hynek Schlawack +License-Expression: MIT +License-File: LICENSE +Keywords: attribute,boilerplate,class +Classifier: Development Status :: 5 - Production/Stable +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Typing :: Typed +Requires-Python: >=3.9 +Description-Content-Type: text/markdown + +

+ + attrs + +

+ + +*attrs* is the Python package that will bring back the **joy** of **writing classes** by relieving you from the drudgery of implementing object protocols (aka [dunder methods](https://www.attrs.org/en/latest/glossary.html#term-dunder-methods)). +Trusted by NASA for [Mars missions since 2020](https://github.com/readme/featured/nasa-ingenuity-helicopter)! + +Its main goal is to help you to write **concise** and **correct** software without slowing down your code. + + +## Sponsors + +*attrs* would not be possible without our [amazing sponsors](https://github.com/sponsors/hynek). +Especially those generously supporting us at the *The Organization* tier and higher: + + + +

+ + + + + + + + + + + +

+ + + +

+ Please consider joining them to help make attrs’s maintenance more sustainable! +

+ + + +## Example + +*attrs* gives you a class decorator and a way to declaratively define the attributes on that class: + + + +```pycon +>>> from attrs import asdict, define, make_class, Factory + +>>> @define +... class SomeClass: +... a_number: int = 42 +... list_of_numbers: list[int] = Factory(list) +... +... def hard_math(self, another_number): +... return self.a_number + sum(self.list_of_numbers) * another_number + + +>>> sc = SomeClass(1, [1, 2, 3]) +>>> sc +SomeClass(a_number=1, list_of_numbers=[1, 2, 3]) + +>>> sc.hard_math(3) +19 +>>> sc == SomeClass(1, [1, 2, 3]) +True +>>> sc != SomeClass(2, [3, 2, 1]) +True + +>>> asdict(sc) +{'a_number': 1, 'list_of_numbers': [1, 2, 3]} + +>>> SomeClass() +SomeClass(a_number=42, list_of_numbers=[]) + +>>> C = make_class("C", ["a", "b"]) +>>> C("foo", "bar") +C(a='foo', b='bar') +``` + +After *declaring* your attributes, *attrs* gives you: + +- a concise and explicit overview of the class's attributes, +- a nice human-readable `__repr__`, +- equality-checking methods, +- an initializer, +- and much more, + +*without* writing dull boilerplate code again and again and *without* runtime performance penalties. + +--- + +This example uses *attrs*'s modern APIs that have been introduced in version 20.1.0, and the *attrs* package import name that has been added in version 21.3.0. +The classic APIs (`@attr.s`, `attr.ib`, plus their serious-business aliases) and the `attr` package import name will remain **indefinitely**. + +Check out [*On The Core API Names*](https://www.attrs.org/en/latest/names.html) for an in-depth explanation! + + +### Hate Type Annotations!? + +No problem! +Types are entirely **optional** with *attrs*. +Simply assign `attrs.field()` to the attributes instead of annotating them with types: + +```python +from attrs import define, field + +@define +class SomeClass: + a_number = field(default=42) + list_of_numbers = field(factory=list) +``` + + +## Data Classes + +On the tin, *attrs* might remind you of `dataclasses` (and indeed, `dataclasses` [are a descendant](https://hynek.me/articles/import-attrs/) of *attrs*). +In practice it does a lot more and is more flexible. +For instance, it allows you to define [special handling of NumPy arrays for equality checks](https://www.attrs.org/en/stable/comparison.html#customization), allows more ways to [plug into the initialization process](https://www.attrs.org/en/stable/init.html#hooking-yourself-into-initialization), has a replacement for `__init_subclass__`, and allows for stepping through the generated methods using a debugger. + +For more details, please refer to our [comparison page](https://www.attrs.org/en/stable/why.html#data-classes), but generally speaking, we are more likely to commit crimes against nature to make things work that one would expect to work, but that are quite complicated in practice. + + +## Project Information + +- [**Changelog**](https://www.attrs.org/en/stable/changelog.html) +- [**Documentation**](https://www.attrs.org/) +- [**PyPI**](https://pypi.org/project/attrs/) +- [**Source Code**](https://github.com/python-attrs/attrs) +- [**Contributing**](https://github.com/python-attrs/attrs/blob/main/.github/CONTRIBUTING.md) +- [**Third-party Extensions**](https://github.com/python-attrs/attrs/wiki/Extensions-to-attrs) +- **Get Help**: use the `python-attrs` tag on [Stack Overflow](https://stackoverflow.com/questions/tagged/python-attrs) + + +### *attrs* for Enterprise + +Available as part of the [Tidelift Subscription](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek). + +The maintainers of *attrs* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. +Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. + +## Release Information + +### Backwards-incompatible Changes + +- Field aliases are now resolved *before* calling `field_transformer`, so transformers receive fully populated `Attribute` objects with usable `alias` values instead of `None`. + The new `Attribute.alias_is_default` flag indicates whether the alias was auto-generated (`True`) or explicitly set by the user (`False`). + [#1509](https://github.com/python-attrs/attrs/issues/1509) + + +### Changes + +- Fix type annotations for `attrs.validators.optional()`, so it no longer rejects tuples with more than one validator. + [#1496](https://github.com/python-attrs/attrs/issues/1496) +- The `attrs.validators.disabled()` contextmanager can now be nested. + [#1513](https://github.com/python-attrs/attrs/issues/1513) +- Frozen classes can set `on_setattr=attrs.setters.NO_OP` in addition to `None`. + [#1515](https://github.com/python-attrs/attrs/issues/1515) +- It's now possible to pass *attrs* **instances** in addition to *attrs* **classes** to `attrs.fields()`. + [#1529](https://github.com/python-attrs/attrs/issues/1529) + + + +--- + +[Full changelog →](https://www.attrs.org/en/stable/changelog.html) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/RECORD new file mode 100644 index 00000000..585c7ee2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/RECORD @@ -0,0 +1,55 @@ +attr/__init__.py,sha256=fOYIvt1eGSqQre4uCS3sJWKZ0mwAuC8UD6qba5OS9_U,2057 +attr/__init__.pyi,sha256=pVGImAUVovq2_TYl_r_HIYnGlyOaoCuEhxo-EvsnnSc,11325 +attr/__pycache__/__init__.cpython-39.pyc,, +attr/__pycache__/_cmp.cpython-39.pyc,, +attr/__pycache__/_compat.cpython-39.pyc,, +attr/__pycache__/_config.cpython-39.pyc,, +attr/__pycache__/_funcs.cpython-39.pyc,, +attr/__pycache__/_make.cpython-39.pyc,, +attr/__pycache__/_next_gen.cpython-39.pyc,, +attr/__pycache__/_version_info.cpython-39.pyc,, +attr/__pycache__/converters.cpython-39.pyc,, +attr/__pycache__/exceptions.cpython-39.pyc,, +attr/__pycache__/filters.cpython-39.pyc,, +attr/__pycache__/setters.cpython-39.pyc,, +attr/__pycache__/validators.cpython-39.pyc,, +attr/_cmp.py,sha256=3Nn1TjxllUYiX_nJoVnEkXoDk0hM1DYKj5DE7GZe4i0,4117 +attr/_cmp.pyi,sha256=U-_RU_UZOyPUEQzXE6RMYQQcjkZRY25wTH99sN0s7MM,368 +attr/_compat.py,sha256=x0g7iEUOnBVJC72zyFCgb1eKqyxS-7f2LGnNyZ_r95s,2829 +attr/_config.py,sha256=dGq3xR6fgZEF6UBt_L0T-eUHIB4i43kRmH0P28sJVw8,843 +attr/_funcs.py,sha256=Ix5IETTfz5F01F-12MF_CSFomIn2h8b67EVVz2gCtBE,16479 +attr/_make.py,sha256=H7OH2eWS5CnBzLUjNFE1WymfPrmF1r8fv2RPdt9MuYA,106129 +attr/_next_gen.py,sha256=BQtCUlzwg2gWHTYXBQvrEYBnzBUrDvO57u0Py6UCPhc,26274 +attr/_typing_compat.pyi,sha256=XDP54TUn-ZKhD62TOQebmzrwFyomhUCoGRpclb6alRA,469 +attr/_version_info.py,sha256=w4R-FYC3NK_kMkGUWJlYP4cVAlH9HRaC-um3fcjYkHM,2222 +attr/_version_info.pyi,sha256=x_M3L3WuB7r_ULXAWjx959udKQ4HLB8l-hsc1FDGNvk,209 +attr/converters.py,sha256=GlDeOzPeTFgeBBLbj9G57Ez5lAk68uhSALRYJ_exe84,3861 +attr/converters.pyi,sha256=orU2bff-VjQa2kMDyvnMQV73oJT2WRyQuw4ZR1ym1bE,643 +attr/exceptions.py,sha256=b4vMbnoQ3VpwWZhqrYi_ssXVCK8o2c4HQSS09cSUM9o,1990 +attr/exceptions.pyi,sha256=zZq8bCUnKAy9mDtBEw42ZhPhAUIHoTKedDQInJD883M,539 +attr/filters.py,sha256=ZBiKWLp3R0LfCZsq7X11pn9WX8NslS2wXM4jsnLOGc8,1795 +attr/filters.pyi,sha256=3J5BG-dTxltBk1_-RuNRUHrv2qu1v8v4aDNAQ7_mifA,208 +attr/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +attr/setters.py,sha256=5-dcT63GQK35ONEzSgfXCkbB7pPkaR-qv15mm4PVSzQ,1617 +attr/setters.pyi,sha256=NnVkaFU1BB4JB8E4JuXyrzTUgvtMpj8p3wBdJY7uix4,584 +attr/validators.py,sha256=m3QRzZTANr4f2C4eVdUoFg11NgXWak8Wat4qQTGhvcs,21553 +attr/validators.pyi,sha256=gM1ZmHaBckyYWI2EirpRNzqm3B19cw5Iq6B4Kno9YCM,4087 +attrs-26.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +attrs-26.1.0.dist-info/METADATA,sha256=TNQOaQ8jvzfLytNO_WdY4GLfHfB8hoM_fjzpW_H6OMw,8754 +attrs-26.1.0.dist-info/RECORD,, +attrs-26.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87 +attrs-26.1.0.dist-info/licenses/LICENSE,sha256=iCEVyV38KvHutnFPjsbVy8q_Znyv-HKfQkINpj9xTp8,1109 +attrs/__init__.py,sha256=RxaAZNwYiEh-fcvHLZNpQ_DWKni73M_jxEPEftiq1Zc,1183 +attrs/__init__.pyi,sha256=2gV79g9UxJppGSM48hAZJ6h_MHb70dZoJL31ZNJeZYI,9416 +attrs/__pycache__/__init__.cpython-39.pyc,, +attrs/__pycache__/converters.cpython-39.pyc,, +attrs/__pycache__/exceptions.cpython-39.pyc,, +attrs/__pycache__/filters.cpython-39.pyc,, +attrs/__pycache__/setters.cpython-39.pyc,, +attrs/__pycache__/validators.cpython-39.pyc,, +attrs/converters.py,sha256=8kQljrVwfSTRu8INwEk8SI0eGrzmWftsT7rM0EqyohM,76 +attrs/exceptions.py,sha256=ACCCmg19-vDFaDPY9vFl199SPXCQMN_bENs4DALjzms,76 +attrs/filters.py,sha256=VOUMZug9uEU6dUuA0dF1jInUK0PL3fLgP0VBS5d-CDE,73 +attrs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +attrs/setters.py,sha256=eL1YidYQV3T2h9_SYIZSZR1FAcHGb1TuCTy0E0Lv2SU,73 +attrs/validators.py,sha256=xcy6wD5TtTkdCG1f4XWbocPSO0faBjk5IfVJfP6SUj0,76 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/WHEEL new file mode 100644 index 00000000..b1b94fd5 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.29.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/licenses/LICENSE b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/licenses/LICENSE new file mode 100644 index 00000000..2bd6453d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs-26.1.0.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Hynek Schlawack and the attrs contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.py new file mode 100644 index 00000000..dc1ce4b9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.py @@ -0,0 +1,72 @@ +# SPDX-License-Identifier: MIT + +from attr import ( + NOTHING, + Attribute, + AttrsInstance, + Converter, + Factory, + NothingType, + _make_getattr, + assoc, + cmp_using, + define, + evolve, + field, + fields, + fields_dict, + frozen, + has, + make_class, + mutable, + resolve_types, + validate, +) +from attr._make import ClassProps +from attr._next_gen import asdict, astuple, inspect + +from . import converters, exceptions, filters, setters, validators + + +__all__ = [ + "NOTHING", + "Attribute", + "AttrsInstance", + "ClassProps", + "Converter", + "Factory", + "NothingType", + "__author__", + "__copyright__", + "__description__", + "__doc__", + "__email__", + "__license__", + "__title__", + "__url__", + "__version__", + "__version_info__", + "asdict", + "assoc", + "astuple", + "cmp_using", + "converters", + "define", + "evolve", + "exceptions", + "field", + "fields", + "fields_dict", + "filters", + "frozen", + "has", + "inspect", + "make_class", + "mutable", + "resolve_types", + "setters", + "validate", + "validators", +] + +__getattr__ = _make_getattr(__name__) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.pyi b/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.pyi new file mode 100644 index 00000000..6364bac4 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/__init__.pyi @@ -0,0 +1,314 @@ +import sys + +from typing import ( + Any, + Callable, + Mapping, + Sequence, + overload, + TypeVar, +) + +# Because we need to type our own stuff, we have to make everything from +# attr explicitly public too. +from attr import __author__ as __author__ +from attr import __copyright__ as __copyright__ +from attr import __description__ as __description__ +from attr import __email__ as __email__ +from attr import __license__ as __license__ +from attr import __title__ as __title__ +from attr import __url__ as __url__ +from attr import __version__ as __version__ +from attr import __version_info__ as __version_info__ +from attr import assoc as assoc +from attr import Attribute as Attribute +from attr import AttrsInstance as AttrsInstance +from attr import cmp_using as cmp_using +from attr import converters as converters +from attr import Converter as Converter +from attr import evolve as evolve +from attr import exceptions as exceptions +from attr import Factory as Factory +from attr import fields as fields +from attr import fields_dict as fields_dict +from attr import filters as filters +from attr import has as has +from attr import make_class as make_class +from attr import NOTHING as NOTHING +from attr import resolve_types as resolve_types +from attr import setters as setters +from attr import validate as validate +from attr import validators as validators +from attr import attrib, asdict as asdict, astuple as astuple +from attr import NothingType as NothingType + +if sys.version_info >= (3, 11): + from typing import dataclass_transform +else: + from typing_extensions import dataclass_transform + +_T = TypeVar("_T") +_C = TypeVar("_C", bound=type) + +_EqOrderType = bool | Callable[[Any], Any] +_ValidatorType = Callable[[Any, "Attribute[_T]", _T], Any] +_CallableConverterType = Callable[[Any], Any] +_ConverterType = _CallableConverterType | Converter[Any, Any] +_ReprType = Callable[[Any], str] +_ReprArgType = bool | _ReprType +_OnSetAttrType = Callable[[Any, "Attribute[Any]", Any], Any] +_OnSetAttrArgType = _OnSetAttrType | list[_OnSetAttrType] | setters._NoOpType +_FieldTransformer = Callable[ + [type, list["Attribute[Any]"]], list["Attribute[Any]"] +] +# FIXME: in reality, if multiple validators are passed they must be in a list +# or tuple, but those are invariant and so would prevent subtypes of +# _ValidatorType from working when passed in a list or tuple. +_ValidatorArgType = _ValidatorType[_T] | Sequence[_ValidatorType[_T]] + +@overload +def field( + *, + default: None = ..., + validator: None = ..., + repr: _ReprArgType = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + converter: None = ..., + factory: None = ..., + kw_only: bool | None = ..., + eq: bool | None = ..., + order: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., + type: type | None = ..., +) -> Any: ... + +# This form catches an explicit None or no default and infers the type from the +# other arguments. +@overload +def field( + *, + default: None = ..., + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType, ...] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., + type: type | None = ..., +) -> _T: ... + +# This form catches an explicit default argument. +@overload +def field( + *, + default: _T, + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType, ...] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., + type: type | None = ..., +) -> _T: ... + +# This form covers type=non-Type: e.g. forward references (str), Any +@overload +def field( + *, + default: _T | None = ..., + validator: _ValidatorArgType[_T] | None = ..., + repr: _ReprArgType = ..., + hash: bool | None = ..., + init: bool = ..., + metadata: Mapping[Any, Any] | None = ..., + converter: _ConverterType + | list[_ConverterType] + | tuple[_ConverterType, ...] + | None = ..., + factory: Callable[[], _T] | None = ..., + kw_only: bool | None = ..., + eq: _EqOrderType | None = ..., + order: _EqOrderType | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + alias: str | None = ..., + type: type | None = ..., +) -> Any: ... +@overload +@dataclass_transform(field_specifiers=(attrib, field)) +def define( + maybe_cls: _C, + *, + these: dict[str, Any] | None = ..., + repr: bool = ..., + unsafe_hash: bool | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: bool | None = ..., + order: bool | None = ..., + auto_detect: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., +) -> _C: ... +@overload +@dataclass_transform(field_specifiers=(attrib, field)) +def define( + maybe_cls: None = ..., + *, + these: dict[str, Any] | None = ..., + repr: bool = ..., + unsafe_hash: bool | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: bool | None = ..., + order: bool | None = ..., + auto_detect: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., +) -> Callable[[_C], _C]: ... + +mutable = define + +@overload +@dataclass_transform(frozen_default=True, field_specifiers=(attrib, field)) +def frozen( + maybe_cls: _C, + *, + these: dict[str, Any] | None = ..., + repr: bool = ..., + unsafe_hash: bool | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: bool | None = ..., + order: bool | None = ..., + auto_detect: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., +) -> _C: ... +@overload +@dataclass_transform(frozen_default=True, field_specifiers=(attrib, field)) +def frozen( + maybe_cls: None = ..., + *, + these: dict[str, Any] | None = ..., + repr: bool = ..., + unsafe_hash: bool | None = ..., + hash: bool | None = ..., + init: bool = ..., + slots: bool = ..., + frozen: bool = ..., + weakref_slot: bool = ..., + str: bool = ..., + auto_attribs: bool = ..., + kw_only: bool = ..., + cache_hash: bool = ..., + auto_exc: bool = ..., + eq: bool | None = ..., + order: bool | None = ..., + auto_detect: bool = ..., + getstate_setstate: bool | None = ..., + on_setattr: _OnSetAttrArgType | None = ..., + field_transformer: _FieldTransformer | None = ..., + match_args: bool = ..., +) -> Callable[[_C], _C]: ... + +class ClassProps: + # XXX: somehow when defining/using enums Mypy starts looking at our own + # (untyped) code and causes tons of errors. + Hashability: Any + KeywordOnly: Any + + is_exception: bool + is_slotted: bool + has_weakref_slot: bool + is_frozen: bool + # kw_only: ClassProps.KeywordOnly + kw_only: Any + collected_fields_by_mro: bool + added_init: bool + added_repr: bool + added_eq: bool + added_ordering: bool + # hashability: ClassProps.Hashability + hashability: Any + added_match_args: bool + added_str: bool + added_pickling: bool + on_setattr_hook: _OnSetAttrType | None + field_transformer: Callable[[Attribute[Any]], Attribute[Any]] | None + + def __init__( + self, + is_exception: bool, + is_slotted: bool, + has_weakref_slot: bool, + is_frozen: bool, + # kw_only: ClassProps.KeywordOnly + kw_only: Any, + collected_fields_by_mro: bool, + added_init: bool, + added_repr: bool, + added_eq: bool, + added_ordering: bool, + # hashability: ClassProps.Hashability + hashability: Any, + added_match_args: bool, + added_str: bool, + added_pickling: bool, + on_setattr_hook: _OnSetAttrType, + field_transformer: Callable[[Attribute[Any]], Attribute[Any]], + ) -> None: ... + @property + def is_hashable(self) -> bool: ... + +def inspect(cls: type) -> ClassProps: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/converters.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/converters.py new file mode 100644 index 00000000..7821f6c0 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/converters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.converters import * # noqa: F403 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/exceptions.py new file mode 100644 index 00000000..3323f9d2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/exceptions.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.exceptions import * # noqa: F403 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/filters.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/filters.py new file mode 100644 index 00000000..3080f483 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/filters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.filters import * # noqa: F403 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/py.typed b/edge-cv-portal/backend/layers/workflow_core/python/attrs/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/setters.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/setters.py new file mode 100644 index 00000000..f3d73bb7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/setters.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.setters import * # noqa: F403 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/attrs/validators.py b/edge-cv-portal/backend/layers/workflow_core/python/attrs/validators.py new file mode 100644 index 00000000..037e124f --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/attrs/validators.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: MIT + +from attr.validators import * # noqa: F403 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/bin/jsonschema b/edge-cv-portal/backend/layers/workflow_core/python/bin/jsonschema new file mode 100755 index 00000000..de1bd8aa --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/bin/jsonschema @@ -0,0 +1,6 @@ +#!/usr/local/bin/python3 +import sys +from jsonschema.cli import main +if __name__ == '__main__': + sys.argv[0] = sys.argv[0].removesuffix('.exe') + sys.exit(main()) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/METADATA new file mode 100644 index 00000000..ba828d2e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/METADATA @@ -0,0 +1,170 @@ +Metadata-Version: 2.4 +Name: jsonschema +Version: 4.26.0 +Summary: An implementation of JSON Schema validation for Python +Project-URL: Homepage, https://github.com/python-jsonschema/jsonschema +Project-URL: Documentation, https://python-jsonschema.readthedocs.io/ +Project-URL: Issues, https://github.com/python-jsonschema/jsonschema/issues/ +Project-URL: Funding, https://github.com/sponsors/Julian +Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-jsonschema?utm_source=pypi-jsonschema&utm_medium=referral&utm_campaign=pypi-link +Project-URL: Changelog, https://github.com/python-jsonschema/jsonschema/blob/main/CHANGELOG.rst +Project-URL: Source, https://github.com/python-jsonschema/jsonschema +Author-email: Julian Berman +License-Expression: MIT +License-File: COPYING +Keywords: data validation,json,json schema,jsonschema,validation +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: File Formats :: JSON +Classifier: Topic :: File Formats :: JSON :: JSON Schema +Requires-Python: >=3.10 +Requires-Dist: attrs>=22.2.0 +Requires-Dist: jsonschema-specifications>=2023.03.6 +Requires-Dist: referencing>=0.28.4 +Requires-Dist: rpds-py>=0.25.0 +Provides-Extra: format +Requires-Dist: fqdn; extra == 'format' +Requires-Dist: idna; extra == 'format' +Requires-Dist: isoduration; extra == 'format' +Requires-Dist: jsonpointer>1.13; extra == 'format' +Requires-Dist: rfc3339-validator; extra == 'format' +Requires-Dist: rfc3987; extra == 'format' +Requires-Dist: uri-template; extra == 'format' +Requires-Dist: webcolors>=1.11; extra == 'format' +Provides-Extra: format-nongpl +Requires-Dist: fqdn; extra == 'format-nongpl' +Requires-Dist: idna; extra == 'format-nongpl' +Requires-Dist: isoduration; extra == 'format-nongpl' +Requires-Dist: jsonpointer>1.13; extra == 'format-nongpl' +Requires-Dist: rfc3339-validator; extra == 'format-nongpl' +Requires-Dist: rfc3986-validator>0.1.0; extra == 'format-nongpl' +Requires-Dist: rfc3987-syntax>=1.1.0; extra == 'format-nongpl' +Requires-Dist: uri-template; extra == 'format-nongpl' +Requires-Dist: webcolors>=24.6.0; extra == 'format-nongpl' +Description-Content-Type: text/x-rst + +========== +jsonschema +========== + +|PyPI| |Pythons| |CI| |ReadTheDocs| |Precommit| |Zenodo| + +.. |PyPI| image:: https://img.shields.io/pypi/v/jsonschema.svg + :alt: PyPI version + :target: https://pypi.org/project/jsonschema/ + +.. |Pythons| image:: https://img.shields.io/pypi/pyversions/jsonschema.svg + :alt: Supported Python versions + :target: https://pypi.org/project/jsonschema/ + +.. |CI| image:: https://github.com/python-jsonschema/jsonschema/workflows/CI/badge.svg + :alt: Build status + :target: https://github.com/python-jsonschema/jsonschema/actions?query=workflow%3ACI + +.. |ReadTheDocs| image:: https://readthedocs.org/projects/python-jsonschema/badge/?version=stable&style=flat + :alt: ReadTheDocs status + :target: https://python-jsonschema.readthedocs.io/en/stable/ + +.. |Precommit| image:: https://results.pre-commit.ci/badge/github/python-jsonschema/jsonschema/main.svg + :alt: pre-commit.ci status + :target: https://results.pre-commit.ci/latest/github/python-jsonschema/jsonschema/main + +.. |Zenodo| image:: https://zenodo.org/badge/3072629.svg + :alt: Zenodo DOI + :target: https://zenodo.org/badge/latestdoi/3072629 + + +``jsonschema`` is an implementation of the `JSON Schema `_ specification for Python. + +.. code:: python + + >>> from jsonschema import validate + + >>> # A sample schema, like what we'd get from json.load() + >>> schema = { + ... "type" : "object", + ... "properties" : { + ... "price" : {"type" : "number"}, + ... "name" : {"type" : "string"}, + ... }, + ... } + + >>> # If no exception is raised by validate(), the instance is valid. + >>> validate(instance={"name" : "Eggs", "price" : 34.99}, schema=schema) + + >>> validate( + ... instance={"name" : "Eggs", "price" : "Invalid"}, schema=schema, + ... ) # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + ... + ValidationError: 'Invalid' is not of type 'number' + +It can also be used from the command line by installing `check-jsonschema `_. + +Features +-------- + +* Full support for `Draft 2020-12 `_, `Draft 2019-09 `_, `Draft 7 `_, `Draft 6 `_, `Draft 4 `_ and `Draft 3 `_ + +* `Lazy validation `_ that can iteratively report *all* validation errors. + +* `Programmatic querying `_ of which properties or items failed validation. + + +Installation +------------ + +``jsonschema`` is available on `PyPI `_. You can install using `pip `_: + +.. code:: bash + + $ pip install jsonschema + + +Extras +====== + +Two extras are available when installing the package, both currently related to ``format`` validation: + + * ``format`` + * ``format-nongpl`` + +They can be used when installing in order to include additional dependencies, e.g.: + +.. code:: bash + + $ pip install jsonschema'[format]' + +Be aware that the mere presence of these dependencies – or even the specification of ``format`` checks in a schema – do *not* activate format checks (as per the specification). +Please read the `format validation documentation `_ for further details. + +About +----- + +I'm Julian Berman. + +``jsonschema`` is on `GitHub `_. + +Get in touch, via GitHub or otherwise, if you've got something to contribute, it'd be most welcome! + +If you feel overwhelmingly grateful, you can also `sponsor me `_. + +And for companies who appreciate ``jsonschema`` and its continued support and growth, ``jsonschema`` is also now supportable via `TideLift `_. + + +Release Information +------------------- + +v4.26.0 +======= + +* Decrease import time by delaying importing of ``urllib.request`` (#1416). diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/RECORD new file mode 100644 index 00000000..529adedf --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/RECORD @@ -0,0 +1,83 @@ +../../bin/jsonschema,sha256=XVo-VccUD0D5V9jRKTuR-5YfL5aVcM5i98D6j58PwSE,167 +jsonschema-4.26.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +jsonschema-4.26.0.dist-info/METADATA,sha256=BbGJhmypipbaOTS_a4NofO0_-FMlJzt34WtwX22LJtI,7592 +jsonschema-4.26.0.dist-info/RECORD,, +jsonschema-4.26.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +jsonschema-4.26.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87 +jsonschema-4.26.0.dist-info/entry_points.txt,sha256=vO7rX4Fs_xIVJy2pnAtKgTSxfpnozAVQ0DjCmpMxnWE,51 +jsonschema-4.26.0.dist-info/licenses/COPYING,sha256=T5KgFaE8TRoEC-8BiqE0MLTxvHO0Gxa7hGw0Z2bedDk,1057 +jsonschema/__init__.py,sha256=p-Rw4TS_0OPHZIJyImDWsdWgmd6CPWHMXLq7BuQxTGc,3941 +jsonschema/__main__.py,sha256=iLsZf2upUB3ilBKTlMnyK-HHt2Cnnfkwwxi_c6gLvSA,115 +jsonschema/__pycache__/__init__.cpython-39.pyc,, +jsonschema/__pycache__/__main__.cpython-39.pyc,, +jsonschema/__pycache__/_format.cpython-39.pyc,, +jsonschema/__pycache__/_keywords.cpython-39.pyc,, +jsonschema/__pycache__/_legacy_keywords.cpython-39.pyc,, +jsonschema/__pycache__/_types.cpython-39.pyc,, +jsonschema/__pycache__/_typing.cpython-39.pyc,, +jsonschema/__pycache__/_utils.cpython-39.pyc,, +jsonschema/__pycache__/cli.cpython-39.pyc,, +jsonschema/__pycache__/exceptions.cpython-39.pyc,, +jsonschema/__pycache__/protocols.cpython-39.pyc,, +jsonschema/__pycache__/validators.cpython-39.pyc,, +jsonschema/_format.py,sha256=lrs1U7iSB2ZX9Z2EalsROYeQm1d9kefw2CLo08G8P4s,15568 +jsonschema/_keywords.py,sha256=r8_DrqAfn6QLwQnmXEggveiSU-UaIL2p2nuPINelfFc,14949 +jsonschema/_legacy_keywords.py,sha256=2tWuwRPWbYS7EAl8wBIC_rabGuv1J4dfYLqNEPpShhA,15191 +jsonschema/_types.py,sha256=f_nkTDbrja3E3fsINTC73y_gMXTU210I_H7qK0NN30c,5456 +jsonschema/_typing.py,sha256=pXLnGy3jNtIUTq037kUfq0we17085eHtJwDJ2t--mQE,617 +jsonschema/_utils.py,sha256=Xv6_wKKslBJlwyj9-j2c8JDFw-4z4aWFnVe2pX8h7U4,10659 +jsonschema/benchmarks/__init__.py,sha256=A0sQrxDBVHSyQ-8ru3L11hMXf3q9gVuB9x_YgHb4R9M,70 +jsonschema/benchmarks/__pycache__/__init__.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/const_vs_enum.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/contains.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/import_benchmark.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/issue232.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/json_schema_test_suite.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/nested_schemas.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/subcomponents.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/unused_registry.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/useless_applicator_schemas.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/useless_keywords.cpython-39.pyc,, +jsonschema/benchmarks/__pycache__/validator_creation.cpython-39.pyc,, +jsonschema/benchmarks/const_vs_enum.py,sha256=DVFi3WDqBalZFOibnjpX1uTSr3Rxa2cPgFcowd7Ukrs,830 +jsonschema/benchmarks/contains.py,sha256=gexQoUrCOwECofbt19BeosQZ7WFL6PDdkX49DWwBlOg,786 +jsonschema/benchmarks/import_benchmark.py,sha256=2DRIOVS6p-bqLn_822B_X5FkIRvsJ2V1CYnV2druzDs,779 +jsonschema/benchmarks/issue232.py,sha256=3LLYLIlBGQnVuyyo2iAv-xky5P6PRFHANx4-zIIQOoE,521 +jsonschema/benchmarks/issue232/issue.json,sha256=eaPOZjMRu5u8RpKrsA9uk7ucPZS5tkKG4D_hkOTQ3Hk,117105 +jsonschema/benchmarks/json_schema_test_suite.py,sha256=PvfabpUYcF4_7csYDTcTauED8rnFEGYbdY5RqTXD08s,320 +jsonschema/benchmarks/nested_schemas.py,sha256=mo07dx-CIgmSOI62CNs4g5xu1FzHklLBpkQoDxWYcKs,1892 +jsonschema/benchmarks/subcomponents.py,sha256=fEyiMzsWeK2pd7DEGCuuY-vzGunwhHczRBWEnBRLKIo,1113 +jsonschema/benchmarks/unused_registry.py,sha256=hwRwONc9cefPtYzkoX_TYRO3GyUojriv0-YQaK3vnj0,940 +jsonschema/benchmarks/useless_applicator_schemas.py,sha256=EVm5-EtOEFoLP_Vt2j4SrCwlx05NhPqNuZQ6LIMP1Dc,3342 +jsonschema/benchmarks/useless_keywords.py,sha256=bj_zKr1oVctFlqyZaObCsYTgFjiiNgPzC0hr1Y868mE,867 +jsonschema/benchmarks/validator_creation.py,sha256=UkUQlLAnussnr_KdCIdad6xx2pXxQLmYtsXoiirKeWQ,285 +jsonschema/cli.py,sha256=av90OtpSxuiko3FAyEHtxpI-NSuX3WMtoQpIx09obJY,8445 +jsonschema/exceptions.py,sha256=b42hUDOfPFcprI4ZlNpjDeLKv8k9vOhgWct2jyDlxzk,15256 +jsonschema/protocols.py,sha256=lgyryVHqFijSFRAz95ch3VN2Fz-oTFyhj9Obcy5P_CI,7198 +jsonschema/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +jsonschema/tests/__pycache__/__init__.cpython-39.pyc,, +jsonschema/tests/__pycache__/_suite.cpython-39.pyc,, +jsonschema/tests/__pycache__/fuzz_validate.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_cli.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_deprecations.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_exceptions.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_format.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_jsonschema_test_suite.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_types.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_utils.cpython-39.pyc,, +jsonschema/tests/__pycache__/test_validators.cpython-39.pyc,, +jsonschema/tests/_suite.py,sha256=2k0X91N7dOHhQc5mrYv40OKf1weioj6RMBqWgLT6-PI,8374 +jsonschema/tests/fuzz_validate.py,sha256=fUA7yTJIihaCwJplkUehZeyB84HcXEcqtY5oPJXIO7I,1114 +jsonschema/tests/test_cli.py,sha256=A89r5LOHy-peLPZA5YDkOaMTWqzQO_w2Tu8WFz_vphM,28544 +jsonschema/tests/test_deprecations.py,sha256=yG6mkRJHpTHbWoxpLC5y5H7fk8erGOs8f_9V4tCBEh8,15754 +jsonschema/tests/test_exceptions.py,sha256=rsEqdjknUz3dEyJb6_DiModPHXsJTEWzDrxUscg8G5o,24301 +jsonschema/tests/test_format.py,sha256=eVm5SMaWF2lOPO28bPAwNvkiQvHCQKy-MnuAgEchfEc,3188 +jsonschema/tests/test_jsonschema_test_suite.py,sha256=tAfxknM65OR9LyDPHu1pkEaombLgjRLnJ6FPiWPdxjg,8461 +jsonschema/tests/test_types.py,sha256=cF51KTDmdsx06MrIc4fXKt0X9fIsVgw5uhT8CamVa8U,6977 +jsonschema/tests/test_utils.py,sha256=sao74o1PyYMxBfqweokQN48CFSS6yhJk5FkCfMJ5PsI,4163 +jsonschema/tests/test_validators.py,sha256=uzl-agyLoZPTufouIHSxFPfPYrdIdjNwjHcD_5NyU4E,87949 +jsonschema/tests/typing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +jsonschema/tests/typing/__pycache__/__init__.cpython-39.pyc,, +jsonschema/tests/typing/__pycache__/test_all_concrete_validators_match_protocol.cpython-39.pyc,, +jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py,sha256=I5XUl5ZYUSZo3APkF10l8Mnfk09oXKqvXWttGGd3sDk,1238 +jsonschema/validators.py,sha256=l_FxuZW0sMoFUwqTZqElL7F1P5nzy3sQaLbZqrEJb6U,47159 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/REQUESTED b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/REQUESTED new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/WHEEL new file mode 100644 index 00000000..ae8ec1bd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.28.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/entry_points.txt b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/entry_points.txt new file mode 100644 index 00000000..eecef9d8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +jsonschema = jsonschema.cli:main diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/licenses/COPYING b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/licenses/COPYING new file mode 100644 index 00000000..af9cfbdb --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema-4.26.0.dist-info/licenses/COPYING @@ -0,0 +1,19 @@ +Copyright (c) 2013 Julian Berman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__init__.py new file mode 100644 index 00000000..d8dec8cf --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__init__.py @@ -0,0 +1,120 @@ +""" +An implementation of JSON Schema for Python. + +The main functionality is provided by the validator classes for each of the +supported JSON Schema versions. + +Most commonly, `jsonschema.validators.validate` is the quickest way to simply +validate a given instance under a schema, and will create a validator +for you. +""" +import warnings + +from jsonschema._format import FormatChecker +from jsonschema._types import TypeChecker +from jsonschema.exceptions import SchemaError, ValidationError +from jsonschema.validators import ( + Draft3Validator, + Draft4Validator, + Draft6Validator, + Draft7Validator, + Draft201909Validator, + Draft202012Validator, + validate, +) + + +def __getattr__(name): + if name == "__version__": + warnings.warn( + "Accessing jsonschema.__version__ is deprecated and will be " + "removed in a future release. Use importlib.metadata directly " + "to query for jsonschema's version.", + DeprecationWarning, + stacklevel=2, + ) + + from importlib import metadata + return metadata.version("jsonschema") + elif name == "RefResolver": + from jsonschema.validators import _RefResolver + warnings.warn( + _RefResolver._DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) + return _RefResolver + elif name == "ErrorTree": + warnings.warn( + "Importing ErrorTree directly from the jsonschema package " + "is deprecated and will become an ImportError. Import it from " + "jsonschema.exceptions instead.", + DeprecationWarning, + stacklevel=2, + ) + from jsonschema.exceptions import ErrorTree + return ErrorTree + elif name == "FormatError": + warnings.warn( + "Importing FormatError directly from the jsonschema package " + "is deprecated and will become an ImportError. Import it from " + "jsonschema.exceptions instead.", + DeprecationWarning, + stacklevel=2, + ) + from jsonschema.exceptions import FormatError + return FormatError + elif name == "Validator": + warnings.warn( + "Importing Validator directly from the jsonschema package " + "is deprecated and will become an ImportError. Import it from " + "jsonschema.protocols instead.", + DeprecationWarning, + stacklevel=2, + ) + from jsonschema.protocols import Validator + return Validator + elif name == "RefResolutionError": + from jsonschema.exceptions import _RefResolutionError + warnings.warn( + _RefResolutionError._DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) + return _RefResolutionError + + format_checkers = { + "draft3_format_checker": Draft3Validator, + "draft4_format_checker": Draft4Validator, + "draft6_format_checker": Draft6Validator, + "draft7_format_checker": Draft7Validator, + "draft201909_format_checker": Draft201909Validator, + "draft202012_format_checker": Draft202012Validator, + } + ValidatorForFormat = format_checkers.get(name) + if ValidatorForFormat is not None: + warnings.warn( + f"Accessing jsonschema.{name} is deprecated and will be " + "removed in a future release. Instead, use the FORMAT_CHECKER " + "attribute on the corresponding Validator.", + DeprecationWarning, + stacklevel=2, + ) + return ValidatorForFormat.FORMAT_CHECKER + + raise AttributeError(f"module {__name__} has no attribute {name}") + + +__all__ = [ + "Draft3Validator", + "Draft4Validator", + "Draft6Validator", + "Draft7Validator", + "Draft201909Validator", + "Draft202012Validator", + "FormatChecker", + "SchemaError", + "TypeChecker", + "ValidationError", + "validate", +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__main__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__main__.py new file mode 100644 index 00000000..fb260ae1 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/__main__.py @@ -0,0 +1,6 @@ +""" +The jsonschema CLI is now deprecated in favor of check-jsonschema. +""" +from jsonschema.cli import main + +main() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_format.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_format.py new file mode 100644 index 00000000..5efda86e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_format.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +from contextlib import suppress +from datetime import date, datetime +from uuid import UUID +import ipaddress +import re +import typing +import warnings + +from jsonschema.exceptions import FormatError + +_FormatCheckCallable = typing.Callable[[object], bool] +#: A format checker callable. +_F = typing.TypeVar("_F", bound=_FormatCheckCallable) +_RaisesType = type[Exception] | tuple[type[Exception], ...] + +_RE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$", re.ASCII) + + +class FormatChecker: + """ + A ``format`` property checker. + + JSON Schema does not mandate that the ``format`` property actually do any + validation. If validation is desired however, instances of this class can + be hooked into validators to enable format validation. + + `FormatChecker` objects always return ``True`` when asked about + formats that they do not know how to validate. + + To add a check for a custom format use the `FormatChecker.checks` + decorator. + + Arguments: + + formats: + + The known formats to validate. This argument can be used to + limit which formats will be used during validation. + + """ + + checkers: dict[ + str, + tuple[_FormatCheckCallable, _RaisesType], + ] = {} # noqa: RUF012 + + def __init__(self, formats: typing.Iterable[str] | None = None): + if formats is None: + formats = self.checkers.keys() + self.checkers = {k: self.checkers[k] for k in formats} + + def __repr__(self): + return f"" + + def checks( + self, format: str, raises: _RaisesType = (), + ) -> typing.Callable[[_F], _F]: + """ + Register a decorated function as validating a new format. + + Arguments: + + format: + + The format that the decorated function will check. + + raises: + + The exception(s) raised by the decorated function when an + invalid instance is found. + + The exception object will be accessible as the + `jsonschema.exceptions.ValidationError.cause` attribute of the + resulting validation error. + + """ + + def _checks(func: _F) -> _F: + self.checkers[format] = (func, raises) + return func + + return _checks + + @classmethod + def cls_checks( + cls, format: str, raises: _RaisesType = (), + ) -> typing.Callable[[_F], _F]: + warnings.warn( + ( + "FormatChecker.cls_checks is deprecated. Call " + "FormatChecker.checks on a specific FormatChecker instance " + "instead." + ), + DeprecationWarning, + stacklevel=2, + ) + return cls._cls_checks(format=format, raises=raises) + + @classmethod + def _cls_checks( + cls, format: str, raises: _RaisesType = (), + ) -> typing.Callable[[_F], _F]: + def _checks(func: _F) -> _F: + cls.checkers[format] = (func, raises) + return func + + return _checks + + def check(self, instance: object, format: str) -> None: + """ + Check whether the instance conforms to the given format. + + Arguments: + + instance (*any primitive type*, i.e. str, number, bool): + + The instance to check + + format: + + The format that instance should conform to + + Raises: + + FormatError: + + if the instance does not conform to ``format`` + + """ + if format not in self.checkers: + return + + func, raises = self.checkers[format] + result, cause = None, None + try: + result = func(instance) + except raises as e: + cause = e + if not result: + raise FormatError(f"{instance!r} is not a {format!r}", cause=cause) + + def conforms(self, instance: object, format: str) -> bool: + """ + Check whether the instance conforms to the given format. + + Arguments: + + instance (*any primitive type*, i.e. str, number, bool): + + The instance to check + + format: + + The format that instance should conform to + + Returns: + + bool: whether it conformed + + """ + try: + self.check(instance, format) + except FormatError: + return False + else: + return True + + +draft3_format_checker = FormatChecker() +draft4_format_checker = FormatChecker() +draft6_format_checker = FormatChecker() +draft7_format_checker = FormatChecker() +draft201909_format_checker = FormatChecker() +draft202012_format_checker = FormatChecker() + +_draft_checkers: dict[str, FormatChecker] = dict( + draft3=draft3_format_checker, + draft4=draft4_format_checker, + draft6=draft6_format_checker, + draft7=draft7_format_checker, + draft201909=draft201909_format_checker, + draft202012=draft202012_format_checker, +) + + +def _checks_drafts( + name=None, + draft3=None, + draft4=None, + draft6=None, + draft7=None, + draft201909=None, + draft202012=None, + raises=(), +) -> typing.Callable[[_F], _F]: + draft3 = draft3 or name + draft4 = draft4 or name + draft6 = draft6 or name + draft7 = draft7 or name + draft201909 = draft201909 or name + draft202012 = draft202012 or name + + def wrap(func: _F) -> _F: + if draft3: + func = _draft_checkers["draft3"].checks(draft3, raises)(func) + if draft4: + func = _draft_checkers["draft4"].checks(draft4, raises)(func) + if draft6: + func = _draft_checkers["draft6"].checks(draft6, raises)(func) + if draft7: + func = _draft_checkers["draft7"].checks(draft7, raises)(func) + if draft201909: + func = _draft_checkers["draft201909"].checks(draft201909, raises)( + func, + ) + if draft202012: + func = _draft_checkers["draft202012"].checks(draft202012, raises)( + func, + ) + + # Oy. This is bad global state, but relied upon for now, until + # deprecation. See #519 and test_format_checkers_come_with_defaults + FormatChecker._cls_checks( + draft202012 or draft201909 or draft7 or draft6 or draft4 or draft3, + raises, + )(func) + return func + + return wrap + + +@_checks_drafts(name="idn-email") +@_checks_drafts(name="email") +def is_email(instance: object) -> bool: + if not isinstance(instance, str): + return True + return "@" in instance + + +@_checks_drafts( + draft3="ip-address", + draft4="ipv4", + draft6="ipv4", + draft7="ipv4", + draft201909="ipv4", + draft202012="ipv4", + raises=ipaddress.AddressValueError, +) +def is_ipv4(instance: object) -> bool: + if not isinstance(instance, str): + return True + return bool(ipaddress.IPv4Address(instance)) + + +@_checks_drafts(name="ipv6", raises=ipaddress.AddressValueError) +def is_ipv6(instance: object) -> bool: + if not isinstance(instance, str): + return True + address = ipaddress.IPv6Address(instance) + return not getattr(address, "scope_id", "") + + +with suppress(ImportError): + from fqdn import FQDN + + @_checks_drafts( + draft3="host-name", + draft4="hostname", + draft6="hostname", + draft7="hostname", + draft201909="hostname", + draft202012="hostname", + # fqdn.FQDN("") raises a ValueError due to a bug + # however, it's not clear when or if that will be fixed, so catch it + # here for now + raises=ValueError, + ) + def is_host_name(instance: object) -> bool: + if not isinstance(instance, str): + return True + return FQDN(instance, min_labels=1).is_valid + + +with suppress(ImportError): + # The built-in `idna` codec only implements RFC 3890, so we go elsewhere. + import idna + + @_checks_drafts( + draft7="idn-hostname", + draft201909="idn-hostname", + draft202012="idn-hostname", + raises=(idna.IDNAError, UnicodeError), + ) + def is_idn_host_name(instance: object) -> bool: + if not isinstance(instance, str): + return True + idna.encode(instance) + return True + + +try: + import rfc3987 +except ImportError: + with suppress(ImportError): + from rfc3986_validator import validate_rfc3986 + + @_checks_drafts(name="uri") + def is_uri(instance: object) -> bool: + if not isinstance(instance, str): + return True + return validate_rfc3986(instance, rule="URI") + + @_checks_drafts( + draft6="uri-reference", + draft7="uri-reference", + draft201909="uri-reference", + draft202012="uri-reference", + raises=ValueError, + ) + def is_uri_reference(instance: object) -> bool: + if not isinstance(instance, str): + return True + return validate_rfc3986(instance, rule="URI_reference") + + with suppress(ImportError): + from rfc3987_syntax import is_valid_syntax as _rfc3987_is_valid_syntax + + @_checks_drafts( + draft7="iri", + draft201909="iri", + draft202012="iri", + raises=ValueError, + ) + def is_iri(instance: object) -> bool: + if not isinstance(instance, str): + return True + return _rfc3987_is_valid_syntax("iri", instance) + + @_checks_drafts( + draft7="iri-reference", + draft201909="iri-reference", + draft202012="iri-reference", + raises=ValueError, + ) + def is_iri_reference(instance: object) -> bool: + if not isinstance(instance, str): + return True + return _rfc3987_is_valid_syntax("iri_reference", instance) + +else: + + @_checks_drafts( + draft7="iri", + draft201909="iri", + draft202012="iri", + raises=ValueError, + ) + def is_iri(instance: object) -> bool: + if not isinstance(instance, str): + return True + return rfc3987.parse(instance, rule="IRI") + + @_checks_drafts( + draft7="iri-reference", + draft201909="iri-reference", + draft202012="iri-reference", + raises=ValueError, + ) + def is_iri_reference(instance: object) -> bool: + if not isinstance(instance, str): + return True + return rfc3987.parse(instance, rule="IRI_reference") + + @_checks_drafts(name="uri", raises=ValueError) + def is_uri(instance: object) -> bool: + if not isinstance(instance, str): + return True + return rfc3987.parse(instance, rule="URI") + + @_checks_drafts( + draft6="uri-reference", + draft7="uri-reference", + draft201909="uri-reference", + draft202012="uri-reference", + raises=ValueError, + ) + def is_uri_reference(instance: object) -> bool: + if not isinstance(instance, str): + return True + return rfc3987.parse(instance, rule="URI_reference") + + +with suppress(ImportError): + from rfc3339_validator import validate_rfc3339 + + @_checks_drafts(name="date-time") + def is_datetime(instance: object) -> bool: + if not isinstance(instance, str): + return True + return validate_rfc3339(instance.upper()) + + @_checks_drafts( + draft7="time", + draft201909="time", + draft202012="time", + ) + def is_time(instance: object) -> bool: + if not isinstance(instance, str): + return True + return is_datetime("1970-01-01T" + instance) + + +@_checks_drafts(name="regex", raises=re.error) +def is_regex(instance: object) -> bool: + if not isinstance(instance, str): + return True + return bool(re.compile(instance)) + + +@_checks_drafts( + draft3="date", + draft7="date", + draft201909="date", + draft202012="date", + raises=ValueError, +) +def is_date(instance: object) -> bool: + if not isinstance(instance, str): + return True + return bool(_RE_DATE.fullmatch(instance) and date.fromisoformat(instance)) + + +@_checks_drafts(draft3="time", raises=ValueError) +def is_draft3_time(instance: object) -> bool: + if not isinstance(instance, str): + return True + return bool(datetime.strptime(instance, "%H:%M:%S")) # noqa: DTZ007 + + +with suppress(ImportError): + import webcolors + + @_checks_drafts(draft3="color", raises=(ValueError, TypeError)) + def is_css21_color(instance: object) -> bool: + if isinstance(instance, str): + try: + webcolors.name_to_hex(instance) + except ValueError: + webcolors.normalize_hex(instance.lower()) + return True + + +with suppress(ImportError): + import jsonpointer + + @_checks_drafts( + draft6="json-pointer", + draft7="json-pointer", + draft201909="json-pointer", + draft202012="json-pointer", + raises=jsonpointer.JsonPointerException, + ) + def is_json_pointer(instance: object) -> bool: + if not isinstance(instance, str): + return True + return bool(jsonpointer.JsonPointer(instance)) + + # TODO: I don't want to maintain this, so it + # needs to go either into jsonpointer (pending + # https://github.com/stefankoegl/python-json-pointer/issues/34) or + # into a new external library. + @_checks_drafts( + draft7="relative-json-pointer", + draft201909="relative-json-pointer", + draft202012="relative-json-pointer", + raises=jsonpointer.JsonPointerException, + ) + def is_relative_json_pointer(instance: object) -> bool: + # Definition taken from: + # https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01#section-3 + if not isinstance(instance, str): + return True + if not instance: + return False + + non_negative_integer, rest = [], "" + for i, character in enumerate(instance): + if character.isdigit(): + # digits with a leading "0" are not allowed + if i > 0 and int(instance[i - 1]) == 0: + return False + + non_negative_integer.append(character) + continue + + if not non_negative_integer: + return False + + rest = instance[i:] + break + return (rest == "#") or bool(jsonpointer.JsonPointer(rest)) + + +with suppress(ImportError): + import uri_template + + @_checks_drafts( + draft6="uri-template", + draft7="uri-template", + draft201909="uri-template", + draft202012="uri-template", + ) + def is_uri_template(instance: object) -> bool: + if not isinstance(instance, str): + return True + return uri_template.validate(instance) + + +with suppress(ImportError): + import isoduration + + @_checks_drafts( + draft201909="duration", + draft202012="duration", + raises=isoduration.DurationParsingException, + ) + def is_duration(instance: object) -> bool: + if not isinstance(instance, str): + return True + isoduration.parse_duration(instance) + # FIXME: See bolsote/isoduration#25 and bolsote/isoduration#21 + return instance.endswith(tuple("DMYWHMS")) + + +@_checks_drafts( + draft201909="uuid", + draft202012="uuid", + raises=ValueError, +) +def is_uuid(instance: object) -> bool: + if not isinstance(instance, str): + return True + UUID(instance) + return all(instance[position] == "-" for position in (8, 13, 18, 23)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_keywords.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_keywords.py new file mode 100644 index 00000000..f30f9541 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_keywords.py @@ -0,0 +1,449 @@ +from fractions import Fraction +import re + +from jsonschema._utils import ( + ensure_list, + equal, + extras_msg, + find_additional_properties, + find_evaluated_item_indexes_by_schema, + find_evaluated_property_keys_by_schema, + uniq, +) +from jsonschema.exceptions import FormatError, ValidationError + + +def patternProperties(validator, patternProperties, instance, schema): + if not validator.is_type(instance, "object"): + return + + for pattern, subschema in patternProperties.items(): + for k, v in instance.items(): + if re.search(pattern, k): + yield from validator.descend( + v, subschema, path=k, schema_path=pattern, + ) + + +def propertyNames(validator, propertyNames, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property in instance: + yield from validator.descend(instance=property, schema=propertyNames) + + +def additionalProperties(validator, aP, instance, schema): + if not validator.is_type(instance, "object"): + return + + extras = set(find_additional_properties(instance, schema)) + + if validator.is_type(aP, "object"): + for extra in extras: + yield from validator.descend(instance[extra], aP, path=extra) + elif not aP and extras: + if "patternProperties" in schema: + verb = "does" if len(extras) == 1 else "do" + joined = ", ".join(repr(each) for each in sorted(extras)) + patterns = ", ".join( + repr(each) for each in sorted(schema["patternProperties"]) + ) + error = f"{joined} {verb} not match any of the regexes: {patterns}" + yield ValidationError(error) + else: + error = "Additional properties are not allowed (%s %s unexpected)" + yield ValidationError(error % extras_msg(sorted(extras, key=str))) + + +def items(validator, items, instance, schema): + if not validator.is_type(instance, "array"): + return + + prefix = len(schema.get("prefixItems", [])) + total = len(instance) + extra = total - prefix + if extra <= 0: + return + + if items is False: + rest = instance[prefix:] if extra != 1 else instance[prefix] + item = "items" if prefix != 1 else "item" + yield ValidationError( + f"Expected at most {prefix} {item} but found {extra} " + f"extra: {rest!r}", + ) + else: + for index in range(prefix, total): + yield from validator.descend( + instance=instance[index], + schema=items, + path=index, + ) + + +def const(validator, const, instance, schema): + if not equal(instance, const): + yield ValidationError(f"{const!r} was expected") + + +def contains(validator, contains, instance, schema): + if not validator.is_type(instance, "array"): + return + + matches = 0 + min_contains = schema.get("minContains", 1) + max_contains = schema.get("maxContains", len(instance)) + + contains_validator = validator.evolve(schema=contains) + + for each in instance: + if contains_validator.is_valid(each): + matches += 1 + if matches > max_contains: + yield ValidationError( + "Too many items match the given schema " + f"(expected at most {max_contains})", + validator="maxContains", + validator_value=max_contains, + ) + return + + if matches < min_contains: + if not matches: + yield ValidationError( + f"{instance!r} does not contain items " + "matching the given schema", + ) + else: + yield ValidationError( + "Too few items match the given schema (expected at least " + f"{min_contains} but only {matches} matched)", + validator="minContains", + validator_value=min_contains, + ) + + +def exclusiveMinimum(validator, minimum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if instance <= minimum: + yield ValidationError( + f"{instance!r} is less than or equal to " + f"the minimum of {minimum!r}", + ) + + +def exclusiveMaximum(validator, maximum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if instance >= maximum: + yield ValidationError( + f"{instance!r} is greater than or equal " + f"to the maximum of {maximum!r}", + ) + + +def minimum(validator, minimum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if instance < minimum: + message = f"{instance!r} is less than the minimum of {minimum!r}" + yield ValidationError(message) + + +def maximum(validator, maximum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if instance > maximum: + message = f"{instance!r} is greater than the maximum of {maximum!r}" + yield ValidationError(message) + + +def multipleOf(validator, dB, instance, schema): + if not validator.is_type(instance, "number"): + return + + if isinstance(dB, float): + quotient = instance / dB + try: + failed = int(quotient) != quotient + except OverflowError: + # When `instance` is large and `dB` is less than one, + # quotient can overflow to infinity; and then casting to int + # raises an error. + # + # In this case we fall back to Fraction logic, which is + # exact and cannot overflow. The performance is also + # acceptable: we try the fast all-float option first, and + # we know that fraction(dB) can have at most a few hundred + # digits in each part. The worst-case slowdown is therefore + # for already-slow enormous integers or Decimals. + failed = (Fraction(instance) / Fraction(dB)).denominator != 1 + else: + failed = instance % dB + + if failed: + yield ValidationError(f"{instance!r} is not a multiple of {dB}") + + +def minItems(validator, mI, instance, schema): + if validator.is_type(instance, "array") and len(instance) < mI: + message = "should be non-empty" if mI == 1 else "is too short" + yield ValidationError(f"{instance!r} {message}") + + +def maxItems(validator, mI, instance, schema): + if validator.is_type(instance, "array") and len(instance) > mI: + message = "is expected to be empty" if mI == 0 else "is too long" + yield ValidationError(f"{instance!r} {message}") + + +def uniqueItems(validator, uI, instance, schema): + if ( + uI + and validator.is_type(instance, "array") + and not uniq(instance) + ): + yield ValidationError(f"{instance!r} has non-unique elements") + + +def pattern(validator, patrn, instance, schema): + if ( + validator.is_type(instance, "string") + and not re.search(patrn, instance) + ): + yield ValidationError(f"{instance!r} does not match {patrn!r}") + + +def format(validator, format, instance, schema): + if validator.format_checker is not None: + try: + validator.format_checker.check(instance, format) + except FormatError as error: + yield ValidationError(error.message, cause=error.cause) + + +def minLength(validator, mL, instance, schema): + if validator.is_type(instance, "string") and len(instance) < mL: + message = "should be non-empty" if mL == 1 else "is too short" + yield ValidationError(f"{instance!r} {message}") + + +def maxLength(validator, mL, instance, schema): + if validator.is_type(instance, "string") and len(instance) > mL: + message = "is expected to be empty" if mL == 0 else "is too long" + yield ValidationError(f"{instance!r} {message}") + + +def dependentRequired(validator, dependentRequired, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property, dependency in dependentRequired.items(): + if property not in instance: + continue + + for each in dependency: + if each not in instance: + message = f"{each!r} is a dependency of {property!r}" + yield ValidationError(message) + + +def dependentSchemas(validator, dependentSchemas, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property, dependency in dependentSchemas.items(): + if property not in instance: + continue + yield from validator.descend( + instance, dependency, schema_path=property, + ) + + +def enum(validator, enums, instance, schema): + if all(not equal(each, instance) for each in enums): + yield ValidationError(f"{instance!r} is not one of {enums!r}") + + +def ref(validator, ref, instance, schema): + yield from validator._validate_reference(ref=ref, instance=instance) + + +def dynamicRef(validator, dynamicRef, instance, schema): + yield from validator._validate_reference(ref=dynamicRef, instance=instance) + + +def type(validator, types, instance, schema): + types = ensure_list(types) + + if not any(validator.is_type(instance, type) for type in types): + reprs = ", ".join(repr(type) for type in types) + yield ValidationError(f"{instance!r} is not of type {reprs}") + + +def properties(validator, properties, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property, subschema in properties.items(): + if property in instance: + yield from validator.descend( + instance[property], + subschema, + path=property, + schema_path=property, + ) + + +def required(validator, required, instance, schema): + if not validator.is_type(instance, "object"): + return + for property in required: + if property not in instance: + yield ValidationError(f"{property!r} is a required property") + + +def minProperties(validator, mP, instance, schema): + if validator.is_type(instance, "object") and len(instance) < mP: + message = ( + "should be non-empty" if mP == 1 + else "does not have enough properties" + ) + yield ValidationError(f"{instance!r} {message}") + + +def maxProperties(validator, mP, instance, schema): + if not validator.is_type(instance, "object"): + return + if validator.is_type(instance, "object") and len(instance) > mP: + message = ( + "is expected to be empty" if mP == 0 + else "has too many properties" + ) + yield ValidationError(f"{instance!r} {message}") + + +def allOf(validator, allOf, instance, schema): + for index, subschema in enumerate(allOf): + yield from validator.descend(instance, subschema, schema_path=index) + + +def anyOf(validator, anyOf, instance, schema): + all_errors = [] + for index, subschema in enumerate(anyOf): + errs = list(validator.descend(instance, subschema, schema_path=index)) + if not errs: + break + all_errors.extend(errs) + else: + yield ValidationError( + f"{instance!r} is not valid under any of the given schemas", + context=all_errors, + ) + + +def oneOf(validator, oneOf, instance, schema): + subschemas = enumerate(oneOf) + all_errors = [] + for index, subschema in subschemas: + errs = list(validator.descend(instance, subschema, schema_path=index)) + if not errs: + first_valid = subschema + break + all_errors.extend(errs) + else: + yield ValidationError( + f"{instance!r} is not valid under any of the given schemas", + context=all_errors, + ) + + more_valid = [ + each for _, each in subschemas + if validator.evolve(schema=each).is_valid(instance) + ] + if more_valid: + more_valid.append(first_valid) + reprs = ", ".join(repr(schema) for schema in more_valid) + yield ValidationError(f"{instance!r} is valid under each of {reprs}") + + +def not_(validator, not_schema, instance, schema): + if validator.evolve(schema=not_schema).is_valid(instance): + message = f"{instance!r} should not be valid under {not_schema!r}" + yield ValidationError(message) + + +def if_(validator, if_schema, instance, schema): + if validator.evolve(schema=if_schema).is_valid(instance): + if "then" in schema: + then = schema["then"] + yield from validator.descend(instance, then, schema_path="then") + elif "else" in schema: + else_ = schema["else"] + yield from validator.descend(instance, else_, schema_path="else") + + +def unevaluatedItems(validator, unevaluatedItems, instance, schema): + if not validator.is_type(instance, "array"): + return + evaluated_item_indexes = find_evaluated_item_indexes_by_schema( + validator, instance, schema, + ) + unevaluated_items = [ + item for index, item in enumerate(instance) + if index not in evaluated_item_indexes + ] + if unevaluated_items: + error = "Unevaluated items are not allowed (%s %s unexpected)" + yield ValidationError(error % extras_msg(unevaluated_items)) + + +def unevaluatedProperties(validator, unevaluatedProperties, instance, schema): + if not validator.is_type(instance, "object"): + return + evaluated_keys = find_evaluated_property_keys_by_schema( + validator, instance, schema, + ) + unevaluated_keys = [] + for property in instance: + if property not in evaluated_keys: + for _ in validator.descend( + instance[property], + unevaluatedProperties, + path=property, + schema_path=property, + ): + # FIXME: Include context for each unevaluated property + # indicating why it's invalid under the subschema. + unevaluated_keys.append(property) # noqa: PERF401 + + if unevaluated_keys: + if unevaluatedProperties is False: + error = "Unevaluated properties are not allowed (%s %s unexpected)" + extras = sorted(unevaluated_keys, key=str) + yield ValidationError(error % extras_msg(extras)) + else: + error = ( + "Unevaluated properties are not valid under " + "the given schema (%s %s unevaluated and invalid)" + ) + yield ValidationError(error % extras_msg(unevaluated_keys)) + + +def prefixItems(validator, prefixItems, instance, schema): + if not validator.is_type(instance, "array"): + return + + for (index, item), subschema in zip(enumerate(instance), prefixItems): + yield from validator.descend( + instance=item, + schema=subschema, + schema_path=index, + path=index, + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_legacy_keywords.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_legacy_keywords.py new file mode 100644 index 00000000..c691589f --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_legacy_keywords.py @@ -0,0 +1,449 @@ +import re + +from referencing.jsonschema import lookup_recursive_ref + +from jsonschema import _utils +from jsonschema.exceptions import ValidationError + + +def ignore_ref_siblings(schema): + """ + Ignore siblings of ``$ref`` if it is present. + + Otherwise, return all keywords. + + Suitable for use with `create`'s ``applicable_validators`` argument. + """ + ref = schema.get("$ref") + if ref is not None: + return [("$ref", ref)] + else: + return schema.items() + + +def dependencies_draft3(validator, dependencies, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property, dependency in dependencies.items(): + if property not in instance: + continue + + if validator.is_type(dependency, "object"): + yield from validator.descend( + instance, dependency, schema_path=property, + ) + elif validator.is_type(dependency, "string"): + if dependency not in instance: + message = f"{dependency!r} is a dependency of {property!r}" + yield ValidationError(message) + else: + for each in dependency: + if each not in instance: + message = f"{each!r} is a dependency of {property!r}" + yield ValidationError(message) + + +def dependencies_draft4_draft6_draft7( + validator, + dependencies, + instance, + schema, +): + """ + Support for the ``dependencies`` keyword from pre-draft 2019-09. + + In later drafts, the keyword was split into separate + ``dependentRequired`` and ``dependentSchemas`` validators. + """ + if not validator.is_type(instance, "object"): + return + + for property, dependency in dependencies.items(): + if property not in instance: + continue + + if validator.is_type(dependency, "array"): + for each in dependency: + if each not in instance: + message = f"{each!r} is a dependency of {property!r}" + yield ValidationError(message) + else: + yield from validator.descend( + instance, dependency, schema_path=property, + ) + + +def disallow_draft3(validator, disallow, instance, schema): + for disallowed in _utils.ensure_list(disallow): + if validator.evolve(schema={"type": [disallowed]}).is_valid(instance): + message = f"{disallowed!r} is disallowed for {instance!r}" + yield ValidationError(message) + + +def extends_draft3(validator, extends, instance, schema): + if validator.is_type(extends, "object"): + yield from validator.descend(instance, extends) + return + for index, subschema in enumerate(extends): + yield from validator.descend(instance, subschema, schema_path=index) + + +def items_draft3_draft4(validator, items, instance, schema): + if not validator.is_type(instance, "array"): + return + + if validator.is_type(items, "object"): + for index, item in enumerate(instance): + yield from validator.descend(item, items, path=index) + else: + for (index, item), subschema in zip(enumerate(instance), items): + yield from validator.descend( + item, subschema, path=index, schema_path=index, + ) + + +def additionalItems(validator, aI, instance, schema): + if ( + not validator.is_type(instance, "array") + or validator.is_type(schema.get("items", {}), "object") + ): + return + + len_items = len(schema.get("items", [])) + if validator.is_type(aI, "object"): + for index, item in enumerate(instance[len_items:], start=len_items): + yield from validator.descend(item, aI, path=index) + elif not aI and len(instance) > len(schema.get("items", [])): + error = "Additional items are not allowed (%s %s unexpected)" + yield ValidationError( + error % _utils.extras_msg(instance[len(schema.get("items", [])):]), + ) + + +def items_draft6_draft7_draft201909(validator, items, instance, schema): + if not validator.is_type(instance, "array"): + return + + if validator.is_type(items, "array"): + for (index, item), subschema in zip(enumerate(instance), items): + yield from validator.descend( + item, subschema, path=index, schema_path=index, + ) + else: + for index, item in enumerate(instance): + yield from validator.descend(item, items, path=index) + + +def minimum_draft3_draft4(validator, minimum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if schema.get("exclusiveMinimum", False): + failed = instance <= minimum + cmp = "less than or equal to" + else: + failed = instance < minimum + cmp = "less than" + + if failed: + message = f"{instance!r} is {cmp} the minimum of {minimum!r}" + yield ValidationError(message) + + +def maximum_draft3_draft4(validator, maximum, instance, schema): + if not validator.is_type(instance, "number"): + return + + if schema.get("exclusiveMaximum", False): + failed = instance >= maximum + cmp = "greater than or equal to" + else: + failed = instance > maximum + cmp = "greater than" + + if failed: + message = f"{instance!r} is {cmp} the maximum of {maximum!r}" + yield ValidationError(message) + + +def properties_draft3(validator, properties, instance, schema): + if not validator.is_type(instance, "object"): + return + + for property, subschema in properties.items(): + if property in instance: + yield from validator.descend( + instance[property], + subschema, + path=property, + schema_path=property, + ) + elif subschema.get("required", False): + error = ValidationError(f"{property!r} is a required property") + error._set( + validator="required", + validator_value=subschema["required"], + instance=instance, + schema=schema, + ) + error.path.appendleft(property) + error.schema_path.extend([property, "required"]) + yield error + + +def type_draft3(validator, types, instance, schema): + types = _utils.ensure_list(types) + + all_errors = [] + for index, type in enumerate(types): + if validator.is_type(type, "object"): + errors = list(validator.descend(instance, type, schema_path=index)) + if not errors: + return + all_errors.extend(errors) + elif validator.is_type(instance, type): + return + + reprs = [] + for type in types: + try: + reprs.append(repr(type["name"])) + except Exception: # noqa: BLE001 + reprs.append(repr(type)) + yield ValidationError( + f"{instance!r} is not of type {', '.join(reprs)}", + context=all_errors, + ) + + +def contains_draft6_draft7(validator, contains, instance, schema): + if not validator.is_type(instance, "array"): + return + + if not any( + validator.evolve(schema=contains).is_valid(element) + for element in instance + ): + yield ValidationError( + f"None of {instance!r} are valid under the given schema", + ) + + +def recursiveRef(validator, recursiveRef, instance, schema): + resolved = lookup_recursive_ref(validator._resolver) + yield from validator.descend( + instance, + resolved.contents, + resolver=resolved.resolver, + ) + + +def find_evaluated_item_indexes_by_schema(validator, instance, schema): + """ + Get all indexes of items that get evaluated under the current schema. + + Covers all keywords related to unevaluatedItems: items, prefixItems, if, + then, else, contains, unevaluatedItems, allOf, oneOf, anyOf + """ + if validator.is_type(schema, "boolean"): + return [] + evaluated_indexes = [] + + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "$recursiveRef" in schema: + resolved = lookup_recursive_ref(validator._resolver) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "items" in schema: + if "additionalItems" in schema: + return list(range(len(instance))) + + if validator.is_type(schema["items"], "object"): + return list(range(len(instance))) + evaluated_indexes += list(range(len(schema["items"]))) + + if "if" in schema: + if validator.evolve(schema=schema["if"]).is_valid(instance): + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["if"], + ) + if "then" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["then"], + ) + elif "else" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["else"], + ) + + for keyword in ["contains", "unevaluatedItems"]: + if keyword in schema: + for k, v in enumerate(instance): + if validator.evolve(schema=schema[keyword]).is_valid(v): + evaluated_indexes.append(k) + + for keyword in ["allOf", "oneOf", "anyOf"]: + if keyword in schema: + for subschema in schema[keyword]: + errs = next(validator.descend(instance, subschema), None) + if errs is None: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, subschema, + ) + + return evaluated_indexes + + +def unevaluatedItems_draft2019(validator, unevaluatedItems, instance, schema): + if not validator.is_type(instance, "array"): + return + evaluated_item_indexes = find_evaluated_item_indexes_by_schema( + validator, instance, schema, + ) + unevaluated_items = [ + item for index, item in enumerate(instance) + if index not in evaluated_item_indexes + ] + if unevaluated_items: + error = "Unevaluated items are not allowed (%s %s unexpected)" + yield ValidationError(error % _utils.extras_msg(unevaluated_items)) + + +def find_evaluated_property_keys_by_schema(validator, instance, schema): + if validator.is_type(schema, "boolean"): + return [] + evaluated_keys = [] + + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "$recursiveRef" in schema: + resolved = lookup_recursive_ref(validator._resolver) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + for keyword in [ + "properties", "additionalProperties", "unevaluatedProperties", + ]: + if keyword in schema: + schema_value = schema[keyword] + if validator.is_type(schema_value, "boolean") and schema_value: + evaluated_keys += instance.keys() + + elif validator.is_type(schema_value, "object"): + for property in schema_value: + if property in instance: + evaluated_keys.append(property) + + if "patternProperties" in schema: + for property in instance: + for pattern in schema["patternProperties"]: + if re.search(pattern, property): + evaluated_keys.append(property) + + if "dependentSchemas" in schema: + for property, subschema in schema["dependentSchemas"].items(): + if property not in instance: + continue + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + for keyword in ["allOf", "oneOf", "anyOf"]: + if keyword in schema: + for subschema in schema[keyword]: + errs = next(validator.descend(instance, subschema), None) + if errs is None: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + if "if" in schema: + if validator.evolve(schema=schema["if"]).is_valid(instance): + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["if"], + ) + if "then" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["then"], + ) + elif "else" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["else"], + ) + + return evaluated_keys + + +def unevaluatedProperties_draft2019(validator, uP, instance, schema): + if not validator.is_type(instance, "object"): + return + evaluated_keys = find_evaluated_property_keys_by_schema( + validator, instance, schema, + ) + unevaluated_keys = [] + for property in instance: + if property not in evaluated_keys: + for _ in validator.descend( + instance[property], + uP, + path=property, + schema_path=property, + ): + # FIXME: Include context for each unevaluated property + # indicating why it's invalid under the subschema. + unevaluated_keys.append(property) # noqa: PERF401 + + if unevaluated_keys: + if uP is False: + error = "Unevaluated properties are not allowed (%s %s unexpected)" + extras = sorted(unevaluated_keys, key=str) + yield ValidationError(error % _utils.extras_msg(extras)) + else: + error = ( + "Unevaluated properties are not valid under " + "the given schema (%s %s unevaluated and invalid)" + ) + yield ValidationError(error % _utils.extras_msg(unevaluated_keys)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_types.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_types.py new file mode 100644 index 00000000..fcfe3f09 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_types.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +import numbers + +from attrs import evolve, field, frozen +from rpds import HashTrieMap + +from jsonschema.exceptions import UndefinedTypeCheck + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + from typing import Any + + +# unfortunately, the type of HashTrieMap is generic, and if used as an attrs +# converter, the generic type is presented to mypy, which then fails to match +# the concrete type of a type checker mapping +# this "do nothing" wrapper presents the correct information to mypy +def _typed_map_converter( + init_val: Mapping[str, Callable[[TypeChecker, Any], bool]], +) -> HashTrieMap[str, Callable[[TypeChecker, Any], bool]]: + return HashTrieMap.convert(init_val) + + +def is_array(checker, instance): + return isinstance(instance, list) + + +def is_bool(checker, instance): + return isinstance(instance, bool) + + +def is_integer(checker, instance): + # bool inherits from int, so ensure bools aren't reported as ints + if isinstance(instance, bool): + return False + return isinstance(instance, int) + + +def is_null(checker, instance): + return instance is None + + +def is_number(checker, instance): + # bool inherits from int, so ensure bools aren't reported as ints + if isinstance(instance, bool): + return False + return isinstance(instance, numbers.Number) + + +def is_object(checker, instance): + return isinstance(instance, dict) + + +def is_string(checker, instance): + return isinstance(instance, str) + + +def is_any(checker, instance): + return True + + +@frozen(repr=False) +class TypeChecker: + """ + A :kw:`type` property checker. + + A `TypeChecker` performs type checking for a `Validator`, converting + between the defined JSON Schema types and some associated Python types or + objects. + + Modifying the behavior just mentioned by redefining which Python objects + are considered to be of which JSON Schema types can be done using + `TypeChecker.redefine` or `TypeChecker.redefine_many`, and types can be + removed via `TypeChecker.remove`. Each of these return a new `TypeChecker`. + + Arguments: + + type_checkers: + + The initial mapping of types to their checking functions. + + """ + + _type_checkers: HashTrieMap[ + str, Callable[[TypeChecker, Any], bool], + ] = field(default=HashTrieMap(), converter=_typed_map_converter) + + def __repr__(self): + types = ", ".join(repr(k) for k in sorted(self._type_checkers)) + return f"<{self.__class__.__name__} types={{{types}}}>" + + def is_type(self, instance, type: str) -> bool: + """ + Check if the instance is of the appropriate type. + + Arguments: + + instance: + + The instance to check + + type: + + The name of the type that is expected. + + Raises: + + `jsonschema.exceptions.UndefinedTypeCheck`: + + if ``type`` is unknown to this object. + + """ + try: + fn = self._type_checkers[type] + except KeyError: + raise UndefinedTypeCheck(type) from None + + return fn(self, instance) + + def redefine(self, type: str, fn) -> TypeChecker: + """ + Produce a new checker with the given type redefined. + + Arguments: + + type: + + The name of the type to check. + + fn (collections.abc.Callable): + + A callable taking exactly two parameters - the type + checker calling the function and the instance to check. + The function should return true if instance is of this + type and false otherwise. + + """ + return self.redefine_many({type: fn}) + + def redefine_many(self, definitions=()) -> TypeChecker: + """ + Produce a new checker with the given types redefined. + + Arguments: + + definitions (dict): + + A dictionary mapping types to their checking functions. + + """ + type_checkers = self._type_checkers.update(definitions) + return evolve(self, type_checkers=type_checkers) + + def remove(self, *types) -> TypeChecker: + """ + Produce a new checker with the given types forgotten. + + Arguments: + + types: + + the names of the types to remove. + + Raises: + + `jsonschema.exceptions.UndefinedTypeCheck`: + + if any given type is unknown to this object + + """ + type_checkers = self._type_checkers + for each in types: + try: + type_checkers = type_checkers.remove(each) + except KeyError: + raise UndefinedTypeCheck(each) from None + return evolve(self, type_checkers=type_checkers) + + +draft3_type_checker = TypeChecker( + { + "any": is_any, + "array": is_array, + "boolean": is_bool, + "integer": is_integer, + "object": is_object, + "null": is_null, + "number": is_number, + "string": is_string, + }, +) +draft4_type_checker = draft3_type_checker.remove("any") +draft6_type_checker = draft4_type_checker.redefine( + "integer", + lambda checker, instance: ( + is_integer(checker, instance) + or (isinstance(instance, float) and instance.is_integer()) + ), +) +draft7_type_checker = draft6_type_checker +draft201909_type_checker = draft7_type_checker +draft202012_type_checker = draft201909_type_checker diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_typing.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_typing.py new file mode 100644 index 00000000..f8dda63a --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_typing.py @@ -0,0 +1,29 @@ +""" +Some (initially private) typing helpers for jsonschema's types. +""" +from collections.abc import Callable, Iterable +from typing import Any, Protocol + +import referencing.jsonschema + +from jsonschema.protocols import Validator + + +class SchemaKeywordValidator(Protocol): + def __call__( + self, + validator: Validator, + value: Any, + instance: Any, + schema: referencing.jsonschema.Schema, + ) -> None: + ... + + +id_of = Callable[[referencing.jsonschema.Schema], str | None] + + +ApplicableValidators = Callable[ + [referencing.jsonschema.Schema], + Iterable[tuple[str, Any]], +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_utils.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_utils.py new file mode 100644 index 00000000..84a0965e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/_utils.py @@ -0,0 +1,355 @@ +from collections.abc import Mapping, MutableMapping, Sequence +from urllib.parse import urlsplit +import itertools +import re + + +class URIDict(MutableMapping): + """ + Dictionary which uses normalized URIs as keys. + """ + + def normalize(self, uri): + return urlsplit(uri).geturl() + + def __init__(self, *args, **kwargs): + self.store = dict() + self.store.update(*args, **kwargs) + + def __getitem__(self, uri): + return self.store[self.normalize(uri)] + + def __setitem__(self, uri, value): + self.store[self.normalize(uri)] = value + + def __delitem__(self, uri): + del self.store[self.normalize(uri)] + + def __iter__(self): + return iter(self.store) + + def __len__(self): # pragma: no cover -- untested, but to be removed + return len(self.store) + + def __repr__(self): # pragma: no cover -- untested, but to be removed + return repr(self.store) + + +class Unset: + """ + An as-of-yet unset attribute or unprovided default parameter. + """ + + def __repr__(self): # pragma: no cover + return "" + + +def format_as_index(container, indices): + """ + Construct a single string containing indexing operations for the indices. + + For example for a container ``bar``, [1, 2, "foo"] -> bar[1][2]["foo"] + + Arguments: + + container (str): + + A word to use for the thing being indexed + + indices (sequence): + + The indices to format. + + """ + if not indices: + return container + return f"{container}[{']['.join(repr(index) for index in indices)}]" + + +def find_additional_properties(instance, schema): + """ + Return the set of additional properties for the given ``instance``. + + Weeds out properties that should have been validated by ``properties`` and + / or ``patternProperties``. + + Assumes ``instance`` is dict-like already. + """ + properties = schema.get("properties", {}) + patterns = "|".join(schema.get("patternProperties", {})) + for property in instance: + if property not in properties: + if patterns and re.search(patterns, property): + continue + yield property + + +def extras_msg(extras): + """ + Create an error message for extra items or properties. + """ + verb = "was" if len(extras) == 1 else "were" + return ", ".join(repr(extra) for extra in extras), verb + + +def ensure_list(thing): + """ + Wrap ``thing`` in a list if it's a single str. + + Otherwise, return it unchanged. + """ + if isinstance(thing, str): + return [thing] + return thing + + +def _mapping_equal(one, two): + """ + Check if two mappings are equal using the semantics of `equal`. + """ + if len(one) != len(two): + return False + return all( + key in two and equal(value, two[key]) + for key, value in one.items() + ) + + +def _sequence_equal(one, two): + """ + Check if two sequences are equal using the semantics of `equal`. + """ + if len(one) != len(two): + return False + return all(equal(i, j) for i, j in zip(one, two)) + + +def equal(one, two): + """ + Check if two things are equal evading some Python type hierarchy semantics. + + Specifically in JSON Schema, evade `bool` inheriting from `int`, + recursing into sequences to do the same. + """ + if one is two: + return True + if isinstance(one, str) or isinstance(two, str): + return one == two + if isinstance(one, Sequence) and isinstance(two, Sequence): + return _sequence_equal(one, two) + if isinstance(one, Mapping) and isinstance(two, Mapping): + return _mapping_equal(one, two) + return unbool(one) == unbool(two) + + +def unbool(element, true=object(), false=object()): + """ + A hack to make True and 1 and False and 0 unique for ``uniq``. + """ + if element is True: + return true + elif element is False: + return false + return element + + +def uniq(container): + """ + Check if all of a container's elements are unique. + + Tries to rely on the container being recursively sortable, or otherwise + falls back on (slow) brute force. + """ + try: + sort = sorted(unbool(i) for i in container) + sliced = itertools.islice(sort, 1, None) + + for i, j in zip(sort, sliced): + if equal(i, j): + return False + + except (NotImplementedError, TypeError): + seen = [] + for e in container: + e = unbool(e) + + for i in seen: + if equal(i, e): + return False + + seen.append(e) + return True + + +def find_evaluated_item_indexes_by_schema(validator, instance, schema): + """ + Get all indexes of items that get evaluated under the current schema. + + Covers all keywords related to unevaluatedItems: items, prefixItems, if, + then, else, contains, unevaluatedItems, allOf, oneOf, anyOf + """ + if validator.is_type(schema, "boolean"): + return [] + evaluated_indexes = [] + + if "items" in schema: + return list(range(len(instance))) + + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + dynamicRef = schema.get("$dynamicRef") + if dynamicRef is not None: + resolved = validator._resolver.lookup(dynamicRef) + evaluated_indexes.extend( + find_evaluated_item_indexes_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + if "prefixItems" in schema: + evaluated_indexes += list(range(len(schema["prefixItems"]))) + + if "if" in schema: + if validator.evolve(schema=schema["if"]).is_valid(instance): + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["if"], + ) + if "then" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["then"], + ) + elif "else" in schema: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, schema["else"], + ) + + for keyword in ["contains", "unevaluatedItems"]: + if keyword in schema: + for k, v in enumerate(instance): + if validator.evolve(schema=schema[keyword]).is_valid(v): + evaluated_indexes.append(k) + + for keyword in ["allOf", "oneOf", "anyOf"]: + if keyword in schema: + for subschema in schema[keyword]: + errs = next(validator.descend(instance, subschema), None) + if errs is None: + evaluated_indexes += find_evaluated_item_indexes_by_schema( + validator, instance, subschema, + ) + + return evaluated_indexes + + +def find_evaluated_property_keys_by_schema(validator, instance, schema): + """ + Get all keys of items that get evaluated under the current schema. + + Covers all keywords related to unevaluatedProperties: properties, + additionalProperties, unevaluatedProperties, patternProperties, + dependentSchemas, allOf, oneOf, anyOf, if, then, else + """ + if validator.is_type(schema, "boolean"): + return [] + evaluated_keys = [] + + ref = schema.get("$ref") + if ref is not None: + resolved = validator._resolver.lookup(ref) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + dynamicRef = schema.get("$dynamicRef") + if dynamicRef is not None: + resolved = validator._resolver.lookup(dynamicRef) + evaluated_keys.extend( + find_evaluated_property_keys_by_schema( + validator.evolve( + schema=resolved.contents, + _resolver=resolved.resolver, + ), + instance, + resolved.contents, + ), + ) + + properties = schema.get("properties") + if validator.is_type(properties, "object"): + evaluated_keys += properties.keys() & instance.keys() + + for keyword in ["additionalProperties", "unevaluatedProperties"]: + if (subschema := schema.get(keyword)) is None: + continue + evaluated_keys += ( + key + for key, value in instance.items() + if is_valid(validator.descend(value, subschema)) + ) + + if "patternProperties" in schema: + for property in instance: + for pattern in schema["patternProperties"]: + if re.search(pattern, property): + evaluated_keys.append(property) + + if "dependentSchemas" in schema: + for property, subschema in schema["dependentSchemas"].items(): + if property not in instance: + continue + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + for keyword in ["allOf", "oneOf", "anyOf"]: + for subschema in schema.get(keyword, []): + if not is_valid(validator.descend(instance, subschema)): + continue + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, subschema, + ) + + if "if" in schema: + if validator.evolve(schema=schema["if"]).is_valid(instance): + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["if"], + ) + if "then" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["then"], + ) + elif "else" in schema: + evaluated_keys += find_evaluated_property_keys_by_schema( + validator, instance, schema["else"], + ) + + return evaluated_keys + + +def is_valid(errs_it): + """Whether there are no errors in the given iterator.""" + return next(errs_it, None) is None diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/__init__.py new file mode 100644 index 00000000..e3dcc689 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/__init__.py @@ -0,0 +1,5 @@ +""" +Benchmarks for validation. + +This package is *not* public API. +""" diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/const_vs_enum.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/const_vs_enum.py new file mode 100644 index 00000000..c6fecd10 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/const_vs_enum.py @@ -0,0 +1,30 @@ +""" +A benchmark for comparing equivalent validation of `const` and `enum`. +""" + +from pyperf import Runner + +from jsonschema import Draft202012Validator + +value = [37] * 100 +const_schema = {"const": list(value)} +enum_schema = {"enum": [list(value)]} + +valid = list(value) +invalid = [*valid, 73] + +const = Draft202012Validator(const_schema) +enum = Draft202012Validator(enum_schema) + +assert const.is_valid(valid) +assert enum.is_valid(valid) +assert not const.is_valid(invalid) +assert not enum.is_valid(invalid) + + +if __name__ == "__main__": + runner = Runner() + runner.bench_func("const valid", lambda: const.is_valid(valid)) + runner.bench_func("const invalid", lambda: const.is_valid(invalid)) + runner.bench_func("enum valid", lambda: enum.is_valid(valid)) + runner.bench_func("enum invalid", lambda: enum.is_valid(invalid)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/contains.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/contains.py new file mode 100644 index 00000000..739cd044 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/contains.py @@ -0,0 +1,28 @@ +""" +A benchmark for validation of the `contains` keyword. +""" + +from pyperf import Runner + +from jsonschema import Draft202012Validator + +schema = { + "type": "array", + "contains": {"const": 37}, +} +validator = Draft202012Validator(schema) + +size = 1000 +beginning = [37] + [0] * (size - 1) +middle = [0] * (size // 2) + [37] + [0] * (size // 2) +end = [0] * (size - 1) + [37] +invalid = [0] * size + + +if __name__ == "__main__": + runner = Runner() + runner.bench_func("baseline", lambda: validator.is_valid([])) + runner.bench_func("beginning", lambda: validator.is_valid(beginning)) + runner.bench_func("middle", lambda: validator.is_valid(middle)) + runner.bench_func("end", lambda: validator.is_valid(end)) + runner.bench_func("invalid", lambda: validator.is_valid(invalid)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/import_benchmark.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/import_benchmark.py new file mode 100644 index 00000000..59fa921d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/import_benchmark.py @@ -0,0 +1,31 @@ +""" +A benchmark which measures the import time of jsonschema +""" + +import subprocess +import sys + + +def import_time(loops): + total_us = 0 + for _ in range(loops): + p = subprocess.run( # noqa: S603 (arguments are static) + [sys.executable, "-X", "importtime", "-c", "import jsonschema"], + stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + check=True, + ) + + line = p.stderr.splitlines()[-1] + field = line.split(b"|")[-2].strip() + us = int(field) # microseconds + total_us += us + + # pyperf expects seconds + return total_us / 1_000_000.0 + +if __name__ == "__main__": + from pyperf import Runner + runner = Runner() + + runner.bench_time_func("Import time (cumulative)", import_time) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232.py new file mode 100644 index 00000000..efd07154 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232.py @@ -0,0 +1,25 @@ +""" +A performance benchmark using the example from issue #232. + +See https://github.com/python-jsonschema/jsonschema/pull/232. +""" +from pathlib import Path + +from pyperf import Runner +from referencing import Registry + +from jsonschema.tests._suite import Version +import jsonschema + +issue232 = Version( + path=Path(__file__).parent / "issue232", + remotes=Registry(), + name="issue232", +) + + +if __name__ == "__main__": + issue232.benchmark( + runner=Runner(), + Validator=jsonschema.Draft4Validator, + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232/issue.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232/issue.json new file mode 100644 index 00000000..804c3408 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/issue232/issue.json @@ -0,0 +1,2653 @@ +[ + { + "description": "Petstore", + "schema": { + "title": "A JSON Schema for Swagger 2.0 API.", + "id": "http://swagger.io/v2/schema.json#", + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "required": [ + "swagger", + "info", + "paths" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "swagger": { + "type": "string", + "enum": [ + "2.0" + ], + "description": "The Swagger version of this document." + }, + "info": { + "$ref": "#/definitions/info" + }, + "host": { + "type": "string", + "pattern": "^[^{}/ :\\\\]+(?::\\d+)?$", + "description": "The host (name or ip) of the API. Example: 'swagger.io'" + }, + "basePath": { + "type": "string", + "pattern": "^/", + "description": "The base path to the API. Example: '/api'." + }, + "schemes": { + "$ref": "#/definitions/schemesList" + }, + "consumes": { + "description": "A list of MIME types accepted by the API.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "produces": { + "description": "A list of MIME types the API can produce.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "paths": { + "$ref": "#/definitions/paths" + }, + "definitions": { + "$ref": "#/definitions/definitions" + }, + "parameters": { + "$ref": "#/definitions/parameterDefinitions" + }, + "responses": { + "$ref": "#/definitions/responseDefinitions" + }, + "security": { + "$ref": "#/definitions/security" + }, + "securityDefinitions": { + "$ref": "#/definitions/securityDefinitions" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + }, + "uniqueItems": true + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "definitions": { + "info": { + "type": "object", + "description": "General information about the API.", + "required": [ + "version", + "title" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "title": { + "type": "string", + "description": "A unique and precise title of the API." + }, + "version": { + "type": "string", + "description": "A semantic version number of the API." + }, + "description": { + "type": "string", + "description": "A longer description of the API. Should be different from the title. GitHub Flavored Markdown is allowed." + }, + "termsOfService": { + "type": "string", + "description": "The terms of service for the API." + }, + "contact": { + "$ref": "#/definitions/contact" + }, + "license": { + "$ref": "#/definitions/license" + } + } + }, + "contact": { + "type": "object", + "description": "Contact information for the owners of the API.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The identifying name of the contact person/organization." + }, + "url": { + "type": "string", + "description": "The URL pointing to the contact information.", + "format": "uri" + }, + "email": { + "type": "string", + "description": "The email address of the contact person/organization.", + "format": "email" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "license": { + "type": "object", + "required": [ + "name" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the license type. It's encouraged to use an OSI compatible license." + }, + "url": { + "type": "string", + "description": "The URL pointing to the license.", + "format": "uri" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "paths": { + "type": "object", + "description": "Relative paths to the individual endpoints. They must be relative to the 'basePath'.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + }, + "^/": { + "$ref": "#/definitions/pathItem" + } + }, + "additionalProperties": false + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "description": "One or more JSON objects describing the schemas being consumed and produced by the API." + }, + "parameterDefinitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/parameter" + }, + "description": "One or more JSON representations for parameters" + }, + "responseDefinitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/response" + }, + "description": "One or more JSON representations for parameters" + }, + "externalDocs": { + "type": "object", + "additionalProperties": false, + "description": "information about external documentation", + "required": [ + "url" + ], + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "examples": { + "type": "object", + "additionalProperties": true + }, + "mimeType": { + "type": "string", + "description": "The MIME type of the HTTP message." + }, + "operation": { + "type": "object", + "required": [ + "responses" + ], + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "summary": { + "type": "string", + "description": "A brief summary of the operation." + }, + "description": { + "type": "string", + "description": "A longer description of the operation, GitHub Flavored Markdown is allowed." + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "operationId": { + "type": "string", + "description": "A unique identifier of the operation." + }, + "produces": { + "description": "A list of MIME types the API can produce.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "consumes": { + "description": "A list of MIME types the API can consume.", + "allOf": [ + { + "$ref": "#/definitions/mediaTypeList" + } + ] + }, + "parameters": { + "$ref": "#/definitions/parametersList" + }, + "responses": { + "$ref": "#/definitions/responses" + }, + "schemes": { + "$ref": "#/definitions/schemesList" + }, + "deprecated": { + "type": "boolean", + "default": false + }, + "security": { + "$ref": "#/definitions/security" + } + } + }, + "pathItem": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "$ref": { + "type": "string" + }, + "get": { + "$ref": "#/definitions/operation" + }, + "put": { + "$ref": "#/definitions/operation" + }, + "post": { + "$ref": "#/definitions/operation" + }, + "delete": { + "$ref": "#/definitions/operation" + }, + "options": { + "$ref": "#/definitions/operation" + }, + "head": { + "$ref": "#/definitions/operation" + }, + "patch": { + "$ref": "#/definitions/operation" + }, + "parameters": { + "$ref": "#/definitions/parametersList" + } + } + }, + "responses": { + "type": "object", + "description": "Response objects names can either be any valid HTTP status code or 'default'.", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^([0-9]{3})$|^(default)$": { + "$ref": "#/definitions/responseValue" + }, + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "not": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + } + }, + "responseValue": { + "oneOf": [ + { + "$ref": "#/definitions/response" + }, + { + "$ref": "#/definitions/jsonReference" + } + ] + }, + "response": { + "type": "object", + "required": [ + "description" + ], + "properties": { + "description": { + "type": "string" + }, + "schema": { + "oneOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "$ref": "#/definitions/fileSchema" + } + ] + }, + "headers": { + "$ref": "#/definitions/headers" + }, + "examples": { + "$ref": "#/definitions/examples" + } + }, + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/header" + } + }, + "header": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "number", + "integer", + "boolean", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "vendorExtension": { + "description": "Any property starting with x- is valid.", + "additionalProperties": true, + "additionalItems": true + }, + "bodyParameter": { + "type": "object", + "required": [ + "name", + "in", + "schema" + ], + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "body" + ] + }, + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "schema": { + "$ref": "#/definitions/schema" + } + }, + "additionalProperties": false + }, + "headerParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "header" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "queryParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "query" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "allowEmptyValue": { + "type": "boolean", + "default": false, + "description": "allows sending a parameter by name only or with an empty value." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormatWithMulti" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "formDataParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "required": { + "type": "boolean", + "description": "Determines whether or not this parameter is required or optional.", + "default": false + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "formData" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "allowEmptyValue": { + "type": "boolean", + "default": false, + "description": "allows sending a parameter by name only or with an empty value." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array", + "file" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormatWithMulti" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "pathParameterSubSchema": { + "additionalProperties": false, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "required": [ + "required" + ], + "properties": { + "required": { + "type": "boolean", + "enum": [ + true + ], + "description": "Determines whether or not this parameter is required or optional." + }, + "in": { + "type": "string", + "description": "Determines the location of the parameter.", + "enum": [ + "path" + ] + }, + "description": { + "type": "string", + "description": "A brief description of the parameter. This could contain examples of use. GitHub Flavored Markdown is allowed." + }, + "name": { + "type": "string", + "description": "The name of the parameter." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean", + "integer", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + } + }, + "nonBodyParameter": { + "type": "object", + "required": [ + "name", + "in", + "type" + ], + "oneOf": [ + { + "$ref": "#/definitions/headerParameterSubSchema" + }, + { + "$ref": "#/definitions/formDataParameterSubSchema" + }, + { + "$ref": "#/definitions/queryParameterSubSchema" + }, + { + "$ref": "#/definitions/pathParameterSubSchema" + } + ] + }, + "parameter": { + "oneOf": [ + { + "$ref": "#/definitions/bodyParameter" + }, + { + "$ref": "#/definitions/nonBodyParameter" + } + ] + }, + "schema": { + "type": "object", + "description": "A deterministic version of a JSON Schema object.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "properties": { + "$ref": { + "type": "string" + }, + "format": { + "type": "string" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "multipleOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/multipleOf" + }, + "maximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "pattern": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/pattern" + }, + "maxItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "uniqueItems": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/uniqueItems" + }, + "maxProperties": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minProperties": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "required": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/stringArray" + }, + "enum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/enum" + }, + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "boolean" + } + ], + "default": {} + }, + "type": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/type" + }, + "items": { + "anyOf": [ + { + "$ref": "#/definitions/schema" + }, + { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + } + ], + "default": {} + }, + "allOf": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/schema" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/schema" + }, + "default": {} + }, + "discriminator": { + "type": "string" + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "xml": { + "$ref": "#/definitions/xml" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "example": {} + }, + "additionalProperties": false + }, + "fileSchema": { + "type": "object", + "description": "A deterministic version of a JSON Schema object.", + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + }, + "required": [ + "type" + ], + "properties": { + "format": { + "type": "string" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "required": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/stringArray" + }, + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + }, + "example": {} + }, + "additionalProperties": false + }, + "primitivesItems": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "number", + "integer", + "boolean", + "array" + ] + }, + "format": { + "type": "string" + }, + "items": { + "$ref": "#/definitions/primitivesItems" + }, + "collectionFormat": { + "$ref": "#/definitions/collectionFormat" + }, + "default": { + "$ref": "#/definitions/default" + }, + "maximum": { + "$ref": "#/definitions/maximum" + }, + "exclusiveMaximum": { + "$ref": "#/definitions/exclusiveMaximum" + }, + "minimum": { + "$ref": "#/definitions/minimum" + }, + "exclusiveMinimum": { + "$ref": "#/definitions/exclusiveMinimum" + }, + "maxLength": { + "$ref": "#/definitions/maxLength" + }, + "minLength": { + "$ref": "#/definitions/minLength" + }, + "pattern": { + "$ref": "#/definitions/pattern" + }, + "maxItems": { + "$ref": "#/definitions/maxItems" + }, + "minItems": { + "$ref": "#/definitions/minItems" + }, + "uniqueItems": { + "$ref": "#/definitions/uniqueItems" + }, + "enum": { + "$ref": "#/definitions/enum" + }, + "multipleOf": { + "$ref": "#/definitions/multipleOf" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "security": { + "type": "array", + "items": { + "$ref": "#/definitions/securityRequirement" + }, + "uniqueItems": true + }, + "securityRequirement": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + }, + "xml": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "attribute": { + "type": "boolean", + "default": false + }, + "wrapped": { + "type": "boolean", + "default": false + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "tag": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/definitions/externalDocs" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "securityDefinitions": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/basicAuthenticationSecurity" + }, + { + "$ref": "#/definitions/apiKeySecurity" + }, + { + "$ref": "#/definitions/oauth2ImplicitSecurity" + }, + { + "$ref": "#/definitions/oauth2PasswordSecurity" + }, + { + "$ref": "#/definitions/oauth2ApplicationSecurity" + }, + { + "$ref": "#/definitions/oauth2AccessCodeSecurity" + } + ] + } + }, + "basicAuthenticationSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "basic" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "apiKeySecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "name", + "in" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "apiKey" + ] + }, + "name": { + "type": "string" + }, + "in": { + "type": "string", + "enum": [ + "header", + "query" + ] + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2ImplicitSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "authorizationUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "implicit" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2PasswordSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "password" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2ApplicationSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "application" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2AccessCodeSecurity": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "flow", + "authorizationUrl", + "tokenUrl" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "oauth2" + ] + }, + "flow": { + "type": "string", + "enum": [ + "accessCode" + ] + }, + "scopes": { + "$ref": "#/definitions/oauth2Scopes" + }, + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "description": { + "type": "string" + } + }, + "patternProperties": { + "^x-": { + "$ref": "#/definitions/vendorExtension" + } + } + }, + "oauth2Scopes": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "mediaTypeList": { + "type": "array", + "items": { + "$ref": "#/definitions/mimeType" + }, + "uniqueItems": true + }, + "parametersList": { + "type": "array", + "description": "The parameters needed to send a valid API call.", + "additionalItems": false, + "items": { + "oneOf": [ + { + "$ref": "#/definitions/parameter" + }, + { + "$ref": "#/definitions/jsonReference" + } + ] + }, + "uniqueItems": true + }, + "schemesList": { + "type": "array", + "description": "The transfer protocol of the API.", + "items": { + "type": "string", + "enum": [ + "http", + "https", + "ws", + "wss" + ] + }, + "uniqueItems": true + }, + "collectionFormat": { + "type": "string", + "enum": [ + "csv", + "ssv", + "tsv", + "pipes" + ], + "default": "csv" + }, + "collectionFormatWithMulti": { + "type": "string", + "enum": [ + "csv", + "ssv", + "tsv", + "pipes", + "multi" + ], + "default": "csv" + }, + "title": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/title" + }, + "description": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/description" + }, + "default": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/default" + }, + "multipleOf": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/multipleOf" + }, + "maximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/maximum" + }, + "exclusiveMaximum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMaximum" + }, + "minimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/minimum" + }, + "exclusiveMinimum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/exclusiveMinimum" + }, + "maxLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minLength": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "pattern": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/pattern" + }, + "maxItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveInteger" + }, + "minItems": { + "$ref": "http://json-schema.org/draft-04/schema#/definitions/positiveIntegerDefault0" + }, + "uniqueItems": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/uniqueItems" + }, + "enum": { + "$ref": "http://json-schema.org/draft-04/schema#/properties/enum" + }, + "jsonReference": { + "type": "object", + "required": [ + "$ref" + ], + "additionalProperties": false, + "properties": { + "$ref": { + "type": "string" + } + } + } + } + }, + "tests": [ + { + "description": "Example petsore", + "data": { + "swagger": "2.0", + "info": { + "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", + "version": "1.0.0", + "title": "Swagger Petstore", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "host": "petstore.swagger.io", + "basePath": "/v2", + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders" + }, + { + "name": "user", + "description": "Operations about user", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + } + ], + "schemes": [ + "http" + ], + "paths": { + "/pet": { + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store", + "description": "", + "operationId": "addPet", + "consumes": [ + "application/json", + "application/xml" + ], + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Pet object that needs to be added to the store", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet", + "description": "", + "operationId": "updatePet", + "consumes": [ + "application/json", + "application/xml" + ], + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Pet object that needs to be added to the store", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "405": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByStatus": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": true, + "type": "array", + "items": { + "type": "string", + "enum": [ + "available", + "pending", + "sold" + ], + "default": "available" + }, + "collectionFormat": "multi" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/findByTags": { + "get": { + "tags": [ + "pet" + ], + "summary": "Finds Pets by tags", + "description": "Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "multi" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ], + "deprecated": true + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "consumes": [ + "application/x-www-form-urlencoded" + ], + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "name": "name", + "in": "formData", + "description": "Updated name of the pet", + "required": false, + "type": "string" + }, + { + "name": "status", + "in": "formData", + "description": "Updated status of the pet", + "required": false, + "type": "string" + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "api_key", + "in": "header", + "required": false, + "type": "string" + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "name": "additionalMetadata", + "in": "formData", + "description": "Additional data to pass to server", + "required": false, + "type": "string" + }, + { + "name": "file", + "in": "formData", + "description": "file to upload", + "required": false, + "type": "file" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/ApiResponse" + } + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/store/inventory": { + "get": { + "tags": [ + "store" + ], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "produces": [ + "application/json" + ], + "parameters": [], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet", + "description": "", + "operationId": "placeOrder", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "order placed for purchasing the pet", + "required": true, + "schema": { + "$ref": "#/definitions/Order" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Order" + } + }, + "400": { + "description": "Invalid Order" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions", + "operationId": "getOrderById", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of pet that needs to be fetched", + "required": true, + "type": "integer", + "maximum": 10.0, + "minimum": 1.0, + "format": "int64" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Order" + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors", + "operationId": "deleteOrder", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "type": "integer", + "minimum": 1.0, + "format": "int64" + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Created user object", + "required": true, + "schema": { + "$ref": "#/definitions/User" + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/createWithArray": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "", + "operationId": "createUsersWithArrayInput", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "List of user object", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array", + "description": "", + "operationId": "createUsersWithListInput", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "List of user object", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": true, + "type": "string" + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "string" + }, + "headers": { + "X-Rate-Limit": { + "type": "integer", + "format": "int32", + "description": "calls per hour allowed by the user" + }, + "X-Expires-After": { + "type": "string", + "format": "date-time", + "description": "date in UTC when token expires" + } + } + }, + "400": { + "description": "Invalid username/password supplied" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing. ", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/User" + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Updated user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be updated", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "description": "Updated user object", + "required": true, + "schema": { + "$ref": "#/definitions/User" + } + } + ], + "responses": { + "400": { + "description": "Invalid user supplied" + }, + "404": { + "description": "User not found" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "produces": [ + "application/xml", + "application/json" + ], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + } + } + } + }, + "securityDefinitions": { + "petstore_auth": { + "type": "oauth2", + "authorizationUrl": "http://petstore.swagger.io/oauth/dialog", + "flow": "implicit", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + }, + "definitions": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "petId": { + "type": "integer", + "format": "int64" + }, + "quantity": { + "type": "integer", + "format": "int32" + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean", + "default": false + } + }, + "xml": { + "name": "Order" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "userStatus": { + "type": "integer", + "format": "int32", + "description": "User Status" + } + }, + "xml": { + "name": "User" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "Tag" + } + }, + "Pet": { + "type": "object", + "required": [ + "name", + "photoUrls" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "category": { + "$ref": "#/definitions/Category" + }, + "name": { + "type": "string", + "example": "doggie" + }, + "photoUrls": { + "type": "array", + "xml": { + "name": "photoUrl", + "wrapped": true + }, + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "xml": { + "name": "tag", + "wrapped": true + }, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "Pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + } + }, + "valid": true + } + ] + } +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/json_schema_test_suite.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/json_schema_test_suite.py new file mode 100644 index 00000000..905fb6a3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/json_schema_test_suite.py @@ -0,0 +1,12 @@ +""" +A performance benchmark using the official test suite. + +This benchmarks jsonschema using every valid example in the +JSON-Schema-Test-Suite. It will take some time to complete. +""" +from pyperf import Runner + +from jsonschema.tests._suite import Suite + +if __name__ == "__main__": + Suite().benchmark(runner=Runner()) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/nested_schemas.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/nested_schemas.py new file mode 100644 index 00000000..b025c47c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/nested_schemas.py @@ -0,0 +1,56 @@ +""" +Validating highly nested schemas shouldn't cause exponential time blowups. + +See https://github.com/python-jsonschema/jsonschema/issues/1097. +""" +from itertools import cycle + +from jsonschema.validators import validator_for + +metaschemaish = { + "$id": "https://example.com/draft/2020-12/schema/strict", + "$schema": "https://json-schema.org/draft/2020-12/schema", + + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": True, + "https://json-schema.org/draft/2020-12/vocab/applicator": True, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": True, + "https://json-schema.org/draft/2020-12/vocab/validation": True, + "https://json-schema.org/draft/2020-12/vocab/meta-data": True, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": True, + "https://json-schema.org/draft/2020-12/vocab/content": True, + }, + "$dynamicAnchor": "meta", + + "$ref": "https://json-schema.org/draft/2020-12/schema", + "unevaluatedProperties": False, +} + + +def nested_schema(levels): + """ + Produce a schema which validates deeply nested objects and arrays. + """ + + names = cycle(["foo", "bar", "baz", "quux", "spam", "eggs"]) + schema = {"type": "object", "properties": {"ham": {"type": "string"}}} + for _, name in zip(range(levels - 1), names): + schema = {"type": "object", "properties": {name: schema}} + return schema + + +validator = validator_for(metaschemaish)(metaschemaish) + +if __name__ == "__main__": + from pyperf import Runner + runner = Runner() + + not_nested = nested_schema(levels=1) + runner.bench_func("not nested", lambda: validator.is_valid(not_nested)) + + for levels in range(1, 11, 3): + schema = nested_schema(levels=levels) + runner.bench_func( + f"nested * {levels}", + lambda schema=schema: validator.is_valid(schema), + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/subcomponents.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/subcomponents.py new file mode 100644 index 00000000..6d78c7be --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/subcomponents.py @@ -0,0 +1,42 @@ +""" +A benchmark which tries to compare the possible slow subparts of validation. +""" +from referencing import Registry +from referencing.jsonschema import DRAFT202012 +from rpds import HashTrieMap, HashTrieSet + +from jsonschema import Draft202012Validator + +schema = { + "type": "array", + "minLength": 1, + "maxLength": 1, + "items": {"type": "integer"}, +} + +hmap = HashTrieMap() +hset = HashTrieSet() + +registry = Registry() + +v = Draft202012Validator(schema) + + +def registry_data_structures(): + return hmap.insert("foo", "bar"), hset.insert("foo") + + +def registry_add(): + resource = DRAFT202012.create_resource(schema) + return registry.with_resource(uri="urn:example", resource=resource) + + +if __name__ == "__main__": + from pyperf import Runner + runner = Runner() + + runner.bench_func("HashMap/HashSet insertion", registry_data_structures) + runner.bench_func("Registry insertion", registry_add) + runner.bench_func("Success", lambda: v.is_valid([1])) + runner.bench_func("Failure", lambda: v.is_valid(["foo"])) + runner.bench_func("Metaschema validation", lambda: v.check_schema(schema)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/unused_registry.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/unused_registry.py new file mode 100644 index 00000000..7b272c23 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/unused_registry.py @@ -0,0 +1,35 @@ +""" +An unused schema registry should not cause slower validation. + +"Unused" here means one where no reference resolution is occurring anyhow. + +See https://github.com/python-jsonschema/jsonschema/issues/1088. +""" +from pyperf import Runner +from referencing import Registry +from referencing.jsonschema import DRAFT201909 + +from jsonschema import Draft201909Validator + +registry = Registry().with_resource( + "urn:example:foo", + DRAFT201909.create_resource({}), +) + +schema = {"$ref": "https://json-schema.org/draft/2019-09/schema"} +instance = {"maxLength": 4} + +no_registry = Draft201909Validator(schema) +with_useless_registry = Draft201909Validator(schema, registry=registry) + +if __name__ == "__main__": + runner = Runner() + + runner.bench_func( + "no registry", + lambda: no_registry.is_valid(instance), + ) + runner.bench_func( + "useless registry", + lambda: with_useless_registry.is_valid(instance), + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_applicator_schemas.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_applicator_schemas.py new file mode 100644 index 00000000..f3229c0b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_applicator_schemas.py @@ -0,0 +1,106 @@ + +""" +A benchmark for validation of applicators containing lots of useless schemas. + +Signals a small possible optimization to remove all such schemas ahead of time. +""" + +from pyperf import Runner + +from jsonschema import Draft202012Validator as Validator + +NUM_USELESS = 100000 + +subschema = {"const": 37} + +valid = 37 +invalid = 12 + +baseline = Validator(subschema) + + +# These should be indistinguishable from just `subschema` +by_name = { + "single subschema": { + "anyOf": Validator({"anyOf": [subschema]}), + "allOf": Validator({"allOf": [subschema]}), + "oneOf": Validator({"oneOf": [subschema]}), + }, + "redundant subschemas": { + "anyOf": Validator({"anyOf": [subschema] * NUM_USELESS}), + "allOf": Validator({"allOf": [subschema] * NUM_USELESS}), + }, + "useless successful subschemas (beginning)": { + "anyOf": Validator({"anyOf": [subschema, *[True] * NUM_USELESS]}), + "allOf": Validator({"allOf": [subschema, *[True] * NUM_USELESS]}), + }, + "useless successful subschemas (middle)": { + "anyOf": Validator( + { + "anyOf": [ + *[True] * (NUM_USELESS // 2), + subschema, + *[True] * (NUM_USELESS // 2), + ], + }, + ), + "allOf": Validator( + { + "allOf": [ + *[True] * (NUM_USELESS // 2), + subschema, + *[True] * (NUM_USELESS // 2), + ], + }, + ), + }, + "useless successful subschemas (end)": { + "anyOf": Validator({"anyOf": [*[True] * NUM_USELESS, subschema]}), + "allOf": Validator({"allOf": [*[True] * NUM_USELESS, subschema]}), + }, + "useless failing subschemas (beginning)": { + "anyOf": Validator({"anyOf": [subschema, *[False] * NUM_USELESS]}), + "oneOf": Validator({"oneOf": [subschema, *[False] * NUM_USELESS]}), + }, + "useless failing subschemas (middle)": { + "anyOf": Validator( + { + "anyOf": [ + *[False] * (NUM_USELESS // 2), + subschema, + *[False] * (NUM_USELESS // 2), + ], + }, + ), + "oneOf": Validator( + { + "oneOf": [ + *[False] * (NUM_USELESS // 2), + subschema, + *[False] * (NUM_USELESS // 2), + ], + }, + ), + }, + "useless failing subschemas (end)": { + "anyOf": Validator({"anyOf": [*[False] * NUM_USELESS, subschema]}), + "oneOf": Validator({"oneOf": [*[False] * NUM_USELESS, subschema]}), + }, +} + +if __name__ == "__main__": + runner = Runner() + + runner.bench_func("baseline valid", lambda: baseline.is_valid(valid)) + runner.bench_func("baseline invalid", lambda: baseline.is_valid(invalid)) + + for group, applicators in by_name.items(): + for applicator, validator in applicators.items(): + runner.bench_func( + f"{group}: {applicator} valid", + lambda validator=validator: validator.is_valid(valid), + ) + runner.bench_func( + f"{group}: {applicator} invalid", + lambda validator=validator: validator.is_valid(invalid), + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_keywords.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_keywords.py new file mode 100644 index 00000000..50f43598 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/useless_keywords.py @@ -0,0 +1,32 @@ +""" +A benchmark for validation of schemas containing lots of useless keywords. + +Checks we filter them out once, ahead of time. +""" + +from pyperf import Runner + +from jsonschema import Draft202012Validator + +NUM_USELESS = 100000 +schema = dict( + [ + ("not", {"const": 42}), + *((str(i), i) for i in range(NUM_USELESS)), + ("type", "integer"), + *((str(i), i) for i in range(NUM_USELESS, NUM_USELESS)), + ("minimum", 37), + ], +) +validator = Draft202012Validator(schema) + +valid = 3737 +invalid = 12 + + +if __name__ == "__main__": + runner = Runner() + runner.bench_func("beginning of schema", lambda: validator.is_valid(42)) + runner.bench_func("middle of schema", lambda: validator.is_valid("foo")) + runner.bench_func("end of schema", lambda: validator.is_valid(12)) + runner.bench_func("valid", lambda: validator.is_valid(3737)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/validator_creation.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/validator_creation.py new file mode 100644 index 00000000..4baeb3a3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/benchmarks/validator_creation.py @@ -0,0 +1,14 @@ +from pyperf import Runner + +from jsonschema import Draft202012Validator + +schema = { + "type": "array", + "minLength": 1, + "maxLength": 1, + "items": {"type": "integer"}, +} + + +if __name__ == "__main__": + Runner().bench_func("validator creation", Draft202012Validator, schema) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/cli.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/cli.py new file mode 100644 index 00000000..f3ca4d6a --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/cli.py @@ -0,0 +1,292 @@ +""" +The ``jsonschema`` command line. +""" + +from importlib import metadata +from json import JSONDecodeError +from pkgutil import resolve_name +from textwrap import dedent +import argparse +import json +import sys +import traceback +import warnings + +from attrs import define, field + +from jsonschema.exceptions import SchemaError +from jsonschema.validators import _RefResolver, validator_for + +warnings.warn( + ( + "The jsonschema CLI is deprecated and will be removed in a future " + "version. Please use check-jsonschema instead, which can be installed " + "from https://pypi.org/project/check-jsonschema/" + ), + DeprecationWarning, + stacklevel=2, +) + + +class _CannotLoadFile(Exception): + pass + + +@define +class _Outputter: + + _formatter = field() + _stdout = field() + _stderr = field() + + @classmethod + def from_arguments(cls, arguments, stdout, stderr): + if arguments["output"] == "plain": + formatter = _PlainFormatter(arguments["error_format"]) + elif arguments["output"] == "pretty": + formatter = _PrettyFormatter() + return cls(formatter=formatter, stdout=stdout, stderr=stderr) + + def load(self, path): + try: + file = open(path) # noqa: SIM115, PTH123 + except FileNotFoundError as error: + self.filenotfound_error(path=path, exc_info=sys.exc_info()) + raise _CannotLoadFile() from error + + with file: + try: + return json.load(file) + except JSONDecodeError as error: + self.parsing_error(path=path, exc_info=sys.exc_info()) + raise _CannotLoadFile() from error + + def filenotfound_error(self, **kwargs): + self._stderr.write(self._formatter.filenotfound_error(**kwargs)) + + def parsing_error(self, **kwargs): + self._stderr.write(self._formatter.parsing_error(**kwargs)) + + def validation_error(self, **kwargs): + self._stderr.write(self._formatter.validation_error(**kwargs)) + + def validation_success(self, **kwargs): + self._stdout.write(self._formatter.validation_success(**kwargs)) + + +@define +class _PrettyFormatter: + + _ERROR_MSG = dedent( + """\ + ===[{type}]===({path})=== + + {body} + ----------------------------- + """, + ) + _SUCCESS_MSG = "===[SUCCESS]===({path})===\n" + + def filenotfound_error(self, path, exc_info): + return self._ERROR_MSG.format( + path=path, + type="FileNotFoundError", + body=f"{path!r} does not exist.", + ) + + def parsing_error(self, path, exc_info): + exc_type, exc_value, exc_traceback = exc_info + exc_lines = "".join( + traceback.format_exception(exc_type, exc_value, exc_traceback), + ) + return self._ERROR_MSG.format( + path=path, + type=exc_type.__name__, + body=exc_lines, + ) + + def validation_error(self, instance_path, error): + return self._ERROR_MSG.format( + path=instance_path, + type=error.__class__.__name__, + body=error, + ) + + def validation_success(self, instance_path): + return self._SUCCESS_MSG.format(path=instance_path) + + +@define +class _PlainFormatter: + + _error_format = field() + + def filenotfound_error(self, path, exc_info): + return f"{path!r} does not exist.\n" + + def parsing_error(self, path, exc_info): + return "Failed to parse {}: {}\n".format( + "" if path == "" else repr(path), + exc_info[1], + ) + + def validation_error(self, instance_path, error): + return self._error_format.format(file_name=instance_path, error=error) + + def validation_success(self, instance_path): + return "" + + +def _resolve_name_with_default(name): + if "." not in name: + name = "jsonschema." + name + return resolve_name(name) + + +parser = argparse.ArgumentParser( + description="JSON Schema Validation CLI", +) +parser.add_argument( + "-i", "--instance", + action="append", + dest="instances", + help=""" + a path to a JSON instance (i.e. filename.json) to validate (may + be specified multiple times). If no instances are provided via this + option, one will be expected on standard input. + """, +) +parser.add_argument( + "-F", "--error-format", + help=""" + the format to use for each validation error message, specified + in a form suitable for str.format. This string will be passed + one formatted object named 'error' for each ValidationError. + Only provide this option when using --output=plain, which is the + default. If this argument is unprovided and --output=plain is + used, a simple default representation will be used. + """, +) +parser.add_argument( + "-o", "--output", + choices=["plain", "pretty"], + default="plain", + help=""" + an output format to use. 'plain' (default) will produce minimal + text with one line for each error, while 'pretty' will produce + more detailed human-readable output on multiple lines. + """, +) +parser.add_argument( + "-V", "--validator", + type=_resolve_name_with_default, + help=""" + the fully qualified object name of a validator to use, or, for + validators that are registered with jsonschema, simply the name + of the class. + """, +) +parser.add_argument( + "--base-uri", + help=""" + a base URI to assign to the provided schema, even if it does not + declare one (via e.g. $id). This option can be used if you wish to + resolve relative references to a particular URI (or local path) + """, +) +parser.add_argument( + "--version", + action="version", + version=metadata.version("jsonschema"), +) +parser.add_argument( + "schema", + help="the path to a JSON Schema to validate with (i.e. schema.json)", +) + + +def parse_args(args): # noqa: D103 + arguments = vars(parser.parse_args(args=args or ["--help"])) + if arguments["output"] != "plain" and arguments["error_format"]: + raise parser.error( + "--error-format can only be used with --output plain", + ) + if arguments["output"] == "plain" and arguments["error_format"] is None: + arguments["error_format"] = "{error.instance}: {error.message}\n" + return arguments + + +def _validate_instance(instance_path, instance, validator, outputter): + invalid = False + for error in validator.iter_errors(instance): + invalid = True + outputter.validation_error(instance_path=instance_path, error=error) + + if not invalid: + outputter.validation_success(instance_path=instance_path) + return invalid + + +def main(args=sys.argv[1:]): # noqa: D103 + sys.exit(run(arguments=parse_args(args=args))) + + +def run(arguments, stdout=sys.stdout, stderr=sys.stderr, stdin=sys.stdin): # noqa: D103 + outputter = _Outputter.from_arguments( + arguments=arguments, + stdout=stdout, + stderr=stderr, + ) + + try: + schema = outputter.load(arguments["schema"]) + except _CannotLoadFile: + return 1 + + Validator = arguments["validator"] + if Validator is None: + Validator = validator_for(schema) + + try: + Validator.check_schema(schema) + except SchemaError as error: + outputter.validation_error( + instance_path=arguments["schema"], + error=error, + ) + return 1 + + if arguments["instances"]: + load, instances = outputter.load, arguments["instances"] + else: + def load(_): + try: + return json.load(stdin) + except JSONDecodeError as error: + outputter.parsing_error( + path="", exc_info=sys.exc_info(), + ) + raise _CannotLoadFile() from error + instances = [""] + + resolver = _RefResolver( + base_uri=arguments["base_uri"], + referrer=schema, + ) if arguments["base_uri"] is not None else None + + validator = Validator(schema, resolver=resolver) + exit_code = 0 + for each in instances: + try: + instance = load(each) + except _CannotLoadFile: + exit_code = 1 + else: + exit_code |= _validate_instance( + instance_path=each, + instance=instance, + validator=validator, + outputter=outputter, + ) + + return exit_code diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/exceptions.py new file mode 100644 index 00000000..d955e356 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/exceptions.py @@ -0,0 +1,490 @@ +""" +Validation errors, and some surrounding helpers. +""" +from __future__ import annotations + +from collections import defaultdict, deque +from pprint import pformat +from textwrap import dedent, indent +from typing import TYPE_CHECKING, Any, ClassVar +import heapq +import re +import warnings + +from attrs import define +from referencing.exceptions import Unresolvable as _Unresolvable + +from jsonschema import _utils + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, MutableMapping, Sequence + + from jsonschema import _types + +WEAK_MATCHES: frozenset[str] = frozenset(["anyOf", "oneOf"]) +STRONG_MATCHES: frozenset[str] = frozenset() + +_JSON_PATH_COMPATIBLE_PROPERTY_PATTERN = re.compile("^[a-zA-Z][a-zA-Z0-9_]*$") + +_unset = _utils.Unset() + + +def _pretty(thing: Any, prefix: str): + """ + Format something for an error message as prettily as we currently can. + """ + return indent(pformat(thing, width=72, sort_dicts=False), prefix).lstrip() + + +def __getattr__(name): + if name == "RefResolutionError": + warnings.warn( + _RefResolutionError._DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) + return _RefResolutionError + raise AttributeError(f"module {__name__} has no attribute {name}") + + +class _Error(Exception): + + _word_for_schema_in_error_message: ClassVar[str] + _word_for_instance_in_error_message: ClassVar[str] + + def __init__( + self, + message: str, + validator: str = _unset, # type: ignore[assignment] + path: Iterable[str | int] = (), + cause: Exception | None = None, + context=(), + validator_value: Any = _unset, + instance: Any = _unset, + schema: Mapping[str, Any] | bool = _unset, # type: ignore[assignment] + schema_path: Iterable[str | int] = (), + parent: _Error | None = None, + type_checker: _types.TypeChecker = _unset, # type: ignore[assignment] + ) -> None: + super().__init__( + message, + validator, + path, + cause, + context, + validator_value, + instance, + schema, + schema_path, + parent, + ) + self.message = message + self.path = self.relative_path = deque(path) + self.schema_path = self.relative_schema_path = deque(schema_path) + self.context = list(context) + self.cause = self.__cause__ = cause + self.validator = validator + self.validator_value = validator_value + self.instance = instance + self.schema = schema + self.parent = parent + self._type_checker = type_checker + + for error in context: + error.parent = self + + def __repr__(self) -> str: + return f"<{self.__class__.__name__}: {self.message!r}>" + + def __str__(self) -> str: + essential_for_verbose = ( + self.validator, self.validator_value, self.instance, self.schema, + ) + if any(m is _unset for m in essential_for_verbose): + return self.message + + schema_path = _utils.format_as_index( + container=self._word_for_schema_in_error_message, + indices=list(self.relative_schema_path)[:-1], + ) + instance_path = _utils.format_as_index( + container=self._word_for_instance_in_error_message, + indices=self.relative_path, + ) + prefix = 16 * " " + + return dedent( + f"""\ + {self.message} + + Failed validating {self.validator!r} in {schema_path}: + {_pretty(self.schema, prefix=prefix)} + + On {instance_path}: + {_pretty(self.instance, prefix=prefix)} + """.rstrip(), + ) + + @classmethod + def create_from(cls, other: _Error): + return cls(**other._contents()) + + @property + def absolute_path(self) -> Sequence[str | int]: + parent = self.parent + if parent is None: + return self.relative_path + + path = deque(self.relative_path) + path.extendleft(reversed(parent.absolute_path)) + return path + + @property + def absolute_schema_path(self) -> Sequence[str | int]: + parent = self.parent + if parent is None: + return self.relative_schema_path + + path = deque(self.relative_schema_path) + path.extendleft(reversed(parent.absolute_schema_path)) + return path + + @property + def json_path(self) -> str: + path = "$" + for elem in self.absolute_path: + if isinstance(elem, int): + path += "[" + str(elem) + "]" + elif _JSON_PATH_COMPATIBLE_PROPERTY_PATTERN.match(elem): + path += "." + elem + else: + escaped_elem = elem.replace("\\", "\\\\").replace("'", r"\'") + path += "['" + escaped_elem + "']" + return path + + def _set( + self, + type_checker: _types.TypeChecker | None = None, + **kwargs: Any, + ) -> None: + if type_checker is not None and self._type_checker is _unset: + self._type_checker = type_checker + + for k, v in kwargs.items(): + if getattr(self, k) is _unset: + setattr(self, k, v) + + def _contents(self): + attrs = ( + "message", "cause", "context", "validator", "validator_value", + "path", "schema_path", "instance", "schema", "parent", + ) + return {attr: getattr(self, attr) for attr in attrs} + + def _matches_type(self) -> bool: + try: + # We ignore this as we want to simply crash if this happens + expected = self.schema["type"] # type: ignore[index] + except (KeyError, TypeError): + return False + + if isinstance(expected, str): + return self._type_checker.is_type(self.instance, expected) + + return any( + self._type_checker.is_type(self.instance, expected_type) + for expected_type in expected + ) + + +class ValidationError(_Error): + """ + An instance was invalid under a provided schema. + """ + + _word_for_schema_in_error_message = "schema" + _word_for_instance_in_error_message = "instance" + + +class SchemaError(_Error): + """ + A schema was invalid under its corresponding metaschema. + """ + + _word_for_schema_in_error_message = "metaschema" + _word_for_instance_in_error_message = "schema" + + +@define(slots=False) +class _RefResolutionError(Exception): # noqa: PLW1641 + """ + A ref could not be resolved. + """ + + _DEPRECATION_MESSAGE = ( + "jsonschema.exceptions.RefResolutionError is deprecated as of version " + "4.18.0. If you wish to catch potential reference resolution errors, " + "directly catch referencing.exceptions.Unresolvable." + ) + + _cause: Exception + + def __eq__(self, other): + if self.__class__ is not other.__class__: + return NotImplemented # pragma: no cover -- uncovered but deprecated # noqa: E501 + return self._cause == other._cause + + def __str__(self) -> str: + return str(self._cause) + + +class _WrappedReferencingError(_RefResolutionError, _Unresolvable): # pragma: no cover -- partially uncovered but to be removed # noqa: E501 + def __init__(self, cause: _Unresolvable): + object.__setattr__(self, "_wrapped", cause) + + def __eq__(self, other): + if other.__class__ is self.__class__: + return self._wrapped == other._wrapped + elif other.__class__ is self._wrapped.__class__: + return self._wrapped == other + return NotImplemented + + def __getattr__(self, attr): + return getattr(self._wrapped, attr) + + def __hash__(self): + return hash(self._wrapped) + + def __repr__(self): + return f"" + + def __str__(self): + return f"{self._wrapped.__class__.__name__}: {self._wrapped}" + + +class UndefinedTypeCheck(Exception): + """ + A type checker was asked to check a type it did not have registered. + """ + + def __init__(self, type: str) -> None: + self.type = type + + def __str__(self) -> str: + return f"Type {self.type!r} is unknown to this type checker" + + +class UnknownType(Exception): + """ + A validator was asked to validate an instance against an unknown type. + """ + + def __init__(self, type, instance, schema): + self.type = type + self.instance = instance + self.schema = schema + + def __str__(self): + prefix = 16 * " " + + return dedent( + f"""\ + Unknown type {self.type!r} for validator with schema: + {_pretty(self.schema, prefix=prefix)} + + While checking instance: + {_pretty(self.instance, prefix=prefix)} + """.rstrip(), + ) + + +class FormatError(Exception): + """ + Validating a format failed. + """ + + def __init__(self, message, cause=None): + super().__init__(message, cause) + self.message = message + self.cause = self.__cause__ = cause + + def __str__(self): + return self.message + + +class ErrorTree: + """ + ErrorTrees make it easier to check which validations failed. + """ + + _instance = _unset + + def __init__(self, errors: Iterable[ValidationError] = ()): + self.errors: MutableMapping[str, ValidationError] = {} + self._contents: Mapping[str, ErrorTree] = defaultdict(self.__class__) + + for error in errors: + container = self + for element in error.path: + container = container[element] + container.errors[error.validator] = error + + container._instance = error.instance + + def __contains__(self, index: str | int): + """ + Check whether ``instance[index]`` has any errors. + """ + return index in self._contents + + def __getitem__(self, index): + """ + Retrieve the child tree one level down at the given ``index``. + + If the index is not in the instance that this tree corresponds + to and is not known by this tree, whatever error would be raised + by ``instance.__getitem__`` will be propagated (usually this is + some subclass of `LookupError`. + """ + if self._instance is not _unset and index not in self: + self._instance[index] + return self._contents[index] + + def __setitem__(self, index: str | int, value: ErrorTree): + """ + Add an error to the tree at the given ``index``. + + .. deprecated:: v4.20.0 + + Setting items on an `ErrorTree` is deprecated without replacement. + To populate a tree, provide all of its sub-errors when you + construct the tree. + """ + warnings.warn( + "ErrorTree.__setitem__ is deprecated without replacement.", + DeprecationWarning, + stacklevel=2, + ) + self._contents[index] = value # type: ignore[index] + + def __iter__(self): + """ + Iterate (non-recursively) over the indices in the instance with errors. + """ + return iter(self._contents) + + def __len__(self): + """ + Return the `total_errors`. + """ + return self.total_errors + + def __repr__(self): + total = len(self) + errors = "error" if total == 1 else "errors" + return f"<{self.__class__.__name__} ({total} total {errors})>" + + @property + def total_errors(self): + """ + The total number of errors in the entire tree, including children. + """ + child_errors = sum(len(tree) for _, tree in self._contents.items()) + return len(self.errors) + child_errors + + +def by_relevance(weak=WEAK_MATCHES, strong=STRONG_MATCHES): + """ + Create a key function that can be used to sort errors by relevance. + + Arguments: + weak (set): + a collection of validation keywords to consider to be + "weak". If there are two errors at the same level of the + instance and one is in the set of weak validation keywords, + the other error will take priority. By default, :kw:`anyOf` + and :kw:`oneOf` are considered weak keywords and will be + superseded by other same-level validation errors. + + strong (set): + a collection of validation keywords to consider to be + "strong" + + """ + + def relevance(error): + validator = error.validator + return ( # prefer errors which are ... + -len(error.path), # 'deeper' and thereby more specific + error.path, # earlier (for sibling errors) + validator not in weak, # for a non-low-priority keyword + validator in strong, # for a high priority keyword + not error._matches_type(), # at least match the instance's type + ) # otherwise we'll treat them the same + + return relevance + + +relevance = by_relevance() +""" +A key function (e.g. to use with `sorted`) which sorts errors by relevance. + +Example: + +.. code:: python + + sorted(validator.iter_errors(12), key=jsonschema.exceptions.relevance) +""" + + +def best_match(errors, key=relevance): + """ + Try to find an error that appears to be the best match among given errors. + + In general, errors that are higher up in the instance (i.e. for which + `ValidationError.path` is shorter) are considered better matches, + since they indicate "more" is wrong with the instance. + + If the resulting match is either :kw:`oneOf` or :kw:`anyOf`, the + *opposite* assumption is made -- i.e. the deepest error is picked, + since these keywords only need to match once, and any other errors + may not be relevant. + + Arguments: + errors (collections.abc.Iterable): + + the errors to select from. Do not provide a mixture of + errors from different validation attempts (i.e. from + different instances or schemas), since it won't produce + sensical output. + + key (collections.abc.Callable): + + the key to use when sorting errors. See `relevance` and + transitively `by_relevance` for more details (the default is + to sort with the defaults of that function). Changing the + default is only useful if you want to change the function + that rates errors but still want the error context descent + done by this function. + + Returns: + the best matching error, or ``None`` if the iterable was empty + + .. note:: + + This function is a heuristic. Its return value may change for a given + set of inputs from version to version if better heuristics are added. + + """ + best = max(errors, key=key, default=None) + if best is None: + return + + while best.context: + # Calculate the minimum via nsmallest, because we don't recurse if + # all nested errors have the same relevance (i.e. if min == max == all) + smallest = heapq.nsmallest(2, best.context, key=key) + if len(smallest) == 2 and key(smallest[0]) == key(smallest[1]): # noqa: PLR2004 + return best + best = smallest[0] + return best diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/protocols.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/protocols.py new file mode 100644 index 00000000..b6288dcc --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/protocols.py @@ -0,0 +1,230 @@ +""" +typing.Protocol classes for jsonschema interfaces. +""" + +# for reference material on Protocols, see +# https://www.python.org/dev/peps/pep-0544/ + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, ClassVar, Protocol, runtime_checkable + +# in order for Sphinx to resolve references accurately from type annotations, +# it needs to see names like `jsonschema.TypeChecker` +# therefore, only import at type-checking time (to avoid circular references), +# but use `jsonschema` for any types which will otherwise not be resolvable +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping + + import referencing.jsonschema + + from jsonschema import _typing + from jsonschema.exceptions import ValidationError + import jsonschema + import jsonschema.validators + +# For code authors working on the validator protocol, these are the three +# use-cases which should be kept in mind: +# +# 1. As a protocol class, it can be used in type annotations to describe the +# available methods and attributes of a validator +# 2. It is the source of autodoc for the validator documentation +# 3. It is runtime_checkable, meaning that it can be used in isinstance() +# checks. +# +# Since protocols are not base classes, isinstance() checking is limited in +# its capabilities. See docs on runtime_checkable for detail + + +@runtime_checkable +class Validator(Protocol): + """ + The protocol to which all validator classes adhere. + + Arguments: + + schema: + + The schema that the validator object will validate with. + It is assumed to be valid, and providing + an invalid schema can lead to undefined behavior. See + `Validator.check_schema` to validate a schema first. + + registry: + + a schema registry that will be used for looking up JSON references + + resolver: + + a resolver that will be used to resolve :kw:`$ref` + properties (JSON references). If unprovided, one will be created. + + .. deprecated:: v4.18.0 + + `RefResolver <_RefResolver>` has been deprecated in favor of + `referencing`, and with it, this argument. + + format_checker: + + if provided, a checker which will be used to assert about + :kw:`format` properties present in the schema. If unprovided, + *no* format validation is done, and the presence of format + within schemas is strictly informational. Certain formats + require additional packages to be installed in order to assert + against instances. Ensure you've installed `jsonschema` with + its `extra (optional) dependencies ` when + invoking ``pip``. + + .. deprecated:: v4.12.0 + + Subclassing validator classes now explicitly warns this is not part of + their public API. + + """ + + #: An object representing the validator's meta schema (the schema that + #: describes valid schemas in the given version). + META_SCHEMA: ClassVar[Mapping] + + #: A mapping of validation keywords (`str`\s) to functions that + #: validate the keyword with that name. For more information see + #: `creating-validators`. + VALIDATORS: ClassVar[Mapping] + + #: A `jsonschema.TypeChecker` that will be used when validating + #: :kw:`type` keywords in JSON schemas. + TYPE_CHECKER: ClassVar[jsonschema.TypeChecker] + + #: A `jsonschema.FormatChecker` that will be used when validating + #: :kw:`format` keywords in JSON schemas. + FORMAT_CHECKER: ClassVar[jsonschema.FormatChecker] + + #: A function which given a schema returns its ID. + ID_OF: _typing.id_of + + #: The schema that will be used to validate instances + schema: Mapping | bool + + def __init__( + self, + schema: Mapping | bool, + resolver: Any = None, # deprecated + format_checker: jsonschema.FormatChecker | None = None, + *, + registry: referencing.jsonschema.SchemaRegistry = ..., + ) -> None: ... + + @classmethod + def check_schema(cls, schema: Mapping | bool) -> None: + """ + Validate the given schema against the validator's `META_SCHEMA`. + + Raises: + + `jsonschema.exceptions.SchemaError`: + + if the schema is invalid + + """ + + def is_type(self, instance: Any, type: str) -> bool: + """ + Check if the instance is of the given (JSON Schema) type. + + Arguments: + + instance: + + the value to check + + type: + + the name of a known (JSON Schema) type + + Returns: + + whether the instance is of the given type + + Raises: + + `jsonschema.exceptions.UnknownType`: + + if ``type`` is not a known type + + """ + + def is_valid(self, instance: Any) -> bool: + """ + Check if the instance is valid under the current `schema`. + + Returns: + + whether the instance is valid or not + + >>> schema = {"maxItems" : 2} + >>> Draft202012Validator(schema).is_valid([2, 3, 4]) + False + + """ + + def iter_errors(self, instance: Any) -> Iterable[ValidationError]: + r""" + Lazily yield each of the validation errors in the given instance. + + >>> schema = { + ... "type" : "array", + ... "items" : {"enum" : [1, 2, 3]}, + ... "maxItems" : 2, + ... } + >>> v = Draft202012Validator(schema) + >>> for error in sorted(v.iter_errors([2, 3, 4]), key=str): + ... print(error.message) + 4 is not one of [1, 2, 3] + [2, 3, 4] is too long + + .. deprecated:: v4.0.0 + + Calling this function with a second schema argument is deprecated. + Use `Validator.evolve` instead. + """ + + def validate(self, instance: Any) -> None: + """ + Check if the instance is valid under the current `schema`. + + Raises: + + `jsonschema.exceptions.ValidationError`: + + if the instance is invalid + + >>> schema = {"maxItems" : 2} + >>> Draft202012Validator(schema).validate([2, 3, 4]) + Traceback (most recent call last): + ... + ValidationError: [2, 3, 4] is too long + + """ + + def evolve(self, **kwargs) -> Validator: + """ + Create a new validator like this one, but with given changes. + + Preserves all other attributes, so can be used to e.g. create a + validator with a different schema but with the same :kw:`$ref` + resolution behavior. + + >>> validator = Draft202012Validator({}) + >>> validator.evolve(schema={"type": "number"}) + Draft202012Validator(schema={'type': 'number'}, format_checker=None) + + The returned object satisfies the validator protocol, but may not + be of the same concrete class! In particular this occurs + when a :kw:`$ref` occurs to a schema with a different + :kw:`$schema` than this one (i.e. for a different draft). + + >>> validator.evolve( + ... schema={"$schema": Draft7Validator.META_SCHEMA["$id"]} + ... ) + Draft7Validator(schema=..., format_checker=None) + """ diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/_suite.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/_suite.py new file mode 100644 index 00000000..d61d3827 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/_suite.py @@ -0,0 +1,285 @@ +""" +Python representations of the JSON Schema Test Suite tests. +""" +from __future__ import annotations + +from contextlib import suppress +from functools import partial +from pathlib import Path +from typing import TYPE_CHECKING, Any +import json +import os +import re +import sys +import unittest + +from attrs import field, frozen +from referencing import Registry +import referencing.jsonschema + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + + from referencing.jsonschema import Schema + import pyperf + +from jsonschema.validators import _VALIDATORS +import jsonschema + +MAGIC_REMOTE_URL = "http://localhost:1234" + +_DELIMITERS = re.compile(r"[\W\- ]+") + + +def _find_suite(): + root = os.environ.get("JSON_SCHEMA_TEST_SUITE") + if root is not None: + return Path(root) + + root = Path(jsonschema.__file__).parent.parent / "json" + if not root.is_dir(): # pragma: no cover + raise ValueError( + ( + "Can't find the JSON-Schema-Test-Suite directory. " + "Set the 'JSON_SCHEMA_TEST_SUITE' environment " + "variable or run the tests from alongside a checkout " + "of the suite." + ), + ) + return root + + +@frozen +class Suite: + + _root: Path = field(factory=_find_suite) + + + def benchmark(self, runner: pyperf.Runner): # pragma: no cover + for name, Validator in _VALIDATORS.items(): + self.version(name=name).benchmark( + runner=runner, + Validator=Validator, + ) + + def version(self, name) -> Version: + Validator = _VALIDATORS[name] + uri: str = Validator.ID_OF(Validator.META_SCHEMA) # type: ignore[assignment] + specification = referencing.jsonschema.specification_with(uri) + + registry = Registry().with_contents( + remotes_in(root=self._root / "remotes", name=name, uri=uri), + default_specification=specification, + ) + return Version( + name=name, + path=self._root / "tests" / name, + remotes=registry, + ) + + +@frozen +class Version: + + _path: Path + _remotes: referencing.jsonschema.SchemaRegistry + + name: str + + def benchmark(self, **kwargs): # pragma: no cover + for case in self.cases(): + case.benchmark(**kwargs) + + def cases(self) -> Iterable[_Case]: + return self._cases_in(paths=self._path.glob("*.json")) + + def format_cases(self) -> Iterable[_Case]: + return self._cases_in(paths=self._path.glob("optional/format/*.json")) + + def optional_cases_of(self, name: str) -> Iterable[_Case]: + return self._cases_in(paths=[self._path / "optional" / f"{name}.json"]) + + def to_unittest_testcase(self, *groups, **kwargs): + name = kwargs.pop("name", "Test" + self.name.title().replace("-", "")) + methods = { + method.__name__: method + for method in ( + test.to_unittest_method(**kwargs) + for group in groups + for case in group + for test in case.tests + ) + } + cls = type(name, (unittest.TestCase,), methods) + + # We're doing crazy things, so if they go wrong, like a function + # behaving differently on some other interpreter, just make them + # not happen. + with suppress(Exception): + cls.__module__ = _someone_save_us_the_module_of_the_caller() + + return cls + + def _cases_in(self, paths: Iterable[Path]) -> Iterable[_Case]: + for path in paths: + for case in json.loads(path.read_text(encoding="utf-8")): + yield _Case.from_dict( + case, + version=self, + subject=path.stem, + remotes=self._remotes, + ) + + +@frozen +class _Case: + + version: Version + + subject: str + description: str + schema: Mapping[str, Any] | bool + tests: list[_Test] + comment: str | None = None + specification: Sequence[dict[str, str]] = () + + @classmethod + def from_dict(cls, data, remotes, **kwargs): + data.update(kwargs) + tests = [ + _Test( + version=data["version"], + subject=data["subject"], + case_description=data["description"], + schema=data["schema"], + remotes=remotes, + **test, + ) for test in data.pop("tests") + ] + return cls(tests=tests, **data) + + def benchmark(self, runner: pyperf.Runner, **kwargs): # pragma: no cover + for test in self.tests: + runner.bench_func( + test.fully_qualified_name, + partial(test.validate_ignoring_errors, **kwargs), + ) + + +def remotes_in( + root: Path, + name: str, + uri: str, +) -> Iterable[tuple[str, Schema]]: + # This messy logic is because the test suite is terrible at indicating + # what remotes are needed for what drafts, and mixes in schemas which + # have no $schema and which are invalid under earlier versions, in with + # other schemas which are needed for tests. + + for each in root.rglob("*.json"): + schema = json.loads(each.read_text()) + + relative = str(each.relative_to(root)).replace("\\", "/") + + if ( + ( # invalid boolean schema + name in {"draft3", "draft4"} + and each.stem == "tree" + ) or + ( # draft/*.json + "$schema" not in schema + and relative.startswith("draft") + and not relative.startswith(name) + ) + ): + continue + yield f"{MAGIC_REMOTE_URL}/{relative}", schema + + +@frozen(repr=False) +class _Test: + + version: Version + + subject: str + case_description: str + description: str + + data: Any + schema: Mapping[str, Any] | bool + + valid: bool + + _remotes: referencing.jsonschema.SchemaRegistry + + comment: str | None = None + + def __repr__(self): # pragma: no cover + return f"" + + @property + def fully_qualified_name(self): # pragma: no cover + return " > ".join( # noqa: FLY002 + [ + self.version.name, + self.subject, + self.case_description, + self.description, + ], + ) + + def to_unittest_method(self, skip=lambda test: None, **kwargs): + if self.valid: + def fn(this): + self.validate(**kwargs) + else: + def fn(this): + with this.assertRaises(jsonschema.ValidationError): + self.validate(**kwargs) + + fn.__name__ = "_".join( + [ + "test", + _DELIMITERS.sub("_", self.subject), + _DELIMITERS.sub("_", self.case_description), + _DELIMITERS.sub("_", self.description), + ], + ) + reason = skip(self) + if reason is None or os.environ.get("JSON_SCHEMA_DEBUG", "0") != "0": + return fn + elif os.environ.get("JSON_SCHEMA_EXPECTED_FAILURES", "0") != "0": # pragma: no cover # noqa: E501 + return unittest.expectedFailure(fn) + else: + return unittest.skip(reason)(fn) + + def validate(self, Validator, **kwargs): + Validator.check_schema(self.schema) + validator = Validator( + schema=self.schema, + registry=self._remotes, + **kwargs, + ) + if os.environ.get("JSON_SCHEMA_DEBUG", "0") != "0": # pragma: no cover + breakpoint() # noqa: T100 + validator.validate(instance=self.data) + + def validate_ignoring_errors(self, Validator): # pragma: no cover + with suppress(jsonschema.ValidationError): + self.validate(Validator=Validator) + + +def _someone_save_us_the_module_of_the_caller(): + """ + The FQON of the module 2nd stack frames up from here. + + This is intended to allow us to dynamically return test case classes that + are indistinguishable from being defined in the module that wants them. + + Otherwise, trial will mis-print the FQON, and copy pasting it won't re-run + the class that really is running. + + Save us all, this is all so so so so so terrible. + """ + + return sys._getframe(2).f_globals["__name__"] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/fuzz_validate.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/fuzz_validate.py new file mode 100644 index 00000000..c12e88bc --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/fuzz_validate.py @@ -0,0 +1,50 @@ +""" +Fuzzing setup for OSS-Fuzz. + +See https://github.com/google/oss-fuzz/tree/master/projects/jsonschema for the +other half of the setup here. +""" +import sys + +from hypothesis import given, strategies + +import jsonschema + +PRIM = strategies.one_of( + strategies.booleans(), + strategies.integers(), + strategies.floats(allow_nan=False, allow_infinity=False), + strategies.text(), +) +DICT = strategies.recursive( + base=strategies.one_of( + strategies.booleans(), + strategies.dictionaries(strategies.text(), PRIM), + ), + extend=lambda inner: strategies.dictionaries(strategies.text(), inner), +) + + +@given(obj1=DICT, obj2=DICT) +def test_schemas(obj1, obj2): + try: + jsonschema.validate(instance=obj1, schema=obj2) + except jsonschema.exceptions.ValidationError: + pass + except jsonschema.exceptions.SchemaError: + pass + + +def main(): + atheris.instrument_all() + atheris.Setup( + sys.argv, + test_schemas.hypothesis.fuzz_one_input, + enable_python_coverage=True, + ) + atheris.Fuzz() + + +if __name__ == "__main__": + import atheris + main() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_cli.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_cli.py new file mode 100644 index 00000000..bed9f3e4 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_cli.py @@ -0,0 +1,904 @@ +from contextlib import redirect_stderr, redirect_stdout +from importlib import metadata +from io import StringIO +from json import JSONDecodeError +from pathlib import Path +from textwrap import dedent +from unittest import TestCase +import json +import os +import subprocess +import sys +import tempfile +import warnings + +from jsonschema import Draft4Validator, Draft202012Validator +from jsonschema.exceptions import ( + SchemaError, + ValidationError, + _RefResolutionError, +) +from jsonschema.validators import _LATEST_VERSION, validate + +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from jsonschema import cli + + +def fake_validator(*errors): + errors = list(reversed(errors)) + + class FakeValidator: + def __init__(self, *args, **kwargs): + pass + + def iter_errors(self, instance): + if errors: + return errors.pop() + return [] # pragma: no cover + + @classmethod + def check_schema(self, schema): + pass + + return FakeValidator + + +def fake_open(all_contents): + def open(path): + contents = all_contents.get(path) + if contents is None: + raise FileNotFoundError(path) + return StringIO(contents) + return open + + +def _message_for(non_json): + try: + json.loads(non_json) + except JSONDecodeError as error: + return str(error) + else: # pragma: no cover + raise RuntimeError("Tried and failed to capture a JSON dump error.") + + +class TestCLI(TestCase): + def run_cli( + self, argv, files=None, stdin=StringIO(), exit_code=0, **override, + ): + arguments = cli.parse_args(argv) + arguments.update(override) + + self.assertFalse(hasattr(cli, "open")) + cli.open = fake_open(files or {}) + try: + stdout, stderr = StringIO(), StringIO() + actual_exit_code = cli.run( + arguments, + stdin=stdin, + stdout=stdout, + stderr=stderr, + ) + finally: + del cli.open + + self.assertEqual( + actual_exit_code, exit_code, msg=dedent( + f""" + Expected an exit code of {exit_code} != {actual_exit_code}. + + stdout: {stdout.getvalue()} + + stderr: {stderr.getvalue()} + """, + ), + ) + return stdout.getvalue(), stderr.getvalue() + + def assertOutputs(self, stdout="", stderr="", **kwargs): + self.assertEqual( + self.run_cli(**kwargs), + (dedent(stdout), dedent(stderr)), + ) + + def test_invalid_instance(self): + error = ValidationError("I am an error!", instance=12) + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_instance=json.dumps(error.instance), + ), + validator=fake_validator([error]), + + argv=["-i", "some_instance", "some_schema"], + + exit_code=1, + stderr="12: I am an error!\n", + ) + + def test_invalid_instance_pretty_output(self): + error = ValidationError("I am an error!", instance=12) + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_instance=json.dumps(error.instance), + ), + validator=fake_validator([error]), + + argv=["-i", "some_instance", "--output", "pretty", "some_schema"], + + exit_code=1, + stderr="""\ + ===[ValidationError]===(some_instance)=== + + I am an error! + ----------------------------- + """, + ) + + def test_invalid_instance_explicit_plain_output(self): + error = ValidationError("I am an error!", instance=12) + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_instance=json.dumps(error.instance), + ), + validator=fake_validator([error]), + + argv=["--output", "plain", "-i", "some_instance", "some_schema"], + + exit_code=1, + stderr="12: I am an error!\n", + ) + + def test_invalid_instance_multiple_errors(self): + instance = 12 + first = ValidationError("First error", instance=instance) + second = ValidationError("Second error", instance=instance) + + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_instance=json.dumps(instance), + ), + validator=fake_validator([first, second]), + + argv=["-i", "some_instance", "some_schema"], + + exit_code=1, + stderr="""\ + 12: First error + 12: Second error + """, + ) + + def test_invalid_instance_multiple_errors_pretty_output(self): + instance = 12 + first = ValidationError("First error", instance=instance) + second = ValidationError("Second error", instance=instance) + + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_instance=json.dumps(instance), + ), + validator=fake_validator([first, second]), + + argv=["-i", "some_instance", "--output", "pretty", "some_schema"], + + exit_code=1, + stderr="""\ + ===[ValidationError]===(some_instance)=== + + First error + ----------------------------- + ===[ValidationError]===(some_instance)=== + + Second error + ----------------------------- + """, + ) + + def test_multiple_invalid_instances(self): + first_instance = 12 + first_errors = [ + ValidationError("An error", instance=first_instance), + ValidationError("Another error", instance=first_instance), + ] + second_instance = "foo" + second_errors = [ValidationError("BOOM", instance=second_instance)] + + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_first_instance=json.dumps(first_instance), + some_second_instance=json.dumps(second_instance), + ), + validator=fake_validator(first_errors, second_errors), + + argv=[ + "-i", "some_first_instance", + "-i", "some_second_instance", + "some_schema", + ], + + exit_code=1, + stderr="""\ + 12: An error + 12: Another error + foo: BOOM + """, + ) + + def test_multiple_invalid_instances_pretty_output(self): + first_instance = 12 + first_errors = [ + ValidationError("An error", instance=first_instance), + ValidationError("Another error", instance=first_instance), + ] + second_instance = "foo" + second_errors = [ValidationError("BOOM", instance=second_instance)] + + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_first_instance=json.dumps(first_instance), + some_second_instance=json.dumps(second_instance), + ), + validator=fake_validator(first_errors, second_errors), + + argv=[ + "--output", "pretty", + "-i", "some_first_instance", + "-i", "some_second_instance", + "some_schema", + ], + + exit_code=1, + stderr="""\ + ===[ValidationError]===(some_first_instance)=== + + An error + ----------------------------- + ===[ValidationError]===(some_first_instance)=== + + Another error + ----------------------------- + ===[ValidationError]===(some_second_instance)=== + + BOOM + ----------------------------- + """, + ) + + def test_custom_error_format(self): + first_instance = 12 + first_errors = [ + ValidationError("An error", instance=first_instance), + ValidationError("Another error", instance=first_instance), + ] + second_instance = "foo" + second_errors = [ValidationError("BOOM", instance=second_instance)] + + self.assertOutputs( + files=dict( + some_schema='{"does not": "matter since it is stubbed"}', + some_first_instance=json.dumps(first_instance), + some_second_instance=json.dumps(second_instance), + ), + validator=fake_validator(first_errors, second_errors), + + argv=[ + "--error-format", ":{error.message}._-_.{error.instance}:", + "-i", "some_first_instance", + "-i", "some_second_instance", + "some_schema", + ], + + exit_code=1, + stderr=":An error._-_.12::Another error._-_.12::BOOM._-_.foo:", + ) + + def test_invalid_schema(self): + self.assertOutputs( + files=dict(some_schema='{"type": 12}'), + argv=["some_schema"], + + exit_code=1, + stderr="""\ + 12: 12 is not valid under any of the given schemas + """, + ) + + def test_invalid_schema_pretty_output(self): + schema = {"type": 12} + + with self.assertRaises(SchemaError) as e: + validate(schema=schema, instance="") + error = str(e.exception) + + self.assertOutputs( + files=dict(some_schema=json.dumps(schema)), + argv=["--output", "pretty", "some_schema"], + + exit_code=1, + stderr=( + "===[SchemaError]===(some_schema)===\n\n" + + str(error) + + "\n-----------------------------\n" + ), + ) + + def test_invalid_schema_multiple_errors(self): + self.assertOutputs( + files=dict(some_schema='{"type": 12, "items": 57}'), + argv=["some_schema"], + + exit_code=1, + stderr="""\ + 57: 57 is not of type 'object', 'boolean' + """, + ) + + def test_invalid_schema_multiple_errors_pretty_output(self): + schema = {"type": 12, "items": 57} + + with self.assertRaises(SchemaError) as e: + validate(schema=schema, instance="") + error = str(e.exception) + + self.assertOutputs( + files=dict(some_schema=json.dumps(schema)), + argv=["--output", "pretty", "some_schema"], + + exit_code=1, + stderr=( + "===[SchemaError]===(some_schema)===\n\n" + + str(error) + + "\n-----------------------------\n" + ), + ) + + def test_invalid_schema_with_invalid_instance(self): + """ + "Validating" an instance that's invalid under an invalid schema + just shows the schema error. + """ + self.assertOutputs( + files=dict( + some_schema='{"type": 12, "minimum": 30}', + some_instance="13", + ), + argv=["-i", "some_instance", "some_schema"], + + exit_code=1, + stderr="""\ + 12: 12 is not valid under any of the given schemas + """, + ) + + def test_invalid_schema_with_invalid_instance_pretty_output(self): + instance, schema = 13, {"type": 12, "minimum": 30} + + with self.assertRaises(SchemaError) as e: + validate(schema=schema, instance=instance) + error = str(e.exception) + + self.assertOutputs( + files=dict( + some_schema=json.dumps(schema), + some_instance=json.dumps(instance), + ), + argv=["--output", "pretty", "-i", "some_instance", "some_schema"], + + exit_code=1, + stderr=( + "===[SchemaError]===(some_schema)===\n\n" + + str(error) + + "\n-----------------------------\n" + ), + ) + + def test_invalid_instance_continues_with_the_rest(self): + self.assertOutputs( + files=dict( + some_schema='{"minimum": 30}', + first_instance="not valid JSON!", + second_instance="12", + ), + argv=[ + "-i", "first_instance", + "-i", "second_instance", + "some_schema", + ], + + exit_code=1, + stderr="""\ + Failed to parse 'first_instance': {} + 12: 12 is less than the minimum of 30 + """.format(_message_for("not valid JSON!")), + ) + + def test_custom_error_format_applies_to_schema_errors(self): + instance, schema = 13, {"type": 12, "minimum": 30} + + with self.assertRaises(SchemaError): + validate(schema=schema, instance=instance) + + self.assertOutputs( + files=dict(some_schema=json.dumps(schema)), + + argv=[ + "--error-format", ":{error.message}._-_.{error.instance}:", + "some_schema", + ], + + exit_code=1, + stderr=":12 is not valid under any of the given schemas._-_.12:", + ) + + def test_instance_is_invalid_JSON(self): + instance = "not valid JSON!" + + self.assertOutputs( + files=dict(some_schema="{}", some_instance=instance), + argv=["-i", "some_instance", "some_schema"], + + exit_code=1, + stderr=f"""\ + Failed to parse 'some_instance': {_message_for(instance)} + """, + ) + + def test_instance_is_invalid_JSON_pretty_output(self): + stdout, stderr = self.run_cli( + files=dict( + some_schema="{}", + some_instance="not valid JSON!", + ), + + argv=["--output", "pretty", "-i", "some_instance", "some_schema"], + + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn( + "(some_instance)===\n\nTraceback (most recent call last):\n", + stderr, + ) + self.assertNotIn("some_schema", stderr) + + def test_instance_is_invalid_JSON_on_stdin(self): + instance = "not valid JSON!" + + self.assertOutputs( + files=dict(some_schema="{}"), + stdin=StringIO(instance), + + argv=["some_schema"], + + exit_code=1, + stderr=f"""\ + Failed to parse : {_message_for(instance)} + """, + ) + + def test_instance_is_invalid_JSON_on_stdin_pretty_output(self): + stdout, stderr = self.run_cli( + files=dict(some_schema="{}"), + stdin=StringIO("not valid JSON!"), + + argv=["--output", "pretty", "some_schema"], + + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn( + "()===\n\nTraceback (most recent call last):\n", + stderr, + ) + self.assertNotIn("some_schema", stderr) + + def test_schema_is_invalid_JSON(self): + schema = "not valid JSON!" + + self.assertOutputs( + files=dict(some_schema=schema), + + argv=["some_schema"], + + exit_code=1, + stderr=f"""\ + Failed to parse 'some_schema': {_message_for(schema)} + """, + ) + + def test_schema_is_invalid_JSON_pretty_output(self): + stdout, stderr = self.run_cli( + files=dict(some_schema="not valid JSON!"), + + argv=["--output", "pretty", "some_schema"], + + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn( + "(some_schema)===\n\nTraceback (most recent call last):\n", + stderr, + ) + + def test_schema_and_instance_are_both_invalid_JSON(self): + """ + Only the schema error is reported, as we abort immediately. + """ + schema, instance = "not valid JSON!", "also not valid JSON!" + self.assertOutputs( + files=dict(some_schema=schema, some_instance=instance), + + argv=["some_schema"], + + exit_code=1, + stderr=f"""\ + Failed to parse 'some_schema': {_message_for(schema)} + """, + ) + + def test_schema_and_instance_are_both_invalid_JSON_pretty_output(self): + """ + Only the schema error is reported, as we abort immediately. + """ + stdout, stderr = self.run_cli( + files=dict( + some_schema="not valid JSON!", + some_instance="also not valid JSON!", + ), + + argv=["--output", "pretty", "-i", "some_instance", "some_schema"], + + exit_code=1, + ) + self.assertFalse(stdout) + self.assertIn( + "(some_schema)===\n\nTraceback (most recent call last):\n", + stderr, + ) + self.assertNotIn("some_instance", stderr) + + def test_instance_does_not_exist(self): + self.assertOutputs( + files=dict(some_schema="{}"), + argv=["-i", "nonexisting_instance", "some_schema"], + + exit_code=1, + stderr="""\ + 'nonexisting_instance' does not exist. + """, + ) + + def test_instance_does_not_exist_pretty_output(self): + self.assertOutputs( + files=dict(some_schema="{}"), + argv=[ + "--output", "pretty", + "-i", "nonexisting_instance", + "some_schema", + ], + + exit_code=1, + stderr="""\ + ===[FileNotFoundError]===(nonexisting_instance)=== + + 'nonexisting_instance' does not exist. + ----------------------------- + """, + ) + + def test_schema_does_not_exist(self): + self.assertOutputs( + argv=["nonexisting_schema"], + + exit_code=1, + stderr="'nonexisting_schema' does not exist.\n", + ) + + def test_schema_does_not_exist_pretty_output(self): + self.assertOutputs( + argv=["--output", "pretty", "nonexisting_schema"], + + exit_code=1, + stderr="""\ + ===[FileNotFoundError]===(nonexisting_schema)=== + + 'nonexisting_schema' does not exist. + ----------------------------- + """, + ) + + def test_neither_instance_nor_schema_exist(self): + self.assertOutputs( + argv=["-i", "nonexisting_instance", "nonexisting_schema"], + + exit_code=1, + stderr="'nonexisting_schema' does not exist.\n", + ) + + def test_neither_instance_nor_schema_exist_pretty_output(self): + self.assertOutputs( + argv=[ + "--output", "pretty", + "-i", "nonexisting_instance", + "nonexisting_schema", + ], + + exit_code=1, + stderr="""\ + ===[FileNotFoundError]===(nonexisting_schema)=== + + 'nonexisting_schema' does not exist. + ----------------------------- + """, + ) + + def test_successful_validation(self): + self.assertOutputs( + files=dict(some_schema="{}", some_instance="{}"), + argv=["-i", "some_instance", "some_schema"], + stdout="", + stderr="", + ) + + def test_successful_validation_pretty_output(self): + self.assertOutputs( + files=dict(some_schema="{}", some_instance="{}"), + argv=["--output", "pretty", "-i", "some_instance", "some_schema"], + stdout="===[SUCCESS]===(some_instance)===\n", + stderr="", + ) + + def test_successful_validation_of_stdin(self): + self.assertOutputs( + files=dict(some_schema="{}"), + stdin=StringIO("{}"), + argv=["some_schema"], + stdout="", + stderr="", + ) + + def test_successful_validation_of_stdin_pretty_output(self): + self.assertOutputs( + files=dict(some_schema="{}"), + stdin=StringIO("{}"), + argv=["--output", "pretty", "some_schema"], + stdout="===[SUCCESS]===()===\n", + stderr="", + ) + + def test_successful_validation_of_just_the_schema(self): + self.assertOutputs( + files=dict(some_schema="{}", some_instance="{}"), + argv=["-i", "some_instance", "some_schema"], + stdout="", + stderr="", + ) + + def test_successful_validation_of_just_the_schema_pretty_output(self): + self.assertOutputs( + files=dict(some_schema="{}", some_instance="{}"), + argv=["--output", "pretty", "-i", "some_instance", "some_schema"], + stdout="===[SUCCESS]===(some_instance)===\n", + stderr="", + ) + + def test_successful_validation_via_explicit_base_uri(self): + ref_schema_file = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115 + ref_schema_file.close() + self.addCleanup(os.remove, ref_schema_file.name) + + ref_path = Path(ref_schema_file.name) + ref_path.write_text('{"definitions": {"num": {"type": "integer"}}}') + + schema = f'{{"$ref": "{ref_path.name}#/definitions/num"}}' + + self.assertOutputs( + files=dict(some_schema=schema, some_instance="1"), + argv=[ + "-i", "some_instance", + "--base-uri", ref_path.parent.as_uri() + "/", + "some_schema", + ], + stdout="", + stderr="", + ) + + def test_unsuccessful_validation_via_explicit_base_uri(self): + ref_schema_file = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115 + ref_schema_file.close() + self.addCleanup(os.remove, ref_schema_file.name) + + ref_path = Path(ref_schema_file.name) + ref_path.write_text('{"definitions": {"num": {"type": "integer"}}}') + + schema = f'{{"$ref": "{ref_path.name}#/definitions/num"}}' + + self.assertOutputs( + files=dict(some_schema=schema, some_instance='"1"'), + argv=[ + "-i", "some_instance", + "--base-uri", ref_path.parent.as_uri() + "/", + "some_schema", + ], + exit_code=1, + stdout="", + stderr="1: '1' is not of type 'integer'\n", + ) + + def test_nonexistent_file_with_explicit_base_uri(self): + schema = '{"$ref": "someNonexistentFile.json#definitions/num"}' + instance = "1" + + with self.assertRaises(_RefResolutionError) as e: + self.assertOutputs( + files=dict( + some_schema=schema, + some_instance=instance, + ), + argv=[ + "-i", "some_instance", + "--base-uri", Path.cwd().as_uri(), + "some_schema", + ], + ) + error = str(e.exception) + self.assertIn(f"{os.sep}someNonexistentFile.json'", error) + + def test_invalid_explicit_base_uri(self): + schema = '{"$ref": "foo.json#definitions/num"}' + instance = "1" + + with self.assertRaises(_RefResolutionError) as e: + self.assertOutputs( + files=dict( + some_schema=schema, + some_instance=instance, + ), + argv=[ + "-i", "some_instance", + "--base-uri", "not@UR1", + "some_schema", + ], + ) + error = str(e.exception) + self.assertEqual( + error, "unknown url type: 'foo.json'", + ) + + def test_it_validates_using_the_latest_validator_when_unspecified(self): + # There isn't a better way now I can think of to ensure that the + # latest version was used, given that the call to validator_for + # is hidden inside the CLI, so guard that that's the case, and + # this test will have to be updated when versions change until + # we can think of a better way to ensure this behavior. + self.assertIs(Draft202012Validator, _LATEST_VERSION) + + self.assertOutputs( + files=dict(some_schema='{"const": "check"}', some_instance='"a"'), + argv=["-i", "some_instance", "some_schema"], + exit_code=1, + stdout="", + stderr="a: 'check' was expected\n", + ) + + def test_it_validates_using_draft7_when_specified(self): + """ + Specifically, `const` validation applies for Draft 7. + """ + schema = """ + { + "$schema": "http://json-schema.org/draft-07/schema#", + "const": "check" + } + """ + instance = '"foo"' + self.assertOutputs( + files=dict(some_schema=schema, some_instance=instance), + argv=["-i", "some_instance", "some_schema"], + exit_code=1, + stdout="", + stderr="foo: 'check' was expected\n", + ) + + def test_it_validates_using_draft4_when_specified(self): + """ + Specifically, `const` validation *does not* apply for Draft 4. + """ + schema = """ + { + "$schema": "http://json-schema.org/draft-04/schema#", + "const": "check" + } + """ + instance = '"foo"' + self.assertOutputs( + files=dict(some_schema=schema, some_instance=instance), + argv=["-i", "some_instance", "some_schema"], + stdout="", + stderr="", + ) + + +class TestParser(TestCase): + + FakeValidator = fake_validator() + + def test_find_validator_by_fully_qualified_object_name(self): + arguments = cli.parse_args( + [ + "--validator", + "jsonschema.tests.test_cli.TestParser.FakeValidator", + "--instance", "mem://some/instance", + "mem://some/schema", + ], + ) + self.assertIs(arguments["validator"], self.FakeValidator) + + def test_find_validator_in_jsonschema(self): + arguments = cli.parse_args( + [ + "--validator", "Draft4Validator", + "--instance", "mem://some/instance", + "mem://some/schema", + ], + ) + self.assertIs(arguments["validator"], Draft4Validator) + + def cli_output_for(self, *argv): + stdout, stderr = StringIO(), StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): # noqa: SIM117 + with self.assertRaises(SystemExit): + cli.parse_args(argv) + return stdout.getvalue(), stderr.getvalue() + + def test_unknown_output(self): + stdout, stderr = self.cli_output_for( + "--output", "foo", + "mem://some/schema", + ) + self.assertIn("invalid choice: 'foo'", stderr) + self.assertFalse(stdout) + + def test_useless_error_format(self): + stdout, stderr = self.cli_output_for( + "--output", "pretty", + "--error-format", "foo", + "mem://some/schema", + ) + self.assertIn( + "--error-format can only be used with --output plain", + stderr, + ) + self.assertFalse(stdout) + + +class TestCLIIntegration(TestCase): + def test_license(self): + our_metadata = metadata.metadata("jsonschema") + self.assertEqual(our_metadata.get("License-Expression"), "MIT") + + def test_version(self): + version = subprocess.check_output( + [sys.executable, "-W", "ignore", "-m", "jsonschema", "--version"], + stderr=subprocess.STDOUT, + ) + version = version.decode("utf-8").strip() + self.assertEqual(version, metadata.version("jsonschema")) + + def test_no_arguments_shows_usage_notes(self): + output = subprocess.check_output( + [sys.executable, "-m", "jsonschema"], + stderr=subprocess.STDOUT, + ) + output_for_help = subprocess.check_output( + [sys.executable, "-m", "jsonschema", "--help"], + stderr=subprocess.STDOUT, + ) + self.assertEqual(output, output_for_help) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_deprecations.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_deprecations.py new file mode 100644 index 00000000..a54b02f3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_deprecations.py @@ -0,0 +1,432 @@ +from contextlib import contextmanager +from io import BytesIO +from unittest import TestCase, mock +import importlib.metadata +import json +import subprocess +import sys +import urllib.request + +import referencing.exceptions + +from jsonschema import FormatChecker, exceptions, protocols, validators + + +class TestDeprecations(TestCase): + def test_version(self): + """ + As of v4.0.0, __version__ is deprecated in favor of importlib.metadata. + """ + + message = "Accessing jsonschema.__version__ is deprecated" + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import __version__ + + self.assertEqual(__version__, importlib.metadata.version("jsonschema")) + self.assertEqual(w.filename, __file__) + + def test_validators_ErrorTree(self): + """ + As of v4.0.0, importing ErrorTree from jsonschema.validators is + deprecated in favor of doing so from jsonschema.exceptions. + """ + + message = "Importing ErrorTree from jsonschema.validators is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema.validators import ErrorTree + + self.assertEqual(ErrorTree, exceptions.ErrorTree) + self.assertEqual(w.filename, __file__) + + def test_import_ErrorTree(self): + """ + As of v4.18.0, importing ErrorTree from the package root is + deprecated in favor of doing so from jsonschema.exceptions. + """ + + message = "Importing ErrorTree directly from the jsonschema package " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import ErrorTree + + self.assertEqual(ErrorTree, exceptions.ErrorTree) + self.assertEqual(w.filename, __file__) + + def test_ErrorTree_setitem(self): + """ + As of v4.20.0, setting items on an ErrorTree is deprecated. + """ + + e = exceptions.ValidationError("some error", path=["foo"]) + tree = exceptions.ErrorTree() + subtree = exceptions.ErrorTree(errors=[e]) + + message = "ErrorTree.__setitem__ is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + tree["foo"] = subtree + + self.assertEqual(tree["foo"], subtree) + self.assertEqual(w.filename, __file__) + + def test_import_FormatError(self): + """ + As of v4.18.0, importing FormatError from the package root is + deprecated in favor of doing so from jsonschema.exceptions. + """ + + message = "Importing FormatError directly from the jsonschema package " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import FormatError + + self.assertEqual(FormatError, exceptions.FormatError) + self.assertEqual(w.filename, __file__) + + def test_import_Validator(self): + """ + As of v4.19.0, importing Validator from the package root is + deprecated in favor of doing so from jsonschema.protocols. + """ + + message = "Importing Validator directly from the jsonschema package " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import Validator + + self.assertEqual(Validator, protocols.Validator) + self.assertEqual(w.filename, __file__) + + def test_validators_validators(self): + """ + As of v4.0.0, accessing jsonschema.validators.validators is + deprecated. + """ + + message = "Accessing jsonschema.validators.validators is deprecated" + with self.assertWarnsRegex(DeprecationWarning, message) as w: + value = validators.validators + + self.assertEqual(value, validators._VALIDATORS) + self.assertEqual(w.filename, __file__) + + def test_validators_meta_schemas(self): + """ + As of v4.0.0, accessing jsonschema.validators.meta_schemas is + deprecated. + """ + + message = "Accessing jsonschema.validators.meta_schemas is deprecated" + with self.assertWarnsRegex(DeprecationWarning, message) as w: + value = validators.meta_schemas + + self.assertEqual(value, validators._META_SCHEMAS) + self.assertEqual(w.filename, __file__) + + def test_RefResolver_in_scope(self): + """ + As of v4.0.0, RefResolver.in_scope is deprecated. + """ + + resolver = validators._RefResolver.from_schema({}) + message = "jsonschema.RefResolver.in_scope is deprecated " + with self.assertWarnsRegex(DeprecationWarning, message) as w: # noqa: SIM117 + with resolver.in_scope("foo"): + pass + + self.assertEqual(w.filename, __file__) + + def test_Validator_is_valid_two_arguments(self): + """ + As of v4.0.0, calling is_valid with two arguments (to provide a + different schema) is deprecated. + """ + + validator = validators.Draft7Validator({}) + message = "Passing a schema to Validator.is_valid is deprecated " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + result = validator.is_valid("foo", {"type": "number"}) + + self.assertFalse(result) + self.assertEqual(w.filename, __file__) + + def test_Validator_iter_errors_two_arguments(self): + """ + As of v4.0.0, calling iter_errors with two arguments (to provide a + different schema) is deprecated. + """ + + validator = validators.Draft7Validator({}) + message = "Passing a schema to Validator.iter_errors is deprecated " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + error, = validator.iter_errors("foo", {"type": "number"}) + + self.assertEqual(error.validator, "type") + self.assertEqual(w.filename, __file__) + + def test_Validator_resolver(self): + """ + As of v4.18.0, accessing Validator.resolver is deprecated. + """ + + validator = validators.Draft7Validator({}) + message = "Accessing Draft7Validator.resolver is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + self.assertIsInstance(validator.resolver, validators._RefResolver) + + self.assertEqual(w.filename, __file__) + + def test_RefResolver(self): + """ + As of v4.18.0, RefResolver is fully deprecated. + """ + + message = "jsonschema.RefResolver is deprecated" + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import RefResolver + self.assertEqual(w.filename, __file__) + + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema.validators import RefResolver # noqa: F401 + self.assertEqual(w.filename, __file__) + + def test_RefResolutionError(self): + """ + As of v4.18.0, RefResolutionError is deprecated in favor of directly + catching errors from the referencing library. + """ + + message = "jsonschema.exceptions.RefResolutionError is deprecated" + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import RefResolutionError + + self.assertEqual(RefResolutionError, exceptions._RefResolutionError) + self.assertEqual(w.filename, __file__) + + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema.exceptions import RefResolutionError + + self.assertEqual(RefResolutionError, exceptions._RefResolutionError) + self.assertEqual(w.filename, __file__) + + def test_catching_Unresolvable_directly(self): + """ + This behavior is the intended behavior (i.e. it's not deprecated), but + given we do "tricksy" things in the iterim to wrap exceptions in a + multiple inheritance subclass, we need to be extra sure it works and + stays working. + """ + validator = validators.Draft202012Validator({"$ref": "urn:nothing"}) + + with self.assertRaises(referencing.exceptions.Unresolvable) as e: + validator.validate(12) + + expected = referencing.exceptions.Unresolvable(ref="urn:nothing") + self.assertEqual( + (e.exception, str(e.exception)), + (expected, "Unresolvable: urn:nothing"), + ) + + def test_catching_Unresolvable_via_RefResolutionError(self): + """ + Until RefResolutionError is removed, it is still possible to catch + exceptions from reference resolution using it, even though they may + have been raised by referencing. + """ + with self.assertWarns(DeprecationWarning): + from jsonschema import RefResolutionError + + validator = validators.Draft202012Validator({"$ref": "urn:nothing"}) + + with self.assertRaises(referencing.exceptions.Unresolvable) as u: + validator.validate(12) + + with self.assertRaises(RefResolutionError) as e: + validator.validate(12) + + self.assertEqual( + (e.exception, str(e.exception)), + (u.exception, "Unresolvable: urn:nothing"), + ) + + def test_WrappedReferencingError_hashability(self): + """ + Ensure the wrapped referencing errors are hashable when possible. + """ + with self.assertWarns(DeprecationWarning): + from jsonschema import RefResolutionError + + validator = validators.Draft202012Validator({"$ref": "urn:nothing"}) + + with self.assertRaises(referencing.exceptions.Unresolvable) as u: + validator.validate(12) + + with self.assertRaises(RefResolutionError) as e: + validator.validate(12) + + self.assertIn(e.exception, {u.exception}) + self.assertIn(u.exception, {e.exception}) + + def test_Validator_subclassing(self): + """ + As of v4.12.0, subclassing a validator class produces an explicit + deprecation warning. + + This was never intended to be public API (and some comments over the + years in issues said so, but obviously that's not a great way to make + sure it's followed). + + A future version will explicitly raise an error. + """ + + message = "Subclassing validator classes is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + class Subclass(validators.Draft202012Validator): + pass + + self.assertEqual(w.filename, __file__) + + with self.assertWarnsRegex(DeprecationWarning, message) as w: + class AnotherSubclass(validators.create(meta_schema={})): + pass + + def test_FormatChecker_cls_checks(self): + """ + As of v4.14.0, FormatChecker.cls_checks is deprecated without + replacement. + """ + + self.addCleanup(FormatChecker.checkers.pop, "boom", None) + + message = "FormatChecker.cls_checks " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + FormatChecker.cls_checks("boom") + + self.assertEqual(w.filename, __file__) + + def test_draftN_format_checker(self): + """ + As of v4.16.0, accessing jsonschema.draftn_format_checker is deprecated + in favor of Validator.FORMAT_CHECKER. + """ + + message = "Accessing jsonschema.draft202012_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft202012_format_checker + + self.assertIs( + draft202012_format_checker, + validators.Draft202012Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + message = "Accessing jsonschema.draft201909_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft201909_format_checker + + self.assertIs( + draft201909_format_checker, + validators.Draft201909Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + message = "Accessing jsonschema.draft7_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft7_format_checker + + self.assertIs( + draft7_format_checker, + validators.Draft7Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + message = "Accessing jsonschema.draft6_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft6_format_checker + + self.assertIs( + draft6_format_checker, + validators.Draft6Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + message = "Accessing jsonschema.draft4_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft4_format_checker + + self.assertIs( + draft4_format_checker, + validators.Draft4Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + message = "Accessing jsonschema.draft3_format_checker is " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + from jsonschema import draft3_format_checker + + self.assertIs( + draft3_format_checker, + validators.Draft3Validator.FORMAT_CHECKER, + ) + self.assertEqual(w.filename, __file__) + + with self.assertRaises(ImportError): + from jsonschema import draft1234_format_checker # noqa: F401 + + def test_import_cli(self): + """ + As of v4.17.0, importing jsonschema.cli is deprecated. + """ + + message = "The jsonschema CLI is deprecated and will be removed " + with self.assertWarnsRegex(DeprecationWarning, message) as w: + import jsonschema.cli + importlib.reload(jsonschema.cli) + + self.assertEqual(w.filename, importlib.__file__) + + def test_cli(self): + """ + As of v4.17.0, the jsonschema CLI is deprecated. + """ + + process = subprocess.run( + [sys.executable, "-m", "jsonschema"], + capture_output=True, + check=True, + ) + self.assertIn(b"The jsonschema CLI is deprecated ", process.stderr) + + def test_automatic_remote_retrieval(self): + """ + Automatic retrieval of remote references is deprecated as of v4.18.0. + """ + ref = "http://bar#/$defs/baz" + schema = {"$defs": {"baz": {"type": "integer"}}} + + if "requests" in sys.modules: # pragma: no cover + self.addCleanup( + sys.modules.__setitem__, "requests", sys.modules["requests"], + ) + sys.modules["requests"] = None + + @contextmanager + def fake_urlopen(request): + self.assertIsInstance(request, urllib.request.Request) + self.assertEqual(request.full_url, "http://bar") + + # Ha ha urllib.request.Request "normalizes" header names and + # Request.get_header does not also normalize them... + (header, value), = request.header_items() + self.assertEqual(header.lower(), "user-agent") + self.assertEqual( + value, "python-jsonschema (deprecated $ref resolution)", + ) + yield BytesIO(json.dumps(schema).encode("utf8")) + + validator = validators.Draft202012Validator({"$ref": ref}) + + message = "Automatically retrieving remote references " + patch = mock.patch.object(urllib.request, "urlopen", new=fake_urlopen) + + with patch, self.assertWarnsRegex(DeprecationWarning, message): + self.assertEqual( + (validator.is_valid({}), validator.is_valid(37)), + (False, True), + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_exceptions.py new file mode 100644 index 00000000..358b9242 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_exceptions.py @@ -0,0 +1,759 @@ +from unittest import TestCase +import textwrap + +import jsonpath_ng + +from jsonschema import exceptions +from jsonschema.validators import _LATEST_VERSION + + +class TestBestMatch(TestCase): + def best_match_of(self, instance, schema): + errors = list(_LATEST_VERSION(schema).iter_errors(instance)) + msg = f"No errors found for {instance} under {schema!r}!" + self.assertTrue(errors, msg=msg) + + best = exceptions.best_match(iter(errors)) + reversed_best = exceptions.best_match(reversed(errors)) + + self.assertEqual( + best._contents(), + reversed_best._contents(), + f"No consistent best match!\nGot: {best}\n\nThen: {reversed_best}", + ) + return best + + def test_shallower_errors_are_better_matches(self): + schema = { + "properties": { + "foo": { + "minProperties": 2, + "properties": {"bar": {"type": "object"}}, + }, + }, + } + best = self.best_match_of(instance={"foo": {"bar": []}}, schema=schema) + self.assertEqual(best.validator, "minProperties") + + def test_oneOf_and_anyOf_are_weak_matches(self): + """ + A property you *must* match is probably better than one you have to + match a part of. + """ + + schema = { + "minProperties": 2, + "anyOf": [{"type": "string"}, {"type": "number"}], + "oneOf": [{"type": "string"}, {"type": "number"}], + } + best = self.best_match_of(instance={}, schema=schema) + self.assertEqual(best.validator, "minProperties") + + def test_if_the_most_relevant_error_is_anyOf_it_is_traversed(self): + """ + If the most relevant error is an anyOf, then we traverse its context + and select the otherwise *least* relevant error, since in this case + that means the most specific, deep, error inside the instance. + + I.e. since only one of the schemas must match, we look for the most + relevant one. + """ + + schema = { + "properties": { + "foo": { + "anyOf": [ + {"type": "string"}, + {"properties": {"bar": {"type": "array"}}}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": {"bar": 12}}, schema=schema) + self.assertEqual(best.validator_value, "array") + + def test_no_anyOf_traversal_for_equally_relevant_errors(self): + """ + We don't traverse into an anyOf (as above) if all of its context errors + seem to be equally "wrong" against the instance. + """ + + schema = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "object"}, + ], + } + best = self.best_match_of(instance=[], schema=schema) + self.assertEqual(best.validator, "anyOf") + + def test_anyOf_traversal_for_single_equally_relevant_error(self): + """ + We *do* traverse anyOf with a single nested error, even though it is + vacuously equally relevant to itself. + """ + + schema = { + "anyOf": [ + {"type": "string"}, + ], + } + best = self.best_match_of(instance=[], schema=schema) + self.assertEqual(best.validator, "type") + + def test_anyOf_traversal_for_single_sibling_errors(self): + """ + We *do* traverse anyOf with a single subschema that fails multiple + times (e.g. on multiple items). + """ + + schema = { + "anyOf": [ + {"items": {"const": 37}}, + ], + } + best = self.best_match_of(instance=[12, 12], schema=schema) + self.assertEqual(best.validator, "const") + + def test_anyOf_traversal_for_non_type_matching_sibling_errors(self): + """ + We *do* traverse anyOf with multiple subschemas when one does not type + match. + """ + + schema = { + "anyOf": [ + {"type": "object"}, + {"items": {"const": 37}}, + ], + } + best = self.best_match_of(instance=[12, 12], schema=schema) + self.assertEqual(best.validator, "const") + + def test_if_the_most_relevant_error_is_oneOf_it_is_traversed(self): + """ + If the most relevant error is an oneOf, then we traverse its context + and select the otherwise *least* relevant error, since in this case + that means the most specific, deep, error inside the instance. + + I.e. since only one of the schemas must match, we look for the most + relevant one. + """ + + schema = { + "properties": { + "foo": { + "oneOf": [ + {"type": "string"}, + {"properties": {"bar": {"type": "array"}}}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": {"bar": 12}}, schema=schema) + self.assertEqual(best.validator_value, "array") + + def test_no_oneOf_traversal_for_equally_relevant_errors(self): + """ + We don't traverse into an oneOf (as above) if all of its context errors + seem to be equally "wrong" against the instance. + """ + + schema = { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + {"type": "object"}, + ], + } + best = self.best_match_of(instance=[], schema=schema) + self.assertEqual(best.validator, "oneOf") + + def test_oneOf_traversal_for_single_equally_relevant_error(self): + """ + We *do* traverse oneOf with a single nested error, even though it is + vacuously equally relevant to itself. + """ + + schema = { + "oneOf": [ + {"type": "string"}, + ], + } + best = self.best_match_of(instance=[], schema=schema) + self.assertEqual(best.validator, "type") + + def test_oneOf_traversal_for_single_sibling_errors(self): + """ + We *do* traverse oneOf with a single subschema that fails multiple + times (e.g. on multiple items). + """ + + schema = { + "oneOf": [ + {"items": {"const": 37}}, + ], + } + best = self.best_match_of(instance=[12, 12], schema=schema) + self.assertEqual(best.validator, "const") + + def test_oneOf_traversal_for_non_type_matching_sibling_errors(self): + """ + We *do* traverse oneOf with multiple subschemas when one does not type + match. + """ + + schema = { + "oneOf": [ + {"type": "object"}, + {"items": {"const": 37}}, + ], + } + best = self.best_match_of(instance=[12, 12], schema=schema) + self.assertEqual(best.validator, "const") + + def test_if_the_most_relevant_error_is_allOf_it_is_traversed(self): + """ + Now, if the error is allOf, we traverse but select the *most* relevant + error from the context, because all schemas here must match anyways. + """ + + schema = { + "properties": { + "foo": { + "allOf": [ + {"type": "string"}, + {"properties": {"bar": {"type": "array"}}}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": {"bar": 12}}, schema=schema) + self.assertEqual(best.validator_value, "string") + + def test_nested_context_for_oneOf(self): + """ + We traverse into nested contexts (a oneOf containing an error in a + nested oneOf here). + """ + + schema = { + "properties": { + "foo": { + "oneOf": [ + {"type": "string"}, + { + "oneOf": [ + {"type": "string"}, + { + "properties": { + "bar": {"type": "array"}, + }, + }, + ], + }, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": {"bar": 12}}, schema=schema) + self.assertEqual(best.validator_value, "array") + + def test_it_prioritizes_matching_types(self): + schema = { + "properties": { + "foo": { + "anyOf": [ + {"type": "array", "minItems": 2}, + {"type": "string", "minLength": 10}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": "bar"}, schema=schema) + self.assertEqual(best.validator, "minLength") + + reordered = { + "properties": { + "foo": { + "anyOf": [ + {"type": "string", "minLength": 10}, + {"type": "array", "minItems": 2}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": "bar"}, schema=reordered) + self.assertEqual(best.validator, "minLength") + + def test_it_prioritizes_matching_union_types(self): + schema = { + "properties": { + "foo": { + "anyOf": [ + {"type": ["array", "object"], "minItems": 2}, + {"type": ["integer", "string"], "minLength": 10}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": "bar"}, schema=schema) + self.assertEqual(best.validator, "minLength") + + reordered = { + "properties": { + "foo": { + "anyOf": [ + {"type": "string", "minLength": 10}, + {"type": "array", "minItems": 2}, + ], + }, + }, + } + best = self.best_match_of(instance={"foo": "bar"}, schema=reordered) + self.assertEqual(best.validator, "minLength") + + def test_boolean_schemas(self): + schema = {"properties": {"foo": False}} + best = self.best_match_of(instance={"foo": "bar"}, schema=schema) + self.assertIsNone(best.validator) + + def test_one_error(self): + validator = _LATEST_VERSION({"minProperties": 2}) + validator.iter_errors({}) + self.assertEqual( + exceptions.best_match(validator.iter_errors({})).validator, + "minProperties", + ) + + def test_no_errors(self): + validator = _LATEST_VERSION({}) + self.assertIsNone(exceptions.best_match(validator.iter_errors({}))) + + +class TestByRelevance(TestCase): + def test_short_paths_are_better_matches(self): + shallow = exceptions.ValidationError("Oh no!", path=["baz"]) + deep = exceptions.ValidationError("Oh yes!", path=["foo", "bar"]) + match = max([shallow, deep], key=exceptions.relevance) + self.assertIs(match, shallow) + + match = max([deep, shallow], key=exceptions.relevance) + self.assertIs(match, shallow) + + def test_global_errors_are_even_better_matches(self): + shallow = exceptions.ValidationError("Oh no!", path=[]) + deep = exceptions.ValidationError("Oh yes!", path=["foo"]) + + errors = sorted([shallow, deep], key=exceptions.relevance) + self.assertEqual( + [list(error.path) for error in errors], + [["foo"], []], + ) + + errors = sorted([deep, shallow], key=exceptions.relevance) + self.assertEqual( + [list(error.path) for error in errors], + [["foo"], []], + ) + + def test_weak_keywords_are_lower_priority(self): + weak = exceptions.ValidationError("Oh no!", path=[], validator="a") + normal = exceptions.ValidationError("Oh yes!", path=[], validator="b") + + best_match = exceptions.by_relevance(weak="a") + + match = max([weak, normal], key=best_match) + self.assertIs(match, normal) + + match = max([normal, weak], key=best_match) + self.assertIs(match, normal) + + def test_strong_keywords_are_higher_priority(self): + weak = exceptions.ValidationError("Oh no!", path=[], validator="a") + normal = exceptions.ValidationError("Oh yes!", path=[], validator="b") + strong = exceptions.ValidationError("Oh fine!", path=[], validator="c") + + best_match = exceptions.by_relevance(weak="a", strong="c") + + match = max([weak, normal, strong], key=best_match) + self.assertIs(match, strong) + + match = max([strong, normal, weak], key=best_match) + self.assertIs(match, strong) + + +class TestErrorTree(TestCase): + def test_it_knows_how_many_total_errors_it_contains(self): + # FIXME: #442 + errors = [ + exceptions.ValidationError("Something", validator=i) + for i in range(8) + ] + tree = exceptions.ErrorTree(errors) + self.assertEqual(tree.total_errors, 8) + + def test_it_contains_an_item_if_the_item_had_an_error(self): + errors = [exceptions.ValidationError("a message", path=["bar"])] + tree = exceptions.ErrorTree(errors) + self.assertIn("bar", tree) + + def test_it_does_not_contain_an_item_if_the_item_had_no_error(self): + errors = [exceptions.ValidationError("a message", path=["bar"])] + tree = exceptions.ErrorTree(errors) + self.assertNotIn("foo", tree) + + def test_keywords_that_failed_appear_in_errors_dict(self): + error = exceptions.ValidationError("a message", validator="foo") + tree = exceptions.ErrorTree([error]) + self.assertEqual(tree.errors, {"foo": error}) + + def test_it_creates_a_child_tree_for_each_nested_path(self): + errors = [ + exceptions.ValidationError("a bar message", path=["bar"]), + exceptions.ValidationError("a bar -> 0 message", path=["bar", 0]), + ] + tree = exceptions.ErrorTree(errors) + self.assertIn(0, tree["bar"]) + self.assertNotIn(1, tree["bar"]) + + def test_children_have_their_errors_dicts_built(self): + e1, e2 = ( + exceptions.ValidationError("1", validator="foo", path=["bar", 0]), + exceptions.ValidationError("2", validator="quux", path=["bar", 0]), + ) + tree = exceptions.ErrorTree([e1, e2]) + self.assertEqual(tree["bar"][0].errors, {"foo": e1, "quux": e2}) + + def test_multiple_errors_with_instance(self): + e1, e2 = ( + exceptions.ValidationError( + "1", + validator="foo", + path=["bar", "bar2"], + instance="i1"), + exceptions.ValidationError( + "2", + validator="quux", + path=["foobar", 2], + instance="i2"), + ) + exceptions.ErrorTree([e1, e2]) + + def test_it_does_not_contain_subtrees_that_are_not_in_the_instance(self): + error = exceptions.ValidationError("123", validator="foo", instance=[]) + tree = exceptions.ErrorTree([error]) + + with self.assertRaises(IndexError): + tree[0] + + def test_if_its_in_the_tree_anyhow_it_does_not_raise_an_error(self): + """ + If a keyword refers to a path that isn't in the instance, the + tree still properly returns a subtree for that path. + """ + + error = exceptions.ValidationError( + "a message", validator="foo", instance={}, path=["foo"], + ) + tree = exceptions.ErrorTree([error]) + self.assertIsInstance(tree["foo"], exceptions.ErrorTree) + + def test_iter(self): + e1, e2 = ( + exceptions.ValidationError( + "1", + validator="foo", + path=["bar", "bar2"], + instance="i1"), + exceptions.ValidationError( + "2", + validator="quux", + path=["foobar", 2], + instance="i2"), + ) + tree = exceptions.ErrorTree([e1, e2]) + self.assertEqual(set(tree), {"bar", "foobar"}) + + def test_repr_single(self): + error = exceptions.ValidationError( + "1", + validator="foo", + path=["bar", "bar2"], + instance="i1", + ) + tree = exceptions.ErrorTree([error]) + self.assertEqual(repr(tree), "") + + def test_repr_multiple(self): + e1, e2 = ( + exceptions.ValidationError( + "1", + validator="foo", + path=["bar", "bar2"], + instance="i1"), + exceptions.ValidationError( + "2", + validator="quux", + path=["foobar", 2], + instance="i2"), + ) + tree = exceptions.ErrorTree([e1, e2]) + self.assertEqual(repr(tree), "") + + def test_repr_empty(self): + tree = exceptions.ErrorTree([]) + self.assertEqual(repr(tree), "") + + +class TestErrorInitReprStr(TestCase): + def make_error(self, **kwargs): + defaults = dict( + message="hello", + validator="type", + validator_value="string", + instance=5, + schema={"type": "string"}, + ) + defaults.update(kwargs) + return exceptions.ValidationError(**defaults) + + def assertShows(self, expected, **kwargs): + expected = textwrap.dedent(expected).rstrip("\n") + + error = self.make_error(**kwargs) + message_line, _, rest = str(error).partition("\n") + self.assertEqual(message_line, error.message) + self.assertEqual(rest, expected) + + def test_it_calls_super_and_sets_args(self): + error = self.make_error() + self.assertGreater(len(error.args), 1) + + def test_repr(self): + self.assertEqual( + repr(exceptions.ValidationError(message="Hello!")), + "", + ) + + def test_unset_error(self): + error = exceptions.ValidationError("message") + self.assertEqual(str(error), "message") + + kwargs = { + "validator": "type", + "validator_value": "string", + "instance": 5, + "schema": {"type": "string"}, + } + # Just the message should show if any of the attributes are unset + for attr in kwargs: + k = dict(kwargs) + del k[attr] + error = exceptions.ValidationError("message", **k) + self.assertEqual(str(error), "message") + + def test_empty_paths(self): + self.assertShows( + """ + Failed validating 'type' in schema: + {'type': 'string'} + + On instance: + 5 + """, + path=[], + schema_path=[], + ) + + def test_one_item_paths(self): + self.assertShows( + """ + Failed validating 'type' in schema: + {'type': 'string'} + + On instance[0]: + 5 + """, + path=[0], + schema_path=["items"], + ) + + def test_multiple_item_paths(self): + self.assertShows( + """ + Failed validating 'type' in schema['items'][0]: + {'type': 'string'} + + On instance[0]['a']: + 5 + """, + path=[0, "a"], + schema_path=["items", 0, 1], + ) + + def test_uses_pprint(self): + self.assertShows( + """ + Failed validating 'maxLength' in schema: + {0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + 6: 6, + 7: 7, + 8: 8, + 9: 9, + 10: 10, + 11: 11, + 12: 12, + 13: 13, + 14: 14, + 15: 15, + 16: 16, + 17: 17, + 18: 18, + 19: 19} + + On instance: + [0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24] + """, + instance=list(range(25)), + schema=dict(zip(range(20), range(20))), + validator="maxLength", + ) + + def test_does_not_reorder_dicts(self): + self.assertShows( + """ + Failed validating 'type' in schema: + {'do': 3, 'not': 7, 'sort': 37, 'me': 73} + + On instance: + {'here': 73, 'too': 37, 'no': 7, 'sorting': 3} + """, + schema={ + "do": 3, + "not": 7, + "sort": 37, + "me": 73, + }, + instance={ + "here": 73, + "too": 37, + "no": 7, + "sorting": 3, + }, + ) + + def test_str_works_with_instances_having_overriden_eq_operator(self): + """ + Check for #164 which rendered exceptions unusable when a + `ValidationError` involved instances with an `__eq__` method + that returned truthy values. + """ + + class DontEQMeBro: + def __eq__(this, other): # pragma: no cover + self.fail("Don't!") + + def __ne__(this, other): # pragma: no cover + self.fail("Don't!") + + instance = DontEQMeBro() + error = exceptions.ValidationError( + "a message", + validator="foo", + instance=instance, + validator_value="some", + schema="schema", + ) + self.assertIn(repr(instance), str(error)) + + +class TestHashable(TestCase): + def test_hashable(self): + {exceptions.ValidationError("")} + {exceptions.SchemaError("")} + + +class TestJsonPathRendering(TestCase): + def validate_json_path_rendering(self, property_name, expected_path): + error = exceptions.ValidationError( + path=[property_name], + message="1", + validator="foo", + instance="i1", + ) + + rendered_json_path = error.json_path + self.assertEqual(rendered_json_path, expected_path) + + re_parsed_name = jsonpath_ng.parse(rendered_json_path).right.fields[0] + self.assertEqual(re_parsed_name, property_name) + + def test_basic(self): + self.validate_json_path_rendering("x", "$.x") + + def test_empty(self): + self.validate_json_path_rendering("", "$['']") + + def test_number(self): + self.validate_json_path_rendering("1", "$['1']") + + def test_period(self): + self.validate_json_path_rendering(".", "$['.']") + + def test_single_quote(self): + self.validate_json_path_rendering("'", r"$['\'']") + + def test_space(self): + self.validate_json_path_rendering(" ", "$[' ']") + + def test_backslash(self): + self.validate_json_path_rendering("\\", r"$['\\']") + + def test_backslash_single_quote(self): + self.validate_json_path_rendering(r"\'", r"$['\\\'']") + + def test_underscore(self): + self.validate_json_path_rendering("_", r"$['_']") + + def test_double_quote(self): + self.validate_json_path_rendering('"', """$['"']""") + + def test_hyphen(self): + self.validate_json_path_rendering("-", "$['-']") + + def test_json_path_injection(self): + self.validate_json_path_rendering("a[0]", "$['a[0]']") + + def test_open_bracket(self): + self.validate_json_path_rendering("[", "$['[']") diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_format.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_format.py new file mode 100644 index 00000000..d829f984 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_format.py @@ -0,0 +1,91 @@ +""" +Tests for the parts of jsonschema related to the :kw:`format` keyword. +""" + +from unittest import TestCase + +from jsonschema import FormatChecker, ValidationError +from jsonschema.exceptions import FormatError +from jsonschema.validators import Draft4Validator + +BOOM = ValueError("Boom!") +BANG = ZeroDivisionError("Bang!") + + +def boom(thing): + if thing == "bang": + raise BANG + raise BOOM + + +class TestFormatChecker(TestCase): + def test_it_can_validate_no_formats(self): + checker = FormatChecker(formats=()) + self.assertFalse(checker.checkers) + + def test_it_raises_a_key_error_for_unknown_formats(self): + with self.assertRaises(KeyError): + FormatChecker(formats=["o noes"]) + + def test_it_can_register_cls_checkers(self): + original = dict(FormatChecker.checkers) + self.addCleanup(FormatChecker.checkers.pop, "boom") + with self.assertWarns(DeprecationWarning): + FormatChecker.cls_checks("boom")(boom) + self.assertEqual( + FormatChecker.checkers, + dict(original, boom=(boom, ())), + ) + + def test_it_can_register_checkers(self): + checker = FormatChecker() + checker.checks("boom")(boom) + self.assertEqual( + checker.checkers, + dict(FormatChecker.checkers, boom=(boom, ())), + ) + + def test_it_catches_registered_errors(self): + checker = FormatChecker() + checker.checks("boom", raises=type(BOOM))(boom) + + with self.assertRaises(FormatError) as cm: + checker.check(instance=12, format="boom") + + self.assertIs(cm.exception.cause, BOOM) + self.assertIs(cm.exception.__cause__, BOOM) + self.assertEqual(str(cm.exception), "12 is not a 'boom'") + + # Unregistered errors should not be caught + with self.assertRaises(type(BANG)): + checker.check(instance="bang", format="boom") + + def test_format_error_causes_become_validation_error_causes(self): + checker = FormatChecker() + checker.checks("boom", raises=ValueError)(boom) + validator = Draft4Validator({"format": "boom"}, format_checker=checker) + + with self.assertRaises(ValidationError) as cm: + validator.validate("BOOM") + + self.assertIs(cm.exception.cause, BOOM) + self.assertIs(cm.exception.__cause__, BOOM) + + def test_format_checkers_come_with_defaults(self): + # This is bad :/ but relied upon. + # The docs for quite awhile recommended people do things like + # validate(..., format_checker=FormatChecker()) + # We should change that, but we can't without deprecation... + checker = FormatChecker() + with self.assertRaises(FormatError): + checker.check(instance="not-an-ipv4", format="ipv4") + + def test_repr(self): + checker = FormatChecker(formats=()) + checker.checks("foo")(lambda thing: True) # pragma: no cover + checker.checks("bar")(lambda thing: True) # pragma: no cover + checker.checks("baz")(lambda thing: True) # pragma: no cover + self.assertEqual( + repr(checker), + "", + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_jsonschema_test_suite.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_jsonschema_test_suite.py new file mode 100644 index 00000000..41c98255 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_jsonschema_test_suite.py @@ -0,0 +1,262 @@ +""" +Test runner for the JSON Schema official test suite + +Tests comprehensive correctness of each draft's validator. + +See https://github.com/json-schema-org/JSON-Schema-Test-Suite for details. +""" + + +from jsonschema.tests._suite import Suite +import jsonschema + +SUITE = Suite() +DRAFT3 = SUITE.version(name="draft3") +DRAFT4 = SUITE.version(name="draft4") +DRAFT6 = SUITE.version(name="draft6") +DRAFT7 = SUITE.version(name="draft7") +DRAFT201909 = SUITE.version(name="draft2019-09") +DRAFT202012 = SUITE.version(name="draft2020-12") + + +def skip(message, **kwargs): + def skipper(test): + if all(value == getattr(test, attr) for attr, value in kwargs.items()): + return message + return skipper + + +def ecmascript_regex(test): + if test.subject == "ecmascript-regex": + return "ECMA regex support will be added in #1142." + + +def missing_format(Validator): + def missing_format(test): # pragma: no cover + schema = test.schema + if ( + schema is True + or schema is False + or "format" not in schema + or schema["format"] in Validator.FORMAT_CHECKER.checkers + or test.valid + ): + return + + return f"Format checker {schema['format']!r} not found." + return missing_format + + +def complex_email_validation(test): + if test.subject != "email": + return + + message = "Complex email validation is (intentionally) unsupported." + return skip( + message=message, + description="an invalid domain", + )(test) or skip( + message=message, + description="an invalid IPv4-address-literal", + )(test) or skip( + message=message, + description="dot after local part is not valid", + )(test) or skip( + message=message, + description="dot before local part is not valid", + )(test) or skip( + message=message, + description="two subsequent dots inside local part are not valid", + )(test) + + +def leap_second(test): + message = "Leap seconds are unsupported." + return skip( + message=message, + subject="time", + description="a valid time string with leap second", + )(test) or skip( + message=message, + subject="time", + description="a valid time string with leap second, Zulu", + )(test) or skip( + message=message, + subject="time", + description="a valid time string with leap second with offset", + )(test) or skip( + message=message, + subject="time", + description="valid leap second, positive time-offset", + )(test) or skip( + message=message, + subject="time", + description="valid leap second, negative time-offset", + )(test) or skip( + message=message, + subject="time", + description="valid leap second, large positive time-offset", + )(test) or skip( + message=message, + subject="time", + description="valid leap second, large negative time-offset", + )(test) or skip( + message=message, + subject="time", + description="valid leap second, zero time-offset", + )(test) or skip( + message=message, + subject="date-time", + description="a valid date-time with a leap second, UTC", + )(test) or skip( + message=message, + subject="date-time", + description="a valid date-time with a leap second, with minus offset", + )(test) + + +TestDraft3 = DRAFT3.to_unittest_testcase( + DRAFT3.cases(), + DRAFT3.format_cases(), + DRAFT3.optional_cases_of(name="bignum"), + DRAFT3.optional_cases_of(name="non-bmp-regex"), + DRAFT3.optional_cases_of(name="zeroTerminatedFloats"), + Validator=jsonschema.Draft3Validator, + format_checker=jsonschema.Draft3Validator.FORMAT_CHECKER, + skip=lambda test: ( + ecmascript_regex(test) + or missing_format(jsonschema.Draft3Validator)(test) + or complex_email_validation(test) + ), +) + + +TestDraft4 = DRAFT4.to_unittest_testcase( + DRAFT4.cases(), + DRAFT4.format_cases(), + DRAFT4.optional_cases_of(name="bignum"), + DRAFT4.optional_cases_of(name="float-overflow"), + DRAFT4.optional_cases_of(name="id"), + DRAFT4.optional_cases_of(name="non-bmp-regex"), + DRAFT4.optional_cases_of(name="zeroTerminatedFloats"), + Validator=jsonschema.Draft4Validator, + format_checker=jsonschema.Draft4Validator.FORMAT_CHECKER, + skip=lambda test: ( + ecmascript_regex(test) + or leap_second(test) + or missing_format(jsonschema.Draft4Validator)(test) + or complex_email_validation(test) + ), +) + + +TestDraft6 = DRAFT6.to_unittest_testcase( + DRAFT6.cases(), + DRAFT6.format_cases(), + DRAFT6.optional_cases_of(name="bignum"), + DRAFT6.optional_cases_of(name="float-overflow"), + DRAFT6.optional_cases_of(name="id"), + DRAFT6.optional_cases_of(name="non-bmp-regex"), + Validator=jsonschema.Draft6Validator, + format_checker=jsonschema.Draft6Validator.FORMAT_CHECKER, + skip=lambda test: ( + ecmascript_regex(test) + or leap_second(test) + or missing_format(jsonschema.Draft6Validator)(test) + or complex_email_validation(test) + ), +) + + +TestDraft7 = DRAFT7.to_unittest_testcase( + DRAFT7.cases(), + DRAFT7.format_cases(), + DRAFT7.optional_cases_of(name="bignum"), + DRAFT7.optional_cases_of(name="cross-draft"), + DRAFT7.optional_cases_of(name="float-overflow"), + DRAFT6.optional_cases_of(name="id"), + DRAFT7.optional_cases_of(name="non-bmp-regex"), + DRAFT7.optional_cases_of(name="unknownKeyword"), + Validator=jsonschema.Draft7Validator, + format_checker=jsonschema.Draft7Validator.FORMAT_CHECKER, + skip=lambda test: ( + ecmascript_regex(test) + or leap_second(test) + or missing_format(jsonschema.Draft7Validator)(test) + or complex_email_validation(test) + ), +) + + +TestDraft201909 = DRAFT201909.to_unittest_testcase( + DRAFT201909.cases(), + DRAFT201909.optional_cases_of(name="anchor"), + DRAFT201909.optional_cases_of(name="bignum"), + DRAFT201909.optional_cases_of(name="cross-draft"), + DRAFT201909.optional_cases_of(name="float-overflow"), + DRAFT201909.optional_cases_of(name="id"), + DRAFT201909.optional_cases_of(name="no-schema"), + DRAFT201909.optional_cases_of(name="non-bmp-regex"), + DRAFT201909.optional_cases_of(name="refOfUnknownKeyword"), + DRAFT201909.optional_cases_of(name="unknownKeyword"), + Validator=jsonschema.Draft201909Validator, + skip=skip( + message="Vocabulary support is still in-progress.", + subject="vocabulary", + description=( + "no validation: invalid number, but it still validates" + ), + ), +) + + +TestDraft201909Format = DRAFT201909.to_unittest_testcase( + DRAFT201909.format_cases(), + name="TestDraft201909Format", + Validator=jsonschema.Draft201909Validator, + format_checker=jsonschema.Draft201909Validator.FORMAT_CHECKER, + skip=lambda test: ( + complex_email_validation(test) + or ecmascript_regex(test) + or leap_second(test) + or missing_format(jsonschema.Draft201909Validator)(test) + or complex_email_validation(test) + ), +) + + +TestDraft202012 = DRAFT202012.to_unittest_testcase( + DRAFT202012.cases(), + DRAFT201909.optional_cases_of(name="anchor"), + DRAFT202012.optional_cases_of(name="bignum"), + DRAFT202012.optional_cases_of(name="cross-draft"), + DRAFT202012.optional_cases_of(name="float-overflow"), + DRAFT202012.optional_cases_of(name="id"), + DRAFT202012.optional_cases_of(name="no-schema"), + DRAFT202012.optional_cases_of(name="non-bmp-regex"), + DRAFT202012.optional_cases_of(name="refOfUnknownKeyword"), + DRAFT202012.optional_cases_of(name="unknownKeyword"), + Validator=jsonschema.Draft202012Validator, + skip=skip( + message="Vocabulary support is still in-progress.", + subject="vocabulary", + description=( + "no validation: invalid number, but it still validates" + ), + ), +) + + +TestDraft202012Format = DRAFT202012.to_unittest_testcase( + DRAFT202012.format_cases(), + name="TestDraft202012Format", + Validator=jsonschema.Draft202012Validator, + format_checker=jsonschema.Draft202012Validator.FORMAT_CHECKER, + skip=lambda test: ( + complex_email_validation(test) + or ecmascript_regex(test) + or leap_second(test) + or missing_format(jsonschema.Draft202012Validator)(test) + or complex_email_validation(test) + ), +) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_types.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_types.py new file mode 100644 index 00000000..bd97b180 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_types.py @@ -0,0 +1,221 @@ +""" +Tests for the `TypeChecker`-based type interface. + +The actual correctness of the type checking is handled in +`test_jsonschema_test_suite`; these tests check that TypeChecker +functions correctly at a more granular level. +""" +from collections import namedtuple +from unittest import TestCase + +from jsonschema import ValidationError, _keywords +from jsonschema._types import TypeChecker +from jsonschema.exceptions import UndefinedTypeCheck, UnknownType +from jsonschema.validators import Draft202012Validator, extend + + +def equals_2(checker, instance): + return instance == 2 + + +def is_namedtuple(instance): + return isinstance(instance, tuple) and getattr(instance, "_fields", None) + + +def is_object_or_named_tuple(checker, instance): + if Draft202012Validator.TYPE_CHECKER.is_type(instance, "object"): + return True + return is_namedtuple(instance) + + +class TestTypeChecker(TestCase): + def test_is_type(self): + checker = TypeChecker({"two": equals_2}) + self.assertEqual( + ( + checker.is_type(instance=2, type="two"), + checker.is_type(instance="bar", type="two"), + ), + (True, False), + ) + + def test_is_unknown_type(self): + with self.assertRaises(UndefinedTypeCheck) as e: + TypeChecker().is_type(4, "foobar") + self.assertIn( + "'foobar' is unknown to this type checker", + str(e.exception), + ) + self.assertTrue( + e.exception.__suppress_context__, + msg="Expected the internal KeyError to be hidden.", + ) + + def test_checks_can_be_added_at_init(self): + checker = TypeChecker({"two": equals_2}) + self.assertEqual(checker, TypeChecker().redefine("two", equals_2)) + + def test_redefine_existing_type(self): + self.assertEqual( + TypeChecker().redefine("two", object()).redefine("two", equals_2), + TypeChecker().redefine("two", equals_2), + ) + + def test_remove(self): + self.assertEqual( + TypeChecker({"two": equals_2}).remove("two"), + TypeChecker(), + ) + + def test_remove_unknown_type(self): + with self.assertRaises(UndefinedTypeCheck) as context: + TypeChecker().remove("foobar") + self.assertIn("foobar", str(context.exception)) + + def test_redefine_many(self): + self.assertEqual( + TypeChecker().redefine_many({"foo": int, "bar": str}), + TypeChecker().redefine("foo", int).redefine("bar", str), + ) + + def test_remove_multiple(self): + self.assertEqual( + TypeChecker({"foo": int, "bar": str}).remove("foo", "bar"), + TypeChecker(), + ) + + def test_type_check_can_raise_key_error(self): + """ + Make sure no one writes: + + try: + self._type_checkers[type](...) + except KeyError: + + ignoring the fact that the function itself can raise that. + """ + + error = KeyError("Stuff") + + def raises_keyerror(checker, instance): + raise error + + with self.assertRaises(KeyError) as context: + TypeChecker({"foo": raises_keyerror}).is_type(4, "foo") + + self.assertIs(context.exception, error) + + def test_repr(self): + checker = TypeChecker({"foo": is_namedtuple, "bar": is_namedtuple}) + self.assertEqual(repr(checker), "") + + +class TestCustomTypes(TestCase): + def test_simple_type_can_be_extended(self): + def int_or_str_int(checker, instance): + if not isinstance(instance, (int, str)): + return False + try: + int(instance) + except ValueError: + return False + return True + + CustomValidator = extend( + Draft202012Validator, + type_checker=Draft202012Validator.TYPE_CHECKER.redefine( + "integer", int_or_str_int, + ), + ) + validator = CustomValidator({"type": "integer"}) + + validator.validate(4) + validator.validate("4") + + with self.assertRaises(ValidationError): + validator.validate(4.4) + + with self.assertRaises(ValidationError): + validator.validate("foo") + + def test_object_can_be_extended(self): + schema = {"type": "object"} + + Point = namedtuple("Point", ["x", "y"]) + + type_checker = Draft202012Validator.TYPE_CHECKER.redefine( + "object", is_object_or_named_tuple, + ) + + CustomValidator = extend( + Draft202012Validator, + type_checker=type_checker, + ) + validator = CustomValidator(schema) + + validator.validate(Point(x=4, y=5)) + + def test_object_extensions_require_custom_validators(self): + schema = {"type": "object", "required": ["x"]} + + type_checker = Draft202012Validator.TYPE_CHECKER.redefine( + "object", is_object_or_named_tuple, + ) + + CustomValidator = extend( + Draft202012Validator, + type_checker=type_checker, + ) + validator = CustomValidator(schema) + + Point = namedtuple("Point", ["x", "y"]) + # Cannot handle required + with self.assertRaises(ValidationError): + validator.validate(Point(x=4, y=5)) + + def test_object_extensions_can_handle_custom_validators(self): + schema = { + "type": "object", + "required": ["x"], + "properties": {"x": {"type": "integer"}}, + } + + type_checker = Draft202012Validator.TYPE_CHECKER.redefine( + "object", is_object_or_named_tuple, + ) + + def coerce_named_tuple(fn): + def coerced(validator, value, instance, schema): + if is_namedtuple(instance): + instance = instance._asdict() + return fn(validator, value, instance, schema) + return coerced + + required = coerce_named_tuple(_keywords.required) + properties = coerce_named_tuple(_keywords.properties) + + CustomValidator = extend( + Draft202012Validator, + type_checker=type_checker, + validators={"required": required, "properties": properties}, + ) + + validator = CustomValidator(schema) + + Point = namedtuple("Point", ["x", "y"]) + # Can now process required and properties + validator.validate(Point(x=4, y=5)) + + with self.assertRaises(ValidationError): + validator.validate(Point(x="not an integer", y=5)) + + # As well as still handle objects. + validator.validate({"x": 4, "y": 5}) + + with self.assertRaises(ValidationError): + validator.validate({"x": "not an integer", "y": 5}) + + def test_unknown_type(self): + with self.assertRaises(UnknownType) as e: + Draft202012Validator({}).is_type(12, "some unknown type") + self.assertIn("'some unknown type'", str(e.exception)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_utils.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_utils.py new file mode 100644 index 00000000..d9764b0f --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_utils.py @@ -0,0 +1,138 @@ +from math import nan +from unittest import TestCase + +from jsonschema._utils import equal + + +class TestEqual(TestCase): + def test_none(self): + self.assertTrue(equal(None, None)) + + def test_nan(self): + self.assertTrue(equal(nan, nan)) + + +class TestDictEqual(TestCase): + def test_equal_dictionaries(self): + dict_1 = {"a": "b", "c": "d"} + dict_2 = {"c": "d", "a": "b"} + self.assertTrue(equal(dict_1, dict_2)) + + def test_equal_dictionaries_with_nan(self): + dict_1 = {"a": nan, "c": "d"} + dict_2 = {"c": "d", "a": nan} + self.assertTrue(equal(dict_1, dict_2)) + + def test_missing_key(self): + dict_1 = {"a": "b", "c": "d"} + dict_2 = {"c": "d", "x": "b"} + self.assertFalse(equal(dict_1, dict_2)) + + def test_additional_key(self): + dict_1 = {"a": "b", "c": "d"} + dict_2 = {"c": "d", "a": "b", "x": "x"} + self.assertFalse(equal(dict_1, dict_2)) + + def test_missing_value(self): + dict_1 = {"a": "b", "c": "d"} + dict_2 = {"c": "d", "a": "x"} + self.assertFalse(equal(dict_1, dict_2)) + + def test_empty_dictionaries(self): + dict_1 = {} + dict_2 = {} + self.assertTrue(equal(dict_1, dict_2)) + + def test_one_none(self): + dict_1 = None + dict_2 = {"a": "b", "c": "d"} + self.assertFalse(equal(dict_1, dict_2)) + + def test_same_item(self): + dict_1 = {"a": "b", "c": "d"} + self.assertTrue(equal(dict_1, dict_1)) + + def test_nested_equal(self): + dict_1 = {"a": {"a": "b", "c": "d"}, "c": "d"} + dict_2 = {"c": "d", "a": {"a": "b", "c": "d"}} + self.assertTrue(equal(dict_1, dict_2)) + + def test_nested_dict_unequal(self): + dict_1 = {"a": {"a": "b", "c": "d"}, "c": "d"} + dict_2 = {"c": "d", "a": {"a": "b", "c": "x"}} + self.assertFalse(equal(dict_1, dict_2)) + + def test_mixed_nested_equal(self): + dict_1 = {"a": ["a", "b", "c", "d"], "c": "d"} + dict_2 = {"c": "d", "a": ["a", "b", "c", "d"]} + self.assertTrue(equal(dict_1, dict_2)) + + def test_nested_list_unequal(self): + dict_1 = {"a": ["a", "b", "c", "d"], "c": "d"} + dict_2 = {"c": "d", "a": ["b", "c", "d", "a"]} + self.assertFalse(equal(dict_1, dict_2)) + + +class TestListEqual(TestCase): + def test_equal_lists(self): + list_1 = ["a", "b", "c"] + list_2 = ["a", "b", "c"] + self.assertTrue(equal(list_1, list_2)) + + def test_equal_lists_with_nan(self): + list_1 = ["a", nan, "c"] + list_2 = ["a", nan, "c"] + self.assertTrue(equal(list_1, list_2)) + + def test_unsorted_lists(self): + list_1 = ["a", "b", "c"] + list_2 = ["b", "b", "a"] + self.assertFalse(equal(list_1, list_2)) + + def test_first_list_larger(self): + list_1 = ["a", "b", "c"] + list_2 = ["a", "b"] + self.assertFalse(equal(list_1, list_2)) + + def test_second_list_larger(self): + list_1 = ["a", "b"] + list_2 = ["a", "b", "c"] + self.assertFalse(equal(list_1, list_2)) + + def test_list_with_none_unequal(self): + list_1 = ["a", "b", None] + list_2 = ["a", "b", "c"] + self.assertFalse(equal(list_1, list_2)) + + list_1 = ["a", "b", None] + list_2 = [None, "b", "c"] + self.assertFalse(equal(list_1, list_2)) + + def test_list_with_none_equal(self): + list_1 = ["a", None, "c"] + list_2 = ["a", None, "c"] + self.assertTrue(equal(list_1, list_2)) + + def test_empty_list(self): + list_1 = [] + list_2 = [] + self.assertTrue(equal(list_1, list_2)) + + def test_one_none(self): + list_1 = None + list_2 = [] + self.assertFalse(equal(list_1, list_2)) + + def test_same_list(self): + list_1 = ["a", "b", "c"] + self.assertTrue(equal(list_1, list_1)) + + def test_equal_nested_lists(self): + list_1 = ["a", ["b", "c"], "d"] + list_2 = ["a", ["b", "c"], "d"] + self.assertTrue(equal(list_1, list_2)) + + def test_unequal_nested_lists(self): + list_1 = ["a", ["b", "c"], "d"] + list_2 = ["a", [], "c"] + self.assertFalse(equal(list_1, list_2)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_validators.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_validators.py new file mode 100644 index 00000000..d419ddf8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/test_validators.py @@ -0,0 +1,2573 @@ +from __future__ import annotations + +from collections import deque, namedtuple +from contextlib import contextmanager +from decimal import Decimal +from io import BytesIO +from typing import Any +from unittest import TestCase, mock +from urllib.request import pathname2url +import json +import os +import sys +import tempfile +import warnings + +from attrs import define, field +from referencing.jsonschema import DRAFT202012 +import referencing.exceptions + +from jsonschema import ( + FormatChecker, + TypeChecker, + exceptions, + protocols, + validators, +) + + +def fail(validator, errors, instance, schema): + for each in errors: + each.setdefault("message", "You told me to fail!") + yield exceptions.ValidationError(**each) + + +class TestCreateAndExtend(TestCase): + def setUp(self): + self.addCleanup( + self.assertEqual, + validators._META_SCHEMAS, + dict(validators._META_SCHEMAS), + ) + self.addCleanup( + self.assertEqual, + validators._VALIDATORS, + dict(validators._VALIDATORS), + ) + + self.meta_schema = {"$id": "some://meta/schema"} + self.validators = {"fail": fail} + self.type_checker = TypeChecker() + self.Validator = validators.create( + meta_schema=self.meta_schema, + validators=self.validators, + type_checker=self.type_checker, + ) + + def test_attrs(self): + self.assertEqual( + ( + self.Validator.VALIDATORS, + self.Validator.META_SCHEMA, + self.Validator.TYPE_CHECKER, + ), ( + self.validators, + self.meta_schema, + self.type_checker, + ), + ) + + def test_init(self): + schema = {"fail": []} + self.assertEqual(self.Validator(schema).schema, schema) + + def test_iter_errors_successful(self): + schema = {"fail": []} + validator = self.Validator(schema) + + errors = list(validator.iter_errors("hello")) + self.assertEqual(errors, []) + + def test_iter_errors_one_error(self): + schema = {"fail": [{"message": "Whoops!"}]} + validator = self.Validator(schema) + + expected_error = exceptions.ValidationError( + "Whoops!", + instance="goodbye", + schema=schema, + validator="fail", + validator_value=[{"message": "Whoops!"}], + schema_path=deque(["fail"]), + ) + + errors = list(validator.iter_errors("goodbye")) + self.assertEqual(len(errors), 1) + self.assertEqual(errors[0]._contents(), expected_error._contents()) + + def test_iter_errors_multiple_errors(self): + schema = { + "fail": [ + {"message": "First"}, + {"message": "Second!", "validator": "asdf"}, + {"message": "Third"}, + ], + } + validator = self.Validator(schema) + + errors = list(validator.iter_errors("goodbye")) + self.assertEqual(len(errors), 3) + + def test_if_a_version_is_provided_it_is_registered(self): + Validator = validators.create( + meta_schema={"$id": "something"}, + version="my version", + ) + self.addCleanup(validators._META_SCHEMAS.pop, "something") + self.addCleanup(validators._VALIDATORS.pop, "my version") + self.assertEqual(Validator.__name__, "MyVersionValidator") + self.assertEqual(Validator.__qualname__, "MyVersionValidator") + + def test_repr(self): + Validator = validators.create( + meta_schema={"$id": "something"}, + version="my version", + ) + self.addCleanup(validators._META_SCHEMAS.pop, "something") + self.addCleanup(validators._VALIDATORS.pop, "my version") + self.assertEqual( + repr(Validator({})), + "MyVersionValidator(schema={}, format_checker=None)", + ) + + def test_long_repr(self): + Validator = validators.create( + meta_schema={"$id": "something"}, + version="my version", + ) + self.addCleanup(validators._META_SCHEMAS.pop, "something") + self.addCleanup(validators._VALIDATORS.pop, "my version") + self.assertEqual( + repr(Validator({"a": list(range(1000))})), ( + "MyVersionValidator(schema={'a': [0, 1, 2, 3, 4, 5, ...]}, " + "format_checker=None)" + ), + ) + + def test_repr_no_version(self): + Validator = validators.create(meta_schema={}) + self.assertEqual( + repr(Validator({})), + "Validator(schema={}, format_checker=None)", + ) + + def test_dashes_are_stripped_from_validator_names(self): + Validator = validators.create( + meta_schema={"$id": "something"}, + version="foo-bar", + ) + self.addCleanup(validators._META_SCHEMAS.pop, "something") + self.addCleanup(validators._VALIDATORS.pop, "foo-bar") + self.assertEqual(Validator.__qualname__, "FooBarValidator") + + def test_if_a_version_is_not_provided_it_is_not_registered(self): + original = dict(validators._META_SCHEMAS) + validators.create(meta_schema={"id": "id"}) + self.assertEqual(validators._META_SCHEMAS, original) + + def test_validates_registers_meta_schema_id(self): + meta_schema_key = "meta schema id" + my_meta_schema = {"id": meta_schema_key} + + validators.create( + meta_schema=my_meta_schema, + version="my version", + id_of=lambda s: s.get("id", ""), + ) + self.addCleanup(validators._META_SCHEMAS.pop, meta_schema_key) + self.addCleanup(validators._VALIDATORS.pop, "my version") + + self.assertIn(meta_schema_key, validators._META_SCHEMAS) + + def test_validates_registers_meta_schema_draft6_id(self): + meta_schema_key = "meta schema $id" + my_meta_schema = {"$id": meta_schema_key} + + validators.create( + meta_schema=my_meta_schema, + version="my version", + ) + self.addCleanup(validators._META_SCHEMAS.pop, meta_schema_key) + self.addCleanup(validators._VALIDATORS.pop, "my version") + + self.assertIn(meta_schema_key, validators._META_SCHEMAS) + + def test_create_default_types(self): + Validator = validators.create(meta_schema={}, validators=()) + self.assertTrue( + all( + Validator({}).is_type(instance=instance, type=type) + for type, instance in [ + ("array", []), + ("boolean", True), + ("integer", 12), + ("null", None), + ("number", 12.0), + ("object", {}), + ("string", "foo"), + ] + ), + ) + + def test_check_schema_with_different_metaschema(self): + """ + One can create a validator class whose metaschema uses a different + dialect than itself. + """ + + NoEmptySchemasValidator = validators.create( + meta_schema={ + "$schema": validators.Draft202012Validator.META_SCHEMA["$id"], + "not": {"const": {}}, + }, + ) + NoEmptySchemasValidator.check_schema({"foo": "bar"}) + + with self.assertRaises(exceptions.SchemaError): + NoEmptySchemasValidator.check_schema({}) + + NoEmptySchemasValidator({"foo": "bar"}).validate("foo") + + def test_check_schema_with_different_metaschema_defaults_to_self(self): + """ + A validator whose metaschema doesn't declare $schema defaults to its + own validation behavior, not the latest "normal" specification. + """ + + NoEmptySchemasValidator = validators.create( + meta_schema={"fail": [{"message": "Meta schema whoops!"}]}, + validators={"fail": fail}, + ) + with self.assertRaises(exceptions.SchemaError): + NoEmptySchemasValidator.check_schema({}) + + def test_extend(self): + original = dict(self.Validator.VALIDATORS) + new = object() + + Extended = validators.extend( + self.Validator, + validators={"new": new}, + ) + self.assertEqual( + ( + Extended.VALIDATORS, + Extended.META_SCHEMA, + Extended.TYPE_CHECKER, + self.Validator.VALIDATORS, + ), ( + dict(original, new=new), + self.Validator.META_SCHEMA, + self.Validator.TYPE_CHECKER, + original, + ), + ) + + def test_extend_idof(self): + """ + Extending a validator preserves its notion of schema IDs. + """ + def id_of(schema): + return schema.get("__test__", self.Validator.ID_OF(schema)) + correct_id = "the://correct/id/" + meta_schema = { + "$id": "the://wrong/id/", + "__test__": correct_id, + } + Original = validators.create( + meta_schema=meta_schema, + validators=self.validators, + type_checker=self.type_checker, + id_of=id_of, + ) + self.assertEqual(Original.ID_OF(Original.META_SCHEMA), correct_id) + + Derived = validators.extend(Original) + self.assertEqual(Derived.ID_OF(Derived.META_SCHEMA), correct_id) + + def test_extend_applicable_validators(self): + """ + Extending a validator preserves its notion of applicable validators. + """ + + schema = { + "$defs": {"test": {"type": "number"}}, + "$ref": "#/$defs/test", + "maximum": 1, + } + + draft4 = validators.Draft4Validator(schema) + self.assertTrue(draft4.is_valid(37)) # as $ref ignores siblings + + Derived = validators.extend(validators.Draft4Validator) + self.assertTrue(Derived(schema).is_valid(37)) + + +class TestValidationErrorMessages(TestCase): + def message_for(self, instance, schema, *args, **kwargs): + cls = kwargs.pop("cls", validators._LATEST_VERSION) + cls.check_schema(schema) + validator = cls(schema, *args, **kwargs) + errors = list(validator.iter_errors(instance)) + self.assertTrue(errors, msg=f"No errors were raised for {instance!r}") + self.assertEqual( + len(errors), + 1, + msg=f"Expected exactly one error, found {errors!r}", + ) + return errors[0].message + + def test_single_type_failure(self): + message = self.message_for(instance=1, schema={"type": "string"}) + self.assertEqual(message, "1 is not of type 'string'") + + def test_single_type_list_failure(self): + message = self.message_for(instance=1, schema={"type": ["string"]}) + self.assertEqual(message, "1 is not of type 'string'") + + def test_multiple_type_failure(self): + types = "string", "object" + message = self.message_for(instance=1, schema={"type": list(types)}) + self.assertEqual(message, "1 is not of type 'string', 'object'") + + def test_object_with_named_type_failure(self): + schema = {"type": [{"name": "Foo", "minimum": 3}]} + message = self.message_for( + instance=1, + schema=schema, + cls=validators.Draft3Validator, + ) + self.assertEqual(message, "1 is not of type 'Foo'") + + def test_minimum(self): + message = self.message_for(instance=1, schema={"minimum": 2}) + self.assertEqual(message, "1 is less than the minimum of 2") + + def test_maximum(self): + message = self.message_for(instance=1, schema={"maximum": 0}) + self.assertEqual(message, "1 is greater than the maximum of 0") + + def test_dependencies_single_element(self): + depend, on = "bar", "foo" + schema = {"dependencies": {depend: on}} + message = self.message_for( + instance={"bar": 2}, + schema=schema, + cls=validators.Draft3Validator, + ) + self.assertEqual(message, "'foo' is a dependency of 'bar'") + + def test_object_without_title_type_failure_draft3(self): + type = {"type": [{"minimum": 3}]} + message = self.message_for( + instance=1, + schema={"type": [type]}, + cls=validators.Draft3Validator, + ) + self.assertEqual( + message, + "1 is not of type {'type': [{'minimum': 3}]}", + ) + + def test_dependencies_list_draft3(self): + depend, on = "bar", "foo" + schema = {"dependencies": {depend: [on]}} + message = self.message_for( + instance={"bar": 2}, + schema=schema, + cls=validators.Draft3Validator, + ) + self.assertEqual(message, "'foo' is a dependency of 'bar'") + + def test_dependencies_list_draft7(self): + depend, on = "bar", "foo" + schema = {"dependencies": {depend: [on]}} + message = self.message_for( + instance={"bar": 2}, + schema=schema, + cls=validators.Draft7Validator, + ) + self.assertEqual(message, "'foo' is a dependency of 'bar'") + + def test_additionalItems_single_failure(self): + message = self.message_for( + instance=[2], + schema={"items": [], "additionalItems": False}, + cls=validators.Draft3Validator, + ) + self.assertIn("(2 was unexpected)", message) + + def test_additionalItems_multiple_failures(self): + message = self.message_for( + instance=[1, 2, 3], + schema={"items": [], "additionalItems": False}, + cls=validators.Draft3Validator, + ) + self.assertIn("(1, 2, 3 were unexpected)", message) + + def test_additionalProperties_single_failure(self): + additional = "foo" + schema = {"additionalProperties": False} + message = self.message_for(instance={additional: 2}, schema=schema) + self.assertIn("('foo' was unexpected)", message) + + def test_additionalProperties_multiple_failures(self): + schema = {"additionalProperties": False} + message = self.message_for( + instance=dict.fromkeys(["foo", "bar"]), + schema=schema, + ) + + self.assertIn(repr("foo"), message) + self.assertIn(repr("bar"), message) + self.assertIn("were unexpected)", message) + + def test_const(self): + schema = {"const": 12} + message = self.message_for( + instance={"foo": "bar"}, + schema=schema, + ) + self.assertIn("12 was expected", message) + + def test_contains_draft_6(self): + schema = {"contains": {"const": 12}} + message = self.message_for( + instance=[2, {}, []], + schema=schema, + cls=validators.Draft6Validator, + ) + self.assertEqual( + message, + "None of [2, {}, []] are valid under the given schema", + ) + + def test_invalid_format_default_message(self): + checker = FormatChecker(formats=()) + checker.checks("thing")(lambda value: False) + + schema = {"format": "thing"} + message = self.message_for( + instance="bla", + schema=schema, + format_checker=checker, + ) + + self.assertIn(repr("bla"), message) + self.assertIn(repr("thing"), message) + self.assertIn("is not a", message) + + def test_additionalProperties_false_patternProperties(self): + schema = {"type": "object", + "additionalProperties": False, + "patternProperties": { + "^abc$": {"type": "string"}, + "^def$": {"type": "string"}, + }} + message = self.message_for( + instance={"zebra": 123}, + schema=schema, + cls=validators.Draft4Validator, + ) + self.assertEqual( + message, + "{} does not match any of the regexes: {}, {}".format( + repr("zebra"), repr("^abc$"), repr("^def$"), + ), + ) + message = self.message_for( + instance={"zebra": 123, "fish": 456}, + schema=schema, + cls=validators.Draft4Validator, + ) + self.assertEqual( + message, + "{}, {} do not match any of the regexes: {}, {}".format( + repr("fish"), repr("zebra"), repr("^abc$"), repr("^def$"), + ), + ) + + def test_False_schema(self): + message = self.message_for( + instance="something", + schema=False, + ) + self.assertEqual(message, "False schema does not allow 'something'") + + def test_multipleOf(self): + message = self.message_for( + instance=3, + schema={"multipleOf": 2}, + ) + self.assertEqual(message, "3 is not a multiple of 2") + + def test_minItems(self): + message = self.message_for(instance=[], schema={"minItems": 2}) + self.assertEqual(message, "[] is too short") + + def test_maxItems(self): + message = self.message_for(instance=[1, 2, 3], schema={"maxItems": 2}) + self.assertEqual(message, "[1, 2, 3] is too long") + + def test_minItems_1(self): + message = self.message_for(instance=[], schema={"minItems": 1}) + self.assertEqual(message, "[] should be non-empty") + + def test_maxItems_0(self): + message = self.message_for(instance=[1, 2, 3], schema={"maxItems": 0}) + self.assertEqual(message, "[1, 2, 3] is expected to be empty") + + def test_minLength(self): + message = self.message_for( + instance="", + schema={"minLength": 2}, + ) + self.assertEqual(message, "'' is too short") + + def test_maxLength(self): + message = self.message_for( + instance="abc", + schema={"maxLength": 2}, + ) + self.assertEqual(message, "'abc' is too long") + + def test_minLength_1(self): + message = self.message_for(instance="", schema={"minLength": 1}) + self.assertEqual(message, "'' should be non-empty") + + def test_maxLength_0(self): + message = self.message_for(instance="abc", schema={"maxLength": 0}) + self.assertEqual(message, "'abc' is expected to be empty") + + def test_minProperties(self): + message = self.message_for(instance={}, schema={"minProperties": 2}) + self.assertEqual(message, "{} does not have enough properties") + + def test_maxProperties(self): + message = self.message_for( + instance={"a": {}, "b": {}, "c": {}}, + schema={"maxProperties": 2}, + ) + self.assertEqual( + message, + "{'a': {}, 'b': {}, 'c': {}} has too many properties", + ) + + def test_minProperties_1(self): + message = self.message_for(instance={}, schema={"minProperties": 1}) + self.assertEqual(message, "{} should be non-empty") + + def test_maxProperties_0(self): + message = self.message_for( + instance={1: 2}, + schema={"maxProperties": 0}, + ) + self.assertEqual(message, "{1: 2} is expected to be empty") + + def test_prefixItems_with_items(self): + message = self.message_for( + instance=[1, 2, "foo"], + schema={"items": False, "prefixItems": [{}, {}]}, + ) + self.assertEqual( + message, + "Expected at most 2 items but found 1 extra: 'foo'", + ) + + def test_prefixItems_with_multiple_extra_items(self): + message = self.message_for( + instance=[1, 2, "foo", 5], + schema={"items": False, "prefixItems": [{}, {}]}, + ) + self.assertEqual( + message, + "Expected at most 2 items but found 2 extra: ['foo', 5]", + ) + + def test_pattern(self): + message = self.message_for( + instance="bbb", + schema={"pattern": "^a*$"}, + ) + self.assertEqual(message, "'bbb' does not match '^a*$'") + + def test_does_not_contain(self): + message = self.message_for( + instance=[], + schema={"contains": {"type": "string"}}, + ) + self.assertEqual( + message, + "[] does not contain items matching the given schema", + ) + + def test_contains_too_few(self): + message = self.message_for( + instance=["foo", 1], + schema={"contains": {"type": "string"}, "minContains": 2}, + ) + self.assertEqual( + message, + "Too few items match the given schema " + "(expected at least 2 but only 1 matched)", + ) + + def test_contains_too_few_both_constrained(self): + message = self.message_for( + instance=["foo", 1], + schema={ + "contains": {"type": "string"}, + "minContains": 2, + "maxContains": 4, + }, + ) + self.assertEqual( + message, + "Too few items match the given schema (expected at least 2 but " + "only 1 matched)", + ) + + def test_contains_too_many(self): + message = self.message_for( + instance=["foo", "bar", "baz"], + schema={"contains": {"type": "string"}, "maxContains": 2}, + ) + self.assertEqual( + message, + "Too many items match the given schema (expected at most 2)", + ) + + def test_contains_too_many_both_constrained(self): + message = self.message_for( + instance=["foo"] * 5, + schema={ + "contains": {"type": "string"}, + "minContains": 2, + "maxContains": 4, + }, + ) + self.assertEqual( + message, + "Too many items match the given schema (expected at most 4)", + ) + + def test_exclusiveMinimum(self): + message = self.message_for( + instance=3, + schema={"exclusiveMinimum": 5}, + ) + self.assertEqual( + message, + "3 is less than or equal to the minimum of 5", + ) + + def test_exclusiveMaximum(self): + message = self.message_for(instance=3, schema={"exclusiveMaximum": 2}) + self.assertEqual( + message, + "3 is greater than or equal to the maximum of 2", + ) + + def test_required(self): + message = self.message_for(instance={}, schema={"required": ["foo"]}) + self.assertEqual(message, "'foo' is a required property") + + def test_dependentRequired(self): + message = self.message_for( + instance={"foo": {}}, + schema={"dependentRequired": {"foo": ["bar"]}}, + ) + self.assertEqual(message, "'bar' is a dependency of 'foo'") + + def test_oneOf_matches_none(self): + message = self.message_for(instance={}, schema={"oneOf": [False]}) + self.assertEqual( + message, + "{} is not valid under any of the given schemas", + ) + + def test_oneOf_matches_too_many(self): + message = self.message_for(instance={}, schema={"oneOf": [True, True]}) + self.assertEqual(message, "{} is valid under each of True, True") + + def test_unevaluated_items(self): + schema = {"type": "array", "unevaluatedItems": False} + message = self.message_for(instance=["foo", "bar"], schema=schema) + self.assertIn( + message, + "Unevaluated items are not allowed ('foo', 'bar' were unexpected)", + ) + + def test_unevaluated_items_on_invalid_type(self): + schema = {"type": "array", "unevaluatedItems": False} + message = self.message_for(instance="foo", schema=schema) + self.assertEqual(message, "'foo' is not of type 'array'") + + def test_unevaluated_properties_invalid_against_subschema(self): + schema = { + "properties": {"foo": {"type": "string"}}, + "unevaluatedProperties": {"const": 12}, + } + message = self.message_for( + instance={ + "foo": "foo", + "bar": "bar", + "baz": 12, + }, + schema=schema, + ) + self.assertEqual( + message, + "Unevaluated properties are not valid under the given schema " + "('bar' was unevaluated and invalid)", + ) + + def test_unevaluated_properties_disallowed(self): + schema = {"type": "object", "unevaluatedProperties": False} + message = self.message_for( + instance={ + "foo": "foo", + "bar": "bar", + }, + schema=schema, + ) + self.assertEqual( + message, + "Unevaluated properties are not allowed " + "('bar', 'foo' were unexpected)", + ) + + def test_unevaluated_properties_on_invalid_type(self): + schema = {"type": "object", "unevaluatedProperties": False} + message = self.message_for(instance="foo", schema=schema) + self.assertEqual(message, "'foo' is not of type 'object'") + + def test_single_item(self): + schema = {"prefixItems": [{}], "items": False} + message = self.message_for( + instance=["foo", "bar", "baz"], + schema=schema, + ) + self.assertEqual( + message, + "Expected at most 1 item but found 2 extra: ['bar', 'baz']", + ) + + def test_heterogeneous_additionalItems_with_Items(self): + schema = {"items": [{}], "additionalItems": False} + message = self.message_for( + instance=["foo", "bar", 37], + schema=schema, + cls=validators.Draft7Validator, + ) + self.assertEqual( + message, + "Additional items are not allowed ('bar', 37 were unexpected)", + ) + + def test_heterogeneous_items_prefixItems(self): + schema = {"prefixItems": [{}], "items": False} + message = self.message_for( + instance=["foo", "bar", 37], + schema=schema, + ) + self.assertEqual( + message, + "Expected at most 1 item but found 2 extra: ['bar', 37]", + ) + + def test_heterogeneous_unevaluatedItems_prefixItems(self): + schema = {"prefixItems": [{}], "unevaluatedItems": False} + message = self.message_for( + instance=["foo", "bar", 37], + schema=schema, + ) + self.assertEqual( + message, + "Unevaluated items are not allowed ('bar', 37 were unexpected)", + ) + + def test_heterogeneous_properties_additionalProperties(self): + """ + Not valid deserialized JSON, but this should not blow up. + """ + schema = {"properties": {"foo": {}}, "additionalProperties": False} + message = self.message_for( + instance={"foo": {}, "a": "baz", 37: 12}, + schema=schema, + ) + self.assertEqual( + message, + "Additional properties are not allowed (37, 'a' were unexpected)", + ) + + def test_heterogeneous_properties_unevaluatedProperties(self): + """ + Not valid deserialized JSON, but this should not blow up. + """ + schema = {"properties": {"foo": {}}, "unevaluatedProperties": False} + message = self.message_for( + instance={"foo": {}, "a": "baz", 37: 12}, + schema=schema, + ) + self.assertEqual( + message, + "Unevaluated properties are not allowed (37, 'a' were unexpected)", + ) + + +class TestValidationErrorDetails(TestCase): + # TODO: These really need unit tests for each individual keyword, rather + # than just these higher level tests. + def test_anyOf(self): + instance = 5 + schema = { + "anyOf": [ + {"minimum": 20}, + {"type": "string"}, + ], + } + + validator = validators.Draft4Validator(schema) + errors = list(validator.iter_errors(instance)) + self.assertEqual(len(errors), 1) + e = errors[0] + + self.assertEqual(e.validator, "anyOf") + self.assertEqual(e.validator_value, schema["anyOf"]) + self.assertEqual(e.instance, instance) + self.assertEqual(e.schema, schema) + self.assertIsNone(e.parent) + + self.assertEqual(e.path, deque([])) + self.assertEqual(e.relative_path, deque([])) + self.assertEqual(e.absolute_path, deque([])) + self.assertEqual(e.json_path, "$") + + self.assertEqual(e.schema_path, deque(["anyOf"])) + self.assertEqual(e.relative_schema_path, deque(["anyOf"])) + self.assertEqual(e.absolute_schema_path, deque(["anyOf"])) + + self.assertEqual(len(e.context), 2) + + e1, e2 = sorted_errors(e.context) + + self.assertEqual(e1.validator, "minimum") + self.assertEqual(e1.validator_value, schema["anyOf"][0]["minimum"]) + self.assertEqual(e1.instance, instance) + self.assertEqual(e1.schema, schema["anyOf"][0]) + self.assertIs(e1.parent, e) + + self.assertEqual(e1.path, deque([])) + self.assertEqual(e1.absolute_path, deque([])) + self.assertEqual(e1.relative_path, deque([])) + self.assertEqual(e1.json_path, "$") + + self.assertEqual(e1.schema_path, deque([0, "minimum"])) + self.assertEqual(e1.relative_schema_path, deque([0, "minimum"])) + self.assertEqual( + e1.absolute_schema_path, deque(["anyOf", 0, "minimum"]), + ) + + self.assertFalse(e1.context) + + self.assertEqual(e2.validator, "type") + self.assertEqual(e2.validator_value, schema["anyOf"][1]["type"]) + self.assertEqual(e2.instance, instance) + self.assertEqual(e2.schema, schema["anyOf"][1]) + self.assertIs(e2.parent, e) + + self.assertEqual(e2.path, deque([])) + self.assertEqual(e2.relative_path, deque([])) + self.assertEqual(e2.absolute_path, deque([])) + self.assertEqual(e2.json_path, "$") + + self.assertEqual(e2.schema_path, deque([1, "type"])) + self.assertEqual(e2.relative_schema_path, deque([1, "type"])) + self.assertEqual(e2.absolute_schema_path, deque(["anyOf", 1, "type"])) + + self.assertEqual(len(e2.context), 0) + + def test_type(self): + instance = {"foo": 1} + schema = { + "type": [ + {"type": "integer"}, + { + "type": "object", + "properties": {"foo": {"enum": [2]}}, + }, + ], + } + + validator = validators.Draft3Validator(schema) + errors = list(validator.iter_errors(instance)) + self.assertEqual(len(errors), 1) + e = errors[0] + + self.assertEqual(e.validator, "type") + self.assertEqual(e.validator_value, schema["type"]) + self.assertEqual(e.instance, instance) + self.assertEqual(e.schema, schema) + self.assertIsNone(e.parent) + + self.assertEqual(e.path, deque([])) + self.assertEqual(e.relative_path, deque([])) + self.assertEqual(e.absolute_path, deque([])) + self.assertEqual(e.json_path, "$") + + self.assertEqual(e.schema_path, deque(["type"])) + self.assertEqual(e.relative_schema_path, deque(["type"])) + self.assertEqual(e.absolute_schema_path, deque(["type"])) + + self.assertEqual(len(e.context), 2) + + e1, e2 = sorted_errors(e.context) + + self.assertEqual(e1.validator, "type") + self.assertEqual(e1.validator_value, schema["type"][0]["type"]) + self.assertEqual(e1.instance, instance) + self.assertEqual(e1.schema, schema["type"][0]) + self.assertIs(e1.parent, e) + + self.assertEqual(e1.path, deque([])) + self.assertEqual(e1.relative_path, deque([])) + self.assertEqual(e1.absolute_path, deque([])) + self.assertEqual(e1.json_path, "$") + + self.assertEqual(e1.schema_path, deque([0, "type"])) + self.assertEqual(e1.relative_schema_path, deque([0, "type"])) + self.assertEqual(e1.absolute_schema_path, deque(["type", 0, "type"])) + + self.assertFalse(e1.context) + + self.assertEqual(e2.validator, "enum") + self.assertEqual(e2.validator_value, [2]) + self.assertEqual(e2.instance, 1) + self.assertEqual(e2.schema, {"enum": [2]}) + self.assertIs(e2.parent, e) + + self.assertEqual(e2.path, deque(["foo"])) + self.assertEqual(e2.relative_path, deque(["foo"])) + self.assertEqual(e2.absolute_path, deque(["foo"])) + self.assertEqual(e2.json_path, "$.foo") + + self.assertEqual( + e2.schema_path, deque([1, "properties", "foo", "enum"]), + ) + self.assertEqual( + e2.relative_schema_path, deque([1, "properties", "foo", "enum"]), + ) + self.assertEqual( + e2.absolute_schema_path, + deque(["type", 1, "properties", "foo", "enum"]), + ) + + self.assertFalse(e2.context) + + def test_single_nesting(self): + instance = {"foo": 2, "bar": [1], "baz": 15, "quux": "spam"} + schema = { + "properties": { + "foo": {"type": "string"}, + "bar": {"minItems": 2}, + "baz": {"maximum": 10, "enum": [2, 4, 6, 8]}, + }, + } + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2, e3, e4 = sorted_errors(errors) + + self.assertEqual(e1.path, deque(["bar"])) + self.assertEqual(e2.path, deque(["baz"])) + self.assertEqual(e3.path, deque(["baz"])) + self.assertEqual(e4.path, deque(["foo"])) + + self.assertEqual(e1.relative_path, deque(["bar"])) + self.assertEqual(e2.relative_path, deque(["baz"])) + self.assertEqual(e3.relative_path, deque(["baz"])) + self.assertEqual(e4.relative_path, deque(["foo"])) + + self.assertEqual(e1.absolute_path, deque(["bar"])) + self.assertEqual(e2.absolute_path, deque(["baz"])) + self.assertEqual(e3.absolute_path, deque(["baz"])) + self.assertEqual(e4.absolute_path, deque(["foo"])) + + self.assertEqual(e1.json_path, "$.bar") + self.assertEqual(e2.json_path, "$.baz") + self.assertEqual(e3.json_path, "$.baz") + self.assertEqual(e4.json_path, "$.foo") + + self.assertEqual(e1.validator, "minItems") + self.assertEqual(e2.validator, "enum") + self.assertEqual(e3.validator, "maximum") + self.assertEqual(e4.validator, "type") + + def test_multiple_nesting(self): + instance = [1, {"foo": 2, "bar": {"baz": [1]}}, "quux"] + schema = { + "type": "string", + "items": { + "type": ["string", "object"], + "properties": { + "foo": {"enum": [1, 3]}, + "bar": { + "type": "array", + "properties": { + "bar": {"required": True}, + "baz": {"minItems": 2}, + }, + }, + }, + }, + } + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2, e3, e4, e5, e6 = sorted_errors(errors) + + self.assertEqual(e1.path, deque([])) + self.assertEqual(e2.path, deque([0])) + self.assertEqual(e3.path, deque([1, "bar"])) + self.assertEqual(e4.path, deque([1, "bar", "bar"])) + self.assertEqual(e5.path, deque([1, "bar", "baz"])) + self.assertEqual(e6.path, deque([1, "foo"])) + + self.assertEqual(e1.json_path, "$") + self.assertEqual(e2.json_path, "$[0]") + self.assertEqual(e3.json_path, "$[1].bar") + self.assertEqual(e4.json_path, "$[1].bar.bar") + self.assertEqual(e5.json_path, "$[1].bar.baz") + self.assertEqual(e6.json_path, "$[1].foo") + + self.assertEqual(e1.schema_path, deque(["type"])) + self.assertEqual(e2.schema_path, deque(["items", "type"])) + self.assertEqual( + list(e3.schema_path), ["items", "properties", "bar", "type"], + ) + self.assertEqual( + list(e4.schema_path), + ["items", "properties", "bar", "properties", "bar", "required"], + ) + self.assertEqual( + list(e5.schema_path), + ["items", "properties", "bar", "properties", "baz", "minItems"], + ) + self.assertEqual( + list(e6.schema_path), ["items", "properties", "foo", "enum"], + ) + + self.assertEqual(e1.validator, "type") + self.assertEqual(e2.validator, "type") + self.assertEqual(e3.validator, "type") + self.assertEqual(e4.validator, "required") + self.assertEqual(e5.validator, "minItems") + self.assertEqual(e6.validator, "enum") + + def test_recursive(self): + schema = { + "definitions": { + "node": { + "anyOf": [{ + "type": "object", + "required": ["name", "children"], + "properties": { + "name": { + "type": "string", + }, + "children": { + "type": "object", + "patternProperties": { + "^.*$": { + "$ref": "#/definitions/node", + }, + }, + }, + }, + }], + }, + }, + "type": "object", + "required": ["root"], + "properties": {"root": {"$ref": "#/definitions/node"}}, + } + + instance = { + "root": { + "name": "root", + "children": { + "a": { + "name": "a", + "children": { + "ab": { + "name": "ab", + # missing "children" + }, + }, + }, + }, + }, + } + validator = validators.Draft4Validator(schema) + + e, = validator.iter_errors(instance) + self.assertEqual(e.absolute_path, deque(["root"])) + self.assertEqual( + e.absolute_schema_path, deque(["properties", "root", "anyOf"]), + ) + self.assertEqual(e.json_path, "$.root") + + e1, = e.context + self.assertEqual(e1.absolute_path, deque(["root", "children", "a"])) + self.assertEqual( + e1.absolute_schema_path, deque( + [ + "properties", + "root", + "anyOf", + 0, + "properties", + "children", + "patternProperties", + "^.*$", + "anyOf", + ], + ), + ) + self.assertEqual(e1.json_path, "$.root.children.a") + + e2, = e1.context + self.assertEqual( + e2.absolute_path, deque( + ["root", "children", "a", "children", "ab"], + ), + ) + self.assertEqual( + e2.absolute_schema_path, deque( + [ + "properties", + "root", + "anyOf", + 0, + "properties", + "children", + "patternProperties", + "^.*$", + "anyOf", + 0, + "properties", + "children", + "patternProperties", + "^.*$", + "anyOf", + ], + ), + ) + self.assertEqual(e2.json_path, "$.root.children.a.children.ab") + + def test_additionalProperties(self): + instance = {"bar": "bar", "foo": 2} + schema = {"additionalProperties": {"type": "integer", "minimum": 5}} + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2 = sorted_errors(errors) + + self.assertEqual(e1.path, deque(["bar"])) + self.assertEqual(e2.path, deque(["foo"])) + + self.assertEqual(e1.json_path, "$.bar") + self.assertEqual(e2.json_path, "$.foo") + + self.assertEqual(e1.validator, "type") + self.assertEqual(e2.validator, "minimum") + + def test_patternProperties(self): + instance = {"bar": 1, "foo": 2} + schema = { + "patternProperties": { + "bar": {"type": "string"}, + "foo": {"minimum": 5}, + }, + } + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2 = sorted_errors(errors) + + self.assertEqual(e1.path, deque(["bar"])) + self.assertEqual(e2.path, deque(["foo"])) + + self.assertEqual(e1.json_path, "$.bar") + self.assertEqual(e2.json_path, "$.foo") + + self.assertEqual(e1.validator, "type") + self.assertEqual(e2.validator, "minimum") + + def test_additionalItems(self): + instance = ["foo", 1] + schema = { + "items": [], + "additionalItems": {"type": "integer", "minimum": 5}, + } + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2 = sorted_errors(errors) + + self.assertEqual(e1.path, deque([0])) + self.assertEqual(e2.path, deque([1])) + + self.assertEqual(e1.json_path, "$[0]") + self.assertEqual(e2.json_path, "$[1]") + + self.assertEqual(e1.validator, "type") + self.assertEqual(e2.validator, "minimum") + + def test_additionalItems_with_items(self): + instance = ["foo", "bar", 1] + schema = { + "items": [{}], + "additionalItems": {"type": "integer", "minimum": 5}, + } + + validator = validators.Draft3Validator(schema) + errors = validator.iter_errors(instance) + e1, e2 = sorted_errors(errors) + + self.assertEqual(e1.path, deque([1])) + self.assertEqual(e2.path, deque([2])) + + self.assertEqual(e1.json_path, "$[1]") + self.assertEqual(e2.json_path, "$[2]") + + self.assertEqual(e1.validator, "type") + self.assertEqual(e2.validator, "minimum") + + def test_propertyNames(self): + instance = {"foo": 12} + schema = {"propertyNames": {"not": {"const": "foo"}}} + + validator = validators.Draft7Validator(schema) + error, = validator.iter_errors(instance) + + self.assertEqual(error.validator, "not") + self.assertEqual( + error.message, + "'foo' should not be valid under {'const': 'foo'}", + ) + self.assertEqual(error.path, deque([])) + self.assertEqual(error.json_path, "$") + self.assertEqual(error.schema_path, deque(["propertyNames", "not"])) + + def test_if_then(self): + schema = { + "if": {"const": 12}, + "then": {"const": 13}, + } + + validator = validators.Draft7Validator(schema) + error, = validator.iter_errors(12) + + self.assertEqual(error.validator, "const") + self.assertEqual(error.message, "13 was expected") + self.assertEqual(error.path, deque([])) + self.assertEqual(error.json_path, "$") + self.assertEqual(error.schema_path, deque(["then", "const"])) + + def test_if_else(self): + schema = { + "if": {"const": 12}, + "else": {"const": 13}, + } + + validator = validators.Draft7Validator(schema) + error, = validator.iter_errors(15) + + self.assertEqual(error.validator, "const") + self.assertEqual(error.message, "13 was expected") + self.assertEqual(error.path, deque([])) + self.assertEqual(error.json_path, "$") + self.assertEqual(error.schema_path, deque(["else", "const"])) + + def test_boolean_schema_False(self): + validator = validators.Draft7Validator(False) + error, = validator.iter_errors(12) + + self.assertEqual( + ( + error.message, + error.validator, + error.validator_value, + error.instance, + error.schema, + error.schema_path, + error.json_path, + ), + ( + "False schema does not allow 12", + None, + None, + 12, + False, + deque([]), + "$", + ), + ) + + def test_ref(self): + ref, schema = "someRef", {"additionalProperties": {"type": "integer"}} + validator = validators.Draft7Validator( + {"$ref": ref}, + resolver=validators._RefResolver("", {}, store={ref: schema}), + ) + error, = validator.iter_errors({"foo": "notAnInteger"}) + + self.assertEqual( + ( + error.message, + error.validator, + error.validator_value, + error.instance, + error.absolute_path, + error.schema, + error.schema_path, + error.json_path, + ), + ( + "'notAnInteger' is not of type 'integer'", + "type", + "integer", + "notAnInteger", + deque(["foo"]), + {"type": "integer"}, + deque(["additionalProperties", "type"]), + "$.foo", + ), + ) + + def test_prefixItems(self): + schema = {"prefixItems": [{"type": "string"}, {}, {}, {"maximum": 3}]} + validator = validators.Draft202012Validator(schema) + type_error, min_error = validator.iter_errors([1, 2, "foo", 5]) + self.assertEqual( + ( + type_error.message, + type_error.validator, + type_error.validator_value, + type_error.instance, + type_error.absolute_path, + type_error.schema, + type_error.schema_path, + type_error.json_path, + ), + ( + "1 is not of type 'string'", + "type", + "string", + 1, + deque([0]), + {"type": "string"}, + deque(["prefixItems", 0, "type"]), + "$[0]", + ), + ) + self.assertEqual( + ( + min_error.message, + min_error.validator, + min_error.validator_value, + min_error.instance, + min_error.absolute_path, + min_error.schema, + min_error.schema_path, + min_error.json_path, + ), + ( + "5 is greater than the maximum of 3", + "maximum", + 3, + 5, + deque([3]), + {"maximum": 3}, + deque(["prefixItems", 3, "maximum"]), + "$[3]", + ), + ) + + def test_prefixItems_with_items(self): + schema = { + "items": {"type": "string"}, + "prefixItems": [{}], + } + validator = validators.Draft202012Validator(schema) + e1, e2 = validator.iter_errors(["foo", 2, "bar", 4, "baz"]) + self.assertEqual( + ( + e1.message, + e1.validator, + e1.validator_value, + e1.instance, + e1.absolute_path, + e1.schema, + e1.schema_path, + e1.json_path, + ), + ( + "2 is not of type 'string'", + "type", + "string", + 2, + deque([1]), + {"type": "string"}, + deque(["items", "type"]), + "$[1]", + ), + ) + self.assertEqual( + ( + e2.message, + e2.validator, + e2.validator_value, + e2.instance, + e2.absolute_path, + e2.schema, + e2.schema_path, + e2.json_path, + ), + ( + "4 is not of type 'string'", + "type", + "string", + 4, + deque([3]), + {"type": "string"}, + deque(["items", "type"]), + "$[3]", + ), + ) + + def test_contains_too_many(self): + """ + `contains` + `maxContains` produces only one error, even if there are + many more incorrectly matching elements. + """ + schema = {"contains": {"type": "string"}, "maxContains": 2} + validator = validators.Draft202012Validator(schema) + error, = validator.iter_errors(["foo", 2, "bar", 4, "baz", "quux"]) + self.assertEqual( + ( + error.message, + error.validator, + error.validator_value, + error.instance, + error.absolute_path, + error.schema, + error.schema_path, + error.json_path, + ), + ( + "Too many items match the given schema (expected at most 2)", + "maxContains", + 2, + ["foo", 2, "bar", 4, "baz", "quux"], + deque([]), + {"contains": {"type": "string"}, "maxContains": 2}, + deque(["contains"]), + "$", + ), + ) + + def test_contains_too_few(self): + schema = {"contains": {"type": "string"}, "minContains": 2} + validator = validators.Draft202012Validator(schema) + error, = validator.iter_errors(["foo", 2, 4]) + self.assertEqual( + ( + error.message, + error.validator, + error.validator_value, + error.instance, + error.absolute_path, + error.schema, + error.schema_path, + error.json_path, + ), + ( + ( + "Too few items match the given schema " + "(expected at least 2 but only 1 matched)" + ), + "minContains", + 2, + ["foo", 2, 4], + deque([]), + {"contains": {"type": "string"}, "minContains": 2}, + deque(["contains"]), + "$", + ), + ) + + def test_contains_none(self): + schema = {"contains": {"type": "string"}, "minContains": 2} + validator = validators.Draft202012Validator(schema) + error, = validator.iter_errors([2, 4]) + self.assertEqual( + ( + error.message, + error.validator, + error.validator_value, + error.instance, + error.absolute_path, + error.schema, + error.schema_path, + error.json_path, + ), + ( + "[2, 4] does not contain items matching the given schema", + "contains", + {"type": "string"}, + [2, 4], + deque([]), + {"contains": {"type": "string"}, "minContains": 2}, + deque(["contains"]), + "$", + ), + ) + + def test_ref_sibling(self): + schema = { + "$defs": {"foo": {"required": ["bar"]}}, + "properties": { + "aprop": { + "$ref": "#/$defs/foo", + "required": ["baz"], + }, + }, + } + + validator = validators.Draft202012Validator(schema) + e1, e2 = validator.iter_errors({"aprop": {}}) + self.assertEqual( + ( + e1.message, + e1.validator, + e1.validator_value, + e1.instance, + e1.absolute_path, + e1.schema, + e1.schema_path, + e1.relative_schema_path, + e1.json_path, + ), + ( + "'bar' is a required property", + "required", + ["bar"], + {}, + deque(["aprop"]), + {"required": ["bar"]}, + deque(["properties", "aprop", "required"]), + deque(["properties", "aprop", "required"]), + "$.aprop", + ), + ) + self.assertEqual( + ( + e2.message, + e2.validator, + e2.validator_value, + e2.instance, + e2.absolute_path, + e2.schema, + e2.schema_path, + e2.relative_schema_path, + e2.json_path, + ), + ( + "'baz' is a required property", + "required", + ["baz"], + {}, + deque(["aprop"]), + {"$ref": "#/$defs/foo", "required": ["baz"]}, + deque(["properties", "aprop", "required"]), + deque(["properties", "aprop", "required"]), + "$.aprop", + ), + ) + + +class MetaSchemaTestsMixin: + # TODO: These all belong upstream + def test_invalid_properties(self): + with self.assertRaises(exceptions.SchemaError): + self.Validator.check_schema({"properties": 12}) + + def test_minItems_invalid_string(self): + with self.assertRaises(exceptions.SchemaError): + # needs to be an integer + self.Validator.check_schema({"minItems": "1"}) + + def test_enum_allows_empty_arrays(self): + """ + Technically, all the spec says is they SHOULD have elements, not MUST. + + (As of Draft 6. Previous drafts do say MUST). + + See #529. + """ + if self.Validator in { + validators.Draft3Validator, + validators.Draft4Validator, + }: + with self.assertRaises(exceptions.SchemaError): + self.Validator.check_schema({"enum": []}) + else: + self.Validator.check_schema({"enum": []}) + + def test_enum_allows_non_unique_items(self): + """ + Technically, all the spec says is they SHOULD be unique, not MUST. + + (As of Draft 6. Previous drafts do say MUST). + + See #529. + """ + if self.Validator in { + validators.Draft3Validator, + validators.Draft4Validator, + }: + with self.assertRaises(exceptions.SchemaError): + self.Validator.check_schema({"enum": [12, 12]}) + else: + self.Validator.check_schema({"enum": [12, 12]}) + + def test_schema_with_invalid_regex(self): + with self.assertRaises(exceptions.SchemaError): + self.Validator.check_schema({"pattern": "*notaregex"}) + + def test_schema_with_invalid_regex_with_disabled_format_validation(self): + self.Validator.check_schema( + {"pattern": "*notaregex"}, + format_checker=None, + ) + + +class ValidatorTestMixin(MetaSchemaTestsMixin): + def test_it_implements_the_validator_protocol(self): + self.assertIsInstance(self.Validator({}), protocols.Validator) + + def test_valid_instances_are_valid(self): + schema, instance = self.valid + self.assertTrue(self.Validator(schema).is_valid(instance)) + + def test_invalid_instances_are_not_valid(self): + schema, instance = self.invalid + self.assertFalse(self.Validator(schema).is_valid(instance)) + + def test_non_existent_properties_are_ignored(self): + self.Validator({object(): object()}).validate(instance=object()) + + def test_evolve(self): + schema, format_checker = {"type": "integer"}, FormatChecker() + original = self.Validator( + schema, + format_checker=format_checker, + ) + new = original.evolve( + schema={"type": "string"}, + format_checker=self.Validator.FORMAT_CHECKER, + ) + + expected = self.Validator( + {"type": "string"}, + format_checker=self.Validator.FORMAT_CHECKER, + _resolver=new._resolver, + ) + + self.assertEqual(new, expected) + self.assertNotEqual(new, original) + + def test_evolve_with_subclass(self): + """ + Subclassing validators isn't supported public API, but some users have + done it, because we don't actually error entirely when it's done :/ + + We need to deprecate doing so first to help as many of these users + ensure they can move to supported APIs, but this test ensures that in + the interim, we haven't broken those users. + """ + + with self.assertWarns(DeprecationWarning): + @define + class OhNo(self.Validator): + foo = field(factory=lambda: [1, 2, 3]) + _bar = field(default=37) + + validator = OhNo({}, bar=12) + self.assertEqual(validator.foo, [1, 2, 3]) + + new = validator.evolve(schema={"type": "integer"}) + self.assertEqual(new.foo, [1, 2, 3]) + self.assertEqual(new._bar, 12) + + def test_is_type_is_true_for_valid_type(self): + self.assertTrue(self.Validator({}).is_type("foo", "string")) + + def test_is_type_is_false_for_invalid_type(self): + self.assertFalse(self.Validator({}).is_type("foo", "array")) + + def test_is_type_evades_bool_inheriting_from_int(self): + self.assertFalse(self.Validator({}).is_type(True, "integer")) + self.assertFalse(self.Validator({}).is_type(True, "number")) + + def test_it_can_validate_with_decimals(self): + schema = {"items": {"type": "number"}} + Validator = validators.extend( + self.Validator, + type_checker=self.Validator.TYPE_CHECKER.redefine( + "number", + lambda checker, thing: isinstance( + thing, (int, float, Decimal), + ) and not isinstance(thing, bool), + ), + ) + + validator = Validator(schema) + validator.validate([1, 1.1, Decimal(1) / Decimal(8)]) + + invalid = ["foo", {}, [], True, None] + self.assertEqual( + [error.instance for error in validator.iter_errors(invalid)], + invalid, + ) + + def test_it_returns_true_for_formats_it_does_not_know_about(self): + validator = self.Validator( + {"format": "carrot"}, format_checker=FormatChecker(), + ) + validator.validate("bugs") + + def test_it_does_not_validate_formats_by_default(self): + validator = self.Validator({}) + self.assertIsNone(validator.format_checker) + + def test_it_validates_formats_if_a_checker_is_provided(self): + checker = FormatChecker() + bad = ValueError("Bad!") + + @checker.checks("foo", raises=ValueError) + def check(value): + if value == "good": + return True + elif value == "bad": + raise bad + else: # pragma: no cover + self.fail(f"What is {value}? [Baby Don't Hurt Me]") + + validator = self.Validator( + {"format": "foo"}, format_checker=checker, + ) + + validator.validate("good") + with self.assertRaises(exceptions.ValidationError) as cm: + validator.validate("bad") + + # Make sure original cause is attached + self.assertIs(cm.exception.cause, bad) + + def test_non_string_custom_type(self): + non_string_type = object() + schema = {"type": [non_string_type]} + Crazy = validators.extend( + self.Validator, + type_checker=self.Validator.TYPE_CHECKER.redefine( + non_string_type, + lambda checker, thing: isinstance(thing, int), + ), + ) + Crazy(schema).validate(15) + + def test_it_properly_formats_tuples_in_errors(self): + """ + A tuple instance properly formats validation errors for uniqueItems. + + See #224 + """ + TupleValidator = validators.extend( + self.Validator, + type_checker=self.Validator.TYPE_CHECKER.redefine( + "array", + lambda checker, thing: isinstance(thing, tuple), + ), + ) + with self.assertRaises(exceptions.ValidationError) as e: + TupleValidator({"uniqueItems": True}).validate((1, 1)) + self.assertIn("(1, 1) has non-unique elements", str(e.exception)) + + def test_check_redefined_sequence(self): + """ + Allow array to validate against another defined sequence type + """ + schema = {"type": "array", "uniqueItems": True} + MyMapping = namedtuple("MyMapping", "a, b") + Validator = validators.extend( + self.Validator, + type_checker=self.Validator.TYPE_CHECKER.redefine_many( + { + "array": lambda checker, thing: isinstance( + thing, (list, deque), + ), + "object": lambda checker, thing: isinstance( + thing, (dict, MyMapping), + ), + }, + ), + ) + validator = Validator(schema) + + valid_instances = [ + deque(["a", None, "1", "", True]), + deque([[False], [0]]), + [deque([False]), deque([0])], + [[deque([False])], [deque([0])]], + [[[[[deque([False])]]]], [[[[deque([0])]]]]], + [deque([deque([False])]), deque([deque([0])])], + [MyMapping("a", 0), MyMapping("a", False)], + [ + MyMapping("a", [deque([0])]), + MyMapping("a", [deque([False])]), + ], + [ + MyMapping("a", [MyMapping("a", deque([0]))]), + MyMapping("a", [MyMapping("a", deque([False]))]), + ], + [deque(deque(deque([False]))), deque(deque(deque([0])))], + ] + + for instance in valid_instances: + validator.validate(instance) + + invalid_instances = [ + deque(["a", "b", "a"]), + deque([[False], [False]]), + [deque([False]), deque([False])], + [[deque([False])], [deque([False])]], + [[[[[deque([False])]]]], [[[[deque([False])]]]]], + [deque([deque([False])]), deque([deque([False])])], + [MyMapping("a", False), MyMapping("a", False)], + [ + MyMapping("a", [deque([False])]), + MyMapping("a", [deque([False])]), + ], + [ + MyMapping("a", [MyMapping("a", deque([False]))]), + MyMapping("a", [MyMapping("a", deque([False]))]), + ], + [deque(deque(deque([False]))), deque(deque(deque([False])))], + ] + + for instance in invalid_instances: + with self.assertRaises(exceptions.ValidationError): + validator.validate(instance) + + def test_it_creates_a_ref_resolver_if_not_provided(self): + with self.assertWarns(DeprecationWarning): + resolver = self.Validator({}).resolver + self.assertIsInstance(resolver, validators._RefResolver) + + def test_it_upconverts_from_deprecated_RefResolvers(self): + ref, schema = "someCoolRef", {"type": "integer"} + resolver = validators._RefResolver("", {}, store={ref: schema}) + validator = self.Validator({"$ref": ref}, resolver=resolver) + + with self.assertRaises(exceptions.ValidationError): + validator.validate(None) + + def test_it_upconverts_from_yet_older_deprecated_legacy_RefResolvers(self): + """ + Legacy RefResolvers support only the context manager form of + resolution. + """ + + class LegacyRefResolver: + @contextmanager + def resolving(this, ref): + self.assertEqual(ref, "the ref") + yield {"type": "integer"} + + resolver = LegacyRefResolver() + schema = {"$ref": "the ref"} + + with self.assertRaises(exceptions.ValidationError): + self.Validator(schema, resolver=resolver).validate(None) + + +class AntiDraft6LeakMixin: + """ + Make sure functionality from draft 6 doesn't leak backwards in time. + """ + + def test_True_is_not_a_schema(self): + with self.assertRaises(exceptions.SchemaError) as e: + self.Validator.check_schema(True) + self.assertIn("True is not of type", str(e.exception)) + + def test_False_is_not_a_schema(self): + with self.assertRaises(exceptions.SchemaError) as e: + self.Validator.check_schema(False) + self.assertIn("False is not of type", str(e.exception)) + + def test_True_is_not_a_schema_even_if_you_forget_to_check(self): + with self.assertRaises(Exception) as e: + self.Validator(True).validate(12) + self.assertNotIsInstance(e.exception, exceptions.ValidationError) + + def test_False_is_not_a_schema_even_if_you_forget_to_check(self): + with self.assertRaises(Exception) as e: + self.Validator(False).validate(12) + self.assertNotIsInstance(e.exception, exceptions.ValidationError) + + +class TestDraft3Validator(AntiDraft6LeakMixin, ValidatorTestMixin, TestCase): + Validator = validators.Draft3Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + def test_any_type_is_valid_for_type_any(self): + validator = self.Validator({"type": "any"}) + validator.validate(object()) + + def test_any_type_is_redefinable(self): + """ + Sigh, because why not. + """ + Crazy = validators.extend( + self.Validator, + type_checker=self.Validator.TYPE_CHECKER.redefine( + "any", lambda checker, thing: isinstance(thing, int), + ), + ) + validator = Crazy({"type": "any"}) + validator.validate(12) + with self.assertRaises(exceptions.ValidationError): + validator.validate("foo") + + def test_is_type_is_true_for_any_type(self): + self.assertTrue(self.Validator({"type": "any"}).is_valid(object())) + + def test_is_type_does_not_evade_bool_if_it_is_being_tested(self): + self.assertTrue(self.Validator({}).is_type(True, "boolean")) + self.assertTrue(self.Validator({"type": "any"}).is_valid(True)) + + +class TestDraft4Validator(AntiDraft6LeakMixin, ValidatorTestMixin, TestCase): + Validator = validators.Draft4Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + +class TestDraft6Validator(ValidatorTestMixin, TestCase): + Validator = validators.Draft6Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + +class TestDraft7Validator(ValidatorTestMixin, TestCase): + Validator = validators.Draft7Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + +class TestDraft201909Validator(ValidatorTestMixin, TestCase): + Validator = validators.Draft201909Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + +class TestDraft202012Validator(ValidatorTestMixin, TestCase): + Validator = validators.Draft202012Validator + valid: tuple[dict, dict] = ({}, {}) + invalid = {"type": "integer"}, "foo" + + +class TestLatestValidator(TestCase): + """ + These really apply to multiple versions but are easiest to test on one. + """ + + def test_ref_resolvers_may_have_boolean_schemas_stored(self): + ref = "someCoolRef" + schema = {"$ref": ref} + resolver = validators._RefResolver("", {}, store={ref: False}) + validator = validators._LATEST_VERSION(schema, resolver=resolver) + + with self.assertRaises(exceptions.ValidationError): + validator.validate(None) + + +class TestValidatorFor(TestCase): + def test_draft_3(self): + schema = {"$schema": "http://json-schema.org/draft-03/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft3Validator, + ) + + schema = {"$schema": "http://json-schema.org/draft-03/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft3Validator, + ) + + def test_draft_4(self): + schema = {"$schema": "http://json-schema.org/draft-04/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft4Validator, + ) + + schema = {"$schema": "http://json-schema.org/draft-04/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft4Validator, + ) + + def test_draft_6(self): + schema = {"$schema": "http://json-schema.org/draft-06/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft6Validator, + ) + + schema = {"$schema": "http://json-schema.org/draft-06/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft6Validator, + ) + + def test_draft_7(self): + schema = {"$schema": "http://json-schema.org/draft-07/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft7Validator, + ) + + schema = {"$schema": "http://json-schema.org/draft-07/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft7Validator, + ) + + def test_draft_201909(self): + schema = {"$schema": "https://json-schema.org/draft/2019-09/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft201909Validator, + ) + + schema = {"$schema": "https://json-schema.org/draft/2019-09/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft201909Validator, + ) + + def test_draft_202012(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + self.assertIs( + validators.validator_for(schema), + validators.Draft202012Validator, + ) + + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema#"} + self.assertIs( + validators.validator_for(schema), + validators.Draft202012Validator, + ) + + def test_True(self): + self.assertIs( + validators.validator_for(True), + validators._LATEST_VERSION, + ) + + def test_False(self): + self.assertIs( + validators.validator_for(False), + validators._LATEST_VERSION, + ) + + def test_custom_validator(self): + Validator = validators.create( + meta_schema={"id": "meta schema id"}, + version="12", + id_of=lambda s: s.get("id", ""), + ) + schema = {"$schema": "meta schema id"} + self.assertIs( + validators.validator_for(schema), + Validator, + ) + + def test_custom_validator_draft6(self): + Validator = validators.create( + meta_schema={"$id": "meta schema $id"}, + version="13", + ) + schema = {"$schema": "meta schema $id"} + self.assertIs( + validators.validator_for(schema), + Validator, + ) + + def test_validator_for_jsonschema_default(self): + self.assertIs(validators.validator_for({}), validators._LATEST_VERSION) + + def test_validator_for_custom_default(self): + self.assertIs(validators.validator_for({}, default=None), None) + + def test_warns_if_meta_schema_specified_was_not_found(self): + with self.assertWarns(DeprecationWarning) as cm: + validators.validator_for(schema={"$schema": "unknownSchema"}) + + self.assertEqual(cm.filename, __file__) + self.assertEqual( + str(cm.warning), + "The metaschema specified by $schema was not found. " + "Using the latest draft to validate, but this will raise " + "an error in the future.", + ) + + def test_does_not_warn_if_meta_schema_is_unspecified(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + validators.validator_for(schema={}, default={}) + self.assertFalse(w) + + def test_validator_for_custom_default_with_schema(self): + schema, default = {"$schema": "mailto:foo@example.com"}, object() + self.assertIs(validators.validator_for(schema, default), default) + + +class TestValidate(TestCase): + def assertUses(self, schema, Validator): + result = [] + with mock.patch.object(Validator, "check_schema", result.append): + validators.validate({}, schema) + self.assertEqual(result, [schema]) + + def test_draft3_validator_is_chosen(self): + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-03/schema#"}, + Validator=validators.Draft3Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-03/schema"}, + Validator=validators.Draft3Validator, + ) + + def test_draft4_validator_is_chosen(self): + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-04/schema#"}, + Validator=validators.Draft4Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-04/schema"}, + Validator=validators.Draft4Validator, + ) + + def test_draft6_validator_is_chosen(self): + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-06/schema#"}, + Validator=validators.Draft6Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-06/schema"}, + Validator=validators.Draft6Validator, + ) + + def test_draft7_validator_is_chosen(self): + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-07/schema#"}, + Validator=validators.Draft7Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={"$schema": "http://json-schema.org/draft-07/schema"}, + Validator=validators.Draft7Validator, + ) + + def test_draft202012_validator_is_chosen(self): + self.assertUses( + schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema#", + }, + Validator=validators.Draft202012Validator, + ) + # Make sure it works without the empty fragment + self.assertUses( + schema={ + "$schema": "https://json-schema.org/draft/2020-12/schema", + }, + Validator=validators.Draft202012Validator, + ) + + def test_draft202012_validator_is_the_default(self): + self.assertUses(schema={}, Validator=validators.Draft202012Validator) + + def test_validation_error_message(self): + with self.assertRaises(exceptions.ValidationError) as e: + validators.validate(12, {"type": "string"}) + self.assertRegex( + str(e.exception), + "(?s)Failed validating '.*' in schema.*On instance", + ) + + def test_schema_error_message(self): + with self.assertRaises(exceptions.SchemaError) as e: + validators.validate(12, {"type": 12}) + self.assertRegex( + str(e.exception), + "(?s)Failed validating '.*' in metaschema.*On schema", + ) + + def test_it_uses_best_match(self): + schema = { + "oneOf": [ + {"type": "number", "minimum": 20}, + {"type": "array"}, + ], + } + with self.assertRaises(exceptions.ValidationError) as e: + validators.validate(12, schema) + self.assertIn("12 is less than the minimum of 20", str(e.exception)) + + +class TestThreading(TestCase): + """ + Threading-related functionality tests. + + jsonschema doesn't promise thread safety, and its validation behavior + across multiple threads may change at any time, but that means it isn't + safe to share *validators* across threads, not that anytime one has + multiple threads that jsonschema won't work (it certainly is intended to). + + These tests ensure that this minimal level of functionality continues to + work. + """ + + def test_validation_across_a_second_thread(self): + failed = [] + + def validate(): + try: + validators.validate(instance=37, schema=True) + except: # pragma: no cover # noqa: E722 + failed.append(sys.exc_info()) + + validate() # just verify it succeeds + + from threading import Thread + thread = Thread(target=validate) + thread.start() + thread.join() + self.assertEqual((thread.is_alive(), failed), (False, [])) + + +class TestReferencing(TestCase): + def test_registry_with_retrieve(self): + def retrieve(uri): + return DRAFT202012.create_resource({"type": "integer"}) + + registry = referencing.Registry(retrieve=retrieve) + schema = {"$ref": "https://example.com/"} + validator = validators.Draft202012Validator(schema, registry=registry) + + self.assertEqual( + (validator.is_valid(12), validator.is_valid("foo")), + (True, False), + ) + + def test_custom_registries_do_not_autoretrieve_remote_resources(self): + registry = referencing.Registry() + schema = {"$ref": "https://example.com/"} + validator = validators.Draft202012Validator(schema, registry=registry) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + with self.assertRaises(referencing.exceptions.Unresolvable): + validator.validate(12) + self.assertFalse(w) + + +class TestRefResolver(TestCase): + + base_uri = "" + stored_uri = "foo://stored" + stored_schema = {"stored": "schema"} + + def setUp(self): + self.referrer = {} + self.store = {self.stored_uri: self.stored_schema} + self.resolver = validators._RefResolver( + self.base_uri, self.referrer, self.store, + ) + + def test_it_does_not_retrieve_schema_urls_from_the_network(self): + ref = validators.Draft3Validator.META_SCHEMA["id"] + with mock.patch.object(self.resolver, "resolve_remote") as patched: # noqa: SIM117 + with self.resolver.resolving(ref) as resolved: + pass + self.assertEqual(resolved, validators.Draft3Validator.META_SCHEMA) + self.assertFalse(patched.called) + + def test_it_resolves_local_refs(self): + ref = "#/properties/foo" + self.referrer["properties"] = {"foo": object()} + with self.resolver.resolving(ref) as resolved: + self.assertEqual(resolved, self.referrer["properties"]["foo"]) + + def test_it_resolves_local_refs_with_id(self): + schema = {"id": "http://bar/schema#", "a": {"foo": "bar"}} + resolver = validators._RefResolver.from_schema( + schema, + id_of=lambda schema: schema.get("id", ""), + ) + with resolver.resolving("#/a") as resolved: + self.assertEqual(resolved, schema["a"]) + with resolver.resolving("http://bar/schema#/a") as resolved: + self.assertEqual(resolved, schema["a"]) + + def test_it_retrieves_stored_refs(self): + with self.resolver.resolving(self.stored_uri) as resolved: + self.assertIs(resolved, self.stored_schema) + + self.resolver.store["cached_ref"] = {"foo": 12} + with self.resolver.resolving("cached_ref#/foo") as resolved: + self.assertEqual(resolved, 12) + + def test_it_retrieves_unstored_refs_via_requests(self): + ref = "http://bar#baz" + schema = {"baz": 12} + + if "requests" in sys.modules: # pragma: no cover + self.addCleanup( + sys.modules.__setitem__, "requests", sys.modules["requests"], + ) + sys.modules["requests"] = ReallyFakeRequests({"http://bar": schema}) + + with self.resolver.resolving(ref) as resolved: + self.assertEqual(resolved, 12) + + def test_it_retrieves_unstored_refs_via_urlopen(self): + ref = "http://bar#baz" + schema = {"baz": 12} + + if "requests" in sys.modules: # pragma: no cover + self.addCleanup( + sys.modules.__setitem__, "requests", sys.modules["requests"], + ) + sys.modules["requests"] = None + + @contextmanager + def fake_urlopen(url): + self.assertEqual(url, "http://bar") + yield BytesIO(json.dumps(schema).encode("utf8")) + + with mock.patch("urllib.request.urlopen", new=fake_urlopen): # noqa: SIM117 + with self.resolver.resolving(ref) as resolved: + pass + self.assertEqual(resolved, 12) + + def test_it_retrieves_local_refs_via_urlopen(self): + with tempfile.NamedTemporaryFile(delete=False, mode="wt") as tempf: + self.addCleanup(os.remove, tempf.name) + json.dump({"foo": "bar"}, tempf) + + ref = f"file://{pathname2url(tempf.name)}#foo" + with self.resolver.resolving(ref) as resolved: + self.assertEqual(resolved, "bar") + + def test_it_can_construct_a_base_uri_from_a_schema(self): + schema = {"id": "foo"} + resolver = validators._RefResolver.from_schema( + schema, + id_of=lambda schema: schema.get("id", ""), + ) + self.assertEqual(resolver.base_uri, "foo") + self.assertEqual(resolver.resolution_scope, "foo") + with resolver.resolving("") as resolved: + self.assertEqual(resolved, schema) + with resolver.resolving("#") as resolved: + self.assertEqual(resolved, schema) + with resolver.resolving("foo") as resolved: + self.assertEqual(resolved, schema) + with resolver.resolving("foo#") as resolved: + self.assertEqual(resolved, schema) + + def test_it_can_construct_a_base_uri_from_a_schema_without_id(self): + schema = {} + resolver = validators._RefResolver.from_schema(schema) + self.assertEqual(resolver.base_uri, "") + self.assertEqual(resolver.resolution_scope, "") + with resolver.resolving("") as resolved: + self.assertEqual(resolved, schema) + with resolver.resolving("#") as resolved: + self.assertEqual(resolved, schema) + + def test_custom_uri_scheme_handlers(self): + def handler(url): + self.assertEqual(url, ref) + return schema + + schema = {"foo": "bar"} + ref = "foo://bar" + resolver = validators._RefResolver("", {}, handlers={"foo": handler}) + with resolver.resolving(ref) as resolved: + self.assertEqual(resolved, schema) + + def test_cache_remote_on(self): + response = [object()] + + def handler(url): + try: + return response.pop() + except IndexError: # pragma: no cover + self.fail("Response must not have been cached!") + + ref = "foo://bar" + resolver = validators._RefResolver( + "", {}, cache_remote=True, handlers={"foo": handler}, + ) + with resolver.resolving(ref): + pass + with resolver.resolving(ref): + pass + + def test_cache_remote_off(self): + response = [object()] + + def handler(url): + try: + return response.pop() + except IndexError: # pragma: no cover + self.fail("Handler called twice!") + + ref = "foo://bar" + resolver = validators._RefResolver( + "", {}, cache_remote=False, handlers={"foo": handler}, + ) + with resolver.resolving(ref): + pass + + def test_if_you_give_it_junk_you_get_a_resolution_error(self): + error = ValueError("Oh no! What's this?") + + def handler(url): + raise error + + ref = "foo://bar" + resolver = validators._RefResolver("", {}, handlers={"foo": handler}) + with self.assertRaises(exceptions._RefResolutionError) as err: # noqa: SIM117 + with resolver.resolving(ref): + self.fail("Shouldn't get this far!") # pragma: no cover + self.assertEqual(err.exception, exceptions._RefResolutionError(error)) + + def test_helpful_error_message_on_failed_pop_scope(self): + resolver = validators._RefResolver("", {}) + resolver.pop_scope() + with self.assertRaises(exceptions._RefResolutionError) as exc: + resolver.pop_scope() + self.assertIn("Failed to pop the scope", str(exc.exception)) + + def test_pointer_within_schema_with_different_id(self): + """ + See #1085. + """ + schema = validators.Draft7Validator.META_SCHEMA + one = validators._RefResolver("", schema) + validator = validators.Draft7Validator(schema, resolver=one) + self.assertFalse(validator.is_valid({"maxLength": "foo"})) + + another = { + "allOf": [{"$ref": validators.Draft7Validator.META_SCHEMA["$id"]}], + } + two = validators._RefResolver("", another) + validator = validators.Draft7Validator(another, resolver=two) + self.assertFalse(validator.is_valid({"maxLength": "foo"})) + + def test_newly_created_validator_with_ref_resolver(self): + """ + See https://github.com/python-jsonschema/jsonschema/issues/1061#issuecomment-1624266555. + """ + + def handle(uri): + self.assertEqual(uri, "http://example.com/foo") + return {"type": "integer"} + + resolver = validators._RefResolver("", {}, handlers={"http": handle}) + Validator = validators.create( + meta_schema={}, + validators=validators.Draft4Validator.VALIDATORS, + ) + schema = {"$id": "http://example.com/bar", "$ref": "foo"} + validator = Validator(schema, resolver=resolver) + self.assertEqual( + (validator.is_valid({}), validator.is_valid(37)), + (False, True), + ) + + def test_refresolver_with_pointer_in_schema_with_no_id(self): + """ + See https://github.com/python-jsonschema/jsonschema/issues/1124#issuecomment-1632574249. + """ + + schema = { + "properties": {"x": {"$ref": "#/definitions/x"}}, + "definitions": {"x": {"type": "integer"}}, + } + + validator = validators.Draft202012Validator( + schema, + resolver=validators._RefResolver("", schema), + ) + self.assertEqual( + (validator.is_valid({"x": "y"}), validator.is_valid({"x": 37})), + (False, True), + ) + + + +def sorted_errors(errors): + def key(error): + return ( + [str(e) for e in error.path], + [str(e) for e in error.schema_path], + ) + return sorted(errors, key=key) + + +@define +class ReallyFakeRequests: + + _responses: dict[str, Any] + + def get(self, url): + response = self._responses.get(url) + if url is None: # pragma: no cover + raise ValueError("Unknown URL: " + repr(url)) + return _ReallyFakeJSONResponse(json.dumps(response)) + + +@define +class _ReallyFakeJSONResponse: + + _response: str + + def json(self): + return json.loads(self._response) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/typing/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/typing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py new file mode 100644 index 00000000..63e8bd40 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/tests/typing/test_all_concrete_validators_match_protocol.py @@ -0,0 +1,38 @@ +""" +This module acts as a test that type checkers will allow each validator +class to be assigned to a variable of type `type[Validator]` + +The assignation is only valid if type checkers recognize each Validator +implementation as a valid implementer of the protocol. +""" +from jsonschema.protocols import Validator +from jsonschema.validators import ( + Draft3Validator, + Draft4Validator, + Draft6Validator, + Draft7Validator, + Draft201909Validator, + Draft202012Validator, +) + +my_validator: type[Validator] + +my_validator = Draft3Validator +my_validator = Draft4Validator +my_validator = Draft6Validator +my_validator = Draft7Validator +my_validator = Draft201909Validator +my_validator = Draft202012Validator + + +# in order to confirm that none of the above were incorrectly typed as 'Any' +# ensure that each of these assignments to a non-validator variable requires an +# ignore +none_var: None + +none_var = Draft3Validator # type: ignore[assignment] +none_var = Draft4Validator # type: ignore[assignment] +none_var = Draft6Validator # type: ignore[assignment] +none_var = Draft7Validator # type: ignore[assignment] +none_var = Draft201909Validator # type: ignore[assignment] +none_var = Draft202012Validator # type: ignore[assignment] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/validators.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/validators.py new file mode 100644 index 00000000..98ddf6bf --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema/validators.py @@ -0,0 +1,1410 @@ +""" +Creation and extension of validators, with implementations for existing drafts. +""" +from __future__ import annotations + +from collections import deque +from collections.abc import Iterable, Mapping, Sequence +from functools import lru_cache +from operator import methodcaller +from typing import TYPE_CHECKING +from urllib.parse import unquote, urldefrag, urljoin, urlsplit +from warnings import warn +import contextlib +import json +import reprlib +import warnings + +from attrs import define, field, fields +from jsonschema_specifications import REGISTRY as SPECIFICATIONS +from rpds import HashTrieMap +import referencing.exceptions +import referencing.jsonschema + +from jsonschema import ( + _format, + _keywords, + _legacy_keywords, + _types, + _typing, + _utils, + exceptions, +) + +if TYPE_CHECKING: + from jsonschema.protocols import Validator + +_UNSET = _utils.Unset() + +_VALIDATORS: dict[str, Validator] = {} +_META_SCHEMAS = _utils.URIDict() + + +def __getattr__(name): + if name == "ErrorTree": + warnings.warn( + "Importing ErrorTree from jsonschema.validators is deprecated. " + "Instead import it from jsonschema.exceptions.", + DeprecationWarning, + stacklevel=2, + ) + from jsonschema.exceptions import ErrorTree + return ErrorTree + elif name == "validators": + warnings.warn( + "Accessing jsonschema.validators.validators is deprecated. " + "Use jsonschema.validators.validator_for with a given schema.", + DeprecationWarning, + stacklevel=2, + ) + return _VALIDATORS + elif name == "meta_schemas": + warnings.warn( + "Accessing jsonschema.validators.meta_schemas is deprecated. " + "Use jsonschema.validators.validator_for with a given schema.", + DeprecationWarning, + stacklevel=2, + ) + return _META_SCHEMAS + elif name == "RefResolver": + warnings.warn( + _RefResolver._DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) + return _RefResolver + raise AttributeError(f"module {__name__} has no attribute {name}") + + +def validates(version): + """ + Register the decorated validator for a ``version`` of the specification. + + Registered validators and their meta schemas will be considered when + parsing :kw:`$schema` keywords' URIs. + + Arguments: + + version (str): + + An identifier to use as the version's name + + Returns: + + collections.abc.Callable: + + a class decorator to decorate the validator with the version + + """ + + def _validates(cls): + _VALIDATORS[version] = cls + meta_schema_id = cls.ID_OF(cls.META_SCHEMA) + _META_SCHEMAS[meta_schema_id] = cls + return cls + return _validates + + +def _warn_for_remote_retrieve(uri: str): + from urllib.request import Request, urlopen + headers = {"User-Agent": "python-jsonschema (deprecated $ref resolution)"} + request = Request(uri, headers=headers) # noqa: S310 + with urlopen(request) as response: # noqa: S310 + warnings.warn( + "Automatically retrieving remote references can be a security " + "vulnerability and is discouraged by the JSON Schema " + "specifications. Relying on this behavior is deprecated " + "and will shortly become an error. If you are sure you want to " + "remotely retrieve your reference and that it is safe to do so, " + "you can find instructions for doing so via referencing.Registry " + "in the referencing documentation " + "(https://referencing.readthedocs.org).", + DeprecationWarning, + stacklevel=9, # Ha ha ha ha magic numbers :/ + ) + return referencing.Resource.from_contents( + json.load(response), + default_specification=referencing.jsonschema.DRAFT202012, + ) + + +_REMOTE_WARNING_REGISTRY = SPECIFICATIONS.combine( + referencing.Registry(retrieve=_warn_for_remote_retrieve), # type: ignore[call-arg] +) + + +def create( + meta_schema: referencing.jsonschema.ObjectSchema, + validators: ( + Mapping[str, _typing.SchemaKeywordValidator] + | Iterable[tuple[str, _typing.SchemaKeywordValidator]] + ) = (), + version: str | None = None, + type_checker: _types.TypeChecker = _types.draft202012_type_checker, + format_checker: _format.FormatChecker = _format.draft202012_format_checker, + id_of: _typing.id_of = referencing.jsonschema.DRAFT202012.id_of, + applicable_validators: _typing.ApplicableValidators = methodcaller( + "items", + ), +) -> type[Validator]: + """ + Create a new validator class. + + Arguments: + + meta_schema: + + the meta schema for the new validator class + + validators: + + a mapping from names to callables, where each callable will + validate the schema property with the given name. + + Each callable should take 4 arguments: + + 1. a validator instance, + 2. the value of the property being validated within the + instance + 3. the instance + 4. the schema + + version: + + an identifier for the version that this validator class will + validate. If provided, the returned validator class will + have its ``__name__`` set to include the version, and also + will have `jsonschema.validators.validates` automatically + called for the given version. + + type_checker: + + a type checker, used when applying the :kw:`type` keyword. + + If unprovided, a `jsonschema.TypeChecker` will be created + with a set of default types typical of JSON Schema drafts. + + format_checker: + + a format checker, used when applying the :kw:`format` keyword. + + If unprovided, a `jsonschema.FormatChecker` will be created + with a set of default formats typical of JSON Schema drafts. + + id_of: + + A function that given a schema, returns its ID. + + applicable_validators: + + A function that, given a schema, returns the list of + applicable schema keywords and associated values + which will be used to validate the instance. + This is mostly used to support pre-draft 7 versions of JSON Schema + which specified behavior around ignoring keywords if they were + siblings of a ``$ref`` keyword. If you're not attempting to + implement similar behavior, you can typically ignore this argument + and leave it at its default. + + Returns: + + a new `jsonschema.protocols.Validator` class + + """ + # preemptively don't shadow the `Validator.format_checker` local + format_checker_arg = format_checker + + specification = referencing.jsonschema.specification_with( + dialect_id=id_of(meta_schema) or "urn:unknown-dialect", + default=referencing.Specification.OPAQUE, + ) + + @define + class Validator: + + VALIDATORS = dict(validators) # noqa: RUF012 + META_SCHEMA = dict(meta_schema) # noqa: RUF012 + TYPE_CHECKER = type_checker + FORMAT_CHECKER = format_checker_arg + ID_OF = staticmethod(id_of) + + _APPLICABLE_VALIDATORS = applicable_validators + _validators = field(init=False, repr=False, eq=False) + + schema: referencing.jsonschema.Schema = field(repr=reprlib.repr) + _ref_resolver = field(default=None, repr=False, alias="resolver") + format_checker: _format.FormatChecker | None = field(default=None) + # TODO: include new meta-schemas added at runtime + _registry: referencing.jsonschema.SchemaRegistry = field( + default=_REMOTE_WARNING_REGISTRY, + kw_only=True, + repr=False, + ) + _resolver = field( + alias="_resolver", + default=None, + kw_only=True, + repr=False, + ) + + def __init_subclass__(cls): + warnings.warn( + ( + "Subclassing validator classes is not intended to " + "be part of their public API. A future version " + "will make doing so an error, as the behavior of " + "subclasses isn't guaranteed to stay the same " + "between releases of jsonschema. Instead, prefer " + "composition of validators, wrapping them in an object " + "owned entirely by the downstream library." + ), + DeprecationWarning, + stacklevel=2, + ) + + def evolve(self, **changes): + cls = self.__class__ + schema = changes.setdefault("schema", self.schema) + NewValidator = validator_for(schema, default=cls) + + for field in fields(cls): # noqa: F402 + if not field.init: + continue + attr_name = field.name + init_name = field.alias + if init_name not in changes: + changes[init_name] = getattr(self, attr_name) + + return NewValidator(**changes) + + cls.evolve = evolve + + def __attrs_post_init__(self): + if self._resolver is None: + registry = self._registry + if registry is not _REMOTE_WARNING_REGISTRY: + registry = SPECIFICATIONS.combine(registry) + resource = specification.create_resource(self.schema) + self._resolver = registry.resolver_with_root(resource) + + if self.schema is True or self.schema is False: + self._validators = [] + else: + self._validators = [ + (self.VALIDATORS[k], k, v) + for k, v in applicable_validators(self.schema) + if k in self.VALIDATORS + ] + + # REMOVEME: Legacy ref resolution state management. + push_scope = getattr(self._ref_resolver, "push_scope", None) + if push_scope is not None: + id = id_of(self.schema) + if id is not None: + push_scope(id) + + @classmethod + def check_schema(cls, schema, format_checker=_UNSET): + Validator = validator_for(cls.META_SCHEMA, default=cls) + if format_checker is _UNSET: + format_checker = Validator.FORMAT_CHECKER + validator = Validator( + schema=cls.META_SCHEMA, + format_checker=format_checker, + ) + for error in validator.iter_errors(schema): + raise exceptions.SchemaError.create_from(error) + + @property + def resolver(self): + warnings.warn( + ( + f"Accessing {self.__class__.__name__}.resolver is " + "deprecated as of v4.18.0, in favor of the " + "https://github.com/python-jsonschema/referencing " + "library, which provides more compliant referencing " + "behavior as well as more flexible APIs for " + "customization." + ), + DeprecationWarning, + stacklevel=2, + ) + if self._ref_resolver is None: + self._ref_resolver = _RefResolver.from_schema( + self.schema, + id_of=id_of, + ) + return self._ref_resolver + + def evolve(self, **changes): + schema = changes.setdefault("schema", self.schema) + NewValidator = validator_for(schema, default=self.__class__) + + for (attr_name, init_name) in evolve_fields: + if init_name not in changes: + changes[init_name] = getattr(self, attr_name) + + return NewValidator(**changes) + + def iter_errors(self, instance, _schema=None): + if _schema is not None: + warnings.warn( + ( + "Passing a schema to Validator.iter_errors " + "is deprecated and will be removed in a future " + "release. Call validator.evolve(schema=new_schema)." + "iter_errors(...) instead." + ), + DeprecationWarning, + stacklevel=2, + ) + validators = [ + (self.VALIDATORS[k], k, v) + for k, v in applicable_validators(_schema) + if k in self.VALIDATORS + ] + else: + _schema, validators = self.schema, self._validators + + if _schema is True: + return + elif _schema is False: + yield exceptions.ValidationError( + f"False schema does not allow {instance!r}", + validator=None, + validator_value=None, + instance=instance, + schema=_schema, + ) + return + + for validator, k, v in validators: + errors = validator(self, v, instance, _schema) or () + for error in errors: + # set details if not already set by the called fn + error._set( + validator=k, + validator_value=v, + instance=instance, + schema=_schema, + type_checker=self.TYPE_CHECKER, + ) + if k not in {"if", "$ref"}: + error.schema_path.appendleft(k) + yield error + + def descend( + self, + instance, + schema, + path=None, + schema_path=None, + resolver=None, + ): + if schema is True: + return + elif schema is False: + yield exceptions.ValidationError( + f"False schema does not allow {instance!r}", + validator=None, + validator_value=None, + instance=instance, + schema=schema, + ) + return + + if self._ref_resolver is not None: + evolved = self.evolve(schema=schema) + else: + if resolver is None: + resolver = self._resolver.in_subresource( + specification.create_resource(schema), + ) + evolved = self.evolve(schema=schema, _resolver=resolver) + + for k, v in applicable_validators(schema): + validator = evolved.VALIDATORS.get(k) + if validator is None: + continue + + errors = validator(evolved, v, instance, schema) or () + for error in errors: + # set details if not already set by the called fn + error._set( + validator=k, + validator_value=v, + instance=instance, + schema=schema, + type_checker=evolved.TYPE_CHECKER, + ) + if k not in {"if", "$ref"}: + error.schema_path.appendleft(k) + if path is not None: + error.path.appendleft(path) + if schema_path is not None: + error.schema_path.appendleft(schema_path) + yield error + + def validate(self, *args, **kwargs): + for error in self.iter_errors(*args, **kwargs): + raise error + + def is_type(self, instance, type): + try: + return self.TYPE_CHECKER.is_type(instance, type) + except exceptions.UndefinedTypeCheck: + exc = exceptions.UnknownType(type, instance, self.schema) + raise exc from None + + def _validate_reference(self, ref, instance): + if self._ref_resolver is None: + try: + resolved = self._resolver.lookup(ref) + except referencing.exceptions.Unresolvable as err: + raise exceptions._WrappedReferencingError(err) from err + + return self.descend( + instance, + resolved.contents, + resolver=resolved.resolver, + ) + else: + resolve = getattr(self._ref_resolver, "resolve", None) + if resolve is None: + with self._ref_resolver.resolving(ref) as resolved: + return self.descend(instance, resolved) + else: + scope, resolved = resolve(ref) + self._ref_resolver.push_scope(scope) + + try: + return list(self.descend(instance, resolved)) + finally: + self._ref_resolver.pop_scope() + + def is_valid(self, instance, _schema=None): + if _schema is not None: + warnings.warn( + ( + "Passing a schema to Validator.is_valid is deprecated " + "and will be removed in a future release. Call " + "validator.evolve(schema=new_schema).is_valid(...) " + "instead." + ), + DeprecationWarning, + stacklevel=2, + ) + self = self.evolve(schema=_schema) + + error = next(self.iter_errors(instance), None) + return error is None + + evolve_fields = [ + (field.name, field.alias) + for field in fields(Validator) + if field.init + ] + + if version is not None: + safe = version.title().replace(" ", "").replace("-", "") + Validator.__name__ = Validator.__qualname__ = f"{safe}Validator" + Validator = validates(version)(Validator) # type: ignore[misc] + + return Validator # type: ignore[return-value] + + +def extend( + validator, + validators=(), + version=None, + type_checker=None, + format_checker=None, +): + """ + Create a new validator class by extending an existing one. + + Arguments: + + validator (jsonschema.protocols.Validator): + + an existing validator class + + validators (collections.abc.Mapping): + + a mapping of new validator callables to extend with, whose + structure is as in `create`. + + .. note:: + + Any validator callables with the same name as an + existing one will (silently) replace the old validator + callable entirely, effectively overriding any validation + done in the "parent" validator class. + + If you wish to instead extend the behavior of a parent's + validator callable, delegate and call it directly in + the new validator function by retrieving it using + ``OldValidator.VALIDATORS["validation_keyword_name"]``. + + version (str): + + a version for the new validator class + + type_checker (jsonschema.TypeChecker): + + a type checker, used when applying the :kw:`type` keyword. + + If unprovided, the type checker of the extended + `jsonschema.protocols.Validator` will be carried along. + + format_checker (jsonschema.FormatChecker): + + a format checker, used when applying the :kw:`format` keyword. + + If unprovided, the format checker of the extended + `jsonschema.protocols.Validator` will be carried along. + + Returns: + + a new `jsonschema.protocols.Validator` class extending the one + provided + + .. note:: Meta Schemas + + The new validator class will have its parent's meta schema. + + If you wish to change or extend the meta schema in the new + validator class, modify ``META_SCHEMA`` directly on the returned + class. Note that no implicit copying is done, so a copy should + likely be made before modifying it, in order to not affect the + old validator. + + """ + all_validators = dict(validator.VALIDATORS) + all_validators.update(validators) + + if type_checker is None: + type_checker = validator.TYPE_CHECKER + if format_checker is None: + format_checker = validator.FORMAT_CHECKER + return create( + meta_schema=validator.META_SCHEMA, + validators=all_validators, + version=version, + type_checker=type_checker, + format_checker=format_checker, + id_of=validator.ID_OF, + applicable_validators=validator._APPLICABLE_VALIDATORS, + ) + + +Draft3Validator = create( + meta_schema=SPECIFICATIONS.contents( + "http://json-schema.org/draft-03/schema#", + ), + validators={ + "$ref": _keywords.ref, + "additionalItems": _legacy_keywords.additionalItems, + "additionalProperties": _keywords.additionalProperties, + "dependencies": _legacy_keywords.dependencies_draft3, + "disallow": _legacy_keywords.disallow_draft3, + "divisibleBy": _keywords.multipleOf, + "enum": _keywords.enum, + "extends": _legacy_keywords.extends_draft3, + "format": _keywords.format, + "items": _legacy_keywords.items_draft3_draft4, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maximum": _legacy_keywords.maximum_draft3_draft4, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minimum": _legacy_keywords.minimum_draft3_draft4, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "properties": _legacy_keywords.properties_draft3, + "type": _legacy_keywords.type_draft3, + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft3_type_checker, + format_checker=_format.draft3_format_checker, + version="draft3", + id_of=referencing.jsonschema.DRAFT3.id_of, + applicable_validators=_legacy_keywords.ignore_ref_siblings, +) + +Draft4Validator = create( + meta_schema=SPECIFICATIONS.contents( + "http://json-schema.org/draft-04/schema#", + ), + validators={ + "$ref": _keywords.ref, + "additionalItems": _legacy_keywords.additionalItems, + "additionalProperties": _keywords.additionalProperties, + "allOf": _keywords.allOf, + "anyOf": _keywords.anyOf, + "dependencies": _legacy_keywords.dependencies_draft4_draft6_draft7, + "enum": _keywords.enum, + "format": _keywords.format, + "items": _legacy_keywords.items_draft3_draft4, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maxProperties": _keywords.maxProperties, + "maximum": _legacy_keywords.maximum_draft3_draft4, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minProperties": _keywords.minProperties, + "minimum": _legacy_keywords.minimum_draft3_draft4, + "multipleOf": _keywords.multipleOf, + "not": _keywords.not_, + "oneOf": _keywords.oneOf, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "properties": _keywords.properties, + "required": _keywords.required, + "type": _keywords.type, + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft4_type_checker, + format_checker=_format.draft4_format_checker, + version="draft4", + id_of=referencing.jsonschema.DRAFT4.id_of, + applicable_validators=_legacy_keywords.ignore_ref_siblings, +) + +Draft6Validator = create( + meta_schema=SPECIFICATIONS.contents( + "http://json-schema.org/draft-06/schema#", + ), + validators={ + "$ref": _keywords.ref, + "additionalItems": _legacy_keywords.additionalItems, + "additionalProperties": _keywords.additionalProperties, + "allOf": _keywords.allOf, + "anyOf": _keywords.anyOf, + "const": _keywords.const, + "contains": _legacy_keywords.contains_draft6_draft7, + "dependencies": _legacy_keywords.dependencies_draft4_draft6_draft7, + "enum": _keywords.enum, + "exclusiveMaximum": _keywords.exclusiveMaximum, + "exclusiveMinimum": _keywords.exclusiveMinimum, + "format": _keywords.format, + "items": _legacy_keywords.items_draft6_draft7_draft201909, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maxProperties": _keywords.maxProperties, + "maximum": _keywords.maximum, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minProperties": _keywords.minProperties, + "minimum": _keywords.minimum, + "multipleOf": _keywords.multipleOf, + "not": _keywords.not_, + "oneOf": _keywords.oneOf, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "properties": _keywords.properties, + "propertyNames": _keywords.propertyNames, + "required": _keywords.required, + "type": _keywords.type, + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft6_type_checker, + format_checker=_format.draft6_format_checker, + version="draft6", + id_of=referencing.jsonschema.DRAFT6.id_of, + applicable_validators=_legacy_keywords.ignore_ref_siblings, +) + +Draft7Validator = create( + meta_schema=SPECIFICATIONS.contents( + "http://json-schema.org/draft-07/schema#", + ), + validators={ + "$ref": _keywords.ref, + "additionalItems": _legacy_keywords.additionalItems, + "additionalProperties": _keywords.additionalProperties, + "allOf": _keywords.allOf, + "anyOf": _keywords.anyOf, + "const": _keywords.const, + "contains": _legacy_keywords.contains_draft6_draft7, + "dependencies": _legacy_keywords.dependencies_draft4_draft6_draft7, + "enum": _keywords.enum, + "exclusiveMaximum": _keywords.exclusiveMaximum, + "exclusiveMinimum": _keywords.exclusiveMinimum, + "format": _keywords.format, + "if": _keywords.if_, + "items": _legacy_keywords.items_draft6_draft7_draft201909, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maxProperties": _keywords.maxProperties, + "maximum": _keywords.maximum, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minProperties": _keywords.minProperties, + "minimum": _keywords.minimum, + "multipleOf": _keywords.multipleOf, + "not": _keywords.not_, + "oneOf": _keywords.oneOf, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "properties": _keywords.properties, + "propertyNames": _keywords.propertyNames, + "required": _keywords.required, + "type": _keywords.type, + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft7_type_checker, + format_checker=_format.draft7_format_checker, + version="draft7", + id_of=referencing.jsonschema.DRAFT7.id_of, + applicable_validators=_legacy_keywords.ignore_ref_siblings, +) + +Draft201909Validator = create( + meta_schema=SPECIFICATIONS.contents( + "https://json-schema.org/draft/2019-09/schema", + ), + validators={ + "$recursiveRef": _legacy_keywords.recursiveRef, + "$ref": _keywords.ref, + "additionalItems": _legacy_keywords.additionalItems, + "additionalProperties": _keywords.additionalProperties, + "allOf": _keywords.allOf, + "anyOf": _keywords.anyOf, + "const": _keywords.const, + "contains": _keywords.contains, + "dependentRequired": _keywords.dependentRequired, + "dependentSchemas": _keywords.dependentSchemas, + "enum": _keywords.enum, + "exclusiveMaximum": _keywords.exclusiveMaximum, + "exclusiveMinimum": _keywords.exclusiveMinimum, + "format": _keywords.format, + "if": _keywords.if_, + "items": _legacy_keywords.items_draft6_draft7_draft201909, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maxProperties": _keywords.maxProperties, + "maximum": _keywords.maximum, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minProperties": _keywords.minProperties, + "minimum": _keywords.minimum, + "multipleOf": _keywords.multipleOf, + "not": _keywords.not_, + "oneOf": _keywords.oneOf, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "properties": _keywords.properties, + "propertyNames": _keywords.propertyNames, + "required": _keywords.required, + "type": _keywords.type, + "unevaluatedItems": _legacy_keywords.unevaluatedItems_draft2019, + "unevaluatedProperties": ( + _legacy_keywords.unevaluatedProperties_draft2019 + ), + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft201909_type_checker, + format_checker=_format.draft201909_format_checker, + version="draft2019-09", +) + +Draft202012Validator = create( + meta_schema=SPECIFICATIONS.contents( + "https://json-schema.org/draft/2020-12/schema", + ), + validators={ + "$dynamicRef": _keywords.dynamicRef, + "$ref": _keywords.ref, + "additionalProperties": _keywords.additionalProperties, + "allOf": _keywords.allOf, + "anyOf": _keywords.anyOf, + "const": _keywords.const, + "contains": _keywords.contains, + "dependentRequired": _keywords.dependentRequired, + "dependentSchemas": _keywords.dependentSchemas, + "enum": _keywords.enum, + "exclusiveMaximum": _keywords.exclusiveMaximum, + "exclusiveMinimum": _keywords.exclusiveMinimum, + "format": _keywords.format, + "if": _keywords.if_, + "items": _keywords.items, + "maxItems": _keywords.maxItems, + "maxLength": _keywords.maxLength, + "maxProperties": _keywords.maxProperties, + "maximum": _keywords.maximum, + "minItems": _keywords.minItems, + "minLength": _keywords.minLength, + "minProperties": _keywords.minProperties, + "minimum": _keywords.minimum, + "multipleOf": _keywords.multipleOf, + "not": _keywords.not_, + "oneOf": _keywords.oneOf, + "pattern": _keywords.pattern, + "patternProperties": _keywords.patternProperties, + "prefixItems": _keywords.prefixItems, + "properties": _keywords.properties, + "propertyNames": _keywords.propertyNames, + "required": _keywords.required, + "type": _keywords.type, + "unevaluatedItems": _keywords.unevaluatedItems, + "unevaluatedProperties": _keywords.unevaluatedProperties, + "uniqueItems": _keywords.uniqueItems, + }, + type_checker=_types.draft202012_type_checker, + format_checker=_format.draft202012_format_checker, + version="draft2020-12", +) + +_LATEST_VERSION: type[Validator] = Draft202012Validator + + +class _RefResolver: + """ + Resolve JSON References. + + Arguments: + + base_uri (str): + + The URI of the referring document + + referrer: + + The actual referring document + + store (dict): + + A mapping from URIs to documents to cache + + cache_remote (bool): + + Whether remote refs should be cached after first resolution + + handlers (dict): + + A mapping from URI schemes to functions that should be used + to retrieve them + + urljoin_cache (:func:`functools.lru_cache`): + + A cache that will be used for caching the results of joining + the resolution scope to subscopes. + + remote_cache (:func:`functools.lru_cache`): + + A cache that will be used for caching the results of + resolved remote URLs. + + Attributes: + + cache_remote (bool): + + Whether remote refs should be cached after first resolution + + .. deprecated:: v4.18.0 + + ``RefResolver`` has been deprecated in favor of `referencing`. + + """ + + _DEPRECATION_MESSAGE = ( + "jsonschema.RefResolver is deprecated as of v4.18.0, in favor of the " + "https://github.com/python-jsonschema/referencing library, which " + "provides more compliant referencing behavior as well as more " + "flexible APIs for customization. A future release will remove " + "RefResolver. Please file a feature request (on referencing) if you " + "are missing an API for the kind of customization you need." + ) + + def __init__( + self, + base_uri, + referrer, + store=HashTrieMap(), + cache_remote=True, + handlers=(), + urljoin_cache=None, + remote_cache=None, + ): + if urljoin_cache is None: + urljoin_cache = lru_cache(1024)(urljoin) + if remote_cache is None: + remote_cache = lru_cache(1024)(self.resolve_from_url) + + self.referrer = referrer + self.cache_remote = cache_remote + self.handlers = dict(handlers) + + self._scopes_stack = [base_uri] + + self.store = _utils.URIDict( + (uri, each.contents) for uri, each in SPECIFICATIONS.items() + ) + self.store.update( + (id, each.META_SCHEMA) for id, each in _META_SCHEMAS.items() + ) + self.store.update(store) + self.store.update( + (schema["$id"], schema) + for schema in store.values() + if isinstance(schema, Mapping) and "$id" in schema + ) + self.store[base_uri] = referrer + + self._urljoin_cache = urljoin_cache + self._remote_cache = remote_cache + + @classmethod + def from_schema( # noqa: D417 + cls, + schema, + id_of=referencing.jsonschema.DRAFT202012.id_of, + *args, + **kwargs, + ): + """ + Construct a resolver from a JSON schema object. + + Arguments: + + schema: + + the referring schema + + Returns: + + `_RefResolver` + + """ + return cls(base_uri=id_of(schema) or "", referrer=schema, *args, **kwargs) # noqa: B026, E501 + + def push_scope(self, scope): + """ + Enter a given sub-scope. + + Treats further dereferences as being performed underneath the + given scope. + """ + self._scopes_stack.append( + self._urljoin_cache(self.resolution_scope, scope), + ) + + def pop_scope(self): + """ + Exit the most recent entered scope. + + Treats further dereferences as being performed underneath the + original scope. + + Don't call this method more times than `push_scope` has been + called. + """ + try: + self._scopes_stack.pop() + except IndexError: + raise exceptions._RefResolutionError( + "Failed to pop the scope from an empty stack. " + "`pop_scope()` should only be called once for every " + "`push_scope()`", + ) from None + + @property + def resolution_scope(self): + """ + Retrieve the current resolution scope. + """ + return self._scopes_stack[-1] + + @property + def base_uri(self): + """ + Retrieve the current base URI, not including any fragment. + """ + uri, _ = urldefrag(self.resolution_scope) + return uri + + @contextlib.contextmanager + def in_scope(self, scope): + """ + Temporarily enter the given scope for the duration of the context. + + .. deprecated:: v4.0.0 + """ + warnings.warn( + "jsonschema.RefResolver.in_scope is deprecated and will be " + "removed in a future release.", + DeprecationWarning, + stacklevel=3, + ) + self.push_scope(scope) + try: + yield + finally: + self.pop_scope() + + @contextlib.contextmanager + def resolving(self, ref): + """ + Resolve the given ``ref`` and enter its resolution scope. + + Exits the scope on exit of this context manager. + + Arguments: + + ref (str): + + The reference to resolve + + """ + url, resolved = self.resolve(ref) + self.push_scope(url) + try: + yield resolved + finally: + self.pop_scope() + + def _find_in_referrer(self, key): + return self._get_subschemas_cache()[key] + + @lru_cache # noqa: B019 + def _get_subschemas_cache(self): + cache = {key: [] for key in _SUBSCHEMAS_KEYWORDS} + for keyword, subschema in _search_schema( + self.referrer, _match_subschema_keywords, + ): + cache[keyword].append(subschema) + return cache + + @lru_cache # noqa: B019 + def _find_in_subschemas(self, url): + subschemas = self._get_subschemas_cache()["$id"] + if not subschemas: + return None + uri, fragment = urldefrag(url) + for subschema in subschemas: + id = subschema["$id"] + if not isinstance(id, str): + continue + target_uri = self._urljoin_cache(self.resolution_scope, id) + if target_uri.rstrip("/") == uri.rstrip("/"): + if fragment: + subschema = self.resolve_fragment(subschema, fragment) + self.store[url] = subschema + return url, subschema + return None + + def resolve(self, ref): + """ + Resolve the given reference. + """ + url = self._urljoin_cache(self.resolution_scope, ref).rstrip("/") + + match = self._find_in_subschemas(url) + if match is not None: + return match + + return url, self._remote_cache(url) + + def resolve_from_url(self, url): + """ + Resolve the given URL. + """ + url, fragment = urldefrag(url) + if not url: + url = self.base_uri + + try: + document = self.store[url] + except KeyError: + try: + document = self.resolve_remote(url) + except Exception as exc: + raise exceptions._RefResolutionError(exc) from exc + + return self.resolve_fragment(document, fragment) + + def resolve_fragment(self, document, fragment): + """ + Resolve a ``fragment`` within the referenced ``document``. + + Arguments: + + document: + + The referent document + + fragment (str): + + a URI fragment to resolve within it + + """ + fragment = fragment.lstrip("/") + + if not fragment: + return document + + if document is self.referrer: + find = self._find_in_referrer + else: + + def find(key): + yield from _search_schema(document, _match_keyword(key)) + + for keyword in ["$anchor", "$dynamicAnchor"]: + for subschema in find(keyword): + if fragment == subschema[keyword]: + return subschema + for keyword in ["id", "$id"]: + for subschema in find(keyword): + if "#" + fragment == subschema[keyword]: + return subschema + + # Resolve via path + parts = unquote(fragment).split("/") if fragment else [] + for part in parts: + part = part.replace("~1", "/").replace("~0", "~") + + if isinstance(document, Sequence): + try: # noqa: SIM105 + part = int(part) + except ValueError: + pass + try: + document = document[part] + except (TypeError, LookupError) as err: + raise exceptions._RefResolutionError( + f"Unresolvable JSON pointer: {fragment!r}", + ) from err + + return document + + def resolve_remote(self, uri): + """ + Resolve a remote ``uri``. + + If called directly, does not check the store first, but after + retrieving the document at the specified URI it will be saved in + the store if :attr:`cache_remote` is True. + + .. note:: + + If the requests_ library is present, ``jsonschema`` will use it to + request the remote ``uri``, so that the correct encoding is + detected and used. + + If it isn't, or if the scheme of the ``uri`` is not ``http`` or + ``https``, UTF-8 is assumed. + + Arguments: + + uri (str): + + The URI to resolve + + Returns: + + The retrieved document + + .. _requests: https://pypi.org/project/requests/ + + """ + try: + import requests + except ImportError: + requests = None + + scheme = urlsplit(uri).scheme + + if scheme in self.handlers: + result = self.handlers[scheme](uri) + elif scheme in ["http", "https"] and requests: + # Requests has support for detecting the correct encoding of + # json over http + result = requests.get(uri).json() + else: + # Otherwise, pass off to urllib and assume utf-8 + from urllib.request import urlopen + with urlopen(uri) as url: # noqa: S310 + result = json.loads(url.read().decode("utf-8")) + + if self.cache_remote: + self.store[uri] = result + return result + + +_SUBSCHEMAS_KEYWORDS = ("$id", "id", "$anchor", "$dynamicAnchor") + + +def _match_keyword(keyword): + + def matcher(value): + if keyword in value: + yield value + + return matcher + + +def _match_subschema_keywords(value): + for keyword in _SUBSCHEMAS_KEYWORDS: + if keyword in value: + yield keyword, value + + +def _search_schema(schema, matcher): + """Breadth-first search routine.""" + values = deque([schema]) + while values: + value = values.pop() + if not isinstance(value, dict): + continue + yield from matcher(value) + values.extendleft(value.values()) + + +def validate(instance, schema, cls=None, *args, **kwargs): # noqa: D417 + """ + Validate an instance under the given schema. + + >>> validate([2, 3, 4], {"maxItems": 2}) + Traceback (most recent call last): + ... + ValidationError: [2, 3, 4] is too long + + :func:`~jsonschema.validators.validate` will first verify that the + provided schema is itself valid, since not doing so can lead to less + obvious error messages and fail in less obvious or consistent ways. + + If you know you have a valid schema already, especially + if you intend to validate multiple instances with + the same schema, you likely would prefer using the + `jsonschema.protocols.Validator.validate` method directly on a + specific validator (e.g. ``Draft202012Validator.validate``). + + + Arguments: + + instance: + + The instance to validate + + schema: + + The schema to validate with + + cls (jsonschema.protocols.Validator): + + The class that will be used to validate the instance. + + If the ``cls`` argument is not provided, two things will happen + in accordance with the specification. First, if the schema has a + :kw:`$schema` keyword containing a known meta-schema [#]_ then the + proper validator will be used. The specification recommends that + all schemas contain :kw:`$schema` properties for this reason. If no + :kw:`$schema` property is found, the default validator class is the + latest released draft. + + Any other provided positional and keyword arguments will be passed + on when instantiating the ``cls``. + + Raises: + + `jsonschema.exceptions.ValidationError`: + + if the instance is invalid + + `jsonschema.exceptions.SchemaError`: + + if the schema itself is invalid + + .. rubric:: Footnotes + .. [#] known by a validator registered with + `jsonschema.validators.validates` + + """ + if cls is None: + cls = validator_for(schema) + + cls.check_schema(schema) + validator = cls(schema, *args, **kwargs) + error = exceptions.best_match(validator.iter_errors(instance)) + if error is not None: + raise error + + +def validator_for( + schema, + default: type[Validator] | _utils.Unset = _UNSET, +) -> type[Validator]: + """ + Retrieve the validator class appropriate for validating the given schema. + + Uses the :kw:`$schema` keyword that should be present in the given + schema to look up the appropriate validator class. + + Arguments: + + schema (collections.abc.Mapping or bool): + + the schema to look at + + default: + + the default to return if the appropriate validator class + cannot be determined. + + If unprovided, the default is to return the latest supported + draft. + + Examples: + + The :kw:`$schema` JSON Schema keyword will control which validator + class is returned: + + >>> schema = { + ... "$schema": "https://json-schema.org/draft/2020-12/schema", + ... "type": "integer", + ... } + >>> jsonschema.validators.validator_for(schema) + + + + Here, a draft 7 schema instead will return the draft 7 validator: + + >>> schema = { + ... "$schema": "http://json-schema.org/draft-07/schema#", + ... "type": "integer", + ... } + >>> jsonschema.validators.validator_for(schema) + + + + Schemas with no ``$schema`` keyword will fallback to the default + argument: + + >>> schema = {"type": "integer"} + >>> jsonschema.validators.validator_for( + ... schema, default=Draft7Validator, + ... ) + + + or if none is provided, to the latest version supported. + Always including the keyword when authoring schemas is highly + recommended. + + """ + DefaultValidator = _LATEST_VERSION if default is _UNSET else default + + if schema is True or schema is False or "$schema" not in schema: + return DefaultValidator # type: ignore[return-value] + if schema["$schema"] not in _META_SCHEMAS and default is _UNSET: + warn( + ( + "The metaschema specified by $schema was not found. " + "Using the latest draft to validate, but this will raise " + "an error in the future." + ), + DeprecationWarning, + stacklevel=2, + ) + return _META_SCHEMAS.get(schema["$schema"], DefaultValidator) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/METADATA new file mode 100644 index 00000000..ff5daefd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/METADATA @@ -0,0 +1,54 @@ +Metadata-Version: 2.4 +Name: jsonschema-specifications +Version: 2025.9.1 +Summary: The JSON Schema meta-schemas and vocabularies, exposed as a Registry +Project-URL: Documentation, https://jsonschema-specifications.readthedocs.io/ +Project-URL: Homepage, https://github.com/python-jsonschema/jsonschema-specifications +Project-URL: Issues, https://github.com/python-jsonschema/jsonschema-specifications/issues/ +Project-URL: Funding, https://github.com/sponsors/Julian +Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-jsonschema-specifications?utm_source=pypi-jsonschema-specifications&utm_medium=referral&utm_campaign=pypi-link +Project-URL: Source, https://github.com/python-jsonschema/jsonschema-specifications +Author-email: Julian Berman +License-Expression: MIT +License-File: COPYING +Keywords: data validation,json,json schema,jsonschema,validation +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: File Formats :: JSON +Classifier: Topic :: File Formats :: JSON :: JSON Schema +Requires-Python: >=3.9 +Requires-Dist: referencing>=0.31.0 +Description-Content-Type: text/x-rst + +============================= +``jsonschema-specifications`` +============================= + +|PyPI| |Pythons| |CI| |ReadTheDocs| + +JSON support files from the `JSON Schema Specifications `_ (metaschemas, vocabularies, etc.), packaged for runtime access from Python as a `referencing-based Schema Registry `_. + +.. |PyPI| image:: https://img.shields.io/pypi/v/jsonschema-specifications.svg + :alt: PyPI version + :target: https://pypi.org/project/jsonschema-specifications/ + +.. |Pythons| image:: https://img.shields.io/pypi/pyversions/jsonschema-specifications.svg + :alt: Supported Python versions + :target: https://pypi.org/project/jsonschema-specifications/ + +.. |CI| image:: https://github.com/python-jsonschema/jsonschema-specifications/workflows/CI/badge.svg + :alt: Build status + :target: https://github.com/python-jsonschema/jsonschema-specifications/actions?query=workflow%3ACI + +.. |ReadTheDocs| image:: https://readthedocs.org/projects/jsonschema-specifications/badge/?version=stable&style=flat + :alt: ReadTheDocs status + :target: https://jsonschema-specifications.readthedocs.io/en/stable/ diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/RECORD new file mode 100644 index 00000000..50fec4b1 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/RECORD @@ -0,0 +1,33 @@ +jsonschema_specifications-2025.9.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +jsonschema_specifications-2025.9.1.dist-info/METADATA,sha256=NavUF1fzK06iR1aSDe1HtwFz13y8BSpabTq1g7Lo2J0,2907 +jsonschema_specifications-2025.9.1.dist-info/RECORD,, +jsonschema_specifications-2025.9.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87 +jsonschema_specifications-2025.9.1.dist-info/licenses/COPYING,sha256=QtzWNJX4e063x3V6-jebtVpT-Ur9el9lfZrfVyNuUVw,1057 +jsonschema_specifications/__init__.py,sha256=qoTB2DKY7qvNrGhMPH6gtmAJRLilmVQ-fFZwT6ryqw0,386 +jsonschema_specifications/__pycache__/__init__.cpython-39.pyc,, +jsonschema_specifications/__pycache__/_core.cpython-39.pyc,, +jsonschema_specifications/_core.py,sha256=tFhc1CMleJ3AJOK_bjxOpFQTdrsUClFGfFxPBU_CebM,1140 +jsonschema_specifications/schemas/draft201909/metaschema.json,sha256=e3YbPhIfCgyh6ioLjizIVrz4AWBLgmjXG6yqICvAwTs,1785 +jsonschema_specifications/schemas/draft201909/vocabularies/applicator,sha256=aJUQDplyb7sQcFhRK77D7P1LJOj9L6zuPlBe5ysNTDE,1860 +jsonschema_specifications/schemas/draft201909/vocabularies/content,sha256=m31PVaTi_bAsQwBo_f-rxzKt3OI42j8d8mkCScM1MnQ,517 +jsonschema_specifications/schemas/draft201909/vocabularies/core,sha256=taLElX9kldClCB8ECevooU5BOayyA_x0hHH47eKvWyw,1531 +jsonschema_specifications/schemas/draft201909/vocabularies/format,sha256=UOu_55BhGoSbjMQAoJwdDg-2q1wNQ6DyIgH9NiUFa_Q,403 +jsonschema_specifications/schemas/draft201909/vocabularies/meta-data,sha256=1H4kRd1qgicaKY2DzGxsuNSuHhXg3Fa-zTehY-zwEoY,892 +jsonschema_specifications/schemas/draft201909/vocabularies/validation,sha256=HlJsHTNac0gF_ILPV5jBK5YK19olF8Zs2lobCTWcPBw,2834 +jsonschema_specifications/schemas/draft202012/metaschema.json,sha256=Qdp29a-3zgYtJI92JGOpL3ykfk4PkFsiS6av7vkd7Q8,2452 +jsonschema_specifications/schemas/draft202012/vocabularies/applicator,sha256=xKbkFHuR_vf-ptwFjLG_k0AvdBS3ZXiosWqvHa1qrO8,1659 +jsonschema_specifications/schemas/draft202012/vocabularies/content,sha256=CDQ3R3ZOSlgUJieTz01lIFenkThjxZUNQyl-jh_axbY,519 +jsonschema_specifications/schemas/draft202012/vocabularies/core,sha256=wtEqjk3RHTNt_IOj9mOqTGnwtJs76wlP_rJbUxb0gD0,1564 +jsonschema_specifications/schemas/draft202012/vocabularies/format-annotation,sha256=q8d1rf79idIjWBcNm_k_Tr0jSVY7u-3WDwK-98gSvMA,448 +jsonschema_specifications/schemas/draft202012/vocabularies/format-assertion,sha256=xSJCuaG7eGsmw-gset1CjDH5yW5XXc6Z5W6l_qptogw,445 +jsonschema_specifications/schemas/draft202012/vocabularies/meta-data,sha256=j3bW4U9Bubku-TO3CM3FFEyLUmhlGtEZGEhfsXVPHHY,892 +jsonschema_specifications/schemas/draft202012/vocabularies/unevaluated,sha256=Lb-8tzmUtnCwl2SSre4f_7RsIWgnhNL1pMpWH54tDLQ,506 +jsonschema_specifications/schemas/draft202012/vocabularies/validation,sha256=cBCjHlQfMtK-ch4t40jfdcmzaHaj7TBId_wKvaHTelg,2834 +jsonschema_specifications/schemas/draft3/metaschema.json,sha256=LPdfZENvtb43Si6qJ6uLfh_WUcm0ba6mxnsC_WTiRYs,2600 +jsonschema_specifications/schemas/draft4/metaschema.json,sha256=4UidC0dV8CeTMCWR0_y48Htok6gqlPJIlfjk7fEbguI,4357 +jsonschema_specifications/schemas/draft6/metaschema.json,sha256=wp386fVINcOgbAOzxdXsDtp3cGVo-cTffPvHVmpRAG0,4437 +jsonschema_specifications/schemas/draft7/metaschema.json,sha256=PVOSCIJhYGxVm2A_OFMpyfGrRbXWZ-uZBodFOwVdQF4,4819 +jsonschema_specifications/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +jsonschema_specifications/tests/__pycache__/__init__.cpython-39.pyc,, +jsonschema_specifications/tests/__pycache__/test_jsonschema_specifications.cpython-39.pyc,, +jsonschema_specifications/tests/test_jsonschema_specifications.py,sha256=WkbYRW6A6FoZ0rivShfqVLSCsAiHJ2x8TxqECJTXPTY,1106 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/WHEEL new file mode 100644 index 00000000..12228d41 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.27.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/licenses/COPYING b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/licenses/COPYING new file mode 100644 index 00000000..a9f853e4 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications-2025.9.1.dist-info/licenses/COPYING @@ -0,0 +1,19 @@ +Copyright (c) 2022 Julian Berman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/__init__.py new file mode 100644 index 00000000..a4235741 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/__init__.py @@ -0,0 +1,12 @@ +""" +The JSON Schema meta-schemas and vocabularies, exposed as a Registry. +""" + +from referencing.jsonschema import EMPTY_REGISTRY as _EMPTY_REGISTRY + +from jsonschema_specifications._core import _schemas + +#: A `referencing.jsonschema.SchemaRegistry` containing all of the official +#: meta-schemas and vocabularies. +REGISTRY = (_schemas() @ _EMPTY_REGISTRY).crawl() +__all__ = ["REGISTRY"] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/_core.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/_core.py new file mode 100644 index 00000000..e67bd712 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/_core.py @@ -0,0 +1,38 @@ +""" +Load all the JSON Schema specification's official schemas. +""" + +import json + +try: + from importlib.resources import files +except ImportError: + from importlib_resources import ( # type: ignore[import-not-found, no-redef] + files, + ) + +from referencing import Resource + + +def _schemas(): + """ + All schemas we ship. + """ + # importlib.resources.abc.Traversal doesn't have nice ways to do this that + # I'm aware of... + # + # It can't recurse arbitrarily, e.g. no ``.glob()``. + # + # So this takes some liberties given the real layout of what we ship + # (only 2 levels of nesting, no directories within the second level). + + for version in files(__package__).joinpath("schemas").iterdir(): + if version.name.startswith("."): + continue + for child in version.iterdir(): + children = [child] if child.is_file() else child.iterdir() + for path in children: + if path.name.startswith("."): + continue + contents = json.loads(path.read_text(encoding="utf-8")) + yield Resource.from_contents(contents) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/metaschema.json new file mode 100644 index 00000000..2248a0c8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/metaschema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/schema", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true, + "https://json-schema.org/draft/2019-09/vocab/applicator": true, + "https://json-schema.org/draft/2019-09/vocab/validation": true, + "https://json-schema.org/draft/2019-09/vocab/meta-data": true, + "https://json-schema.org/draft/2019-09/vocab/format": false, + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "properties": { + "definitions": { + "$comment": "While no longer an official keyword as it is replaced by $defs, this keyword is retained in the meta-schema to prevent incompatible extensions as it remains in common use.", + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" is no longer a keyword, but schema authors should avoid redefining it to facilitate a smooth transition to \"dependentSchemas\" and \"dependentRequired\"", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + } + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/applicator b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/applicator new file mode 100644 index 00000000..24a1cc4f --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/applicator @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/applicator": true + }, + "$recursiveAnchor": true, + + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "additionalItems": { "$recursiveRef": "#" }, + "unevaluatedItems": { "$recursiveRef": "#" }, + "items": { + "anyOf": [ + { "$recursiveRef": "#" }, + { "$ref": "#/$defs/schemaArray" } + ] + }, + "contains": { "$recursiveRef": "#" }, + "additionalProperties": { "$recursiveRef": "#" }, + "unevaluatedProperties": { "$recursiveRef": "#" }, + "properties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { + "$recursiveRef": "#" + } + }, + "propertyNames": { "$recursiveRef": "#" }, + "if": { "$recursiveRef": "#" }, + "then": { "$recursiveRef": "#" }, + "else": { "$recursiveRef": "#" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$recursiveRef": "#" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$recursiveRef": "#" } + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/content b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/content new file mode 100644 index 00000000..f6752a8e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/content @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/content": true + }, + "$recursiveAnchor": true, + + "title": "Content vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "contentSchema": { "$recursiveRef": "#" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/core b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/core new file mode 100644 index 00000000..eb708a56 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/core @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/core": true + }, + "$recursiveAnchor": true, + + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$anchor": { + "type": "string", + "pattern": "^[A-Za-z][-A-Za-z0-9.:_]*$" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveRef": { + "type": "string", + "format": "uri-reference" + }, + "$recursiveAnchor": { + "type": "boolean", + "default": false + }, + "$vocabulary": { + "type": "object", + "propertyNames": { + "type": "string", + "format": "uri" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$recursiveRef": "#" }, + "default": {} + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/format b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/format new file mode 100644 index 00000000..09bbfdda --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/format @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/format", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/format": true + }, + "$recursiveAnchor": true, + + "title": "Format vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/meta-data b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/meta-data new file mode 100644 index 00000000..da04cff6 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/meta-data @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/meta-data": true + }, + "$recursiveAnchor": true, + + "title": "Meta-data vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/validation b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/validation new file mode 100644 index 00000000..9f59677b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft201909/vocabularies/validation @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "https://json-schema.org/draft/2019-09/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2019-09/vocab/validation": true + }, + "$recursiveAnchor": true, + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/metaschema.json new file mode 100644 index 00000000..d5e2d31c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/metaschema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/schema", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true, + "https://json-schema.org/draft/2020-12/vocab/applicator": true, + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true, + "https://json-schema.org/draft/2020-12/vocab/validation": true, + "https://json-schema.org/draft/2020-12/vocab/meta-data": true, + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true, + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Core and Validation specifications meta-schema", + "allOf": [ + {"$ref": "meta/core"}, + {"$ref": "meta/applicator"}, + {"$ref": "meta/unevaluated"}, + {"$ref": "meta/validation"}, + {"$ref": "meta/meta-data"}, + {"$ref": "meta/format-annotation"}, + {"$ref": "meta/content"} + ], + "type": ["object", "boolean"], + "$comment": "This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.", + "properties": { + "definitions": { + "$comment": "\"definitions\" has been replaced by \"$defs\".", + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "deprecated": true, + "default": {} + }, + "dependencies": { + "$comment": "\"dependencies\" has been split and replaced by \"dependentSchemas\" and \"dependentRequired\" in order to serve their differing semantics.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$dynamicRef": "#meta" }, + { "$ref": "meta/validation#/$defs/stringArray" } + ] + }, + "deprecated": true, + "default": {} + }, + "$recursiveAnchor": { + "$comment": "\"$recursiveAnchor\" has been replaced by \"$dynamicAnchor\".", + "$ref": "meta/core#/$defs/anchorString", + "deprecated": true + }, + "$recursiveRef": { + "$comment": "\"$recursiveRef\" has been replaced by \"$dynamicRef\".", + "$ref": "meta/core#/$defs/uriReferenceString", + "deprecated": true + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/applicator b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/applicator new file mode 100644 index 00000000..ca699230 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/applicator @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/applicator", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/applicator": true + }, + "$dynamicAnchor": "meta", + + "title": "Applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "prefixItems": { "$ref": "#/$defs/schemaArray" }, + "items": { "$dynamicRef": "#meta" }, + "contains": { "$dynamicRef": "#meta" }, + "additionalProperties": { "$dynamicRef": "#meta" }, + "properties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependentSchemas": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" }, + "default": {} + }, + "propertyNames": { "$dynamicRef": "#meta" }, + "if": { "$dynamicRef": "#meta" }, + "then": { "$dynamicRef": "#meta" }, + "else": { "$dynamicRef": "#meta" }, + "allOf": { "$ref": "#/$defs/schemaArray" }, + "anyOf": { "$ref": "#/$defs/schemaArray" }, + "oneOf": { "$ref": "#/$defs/schemaArray" }, + "not": { "$dynamicRef": "#meta" } + }, + "$defs": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$dynamicRef": "#meta" } + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/content b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/content new file mode 100644 index 00000000..2f6e056a --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/content @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/content", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/content": true + }, + "$dynamicAnchor": "meta", + + "title": "Content vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "contentEncoding": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentSchema": { "$dynamicRef": "#meta" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/core b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/core new file mode 100644 index 00000000..dfc092d9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/core @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/core", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/core": true + }, + "$dynamicAnchor": "meta", + + "title": "Core vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "$id": { + "$ref": "#/$defs/uriReferenceString", + "$comment": "Non-empty fragments not allowed.", + "pattern": "^[^#]*#?$" + }, + "$schema": { "$ref": "#/$defs/uriString" }, + "$ref": { "$ref": "#/$defs/uriReferenceString" }, + "$anchor": { "$ref": "#/$defs/anchorString" }, + "$dynamicRef": { "$ref": "#/$defs/uriReferenceString" }, + "$dynamicAnchor": { "$ref": "#/$defs/anchorString" }, + "$vocabulary": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/uriString" }, + "additionalProperties": { + "type": "boolean" + } + }, + "$comment": { + "type": "string" + }, + "$defs": { + "type": "object", + "additionalProperties": { "$dynamicRef": "#meta" } + } + }, + "$defs": { + "anchorString": { + "type": "string", + "pattern": "^[A-Za-z_][-A-Za-z0-9._]*$" + }, + "uriString": { + "type": "string", + "format": "uri" + }, + "uriReferenceString": { + "type": "string", + "format": "uri-reference" + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-annotation b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-annotation new file mode 100644 index 00000000..51ef7ea1 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-annotation @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-annotation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-annotation": true + }, + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for annotation results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-assertion b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-assertion new file mode 100644 index 00000000..5e73fd75 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/format-assertion @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/format-assertion", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/format-assertion": true + }, + "$dynamicAnchor": "meta", + + "title": "Format vocabulary meta-schema for assertion results", + "type": ["object", "boolean"], + "properties": { + "format": { "type": "string" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/meta-data b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/meta-data new file mode 100644 index 00000000..05cbc22a --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/meta-data @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/meta-data", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/meta-data": true + }, + "$dynamicAnchor": "meta", + + "title": "Meta-data vocabulary meta-schema", + + "type": ["object", "boolean"], + "properties": { + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "deprecated": { + "type": "boolean", + "default": false + }, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/unevaluated b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/unevaluated new file mode 100644 index 00000000..5f62a3ff --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/unevaluated @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/unevaluated", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/unevaluated": true + }, + "$dynamicAnchor": "meta", + + "title": "Unevaluated applicator vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "unevaluatedItems": { "$dynamicRef": "#meta" }, + "unevaluatedProperties": { "$dynamicRef": "#meta" } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/validation b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/validation new file mode 100644 index 00000000..606b87ba --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft202012/vocabularies/validation @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://json-schema.org/draft/2020-12/meta/validation", + "$vocabulary": { + "https://json-schema.org/draft/2020-12/vocab/validation": true + }, + "$dynamicAnchor": "meta", + + "title": "Validation vocabulary meta-schema", + "type": ["object", "boolean"], + "properties": { + "type": { + "anyOf": [ + { "$ref": "#/$defs/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/$defs/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/$defs/nonNegativeInteger" }, + "minLength": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "maxItems": { "$ref": "#/$defs/nonNegativeInteger" }, + "minItems": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxContains": { "$ref": "#/$defs/nonNegativeInteger" }, + "minContains": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 1 + }, + "maxProperties": { "$ref": "#/$defs/nonNegativeInteger" }, + "minProperties": { "$ref": "#/$defs/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/$defs/stringArray" }, + "dependentRequired": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringArray" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "$ref": "#/$defs/nonNegativeInteger", + "default": 0 + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + } +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft3/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft3/metaschema.json new file mode 100644 index 00000000..8b26b1f8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft3/metaschema.json @@ -0,0 +1,172 @@ +{ + "$schema" : "http://json-schema.org/draft-03/schema#", + "id" : "http://json-schema.org/draft-03/schema#", + "type" : "object", + + "properties" : { + "type" : { + "type" : ["string", "array"], + "items" : { + "type" : ["string", {"$ref" : "#"}] + }, + "uniqueItems" : true, + "default" : "any" + }, + + "properties" : { + "type" : "object", + "additionalProperties" : {"$ref" : "#"}, + "default" : {} + }, + + "patternProperties" : { + "type" : "object", + "additionalProperties" : {"$ref" : "#"}, + "default" : {} + }, + + "additionalProperties" : { + "type" : [{"$ref" : "#"}, "boolean"], + "default" : {} + }, + + "items" : { + "type" : [{"$ref" : "#"}, "array"], + "items" : {"$ref" : "#"}, + "default" : {} + }, + + "additionalItems" : { + "type" : [{"$ref" : "#"}, "boolean"], + "default" : {} + }, + + "required" : { + "type" : "boolean", + "default" : false + }, + + "dependencies" : { + "type" : "object", + "additionalProperties" : { + "type" : ["string", "array", {"$ref" : "#"}], + "items" : { + "type" : "string" + } + }, + "default" : {} + }, + + "minimum" : { + "type" : "number" + }, + + "maximum" : { + "type" : "number" + }, + + "exclusiveMinimum" : { + "type" : "boolean", + "default" : false + }, + + "exclusiveMaximum" : { + "type" : "boolean", + "default" : false + }, + + "minItems" : { + "type" : "integer", + "minimum" : 0, + "default" : 0 + }, + + "maxItems" : { + "type" : "integer", + "minimum" : 0 + }, + + "uniqueItems" : { + "type" : "boolean", + "default" : false + }, + + "pattern" : { + "type" : "string", + "format" : "regex" + }, + + "minLength" : { + "type" : "integer", + "minimum" : 0, + "default" : 0 + }, + + "maxLength" : { + "type" : "integer" + }, + + "enum" : { + "type" : "array", + "minItems" : 1, + "uniqueItems" : true + }, + + "default" : { + "type" : "any" + }, + + "title" : { + "type" : "string" + }, + + "description" : { + "type" : "string" + }, + + "format" : { + "type" : "string" + }, + + "divisibleBy" : { + "type" : "number", + "minimum" : 0, + "exclusiveMinimum" : true, + "default" : 1 + }, + + "disallow" : { + "type" : ["string", "array"], + "items" : { + "type" : ["string", {"$ref" : "#"}] + }, + "uniqueItems" : true + }, + + "extends" : { + "type" : [{"$ref" : "#"}, "array"], + "items" : {"$ref" : "#"}, + "default" : {} + }, + + "id" : { + "type" : "string" + }, + + "$ref" : { + "type" : "string" + }, + + "$schema" : { + "type" : "string", + "format" : "uri" + } + }, + + "dependencies" : { + "exclusiveMinimum" : "minimum", + "exclusiveMaximum" : "maximum" + }, + + "default" : {} +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft4/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft4/metaschema.json new file mode 100644 index 00000000..bcbb8474 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft4/metaschema.json @@ -0,0 +1,149 @@ +{ + "id": "http://json-schema.org/draft-04/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "positiveInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveIntegerDefault0": { + "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] + }, + "simpleTypes": { + "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + } + }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "$schema": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { "$ref": "#/definitions/positiveInteger" }, + "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/positiveInteger" }, + "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { "$ref": "#/definitions/positiveInteger" }, + "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "dependencies": { + "exclusiveMaximum": [ "maximum" ], + "exclusiveMinimum": [ "minimum" ] + }, + "default": {} +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft6/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft6/metaschema.json new file mode 100644 index 00000000..a0d2bf78 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft6/metaschema.json @@ -0,0 +1,153 @@ +{ + "$schema": "http://json-schema.org/draft-06/schema#", + "$id": "http://json-schema.org/draft-06/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "examples": { + "type": "array", + "items": {} + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": {}, + "enum": { + "type": "array" + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": {} +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft7/metaschema.json b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft7/metaschema.json new file mode 100644 index 00000000..746cde96 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/schemas/draft7/metaschema.json @@ -0,0 +1,166 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://json-schema.org/draft-07/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": true + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "if": {"$ref": "#"}, + "then": {"$ref": "#"}, + "else": {"$ref": "#"}, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": true +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/tests/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/tests/test_jsonschema_specifications.py b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/tests/test_jsonschema_specifications.py new file mode 100644 index 00000000..fd2927e0 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/jsonschema_specifications/tests/test_jsonschema_specifications.py @@ -0,0 +1,41 @@ +from collections.abc import Mapping +from pathlib import Path + +import pytest + +from jsonschema_specifications import REGISTRY + + +def test_it_contains_metaschemas(): + schema = REGISTRY.contents("http://json-schema.org/draft-07/schema#") + assert isinstance(schema, Mapping) + assert schema["$id"] == "http://json-schema.org/draft-07/schema#" + assert schema["title"] == "Core schema meta-schema" + + +def test_it_is_crawled(): + assert REGISTRY.crawl() == REGISTRY + + +@pytest.mark.parametrize( + "ignored_relative_path", + ["schemas/.DS_Store", "schemas/draft7/.DS_Store"], +) +def test_it_copes_with_dotfiles(ignored_relative_path): + """ + Ignore files like .DS_Store if someone has actually caused one to exist. + + We test here through the private interface as of course the global has + already loaded our schemas. + """ + + import jsonschema_specifications + + package = Path(jsonschema_specifications.__file__).parent + + ignored = package / ignored_relative_path + ignored.touch() + try: + list(jsonschema_specifications._schemas()) + finally: + ignored.unlink() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/METADATA new file mode 100644 index 00000000..9a6abbc8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/METADATA @@ -0,0 +1,64 @@ +Metadata-Version: 2.4 +Name: referencing +Version: 0.37.0 +Summary: JSON Referencing + Python +Project-URL: Documentation, https://referencing.readthedocs.io/ +Project-URL: Homepage, https://github.com/python-jsonschema/referencing +Project-URL: Issues, https://github.com/python-jsonschema/referencing/issues/ +Project-URL: Funding, https://github.com/sponsors/Julian +Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-referencing?utm_source=pypi-referencing&utm_medium=referral&utm_campaign=pypi-link +Project-URL: Changelog, https://referencing.readthedocs.io/en/stable/changes/ +Project-URL: Source, https://github.com/python-jsonschema/referencing +Author-email: Julian Berman +License-Expression: MIT +License-File: COPYING +Keywords: asyncapi,json,jsonschema,openapi,referencing +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: File Formats :: JSON +Classifier: Topic :: File Formats :: JSON :: JSON Schema +Requires-Python: >=3.10 +Requires-Dist: attrs>=22.2.0 +Requires-Dist: rpds-py>=0.7.0 +Requires-Dist: typing-extensions>=4.4.0; python_version < '3.13' +Description-Content-Type: text/x-rst + +=============== +``referencing`` +=============== + +|PyPI| |Pythons| |CI| |ReadTheDocs| |pre-commit| + +.. |PyPI| image:: https://img.shields.io/pypi/v/referencing.svg + :alt: PyPI version + :target: https://pypi.org/project/referencing/ + +.. |Pythons| image:: https://img.shields.io/pypi/pyversions/referencing.svg + :alt: Supported Python versions + :target: https://pypi.org/project/referencing/ + +.. |CI| image:: https://github.com/python-jsonschema/referencing/workflows/CI/badge.svg + :alt: Build status + :target: https://github.com/python-jsonschema/referencing/actions?query=workflow%3ACI + +.. |ReadTheDocs| image:: https://readthedocs.org/projects/referencing/badge/?version=stable&style=flat + :alt: ReadTheDocs status + :target: https://referencing.readthedocs.io/en/stable/ + +.. |pre-commit| image:: https://results.pre-commit.ci/badge/github/python-jsonschema/referencing/main.svg + :alt: pre-commit.ci status + :target: https://results.pre-commit.ci/latest/github/python-jsonschema/referencing/main + + +An implementation-agnostic implementation of JSON reference resolution. + +See `the documentation `_ for more details. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/RECORD new file mode 100644 index 00000000..349c26cc --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/RECORD @@ -0,0 +1,33 @@ +referencing-0.37.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +referencing-0.37.0.dist-info/METADATA,sha256=yTsA5qsVJP1ghIN2YEwYmhFDdHdkdcJaMdV75Hsp3mo,2845 +referencing-0.37.0.dist-info/RECORD,, +referencing-0.37.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87 +referencing-0.37.0.dist-info/licenses/COPYING,sha256=QtzWNJX4e063x3V6-jebtVpT-Ur9el9lfZrfVyNuUVw,1057 +referencing/__init__.py,sha256=5IZKXaAH_FWyCJRkaTn1XcptLfg9cveLb9u5nYUxJKs,207 +referencing/__pycache__/__init__.cpython-39.pyc,, +referencing/__pycache__/_attrs.cpython-39.pyc,, +referencing/__pycache__/_core.cpython-39.pyc,, +referencing/__pycache__/exceptions.cpython-39.pyc,, +referencing/__pycache__/jsonschema.cpython-39.pyc,, +referencing/__pycache__/retrieval.cpython-39.pyc,, +referencing/__pycache__/typing.cpython-39.pyc,, +referencing/_attrs.py,sha256=bgT-KMhDVLeGtWxM_SGKYeLaZBFzT2kUVFdAkOcXi8g,791 +referencing/_attrs.pyi,sha256=g2wX-aLEqQAvWU-s0qIN2OMGAqjOA0R72R_uSffO8_M,573 +referencing/_core.py,sha256=2SPGfaTvzA3sRqbj_4R2wC--fiXolCUVpLFLJJ6zdZ8,24732 +referencing/exceptions.py,sha256=zFgaEg6WiKeT58MQuKNsgGDnHszp26c4oReC6sF9gHM,4176 +referencing/jsonschema.py,sha256=jXZ6t6x9oTvTHMIC0tRyaFjW5r2qeHObYuu7UhSfK3Q,18615 +referencing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +referencing/retrieval.py,sha256=cn9EeAdaaTbnMDdC4JRIhiEpXDjflRdHCOLFrw1ZhLA,2729 +referencing/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +referencing/tests/__pycache__/__init__.cpython-39.pyc,, +referencing/tests/__pycache__/test_core.cpython-39.pyc,, +referencing/tests/__pycache__/test_exceptions.cpython-39.pyc,, +referencing/tests/__pycache__/test_jsonschema.cpython-39.pyc,, +referencing/tests/__pycache__/test_referencing_suite.cpython-39.pyc,, +referencing/tests/__pycache__/test_retrieval.cpython-39.pyc,, +referencing/tests/test_core.py,sha256=eap0CAaI23vjMIbVyEj92qLddp3iHH3AxC55CKUN4LU,37854 +referencing/tests/test_exceptions.py,sha256=7eOdHyobXMt7-h5AnnH7u8iw2uHPaH7U4Bs9JhLgjWo,934 +referencing/tests/test_jsonschema.py,sha256=4QnjUWOAMAn5yeA8ZtldJkhI54vwKWJWB0LDzNdx5xc,11687 +referencing/tests/test_referencing_suite.py,sha256=wD6veMfLsUu0s4MLjm7pS8cg4cIfL7FMBENngk73zCI,2335 +referencing/tests/test_retrieval.py,sha256=vcbnfA4TqVeqUzW073wO-nLeqVIv0rQZWNWv0z9km48,3719 +referencing/typing.py,sha256=WjUbnZ6jPAd31cnCFAaeWIVENzyHtHdJyOlelv1GY70,1445 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/WHEEL new file mode 100644 index 00000000..12228d41 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.27.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/licenses/COPYING b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/licenses/COPYING new file mode 100644 index 00000000..a9f853e4 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing-0.37.0.dist-info/licenses/COPYING @@ -0,0 +1,19 @@ +Copyright (c) 2022 Julian Berman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/__init__.py new file mode 100644 index 00000000..e09207d7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/__init__.py @@ -0,0 +1,7 @@ +""" +Cross-specification, implementation-agnostic JSON referencing. +""" + +from referencing._core import Anchor, Registry, Resource, Specification + +__all__ = ["Anchor", "Registry", "Resource", "Specification"] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.py new file mode 100644 index 00000000..ae85b865 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from typing import NoReturn, TypeVar + +from attrs import define as _define, frozen as _frozen + +_T = TypeVar("_T") + + +def define(cls: type[_T]) -> type[_T]: # pragma: no cover + cls.__init_subclass__ = _do_not_subclass + return _define(cls) + + +def frozen(cls: type[_T]) -> type[_T]: + cls.__init_subclass__ = _do_not_subclass + return _frozen(cls) + + +class UnsupportedSubclassing(Exception): + def __str__(self): + return ( + "Subclassing is not part of referencing's public API. " + "If no other suitable API exists for what you're trying to do, " + "feel free to file an issue asking for one." + ) + + +@staticmethod +def _do_not_subclass() -> NoReturn: # pragma: no cover + raise UnsupportedSubclassing() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.pyi b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.pyi new file mode 100644 index 00000000..eca6db2b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_attrs.pyi @@ -0,0 +1,21 @@ +from collections.abc import Callable +from typing import Any, TypeVar + +from attr import attrib, field + +class UnsupportedSubclassing(Exception): ... + +_T = TypeVar("_T") + +def __dataclass_transform__( + *, + frozen_default: bool = False, + field_descriptors: tuple[type | Callable[..., Any], ...] = ..., +) -> Callable[[_T], _T]: ... +@__dataclass_transform__(field_descriptors=(attrib, field)) +def define(cls: type[_T]) -> type[_T]: ... +@__dataclass_transform__( + frozen_default=True, + field_descriptors=(attrib, field), +) +def frozen(cls: type[_T]) -> type[_T]: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/_core.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_core.py new file mode 100644 index 00000000..42068451 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/_core.py @@ -0,0 +1,739 @@ +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator, Sequence +from enum import Enum +from typing import Any, ClassVar, Generic, Protocol +from urllib.parse import unquote, urldefrag, urljoin + +from attrs import evolve, field +from rpds import HashTrieMap, HashTrieSet, List + +try: + from typing_extensions import TypeVar +except ImportError: # pragma: no cover + from typing import TypeVar + +from referencing import exceptions +from referencing._attrs import frozen +from referencing.typing import URI, Anchor as AnchorType, D, Mapping, Retrieve + +EMPTY_UNCRAWLED: HashTrieSet[URI] = HashTrieSet() +EMPTY_PREVIOUS_RESOLVERS: List[URI] = List() + + +class _Unset(Enum): + """ + What sillyness... + """ + + SENTINEL = 1 + + +_UNSET = _Unset.SENTINEL + + +class _MaybeInSubresource(Protocol[D]): + def __call__( + self, + segments: Sequence[int | str], + resolver: Resolver[D], + subresource: Resource[D], + ) -> Resolver[D]: ... + + +def _detect_or_error(contents: D) -> Specification[D]: + if not isinstance(contents, Mapping): + raise exceptions.CannotDetermineSpecification(contents) + + jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType] + if not isinstance(jsonschema_dialect_id, str): + raise exceptions.CannotDetermineSpecification(contents) + + from referencing.jsonschema import specification_with + + return specification_with(jsonschema_dialect_id) + + +def _detect_or_default( + default: Specification[D], +) -> Callable[[D], Specification[D]]: + def _detect(contents: D) -> Specification[D]: + if not isinstance(contents, Mapping): + return default + + jsonschema_dialect_id = contents.get("$schema") # type: ignore[reportUnknownMemberType] + if jsonschema_dialect_id is None: + return default + + from referencing.jsonschema import specification_with + + return specification_with( + jsonschema_dialect_id, # type: ignore[reportUnknownArgumentType] + default=default, + ) + + return _detect + + +class _SpecificationDetector: + def __get__( + self, + instance: Specification[D] | None, + cls: type[Specification[D]], + ) -> Callable[[D], Specification[D]]: + if instance is None: + return _detect_or_error + else: + return _detect_or_default(instance) + + +@frozen +class Specification(Generic[D]): + """ + A specification which defines referencing behavior. + + The various methods of a `Specification` allow for varying referencing + behavior across JSON Schema specification versions, etc. + """ + + #: A short human-readable name for the specification, used for debugging. + name: str + + #: Find the ID of a given document. + id_of: Callable[[D], URI | None] + + #: Retrieve the subresources of the given document (without traversing into + #: the subresources themselves). + subresources_of: Callable[[D], Iterable[D]] + + #: While resolving a JSON pointer, conditionally enter a subresource + #: (if e.g. we have just entered a keyword whose value is a subresource) + maybe_in_subresource: _MaybeInSubresource[D] + + #: Retrieve the anchors contained in the given document. + _anchors_in: Callable[ + [Specification[D], D], + Iterable[AnchorType[D]], + ] = field(alias="anchors_in") + + #: An opaque specification where resources have no subresources + #: nor internal identifiers. + OPAQUE: ClassVar[Specification[Any]] + + #: Attempt to discern which specification applies to the given contents. + #: + #: May be called either as an instance method or as a class method, with + #: slightly different behavior in the following case: + #: + #: Recall that not all contents contains enough internal information about + #: which specification it is written for -- the JSON Schema ``{}``, + #: for instance, is valid under many different dialects and may be + #: interpreted as any one of them. + #: + #: When this method is used as an instance method (i.e. called on a + #: specific specification), that specification is used as the default + #: if the given contents are unidentifiable. + #: + #: On the other hand when called as a class method, an error is raised. + #: + #: To reiterate, ``DRAFT202012.detect({})`` will return ``DRAFT202012`` + #: whereas the class method ``Specification.detect({})`` will raise an + #: error. + #: + #: (Note that of course ``DRAFT202012.detect(...)`` may return some other + #: specification when given a schema which *does* identify as being for + #: another version). + #: + #: Raises: + #: + #: `CannotDetermineSpecification` + #: + #: if the given contents don't have any discernible + #: information which could be used to guess which + #: specification they identify as + detect = _SpecificationDetector() + + def __repr__(self) -> str: + return f"" + + def anchors_in(self, contents: D): + """ + Retrieve the anchors contained in the given document. + """ + return self._anchors_in(self, contents) + + def create_resource(self, contents: D) -> Resource[D]: + """ + Create a resource which is interpreted using this specification. + """ + return Resource(contents=contents, specification=self) + + +Specification.OPAQUE = Specification( + name="opaque", + id_of=lambda contents: None, + subresources_of=lambda contents: [], + anchors_in=lambda specification, contents: [], + maybe_in_subresource=lambda segments, resolver, subresource: resolver, +) + + +@frozen +class Resource(Generic[D]): + r""" + A document (deserialized JSON) with a concrete interpretation under a spec. + + In other words, a Python object, along with an instance of `Specification` + which describes how the document interacts with referencing -- both + internally (how it refers to other `Resource`\ s) and externally (how it + should be identified such that it is referenceable by other documents). + """ + + contents: D + _specification: Specification[D] = field(alias="specification") + + @classmethod + def from_contents( + cls, + contents: D, + default_specification: ( + type[Specification[D]] | Specification[D] + ) = Specification, + ) -> Resource[D]: + """ + Create a resource guessing which specification applies to the contents. + + Raises: + + `CannotDetermineSpecification` + + if the given contents don't have any discernible + information which could be used to guess which + specification they identify as + + """ + specification = default_specification.detect(contents) + return specification.create_resource(contents=contents) + + @classmethod + def opaque(cls, contents: D) -> Resource[D]: + """ + Create an opaque `Resource` -- i.e. one with opaque specification. + + See `Specification.OPAQUE` for details. + """ + return Specification.OPAQUE.create_resource(contents=contents) + + def id(self) -> URI | None: + """ + Retrieve this resource's (specification-specific) identifier. + """ + id = self._specification.id_of(self.contents) + if id is None: + return + return id.rstrip("#") + + def subresources(self) -> Iterable[Resource[D]]: + """ + Retrieve this resource's subresources. + """ + return ( + Resource.from_contents( + each, + default_specification=self._specification, + ) + for each in self._specification.subresources_of(self.contents) + ) + + def anchors(self) -> Iterable[AnchorType[D]]: + """ + Retrieve this resource's (specification-specific) identifier. + """ + return self._specification.anchors_in(self.contents) + + def pointer(self, pointer: str, resolver: Resolver[D]) -> Resolved[D]: + """ + Resolve the given JSON pointer. + + Raises: + + `exceptions.PointerToNowhere` + + if the pointer points to a location not present in the document + + """ + if not pointer: + return Resolved(contents=self.contents, resolver=resolver) + + contents = self.contents + segments: list[int | str] = [] + for segment in unquote(pointer[1:]).split("/"): + if isinstance(contents, Sequence): + segment = int(segment) + else: + segment = segment.replace("~1", "/").replace("~0", "~") + try: + contents = contents[segment] # type: ignore[reportUnknownArgumentType] + except LookupError as lookup_error: + error = exceptions.PointerToNowhere(ref=pointer, resource=self) + raise error from lookup_error + + segments.append(segment) + last = resolver + resolver = self._specification.maybe_in_subresource( + segments=segments, + resolver=resolver, + subresource=self._specification.create_resource(contents), + ) + if resolver is not last: + segments = [] + return Resolved(contents=contents, resolver=resolver) # type: ignore[reportUnknownArgumentType] + + +def _fail_to_retrieve(uri: URI): + raise exceptions.NoSuchResource(ref=uri) + + +@frozen +class Registry(Mapping[URI, Resource[D]]): + r""" + A registry of `Resource`\ s, each identified by their canonical URIs. + + Registries store a collection of in-memory resources, and optionally + enable additional resources which may be stored elsewhere (e.g. in a + database, a separate set of files, over the network, etc.). + + They also lazily walk their known resources, looking for subresources + within them. In other words, subresources contained within any added + resources will be retrievable via their own IDs (though this discovery of + subresources will be delayed until necessary). + + Registries are immutable, and their methods return new instances of the + registry with the additional resources added to them. + + The ``retrieve`` argument can be used to configure retrieval of resources + dynamically, either over the network, from a database, or the like. + Pass it a callable which will be called if any URI not present in the + registry is accessed. It must either return a `Resource` or else raise a + `NoSuchResource` exception indicating that the resource does not exist + even according to the retrieval logic. + """ + + _resources: HashTrieMap[URI, Resource[D]] = field( + default=HashTrieMap(), + converter=HashTrieMap.convert, # type: ignore[reportGeneralTypeIssues] + alias="resources", + ) + _anchors: HashTrieMap[tuple[URI, str], AnchorType[D]] = HashTrieMap() + _uncrawled: HashTrieSet[URI] = EMPTY_UNCRAWLED + _retrieve: Retrieve[D] = field(default=_fail_to_retrieve, alias="retrieve") + + def __getitem__(self, uri: URI) -> Resource[D]: + """ + Return the (already crawled) `Resource` identified by the given URI. + """ + try: + return self._resources[uri.rstrip("#")] + except KeyError: + raise exceptions.NoSuchResource(ref=uri) from None + + def __iter__(self) -> Iterator[URI]: + """ + Iterate over all crawled URIs in the registry. + """ + return iter(self._resources) + + def __len__(self) -> int: + """ + Count the total number of fully crawled resources in this registry. + """ + return len(self._resources) + + def __rmatmul__( + self, + new: Resource[D] | Iterable[Resource[D]], + ) -> Registry[D]: + """ + Create a new registry with resource(s) added using their internal IDs. + + Resources must have a internal IDs (e.g. the :kw:`$id` keyword in + modern JSON Schema versions), otherwise an error will be raised. + + Both a single resource as well as an iterable of resources works, i.e.: + + * ``resource @ registry`` or + + * ``[iterable, of, multiple, resources] @ registry`` + + which -- again, assuming the resources have internal IDs -- is + equivalent to calling `Registry.with_resources` as such: + + .. code:: python + + registry.with_resources( + (resource.id(), resource) for resource in new_resources + ) + + Raises: + + `NoInternalID` + + if the resource(s) in fact do not have IDs + + """ + if isinstance(new, Resource): + new = (new,) + + resources = self._resources + uncrawled = self._uncrawled + for resource in new: + id = resource.id() + if id is None: + raise exceptions.NoInternalID(resource=resource) + uncrawled = uncrawled.insert(id) + resources = resources.insert(id, resource) + return evolve(self, resources=resources, uncrawled=uncrawled) + + def __repr__(self) -> str: + size = len(self) + pluralized = "resource" if size == 1 else "resources" + if self._uncrawled: + uncrawled = len(self._uncrawled) + if uncrawled == size: + summary = f"uncrawled {pluralized}" + else: + summary = f"{pluralized}, {uncrawled} uncrawled" + else: + summary = f"{pluralized}" + return f"" + + def get_or_retrieve(self, uri: URI) -> Retrieved[D, Resource[D]]: + """ + Get a resource from the registry, crawling or retrieving if necessary. + + May involve crawling to find the given URI if it is not already known, + so the returned object is a `Retrieved` object which contains both the + resource value as well as the registry which ultimately contained it. + """ + resource = self._resources.get(uri) + if resource is not None: + return Retrieved(registry=self, value=resource) + + registry = self.crawl() + resource = registry._resources.get(uri) + if resource is not None: + return Retrieved(registry=registry, value=resource) + + try: + resource = registry._retrieve(uri) + except ( + exceptions.CannotDetermineSpecification, + exceptions.NoSuchResource, + ): + raise + except Exception as error: + raise exceptions.Unretrievable(ref=uri) from error + else: + registry = registry.with_resource(uri, resource) + return Retrieved(registry=registry, value=resource) + + def remove(self, uri: URI): + """ + Return a registry with the resource identified by a given URI removed. + """ + if uri not in self._resources: + raise exceptions.NoSuchResource(ref=uri) + + return evolve( + self, + resources=self._resources.remove(uri), + uncrawled=self._uncrawled.discard(uri), + anchors=HashTrieMap( + (k, v) for k, v in self._anchors.items() if k[0] != uri + ), + ) + + def anchor(self, uri: URI, name: str): + """ + Retrieve a given anchor from a resource which must already be crawled. + """ + value = self._anchors.get((uri, name)) + if value is not None: + return Retrieved(value=value, registry=self) + + registry = self.crawl() + value = registry._anchors.get((uri, name)) + if value is not None: + return Retrieved(value=value, registry=registry) + + resource = self[uri] + canonical_uri = resource.id() + if canonical_uri is not None: + value = registry._anchors.get((canonical_uri, name)) + if value is not None: + return Retrieved(value=value, registry=registry) + + if "/" in name: + raise exceptions.InvalidAnchor( + ref=uri, + resource=resource, + anchor=name, + ) + raise exceptions.NoSuchAnchor(ref=uri, resource=resource, anchor=name) + + def contents(self, uri: URI) -> D: + """ + Retrieve the (already crawled) contents identified by the given URI. + """ + return self[uri].contents + + def crawl(self) -> Registry[D]: + """ + Crawl all added resources, discovering subresources. + """ + resources = self._resources + anchors = self._anchors + uncrawled = [(uri, resources[uri]) for uri in self._uncrawled] + while uncrawled: + uri, resource = uncrawled.pop() + + id = resource.id() + if id is not None: + uri = urljoin(uri, id) + resources = resources.insert(uri, resource) + for each in resource.anchors(): + anchors = anchors.insert((uri, each.name), each) + uncrawled.extend((uri, each) for each in resource.subresources()) + return evolve( + self, + resources=resources, + anchors=anchors, + uncrawled=EMPTY_UNCRAWLED, + ) + + def with_resource(self, uri: URI, resource: Resource[D]): + """ + Add the given `Resource` to the registry, without crawling it. + """ + return self.with_resources([(uri, resource)]) + + def with_resources( + self, + pairs: Iterable[tuple[URI, Resource[D]]], + ) -> Registry[D]: + r""" + Add the given `Resource`\ s to the registry, without crawling them. + """ + resources = self._resources + uncrawled = self._uncrawled + for uri, resource in pairs: + # Empty fragment URIs are equivalent to URIs without the fragment. + # TODO: Is this true for non JSON Schema resources? Probably not. + uri = uri.rstrip("#") + uncrawled = uncrawled.insert(uri) + resources = resources.insert(uri, resource) + return evolve(self, resources=resources, uncrawled=uncrawled) + + def with_contents( + self, + pairs: Iterable[tuple[URI, D]], + **kwargs: Any, + ) -> Registry[D]: + r""" + Add the given contents to the registry, autodetecting when necessary. + """ + return self.with_resources( + (uri, Resource.from_contents(each, **kwargs)) + for uri, each in pairs + ) + + def combine(self, *registries: Registry[D]) -> Registry[D]: + """ + Combine together one or more other registries, producing a unified one. + """ + if registries == (self,): + return self + resources = self._resources + anchors = self._anchors + uncrawled = self._uncrawled + retrieve = self._retrieve + for registry in registries: + resources = resources.update(registry._resources) + anchors = anchors.update(registry._anchors) + uncrawled = uncrawled.update(registry._uncrawled) + + if registry._retrieve is not _fail_to_retrieve: + if registry._retrieve is not retrieve is not _fail_to_retrieve: + raise ValueError( # noqa: TRY003 + "Cannot combine registries with conflicting retrieval " + "functions.", + ) + retrieve = registry._retrieve + return evolve( + self, + anchors=anchors, + resources=resources, + uncrawled=uncrawled, + retrieve=retrieve, + ) + + def resolver(self, base_uri: URI = "") -> Resolver[D]: + """ + Return a `Resolver` which resolves references against this registry. + """ + return Resolver(base_uri=base_uri, registry=self) + + def resolver_with_root(self, resource: Resource[D]) -> Resolver[D]: + """ + Return a `Resolver` with a specific root resource. + """ + uri = resource.id() or "" + return Resolver( + base_uri=uri, + registry=self.with_resource(uri, resource), + ) + + +#: An anchor or resource. +AnchorOrResource = TypeVar( + "AnchorOrResource", + AnchorType[Any], + Resource[Any], + default=Resource[Any], +) + + +@frozen +class Retrieved(Generic[D, AnchorOrResource]): + """ + A value retrieved from a `Registry`. + """ + + value: AnchorOrResource + registry: Registry[D] + + +@frozen +class Resolved(Generic[D]): + """ + A reference resolved to its contents by a `Resolver`. + """ + + contents: D + resolver: Resolver[D] + + +@frozen +class Resolver(Generic[D]): + """ + A reference resolver. + + Resolvers help resolve references (including relative ones) by + pairing a fixed base URI with a `Registry`. + + This object, under normal circumstances, is expected to be used by + *implementers of libraries* built on top of `referencing` (e.g. JSON Schema + implementations or other libraries resolving JSON references), + not directly by end-users populating registries or while writing + schemas or other resources. + + References are resolved against the base URI, and the combined URI + is then looked up within the registry. + + The process of resolving a reference may itself involve calculating + a *new* base URI for future reference resolution (e.g. if an + intermediate resource sets a new base URI), or may involve encountering + additional subresources and adding them to a new registry. + """ + + _base_uri: URI = field(alias="base_uri") + _registry: Registry[D] = field(alias="registry") + _previous: List[URI] = field(default=List(), repr=False, alias="previous") + + def lookup(self, ref: URI) -> Resolved[D]: + """ + Resolve the given reference to the resource it points to. + + Raises: + + `exceptions.Unresolvable` + + or a subclass thereof (see below) if the reference isn't + resolvable + + `exceptions.NoSuchAnchor` + + if the reference is to a URI where a resource exists but + contains a plain name fragment which does not exist within + the resource + + `exceptions.PointerToNowhere` + + if the reference is to a URI where a resource exists but + contains a JSON pointer to a location within the resource + that does not exist + + """ + if ref.startswith("#"): + uri, fragment = self._base_uri, ref[1:] + else: + uri, fragment = urldefrag(urljoin(self._base_uri, ref)) + try: + retrieved = self._registry.get_or_retrieve(uri) + except exceptions.NoSuchResource: + raise exceptions.Unresolvable(ref=ref) from None + except exceptions.Unretrievable as error: + raise exceptions.Unresolvable(ref=ref) from error + + if fragment.startswith("/"): + resolver = self._evolve(registry=retrieved.registry, base_uri=uri) + return retrieved.value.pointer(pointer=fragment, resolver=resolver) + + if fragment: + retrieved = retrieved.registry.anchor(uri, fragment) + resolver = self._evolve(registry=retrieved.registry, base_uri=uri) + return retrieved.value.resolve(resolver=resolver) + + resolver = self._evolve(registry=retrieved.registry, base_uri=uri) + return Resolved(contents=retrieved.value.contents, resolver=resolver) + + def in_subresource(self, subresource: Resource[D]) -> Resolver[D]: + """ + Create a resolver for a subresource (which may have a new base URI). + """ + id = subresource.id() + if id is None: + return self + return evolve(self, base_uri=urljoin(self._base_uri, id)) + + def dynamic_scope(self) -> Iterable[tuple[URI, Registry[D]]]: + """ + In specs with such a notion, return the URIs in the dynamic scope. + """ + for uri in self._previous: + yield uri, self._registry + + def _evolve(self, base_uri: URI, **kwargs: Any): + """ + Evolve, appending to the dynamic scope. + """ + previous = self._previous + if self._base_uri and (not previous or base_uri != self._base_uri): + previous = previous.push_front(self._base_uri) + return evolve(self, base_uri=base_uri, previous=previous, **kwargs) + + +@frozen +class Anchor(Generic[D]): + """ + A simple anchor in a `Resource`. + """ + + name: str + resource: Resource[D] + + def resolve(self, resolver: Resolver[D]): + """ + Return the resource for this anchor. + """ + return Resolved(contents=self.resource.contents, resolver=resolver) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/exceptions.py new file mode 100644 index 00000000..3267fc70 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/exceptions.py @@ -0,0 +1,165 @@ +""" +Errors, oh no! +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import attrs + +from referencing._attrs import frozen + +if TYPE_CHECKING: + from referencing import Resource + from referencing.typing import URI + + +@frozen +class NoSuchResource(KeyError): + """ + The given URI is not present in a registry. + + Unlike most exceptions, this class *is* intended to be publicly + instantiable and *is* part of the public API of the package. + """ + + ref: URI + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return NotImplemented + return attrs.astuple(self) == attrs.astuple(other) + + def __hash__(self) -> int: + return hash(attrs.astuple(self)) + + +@frozen +class NoInternalID(Exception): + """ + A resource has no internal ID, but one is needed. + + E.g. in modern JSON Schema drafts, this is the :kw:`$id` keyword. + + One might be needed if a resource was to-be added to a registry but no + other URI is available, and the resource doesn't declare its canonical URI. + """ + + resource: Resource[Any] + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return NotImplemented + return attrs.astuple(self) == attrs.astuple(other) + + def __hash__(self) -> int: + return hash(attrs.astuple(self)) + + +@frozen +class Unretrievable(KeyError): + """ + The given URI is not present in a registry, and retrieving it failed. + """ + + ref: URI + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return NotImplemented + return attrs.astuple(self) == attrs.astuple(other) + + def __hash__(self) -> int: + return hash(attrs.astuple(self)) + + +@frozen +class CannotDetermineSpecification(Exception): + """ + Attempting to detect the appropriate `Specification` failed. + + This happens if no discernible information is found in the contents of the + new resource which would help identify it. + """ + + contents: Any + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return NotImplemented + return attrs.astuple(self) == attrs.astuple(other) + + def __hash__(self) -> int: + return hash(attrs.astuple(self)) + + +@attrs.frozen # Because here we allow subclassing below. +class Unresolvable(Exception): + """ + A reference was unresolvable. + """ + + ref: URI + + def __eq__(self, other: object) -> bool: + if self.__class__ is not other.__class__: + return NotImplemented + return attrs.astuple(self) == attrs.astuple(other) + + def __hash__(self) -> int: + return hash(attrs.astuple(self)) + + +@frozen +class PointerToNowhere(Unresolvable): + """ + A JSON Pointer leads to a part of a document that does not exist. + """ + + resource: Resource[Any] + + def __str__(self) -> str: + msg = f"{self.ref!r} does not exist within {self.resource.contents!r}" + if self.ref == "/": + msg += ( + ". The pointer '/' is a valid JSON Pointer but it points to " + "an empty string property ''. If you intended to point " + "to the entire resource, you should use '#'." + ) + return msg + + +@frozen +class NoSuchAnchor(Unresolvable): + """ + An anchor does not exist within a particular resource. + """ + + resource: Resource[Any] + anchor: str + + def __str__(self) -> str: + return ( + f"{self.anchor!r} does not exist within {self.resource.contents!r}" + ) + + +@frozen +class InvalidAnchor(Unresolvable): + """ + An anchor which could never exist in a resource was dereferenced. + + It is somehow syntactically invalid. + """ + + resource: Resource[Any] + anchor: str + + def __str__(self) -> str: + return ( + f"'#{self.anchor}' is not a valid anchor, neither as a " + "plain name anchor nor as a JSON Pointer. You may have intended " + f"to use '#/{self.anchor}', as the slash is required *before each " + "segment* of a JSON pointer." + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/jsonschema.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/jsonschema.py new file mode 100644 index 00000000..93e77a78 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/jsonschema.py @@ -0,0 +1,642 @@ +""" +Referencing implementations for JSON Schema specs (historic & current). +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence, Set +from typing import Any + +from referencing import Anchor, Registry, Resource, Specification, exceptions +from referencing._attrs import frozen +from referencing._core import ( + _UNSET, # type: ignore[reportPrivateUsage] + Resolved as _Resolved, + Resolver as _Resolver, + _Unset, # type: ignore[reportPrivateUsage] +) +from referencing.typing import URI, Anchor as AnchorType, Mapping + +#: A JSON Schema which is a JSON object +ObjectSchema = Mapping[str, Any] + +#: A JSON Schema of any kind +Schema = bool | ObjectSchema + +#: A Resource whose contents are JSON Schemas +SchemaResource = Resource[Schema] + +#: A JSON Schema Registry +SchemaRegistry = Registry[Schema] + +#: The empty JSON Schema Registry +EMPTY_REGISTRY: SchemaRegistry = Registry() + + +@frozen +class UnknownDialect(Exception): + """ + A dialect identifier was found for a dialect unknown by this library. + + If it's a custom ("unofficial") dialect, be sure you've registered it. + """ + + uri: URI + + +def _dollar_id(contents: Schema) -> URI | None: + if isinstance(contents, bool): + return + return contents.get("$id") + + +def _legacy_dollar_id(contents: Schema) -> URI | None: + if isinstance(contents, bool) or "$ref" in contents: + return + id = contents.get("$id") + if id is not None and not id.startswith("#"): + return id + + +def _legacy_id(contents: ObjectSchema) -> URI | None: + if "$ref" in contents: + return + id = contents.get("id") + if id is not None and not id.startswith("#"): + return id + + +def _anchor( + specification: Specification[Schema], + contents: Schema, +) -> Iterable[AnchorType[Schema]]: + if isinstance(contents, bool): + return + anchor = contents.get("$anchor") + if anchor is not None: + yield Anchor( + name=anchor, + resource=specification.create_resource(contents), + ) + + dynamic_anchor = contents.get("$dynamicAnchor") + if dynamic_anchor is not None: + yield DynamicAnchor( + name=dynamic_anchor, + resource=specification.create_resource(contents), + ) + + +def _anchor_2019( + specification: Specification[Schema], + contents: Schema, +) -> Iterable[Anchor[Schema]]: + if isinstance(contents, bool): + return [] + anchor = contents.get("$anchor") + if anchor is None: + return [] + return [ + Anchor( + name=anchor, + resource=specification.create_resource(contents), + ), + ] + + +def _legacy_anchor_in_dollar_id( + specification: Specification[Schema], + contents: Schema, +) -> Iterable[Anchor[Schema]]: + if isinstance(contents, bool): + return [] + id = contents.get("$id", "") + if not id.startswith("#"): + return [] + return [ + Anchor( + name=id[1:], + resource=specification.create_resource(contents), + ), + ] + + +def _legacy_anchor_in_id( + specification: Specification[ObjectSchema], + contents: ObjectSchema, +) -> Iterable[Anchor[ObjectSchema]]: + id = contents.get("id", "") + if not id.startswith("#"): + return [] + return [ + Anchor( + name=id[1:], + resource=specification.create_resource(contents), + ), + ] + + +def _subresources_of( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + """ + Create a callable returning JSON Schema specification-style subschemas. + + Relies on specifying the set of keywords containing subschemas in their + values, in a subobject's values, or in a subarray. + """ + + def subresources_of(contents: Schema) -> Iterable[ObjectSchema]: + if isinstance(contents, bool): + return + for each in in_value: + if each in contents: + yield contents[each] + for each in in_subarray: + if each in contents: + yield from contents[each] + for each in in_subvalues: + if each in contents: + yield from contents[each].values() + + return subresources_of + + +def _subresources_of_with_crazy_items( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + """ + Specifically handle older drafts where there are some funky keywords. + """ + + def subresources_of(contents: Schema) -> Iterable[ObjectSchema]: + if isinstance(contents, bool): + return + for each in in_value: + if each in contents: + yield contents[each] + for each in in_subarray: + if each in contents: + yield from contents[each] + for each in in_subvalues: + if each in contents: + yield from contents[each].values() + + items = contents.get("items") + if items is not None: + if isinstance(items, Sequence): + yield from items + else: + yield items + + return subresources_of + + +def _subresources_of_with_crazy_items_dependencies( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + """ + Specifically handle older drafts where there are some funky keywords. + """ + + def subresources_of(contents: Schema) -> Iterable[ObjectSchema]: + if isinstance(contents, bool): + return + for each in in_value: + if each in contents: + yield contents[each] + for each in in_subarray: + if each in contents: + yield from contents[each] + for each in in_subvalues: + if each in contents: + yield from contents[each].values() + + items = contents.get("items") + if items is not None: + if isinstance(items, Sequence): + yield from items + else: + yield items + dependencies = contents.get("dependencies") + if dependencies is not None: + values = iter(dependencies.values()) + value = next(values, None) + if isinstance(value, Mapping): + yield value + yield from values + + return subresources_of + + +def _subresources_of_with_crazy_aP_items_dependencies( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + """ + Specifically handle even older drafts where there are some funky keywords. + """ + + def subresources_of(contents: ObjectSchema) -> Iterable[ObjectSchema]: + for each in in_value: + if each in contents: + yield contents[each] + for each in in_subarray: + if each in contents: + yield from contents[each] + for each in in_subvalues: + if each in contents: + yield from contents[each].values() + + items = contents.get("items") + if items is not None: + if isinstance(items, Sequence): + yield from items + else: + yield items + dependencies = contents.get("dependencies") + if dependencies is not None: + values = iter(dependencies.values()) + value = next(values, None) + if isinstance(value, Mapping): + yield value + yield from values + + for each in "additionalItems", "additionalProperties": + value = contents.get(each) + if isinstance(value, Mapping): + yield value + + return subresources_of + + +def _maybe_in_subresource( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + in_child = in_subvalues | in_subarray + + def maybe_in_subresource( + segments: Sequence[int | str], + resolver: _Resolver[Any], + subresource: Resource[Any], + ) -> _Resolver[Any]: + _segments = iter(segments) + for segment in _segments: + if segment not in in_value and ( + segment not in in_child or next(_segments, None) is None + ): + return resolver + return resolver.in_subresource(subresource) + + return maybe_in_subresource + + +def _maybe_in_subresource_crazy_items( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + in_child = in_subvalues | in_subarray + + def maybe_in_subresource( + segments: Sequence[int | str], + resolver: _Resolver[Any], + subresource: Resource[Any], + ) -> _Resolver[Any]: + _segments = iter(segments) + for segment in _segments: + if segment == "items" and isinstance( + subresource.contents, + Mapping, + ): + return resolver.in_subresource(subresource) + if segment not in in_value and ( + segment not in in_child or next(_segments, None) is None + ): + return resolver + return resolver.in_subresource(subresource) + + return maybe_in_subresource + + +def _maybe_in_subresource_crazy_items_dependencies( + in_value: Set[str] = frozenset(), + in_subvalues: Set[str] = frozenset(), + in_subarray: Set[str] = frozenset(), +): + in_child = in_subvalues | in_subarray + + def maybe_in_subresource( + segments: Sequence[int | str], + resolver: _Resolver[Any], + subresource: Resource[Any], + ) -> _Resolver[Any]: + _segments = iter(segments) + for segment in _segments: + if segment in {"items", "dependencies"} and isinstance( + subresource.contents, + Mapping, + ): + return resolver.in_subresource(subresource) + if segment not in in_value and ( + segment not in in_child or next(_segments, None) is None + ): + return resolver + return resolver.in_subresource(subresource) + + return maybe_in_subresource + + +#: JSON Schema draft 2020-12 +DRAFT202012 = Specification( + name="draft2020-12", + id_of=_dollar_id, + subresources_of=_subresources_of( + in_value={ + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + }, + in_subarray={"allOf", "anyOf", "oneOf", "prefixItems"}, + in_subvalues={ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + }, + ), + anchors_in=_anchor, + maybe_in_subresource=_maybe_in_subresource( + in_value={ + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + }, + in_subarray={"allOf", "anyOf", "oneOf", "prefixItems"}, + in_subvalues={ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + }, + ), +) +#: JSON Schema draft 2019-09 +DRAFT201909 = Specification( + name="draft2019-09", + id_of=_dollar_id, + subresources_of=_subresources_of_with_crazy_items( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + }, + ), + anchors_in=_anchor_2019, + maybe_in_subresource=_maybe_in_subresource_crazy_items( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "contentSchema", + "else", + "if", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={ + "$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties", + }, + ), +) +#: JSON Schema draft 7 +DRAFT7 = Specification( + name="draft-07", + id_of=_legacy_dollar_id, + subresources_of=_subresources_of_with_crazy_items_dependencies( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "else", + "if", + "not", + "propertyNames", + "then", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), + anchors_in=_legacy_anchor_in_dollar_id, + maybe_in_subresource=_maybe_in_subresource_crazy_items_dependencies( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "else", + "if", + "not", + "propertyNames", + "then", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), +) +#: JSON Schema draft 6 +DRAFT6 = Specification( + name="draft-06", + id_of=_legacy_dollar_id, + subresources_of=_subresources_of_with_crazy_items_dependencies( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "not", + "propertyNames", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), + anchors_in=_legacy_anchor_in_dollar_id, + maybe_in_subresource=_maybe_in_subresource_crazy_items_dependencies( + in_value={ + "additionalItems", + "additionalProperties", + "contains", + "not", + "propertyNames", + }, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), +) +#: JSON Schema draft 4 +DRAFT4 = Specification( + name="draft-04", + id_of=_legacy_id, + subresources_of=_subresources_of_with_crazy_aP_items_dependencies( + in_value={"not"}, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), + anchors_in=_legacy_anchor_in_id, + maybe_in_subresource=_maybe_in_subresource_crazy_items_dependencies( + in_value={"additionalItems", "additionalProperties", "not"}, + in_subarray={"allOf", "anyOf", "oneOf"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), +) +#: JSON Schema draft 3 +DRAFT3 = Specification( + name="draft-03", + id_of=_legacy_id, + subresources_of=_subresources_of_with_crazy_aP_items_dependencies( + in_subarray={"extends"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), + anchors_in=_legacy_anchor_in_id, + maybe_in_subresource=_maybe_in_subresource_crazy_items_dependencies( + in_value={"additionalItems", "additionalProperties"}, + in_subarray={"extends"}, + in_subvalues={"definitions", "patternProperties", "properties"}, + ), +) + + +_SPECIFICATIONS: Registry[Specification[Schema]] = Registry( + { + dialect_id: Resource.opaque(specification) + for dialect_id, specification in [ + ("https://json-schema.org/draft/2020-12/schema", DRAFT202012), + ("https://json-schema.org/draft/2019-09/schema", DRAFT201909), + ("http://json-schema.org/draft-07/schema", DRAFT7), + ("http://json-schema.org/draft-06/schema", DRAFT6), + ("http://json-schema.org/draft-04/schema", DRAFT4), + ("http://json-schema.org/draft-03/schema", DRAFT3), + ] + }, +) + + +def specification_with( + dialect_id: URI, + default: Specification[Any] | _Unset = _UNSET, +) -> Specification[Any]: + """ + Retrieve the `Specification` with the given dialect identifier. + + Raises: + + `UnknownDialect` + + if the given ``dialect_id`` isn't known + + """ + resource = _SPECIFICATIONS.get(dialect_id.rstrip("#")) + if resource is not None: + return resource.contents + if default is _UNSET: + raise UnknownDialect(dialect_id) + return default + + +@frozen +class DynamicAnchor: + """ + Dynamic anchors, introduced in draft 2020. + """ + + name: str + resource: SchemaResource + + def resolve(self, resolver: _Resolver[Schema]) -> _Resolved[Schema]: + """ + Resolve this anchor dynamically. + """ + last = self.resource + for uri, registry in resolver.dynamic_scope(): + try: + anchor = registry.anchor(uri, self.name).value + except exceptions.NoSuchAnchor: + continue + if isinstance(anchor, DynamicAnchor): + last = anchor.resource + return _Resolved( + contents=last.contents, + resolver=resolver.in_subresource(last), + ) + + +def lookup_recursive_ref(resolver: _Resolver[Schema]) -> _Resolved[Schema]: + """ + Recursive references (via recursive anchors), present only in draft 2019. + + As per the 2019 specification (§ 8.2.4.2.1), only the ``#`` recursive + reference is supported (and is therefore assumed to be the relevant + reference). + """ + resolved = resolver.lookup("#") + if isinstance(resolved.contents, Mapping) and resolved.contents.get( + "$recursiveAnchor", + ): + for uri, _ in resolver.dynamic_scope(): + next_resolved = resolver.lookup(uri) + if not isinstance( + next_resolved.contents, + Mapping, + ) or not next_resolved.contents.get("$recursiveAnchor"): + break + resolved = next_resolved + return resolved diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/py.typed b/edge-cv-portal/backend/layers/workflow_core/python/referencing/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/retrieval.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/retrieval.py new file mode 100644 index 00000000..ac5a65bd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/retrieval.py @@ -0,0 +1,94 @@ +""" +Helpers related to (dynamic) resource retrieval. +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import TYPE_CHECKING +import json + +try: + from typing_extensions import TypeVar +except ImportError: # pragma: no cover + from typing import TypeVar + +from referencing import Resource + +if TYPE_CHECKING: + from collections.abc import Callable + + from referencing.typing import URI, D, Retrieve + +#: A serialized document (e.g. a JSON string) +_T = TypeVar("_T", default=str) + + +def to_cached_resource( + cache: Callable[[Retrieve[D]], Retrieve[D]] | None = None, + loads: Callable[[_T], D] = json.loads, + from_contents: Callable[[D], Resource[D]] = Resource.from_contents, +) -> Callable[[Callable[[URI], _T]], Retrieve[D]]: + """ + Create a retriever which caches its return values from a simpler callable. + + Takes a function which returns things like serialized JSON (strings) and + returns something suitable for passing to `Registry` as a retrieve + function. + + This decorator both reduces a small bit of boilerplate for a common case + (deserializing JSON from strings and creating `Resource` objects from the + result) as well as makes the probable need for caching a bit easier. + Retrievers which otherwise do expensive operations (like hitting the + network) might otherwise be called repeatedly. + + Examples + -------- + + .. testcode:: + + from referencing import Registry + from referencing.typing import URI + import referencing.retrieval + + + @referencing.retrieval.to_cached_resource() + def retrieve(uri: URI): + print(f"Retrieved {uri}") + + # Normally, go get some expensive JSON from the network, a file ... + return ''' + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "foo": "bar" + } + ''' + + one = Registry(retrieve=retrieve).get_or_retrieve("urn:example:foo") + print(one.value.contents["foo"]) + + # Retrieving the same URI again reuses the same value (and thus doesn't + # print another retrieval message here) + two = Registry(retrieve=retrieve).get_or_retrieve("urn:example:foo") + print(two.value.contents["foo"]) + + .. testoutput:: + + Retrieved urn:example:foo + bar + bar + + """ + if cache is None: + cache = lru_cache(maxsize=None) + + def decorator(retrieve: Callable[[URI], _T]): + @cache + def cached_retrieve(uri: URI): + response = retrieve(uri) + contents = loads(response) + return from_contents(contents) + + return cached_retrieve + + return decorator diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_core.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_core.py new file mode 100644 index 00000000..3edddbc3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_core.py @@ -0,0 +1,1057 @@ +from rpds import HashTrieMap +import pytest + +from referencing import Anchor, Registry, Resource, Specification, exceptions +from referencing.jsonschema import DRAFT202012 + +ID_AND_CHILDREN = Specification( + name="id-and-children", + id_of=lambda contents: contents.get("ID"), + subresources_of=lambda contents: contents.get("children", []), + anchors_in=lambda specification, contents: [ + Anchor( + name=name, + resource=specification.create_resource(contents=each), + ) + for name, each in contents.get("anchors", {}).items() + ], + maybe_in_subresource=lambda segments, resolver, subresource: ( + resolver.in_subresource(subresource) + if not len(segments) % 2 + and all(each == "children" for each in segments[::2]) + else resolver + ), +) + + +def blow_up(uri): # pragma: no cover + """ + A retriever suitable for use in tests which expect it never to be used. + """ + raise RuntimeError("This retrieve function expects to never be called!") + + +class TestRegistry: + def test_with_resource(self): + """ + Adding a resource to the registry then allows re-retrieving it. + """ + + resource = Resource.opaque(contents={"foo": "bar"}) + uri = "urn:example" + registry = Registry().with_resource(uri=uri, resource=resource) + assert registry[uri] is resource + + def test_with_resources(self): + """ + Adding multiple resources to the registry is like adding each one. + """ + + one = Resource.opaque(contents={}) + two = Resource(contents={"foo": "bar"}, specification=ID_AND_CHILDREN) + registry = Registry().with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/foo/bar", two), + ], + ) + assert registry == Registry().with_resource( + uri="http://example.com/1", + resource=one, + ).with_resource( + uri="http://example.com/foo/bar", + resource=two, + ) + + def test_matmul_resource(self): + uri = "urn:example:resource" + resource = ID_AND_CHILDREN.create_resource({"ID": uri, "foo": 12}) + registry = resource @ Registry() + assert registry == Registry().with_resource(uri, resource) + + def test_matmul_many_resources(self): + one_uri = "urn:example:one" + one = ID_AND_CHILDREN.create_resource({"ID": one_uri, "foo": 12}) + + two_uri = "urn:example:two" + two = ID_AND_CHILDREN.create_resource({"ID": two_uri, "foo": 12}) + + registry = [one, two] @ Registry() + assert registry == Registry().with_resources( + [(one_uri, one), (two_uri, two)], + ) + + def test_matmul_resource_without_id(self): + resource = Resource.opaque(contents={"foo": "bar"}) + with pytest.raises(exceptions.NoInternalID) as e: + resource @ Registry() + assert e.value == exceptions.NoInternalID(resource=resource) + + def test_with_contents_from_json_schema(self): + uri = "urn:example" + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + registry = Registry().with_contents([(uri, schema)]) + + expected = Resource(contents=schema, specification=DRAFT202012) + assert registry[uri] == expected + + def test_with_contents_and_default_specification(self): + uri = "urn:example" + registry = Registry().with_contents( + [(uri, {"foo": "bar"})], + default_specification=Specification.OPAQUE, + ) + assert registry[uri] == Resource.opaque({"foo": "bar"}) + + def test_len(self): + total = 5 + registry = Registry().with_contents( + [(str(i), {"foo": "bar"}) for i in range(total)], + default_specification=Specification.OPAQUE, + ) + assert len(registry) == total + + def test_bool_empty(self): + assert not Registry() + + def test_bool_not_empty(self): + registry = Registry().with_contents( + [(str(i), {"foo": "bar"}) for i in range(3)], + default_specification=Specification.OPAQUE, + ) + assert registry + + def test_iter(self): + registry = Registry().with_contents( + [(str(i), {"foo": "bar"}) for i in range(8)], + default_specification=Specification.OPAQUE, + ) + assert set(registry) == {str(i) for i in range(8)} + + def test_crawl_still_has_top_level_resource(self): + resource = Resource.opaque({"foo": "bar"}) + uri = "urn:example" + registry = Registry({uri: resource}).crawl() + assert registry[uri] is resource + + def test_crawl_finds_a_subresource(self): + child_id = "urn:child" + root = ID_AND_CHILDREN.create_resource( + {"ID": "urn:root", "children": [{"ID": child_id, "foo": 12}]}, + ) + registry = root @ Registry() + with pytest.raises(LookupError): + registry[child_id] + + expected = ID_AND_CHILDREN.create_resource({"ID": child_id, "foo": 12}) + assert registry.crawl()[child_id] == expected + + def test_crawl_finds_anchors_with_id(self): + resource = ID_AND_CHILDREN.create_resource( + {"ID": "urn:bar", "anchors": {"foo": 12}}, + ) + registry = resource @ Registry() + + assert registry.crawl().anchor(resource.id(), "foo").value == Anchor( + name="foo", + resource=ID_AND_CHILDREN.create_resource(12), + ) + + def test_crawl_finds_anchors_no_id(self): + resource = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}}) + registry = Registry().with_resource("urn:root", resource) + + assert registry.crawl().anchor("urn:root", "foo").value == Anchor( + name="foo", + resource=ID_AND_CHILDREN.create_resource(12), + ) + + def test_contents(self): + resource = Resource.opaque({"foo": "bar"}) + uri = "urn:example" + registry = Registry().with_resource(uri, resource) + assert registry.contents(uri) == {"foo": "bar"} + + def test_getitem_strips_empty_fragments(self): + uri = "http://example.com/" + resource = ID_AND_CHILDREN.create_resource({"ID": uri + "#"}) + registry = resource @ Registry() + assert registry[uri] == registry[uri + "#"] == resource + + def test_contents_strips_empty_fragments(self): + uri = "http://example.com/" + resource = ID_AND_CHILDREN.create_resource({"ID": uri + "#"}) + registry = resource @ Registry() + assert ( + registry.contents(uri) + == registry.contents(uri + "#") + == {"ID": uri + "#"} + ) + + def test_contents_nonexistent_resource(self): + registry = Registry() + with pytest.raises(exceptions.NoSuchResource) as e: + registry.contents("urn:example") + assert e.value == exceptions.NoSuchResource(ref="urn:example") + + def test_crawled_anchor(self): + resource = ID_AND_CHILDREN.create_resource({"anchors": {"foo": "bar"}}) + registry = Registry().with_resource("urn:example", resource) + retrieved = registry.anchor("urn:example", "foo") + assert retrieved.value == Anchor( + name="foo", + resource=ID_AND_CHILDREN.create_resource("bar"), + ) + assert retrieved.registry == registry.crawl() + + def test_anchor_in_nonexistent_resource(self): + registry = Registry() + with pytest.raises(exceptions.NoSuchResource) as e: + registry.anchor("urn:example", "foo") + assert e.value == exceptions.NoSuchResource(ref="urn:example") + + def test_init(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = Registry( + { + "http://example.com/1": one, + "http://example.com/foo/bar": two, + }, + ) + assert ( + registry + == Registry() + .with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/foo/bar", two), + ], + ) + .crawl() + ) + + def test_dict_conversion(self): + """ + Passing a `dict` to `Registry` gets converted to a `HashTrieMap`. + + So continuing to use the registry works. + """ + + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = Registry( + {"http://example.com/1": one}, + ).with_resource("http://example.com/foo/bar", two) + assert ( + registry.crawl() + == Registry() + .with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/foo/bar", two), + ], + ) + .crawl() + ) + + def test_no_such_resource(self): + registry = Registry() + with pytest.raises(exceptions.NoSuchResource) as e: + registry["urn:bigboom"] + assert e.value == exceptions.NoSuchResource(ref="urn:bigboom") + + def test_combine(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + three = ID_AND_CHILDREN.create_resource({"baz": "quux"}) + four = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}}) + + first = Registry({"http://example.com/1": one}) + second = Registry().with_resource("http://example.com/foo/bar", two) + third = Registry( + { + "http://example.com/1": one, + "http://example.com/baz": three, + }, + ) + fourth = ( + Registry() + .with_resource( + "http://example.com/foo/quux", + four, + ) + .crawl() + ) + assert first.combine(second, third, fourth) == Registry( + [ + ("http://example.com/1", one), + ("http://example.com/baz", three), + ("http://example.com/foo/quux", four), + ], + anchors=HashTrieMap( + { + ("http://example.com/foo/quux", "foo"): Anchor( + name="foo", + resource=ID_AND_CHILDREN.create_resource(12), + ), + }, + ), + ).with_resource("http://example.com/foo/bar", two) + + def test_combine_self(self): + """ + Combining a registry with itself short-circuits. + + This is a performance optimization -- otherwise we do lots more work + (in jsonschema this seems to correspond to making the test suite take + *3x* longer). + """ + + registry = Registry({"urn:foo": "bar"}) + assert registry.combine(registry) is registry + + def test_combine_with_uncrawled_resources(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + three = ID_AND_CHILDREN.create_resource({"baz": "quux"}) + + first = Registry().with_resource("http://example.com/1", one) + second = Registry().with_resource("http://example.com/foo/bar", two) + third = Registry( + { + "http://example.com/1": one, + "http://example.com/baz": three, + }, + ) + expected = Registry( + [ + ("http://example.com/1", one), + ("http://example.com/foo/bar", two), + ("http://example.com/baz", three), + ], + ) + combined = first.combine(second, third) + assert combined != expected + assert combined.crawl() == expected + + def test_combine_with_single_retrieve(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + three = ID_AND_CHILDREN.create_resource({"baz": "quux"}) + + def retrieve(uri): # pragma: no cover + pass + + first = Registry().with_resource("http://example.com/1", one) + second = Registry( + retrieve=retrieve, + ).with_resource("http://example.com/2", two) + third = Registry().with_resource("http://example.com/3", three) + + assert first.combine(second, third) == Registry( + retrieve=retrieve, + ).with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/2", two), + ("http://example.com/3", three), + ], + ) + assert second.combine(first, third) == Registry( + retrieve=retrieve, + ).with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/2", two), + ("http://example.com/3", three), + ], + ) + + def test_combine_with_common_retrieve(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + three = ID_AND_CHILDREN.create_resource({"baz": "quux"}) + + def retrieve(uri): # pragma: no cover + pass + + first = Registry(retrieve=retrieve).with_resource( + "http://example.com/1", + one, + ) + second = Registry( + retrieve=retrieve, + ).with_resource("http://example.com/2", two) + third = Registry(retrieve=retrieve).with_resource( + "http://example.com/3", + three, + ) + + assert first.combine(second, third) == Registry( + retrieve=retrieve, + ).with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/2", two), + ("http://example.com/3", three), + ], + ) + assert second.combine(first, third) == Registry( + retrieve=retrieve, + ).with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/2", two), + ("http://example.com/3", three), + ], + ) + + def test_combine_conflicting_retrieve(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + three = ID_AND_CHILDREN.create_resource({"baz": "quux"}) + + def foo_retrieve(uri): # pragma: no cover + pass + + def bar_retrieve(uri): # pragma: no cover + pass + + first = Registry(retrieve=foo_retrieve).with_resource( + "http://example.com/1", + one, + ) + second = Registry().with_resource("http://example.com/2", two) + third = Registry(retrieve=bar_retrieve).with_resource( + "http://example.com/3", + three, + ) + + with pytest.raises(Exception, match="conflict.*retriev"): + first.combine(second, third) + + def test_remove(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = Registry({"urn:foo": one, "urn:bar": two}) + assert registry.remove("urn:foo") == Registry({"urn:bar": two}) + + def test_remove_uncrawled(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = Registry().with_resources( + [("urn:foo", one), ("urn:bar", two)], + ) + assert registry.remove("urn:foo") == Registry().with_resource( + "urn:bar", + two, + ) + + def test_remove_with_anchors(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"anchors": {"foo": "bar"}}) + registry = ( + Registry() + .with_resources( + [("urn:foo", one), ("urn:bar", two)], + ) + .crawl() + ) + assert ( + registry.remove("urn:bar") + == Registry() + .with_resource( + "urn:foo", + one, + ) + .crawl() + ) + + def test_remove_nonexistent_uri(self): + with pytest.raises(exceptions.NoSuchResource) as e: + Registry().remove("urn:doesNotExist") + assert e.value == exceptions.NoSuchResource(ref="urn:doesNotExist") + + def test_retrieve(self): + foo = Resource.opaque({"foo": "bar"}) + registry = Registry(retrieve=lambda uri: foo) + assert registry.get_or_retrieve("urn:example").value == foo + + def test_retrieve_arbitrary_exception(self): + foo = Resource.opaque({"foo": "bar"}) + + def retrieve(uri): + if uri == "urn:succeed": + return foo + raise Exception("Oh no!") + + registry = Registry(retrieve=retrieve) + assert registry.get_or_retrieve("urn:succeed").value == foo + with pytest.raises(exceptions.Unretrievable): + registry.get_or_retrieve("urn:uhoh") + + def test_retrieve_no_such_resource(self): + foo = Resource.opaque({"foo": "bar"}) + + def retrieve(uri): + if uri == "urn:succeed": + return foo + raise exceptions.NoSuchResource(ref=uri) + + registry = Registry(retrieve=retrieve) + assert registry.get_or_retrieve("urn:succeed").value == foo + with pytest.raises(exceptions.NoSuchResource): + registry.get_or_retrieve("urn:uhoh") + + def test_retrieve_cannot_determine_specification(self): + def retrieve(uri): + return Resource.from_contents({}) + + registry = Registry(retrieve=retrieve) + with pytest.raises(exceptions.CannotDetermineSpecification): + registry.get_or_retrieve("urn:uhoh") + + def test_retrieve_already_available_resource(self): + foo = Resource.opaque({"foo": "bar"}) + registry = Registry({"urn:example": foo}, retrieve=blow_up) + assert registry["urn:example"] == foo + assert registry.get_or_retrieve("urn:example").value == foo + + def test_retrieve_first_checks_crawlable_resource(self): + child = ID_AND_CHILDREN.create_resource({"ID": "urn:child", "foo": 12}) + root = ID_AND_CHILDREN.create_resource({"children": [child.contents]}) + registry = Registry(retrieve=blow_up).with_resource("urn:root", root) + assert registry.crawl()["urn:child"] == child + + def test_resolver(self): + one = Resource.opaque(contents={}) + registry = Registry({"http://example.com": one}) + resolver = registry.resolver(base_uri="http://example.com") + assert resolver.lookup("#").contents == {} + + def test_resolver_with_root_identified(self): + root = ID_AND_CHILDREN.create_resource({"ID": "http://example.com"}) + resolver = Registry().resolver_with_root(root) + assert resolver.lookup("http://example.com").contents == root.contents + assert resolver.lookup("#").contents == root.contents + + def test_resolver_with_root_unidentified(self): + root = Resource.opaque(contents={}) + resolver = Registry().resolver_with_root(root) + assert resolver.lookup("#").contents == root.contents + + def test_repr(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = Registry().with_resources( + [ + ("http://example.com/1", one), + ("http://example.com/foo/bar", two), + ], + ) + assert repr(registry) == "" + assert repr(registry.crawl()) == "" + + def test_repr_mixed_crawled(self): + one = Resource.opaque(contents={}) + two = ID_AND_CHILDREN.create_resource({"foo": "bar"}) + registry = ( + Registry( + {"http://example.com/1": one}, + ) + .crawl() + .with_resource(uri="http://example.com/foo/bar", resource=two) + ) + assert repr(registry) == "" + + def test_repr_one_resource(self): + registry = Registry().with_resource( + uri="http://example.com/1", + resource=Resource.opaque(contents={}), + ) + assert repr(registry) == "" + + def test_repr_empty(self): + assert repr(Registry()) == "" + + +class TestResource: + def test_from_contents_from_json_schema(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + resource = Resource.from_contents(schema) + assert resource == Resource(contents=schema, specification=DRAFT202012) + + def test_from_contents_with_no_discernible_information(self): + """ + Creating a resource with no discernible way to see what + specification it belongs to (e.g. no ``$schema`` keyword for JSON + Schema) raises an error. + """ + + with pytest.raises(exceptions.CannotDetermineSpecification): + Resource.from_contents({"foo": "bar"}) + + def test_from_contents_with_no_discernible_information_and_default(self): + resource = Resource.from_contents( + {"foo": "bar"}, + default_specification=Specification.OPAQUE, + ) + assert resource == Resource.opaque(contents={"foo": "bar"}) + + def test_from_contents_unneeded_default(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + resource = Resource.from_contents( + schema, + default_specification=Specification.OPAQUE, + ) + assert resource == Resource( + contents=schema, + specification=DRAFT202012, + ) + + def test_non_mapping_from_contents(self): + resource = Resource.from_contents( + True, + default_specification=ID_AND_CHILDREN, + ) + assert resource == Resource( + contents=True, + specification=ID_AND_CHILDREN, + ) + + def test_from_contents_with_fallback(self): + resource = Resource.from_contents( + {"foo": "bar"}, + default_specification=Specification.OPAQUE, + ) + assert resource == Resource.opaque(contents={"foo": "bar"}) + + def test_id_delegates_to_specification(self): + specification = Specification( + name="", + id_of=lambda contents: "urn:fixedID", + subresources_of=lambda contents: [], + anchors_in=lambda specification, contents: [], + maybe_in_subresource=( + lambda segments, resolver, subresource: resolver + ), + ) + resource = Resource( + contents={"foo": "baz"}, + specification=specification, + ) + assert resource.id() == "urn:fixedID" + + def test_id_strips_empty_fragment(self): + uri = "http://example.com/" + root = ID_AND_CHILDREN.create_resource({"ID": uri + "#"}) + assert root.id() == uri + + def test_subresources_delegates_to_specification(self): + resource = ID_AND_CHILDREN.create_resource({"children": [{}, 12]}) + assert list(resource.subresources()) == [ + ID_AND_CHILDREN.create_resource(each) for each in [{}, 12] + ] + + def test_subresource_with_different_specification(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + resource = ID_AND_CHILDREN.create_resource({"children": [schema]}) + assert list(resource.subresources()) == [ + DRAFT202012.create_resource(schema), + ] + + def test_anchors_delegates_to_specification(self): + resource = ID_AND_CHILDREN.create_resource( + {"anchors": {"foo": {}, "bar": 1, "baz": ""}}, + ) + assert list(resource.anchors()) == [ + Anchor(name="foo", resource=ID_AND_CHILDREN.create_resource({})), + Anchor(name="bar", resource=ID_AND_CHILDREN.create_resource(1)), + Anchor(name="baz", resource=ID_AND_CHILDREN.create_resource("")), + ] + + def test_pointer_to_mapping(self): + resource = Resource.opaque(contents={"foo": "baz"}) + resolver = Registry().resolver() + assert resource.pointer("/foo", resolver=resolver).contents == "baz" + + def test_pointer_to_array(self): + resource = Resource.opaque(contents={"foo": {"bar": [3]}}) + resolver = Registry().resolver() + assert resource.pointer("/foo/bar/0", resolver=resolver).contents == 3 + + def test_root_pointer(self): + contents = {"foo": "baz"} + resource = Resource.opaque(contents=contents) + resolver = Registry().resolver() + assert resource.pointer("", resolver=resolver).contents == contents + + def test_opaque(self): + contents = {"foo": "bar"} + assert Resource.opaque(contents) == Resource( + contents=contents, + specification=Specification.OPAQUE, + ) + + +class TestResolver: + def test_lookup_exact_uri(self): + resource = Resource.opaque(contents={"foo": "baz"}) + resolver = Registry({"http://example.com/1": resource}).resolver() + resolved = resolver.lookup("http://example.com/1") + assert resolved.contents == resource.contents + + def test_lookup_subresource(self): + root = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "children": [ + {"ID": "http://example.com/a", "foo": 12}, + ], + }, + ) + registry = root @ Registry() + resolved = registry.resolver().lookup("http://example.com/a") + assert resolved.contents == {"ID": "http://example.com/a", "foo": 12} + + def test_lookup_anchor_with_id(self): + root = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "anchors": {"foo": 12}, + }, + ) + registry = root @ Registry() + resolved = registry.resolver().lookup("http://example.com/#foo") + assert resolved.contents == 12 + + def test_lookup_anchor_without_id(self): + root = ID_AND_CHILDREN.create_resource({"anchors": {"foo": 12}}) + resolver = Registry().with_resource("urn:example", root).resolver() + resolved = resolver.lookup("urn:example#foo") + assert resolved.contents == 12 + + def test_lookup_unknown_reference(self): + resolver = Registry().resolver() + ref = "http://example.com/does/not/exist" + with pytest.raises(exceptions.Unresolvable) as e: + resolver.lookup(ref) + assert e.value == exceptions.Unresolvable(ref=ref) + + def test_lookup_non_existent_pointer(self): + resource = Resource.opaque({"foo": {}}) + resolver = Registry({"http://example.com/1": resource}).resolver() + ref = "http://example.com/1#/foo/bar" + with pytest.raises(exceptions.Unresolvable) as e: + resolver.lookup(ref) + assert e.value == exceptions.PointerToNowhere( + ref="/foo/bar", + resource=resource, + ) + assert str(e.value) == "'/foo/bar' does not exist within {'foo': {}}" + + def test_lookup_non_existent_pointer_to_array_index(self): + resource = Resource.opaque([1, 2, 4, 8]) + resolver = Registry({"http://example.com/1": resource}).resolver() + ref = "http://example.com/1#/10" + with pytest.raises(exceptions.Unresolvable) as e: + resolver.lookup(ref) + assert e.value == exceptions.PointerToNowhere( + ref="/10", + resource=resource, + ) + + def test_lookup_pointer_to_empty_string(self): + resolver = Registry().resolver_with_root(Resource.opaque({"": {}})) + assert resolver.lookup("#/").contents == {} + + def test_lookup_non_existent_pointer_to_empty_string(self): + resource = Resource.opaque({"foo": {}}) + resolver = Registry().resolver_with_root(resource) + with pytest.raises( + exceptions.Unresolvable, + match="^'/' does not exist within {'foo': {}}.*'#'", + ) as e: + resolver.lookup("#/") + assert e.value == exceptions.PointerToNowhere( + ref="/", + resource=resource, + ) + + def test_lookup_non_existent_anchor(self): + root = ID_AND_CHILDREN.create_resource({"anchors": {}}) + resolver = Registry().with_resource("urn:example", root).resolver() + resolved = resolver.lookup("urn:example") + assert resolved.contents == root.contents + + ref = "urn:example#noSuchAnchor" + with pytest.raises(exceptions.Unresolvable) as e: + resolver.lookup(ref) + assert "'noSuchAnchor' does not exist" in str(e.value) + assert e.value == exceptions.NoSuchAnchor( + ref="urn:example", + resource=root, + anchor="noSuchAnchor", + ) + + def test_lookup_invalid_JSON_pointerish_anchor(self): + resolver = Registry().resolver_with_root( + ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "foo": {"bar": 12}, + }, + ), + ) + + valid = resolver.lookup("#/foo/bar") + assert valid.contents == 12 + + with pytest.raises(exceptions.InvalidAnchor) as e: + resolver.lookup("#foo/bar") + assert " '#/foo/bar'" in str(e.value) + + def test_lookup_retrieved_resource(self): + resource = Resource.opaque(contents={"foo": "baz"}) + resolver = Registry(retrieve=lambda uri: resource).resolver() + resolved = resolver.lookup("http://example.com/") + assert resolved.contents == resource.contents + + def test_lookup_failed_retrieved_resource(self): + """ + Unretrievable exceptions are also wrapped in Unresolvable. + """ + + uri = "http://example.com/" + + registry = Registry(retrieve=blow_up) + with pytest.raises(exceptions.Unretrievable): + registry.get_or_retrieve(uri) + + resolver = registry.resolver() + with pytest.raises(exceptions.Unresolvable): + resolver.lookup(uri) + + def test_repeated_lookup_from_retrieved_resource(self): + """ + A (custom-)retrieved resource is added to the registry returned by + looking it up. + """ + resource = Resource.opaque(contents={"foo": "baz"}) + once = [resource] + + def retrieve(uri): + return once.pop() + + resolver = Registry(retrieve=retrieve).resolver() + resolved = resolver.lookup("http://example.com/") + assert resolved.contents == resource.contents + + resolved = resolved.resolver.lookup("http://example.com/") + assert resolved.contents == resource.contents + + def test_repeated_anchor_lookup_from_retrieved_resource(self): + resource = Resource.opaque(contents={"foo": "baz"}) + once = [resource] + + def retrieve(uri): + return once.pop() + + resolver = Registry(retrieve=retrieve).resolver() + resolved = resolver.lookup("http://example.com/") + assert resolved.contents == resource.contents + + resolved = resolved.resolver.lookup("#") + assert resolved.contents == resource.contents + + # FIXME: The tests below aren't really representable in the current + # suite, though we should probably think of ways to do so. + + def test_in_subresource(self): + root = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "children": [ + { + "ID": "child/", + "children": [{"ID": "grandchild"}], + }, + ], + }, + ) + registry = root @ Registry() + + resolver = registry.resolver() + first = resolver.lookup("http://example.com/") + assert first.contents == root.contents + + with pytest.raises(exceptions.Unresolvable): + first.resolver.lookup("grandchild") + + sub = first.resolver.in_subresource( + ID_AND_CHILDREN.create_resource(first.contents["children"][0]), + ) + second = sub.lookup("grandchild") + assert second.contents == {"ID": "grandchild"} + + def test_in_pointer_subresource(self): + root = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "children": [ + { + "ID": "child/", + "children": [{"ID": "grandchild"}], + }, + ], + }, + ) + registry = root @ Registry() + + resolver = registry.resolver() + first = resolver.lookup("http://example.com/") + assert first.contents == root.contents + + with pytest.raises(exceptions.Unresolvable): + first.resolver.lookup("grandchild") + + second = first.resolver.lookup("#/children/0") + third = second.resolver.lookup("grandchild") + assert third.contents == {"ID": "grandchild"} + + def test_dynamic_scope(self): + one = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/", + "children": [ + { + "ID": "child/", + "children": [{"ID": "grandchild"}], + }, + ], + }, + ) + two = ID_AND_CHILDREN.create_resource( + { + "ID": "http://example.com/two", + "children": [{"ID": "two-child/"}], + }, + ) + registry = [one, two] @ Registry() + + resolver = registry.resolver() + first = resolver.lookup("http://example.com/") + second = first.resolver.lookup("#/children/0") + third = second.resolver.lookup("grandchild") + fourth = third.resolver.lookup("http://example.com/two") + assert list(fourth.resolver.dynamic_scope()) == [ + ("http://example.com/child/grandchild", fourth.resolver._registry), + ("http://example.com/child/", fourth.resolver._registry), + ("http://example.com/", fourth.resolver._registry), + ] + assert list(third.resolver.dynamic_scope()) == [ + ("http://example.com/child/", third.resolver._registry), + ("http://example.com/", third.resolver._registry), + ] + assert list(second.resolver.dynamic_scope()) == [ + ("http://example.com/", second.resolver._registry), + ] + assert list(first.resolver.dynamic_scope()) == [] + + +class TestSpecification: + def test_create_resource(self): + specification = Specification( + name="", + id_of=lambda contents: "urn:fixedID", + subresources_of=lambda contents: [], + anchors_in=lambda specification, contents: [], + maybe_in_subresource=( + lambda segments, resolver, subresource: resolver + ), + ) + resource = specification.create_resource(contents={"foo": "baz"}) + assert resource == Resource( + contents={"foo": "baz"}, + specification=specification, + ) + assert resource.id() == "urn:fixedID" + + def test_detect_from_json_schema(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + specification = Specification.detect(schema) + assert specification == DRAFT202012 + + def test_detect_with_no_discernible_information(self): + with pytest.raises(exceptions.CannotDetermineSpecification): + Specification.detect({"foo": "bar"}) + + def test_detect_with_non_URI_schema(self): + with pytest.raises(exceptions.CannotDetermineSpecification): + Specification.detect({"$schema": 37}) + + def test_detect_with_no_discernible_information_and_default(self): + specification = Specification.OPAQUE.detect({"foo": "bar"}) + assert specification is Specification.OPAQUE + + def test_detect_unneeded_default(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + specification = Specification.OPAQUE.detect(schema) + assert specification == DRAFT202012 + + def test_non_mapping_detect(self): + with pytest.raises(exceptions.CannotDetermineSpecification): + Specification.detect(True) + + def test_non_mapping_detect_with_default(self): + specification = ID_AND_CHILDREN.detect(True) + assert specification is ID_AND_CHILDREN + + def test_detect_with_fallback(self): + specification = Specification.OPAQUE.detect({"foo": "bar"}) + assert specification is Specification.OPAQUE + + def test_repr(self): + assert ( + repr(ID_AND_CHILDREN) == "" + ) + + +class TestOpaqueSpecification: + THINGS = [{"foo": "bar"}, True, 37, "foo", object()] + + @pytest.mark.parametrize("thing", THINGS) + def test_no_id(self, thing): + """ + An arbitrary thing has no ID. + """ + + assert Specification.OPAQUE.id_of(thing) is None + + @pytest.mark.parametrize("thing", THINGS) + def test_no_subresources(self, thing): + """ + An arbitrary thing has no subresources. + """ + + assert list(Specification.OPAQUE.subresources_of(thing)) == [] + + @pytest.mark.parametrize("thing", THINGS) + def test_no_anchors(self, thing): + """ + An arbitrary thing has no anchors. + """ + + assert list(Specification.OPAQUE.anchors_in(thing)) == [] + + +@pytest.mark.parametrize( + "cls", + [Anchor, Registry, Resource, Specification, exceptions.PointerToNowhere], +) +def test_nonsubclassable(cls): + with pytest.raises(Exception, match="(?i)subclassing"): + + class Boom(cls): # pragma: no cover + pass diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_exceptions.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_exceptions.py new file mode 100644 index 00000000..85cf99ec --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_exceptions.py @@ -0,0 +1,34 @@ +import itertools + +import pytest + +from referencing import Resource, exceptions + + +def pairs(choices): + return itertools.combinations(choices, 2) + + +TRUE = Resource.opaque(True) + + +thunks = ( + lambda: exceptions.CannotDetermineSpecification(TRUE), + lambda: exceptions.NoSuchResource("urn:example:foo"), + lambda: exceptions.NoInternalID(TRUE), + lambda: exceptions.InvalidAnchor(resource=TRUE, anchor="foo", ref="a#b"), + lambda: exceptions.NoSuchAnchor(resource=TRUE, anchor="foo", ref="a#b"), + lambda: exceptions.PointerToNowhere(resource=TRUE, ref="urn:example:foo"), + lambda: exceptions.Unresolvable("urn:example:foo"), + lambda: exceptions.Unretrievable("urn:example:foo"), +) + + +@pytest.mark.parametrize("one, two", pairs(each() for each in thunks)) +def test_eq_incompatible_types(one, two): + assert one != two + + +@pytest.mark.parametrize("thunk", thunks) +def test_hash(thunk): + assert thunk() in {thunk()} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_jsonschema.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_jsonschema.py new file mode 100644 index 00000000..c80714d0 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_jsonschema.py @@ -0,0 +1,382 @@ +import pytest + +from referencing import Registry, Resource, Specification +import referencing.jsonschema + + +@pytest.mark.parametrize( + "uri, expected", + [ + ( + "https://json-schema.org/draft/2020-12/schema", + referencing.jsonschema.DRAFT202012, + ), + ( + "https://json-schema.org/draft/2019-09/schema", + referencing.jsonschema.DRAFT201909, + ), + ( + "http://json-schema.org/draft-07/schema#", + referencing.jsonschema.DRAFT7, + ), + ( + "http://json-schema.org/draft-06/schema#", + referencing.jsonschema.DRAFT6, + ), + ( + "http://json-schema.org/draft-04/schema#", + referencing.jsonschema.DRAFT4, + ), + ( + "http://json-schema.org/draft-03/schema#", + referencing.jsonschema.DRAFT3, + ), + ], +) +def test_schemas_with_explicit_schema_keywords_are_detected(uri, expected): + """ + The $schema keyword in JSON Schema is a dialect identifier. + """ + contents = {"$schema": uri} + resource = Resource.from_contents(contents) + assert resource == Resource(contents=contents, specification=expected) + + +def test_unknown_dialect(): + dialect_id = "http://example.com/unknown-json-schema-dialect-id" + with pytest.raises(referencing.jsonschema.UnknownDialect) as excinfo: + Resource.from_contents({"$schema": dialect_id}) + assert excinfo.value.uri == dialect_id + + +@pytest.mark.parametrize( + "id, specification", + [ + ("$id", referencing.jsonschema.DRAFT202012), + ("$id", referencing.jsonschema.DRAFT201909), + ("$id", referencing.jsonschema.DRAFT7), + ("$id", referencing.jsonschema.DRAFT6), + ("id", referencing.jsonschema.DRAFT4), + ("id", referencing.jsonschema.DRAFT3), + ], +) +def test_id_of_mapping(id, specification): + uri = "http://example.com/some-schema" + assert specification.id_of({id: uri}) == uri + + +@pytest.mark.parametrize( + "specification", + [ + referencing.jsonschema.DRAFT202012, + referencing.jsonschema.DRAFT201909, + referencing.jsonschema.DRAFT7, + referencing.jsonschema.DRAFT6, + ], +) +@pytest.mark.parametrize("value", [True, False]) +def test_id_of_bool(specification, value): + assert specification.id_of(value) is None + + +@pytest.mark.parametrize( + "specification", + [ + referencing.jsonschema.DRAFT202012, + referencing.jsonschema.DRAFT201909, + referencing.jsonschema.DRAFT7, + referencing.jsonschema.DRAFT6, + ], +) +@pytest.mark.parametrize("value", [True, False]) +def test_anchors_in_bool(specification, value): + assert list(specification.anchors_in(value)) == [] + + +@pytest.mark.parametrize( + "specification", + [ + referencing.jsonschema.DRAFT202012, + referencing.jsonschema.DRAFT201909, + referencing.jsonschema.DRAFT7, + referencing.jsonschema.DRAFT6, + ], +) +@pytest.mark.parametrize("value", [True, False]) +def test_subresources_of_bool(specification, value): + assert list(specification.subresources_of(value)) == [] + + +@pytest.mark.parametrize( + "uri, expected", + [ + ( + "https://json-schema.org/draft/2020-12/schema", + referencing.jsonschema.DRAFT202012, + ), + ( + "https://json-schema.org/draft/2019-09/schema", + referencing.jsonschema.DRAFT201909, + ), + ( + "http://json-schema.org/draft-07/schema#", + referencing.jsonschema.DRAFT7, + ), + ( + "http://json-schema.org/draft-06/schema#", + referencing.jsonschema.DRAFT6, + ), + ( + "http://json-schema.org/draft-04/schema#", + referencing.jsonschema.DRAFT4, + ), + ( + "http://json-schema.org/draft-03/schema#", + referencing.jsonschema.DRAFT3, + ), + ], +) +def test_specification_with(uri, expected): + assert referencing.jsonschema.specification_with(uri) == expected + + +@pytest.mark.parametrize( + "uri, expected", + [ + ( + "http://json-schema.org/draft-07/schema", + referencing.jsonschema.DRAFT7, + ), + ( + "http://json-schema.org/draft-06/schema", + referencing.jsonschema.DRAFT6, + ), + ( + "http://json-schema.org/draft-04/schema", + referencing.jsonschema.DRAFT4, + ), + ( + "http://json-schema.org/draft-03/schema", + referencing.jsonschema.DRAFT3, + ), + ], +) +def test_specification_with_no_empty_fragment(uri, expected): + assert referencing.jsonschema.specification_with(uri) == expected + + +def test_specification_with_unknown_dialect(): + dialect_id = "http://example.com/unknown-json-schema-dialect-id" + with pytest.raises(referencing.jsonschema.UnknownDialect) as excinfo: + referencing.jsonschema.specification_with(dialect_id) + assert excinfo.value.uri == dialect_id + + +def test_specification_with_default(): + dialect_id = "http://example.com/unknown-json-schema-dialect-id" + specification = referencing.jsonschema.specification_with( + dialect_id, + default=Specification.OPAQUE, + ) + assert specification is Specification.OPAQUE + + +# FIXME: The tests below should move to the referencing suite but I haven't yet +# figured out how to represent dynamic (& recursive) ref lookups in it. +def test_lookup_trivial_dynamic_ref(): + one = referencing.jsonschema.DRAFT202012.create_resource( + {"$dynamicAnchor": "foo"}, + ) + resolver = Registry().with_resource("http://example.com", one).resolver() + resolved = resolver.lookup("http://example.com#foo") + assert resolved.contents == one.contents + + +def test_multiple_lookup_trivial_dynamic_ref(): + TRUE = referencing.jsonschema.DRAFT202012.create_resource(True) + root = referencing.jsonschema.DRAFT202012.create_resource( + { + "$id": "http://example.com", + "$dynamicAnchor": "fooAnchor", + "$defs": { + "foo": { + "$id": "foo", + "$dynamicAnchor": "fooAnchor", + "$defs": { + "bar": True, + "baz": { + "$dynamicAnchor": "fooAnchor", + }, + }, + }, + }, + }, + ) + resolver = ( + Registry() + .with_resources( + [ + ("http://example.com", root), + ("http://example.com/foo/", TRUE), + ("http://example.com/foo/bar", root), + ], + ) + .resolver() + ) + + first = resolver.lookup("http://example.com") + second = first.resolver.lookup("foo/") + resolver = second.resolver.lookup("bar").resolver + fourth = resolver.lookup("#fooAnchor") + assert fourth.contents == root.contents + + +def test_multiple_lookup_dynamic_ref_to_nondynamic_ref(): + one = referencing.jsonschema.DRAFT202012.create_resource( + {"$anchor": "fooAnchor"}, + ) + two = referencing.jsonschema.DRAFT202012.create_resource( + { + "$id": "http://example.com", + "$dynamicAnchor": "fooAnchor", + "$defs": { + "foo": { + "$id": "foo", + "$dynamicAnchor": "fooAnchor", + "$defs": { + "bar": True, + "baz": { + "$dynamicAnchor": "fooAnchor", + }, + }, + }, + }, + }, + ) + resolver = ( + Registry() + .with_resources( + [ + ("http://example.com", two), + ("http://example.com/foo/", one), + ("http://example.com/foo/bar", two), + ], + ) + .resolver() + ) + + first = resolver.lookup("http://example.com") + second = first.resolver.lookup("foo/") + resolver = second.resolver.lookup("bar").resolver + fourth = resolver.lookup("#fooAnchor") + assert fourth.contents == two.contents + + +def test_lookup_trivial_recursive_ref(): + one = referencing.jsonschema.DRAFT201909.create_resource( + {"$recursiveAnchor": True}, + ) + resolver = Registry().with_resource("http://example.com", one).resolver() + first = resolver.lookup("http://example.com") + resolved = referencing.jsonschema.lookup_recursive_ref( + resolver=first.resolver, + ) + assert resolved.contents == one.contents + + +def test_lookup_recursive_ref_to_bool(): + TRUE = referencing.jsonschema.DRAFT201909.create_resource(True) + registry = Registry({"http://example.com": TRUE}) + resolved = referencing.jsonschema.lookup_recursive_ref( + resolver=registry.resolver(base_uri="http://example.com"), + ) + assert resolved.contents == TRUE.contents + + +def test_multiple_lookup_recursive_ref_to_bool(): + TRUE = referencing.jsonschema.DRAFT201909.create_resource(True) + root = referencing.jsonschema.DRAFT201909.create_resource( + { + "$id": "http://example.com", + "$recursiveAnchor": True, + "$defs": { + "foo": { + "$id": "foo", + "$recursiveAnchor": True, + "$defs": { + "bar": True, + "baz": { + "$recursiveAnchor": True, + "$anchor": "fooAnchor", + }, + }, + }, + }, + }, + ) + resolver = ( + Registry() + .with_resources( + [ + ("http://example.com", root), + ("http://example.com/foo/", TRUE), + ("http://example.com/foo/bar", root), + ], + ) + .resolver() + ) + + first = resolver.lookup("http://example.com") + second = first.resolver.lookup("foo/") + resolver = second.resolver.lookup("bar").resolver + fourth = referencing.jsonschema.lookup_recursive_ref(resolver=resolver) + assert fourth.contents == root.contents + + +def test_multiple_lookup_recursive_ref_with_nonrecursive_ref(): + one = referencing.jsonschema.DRAFT201909.create_resource( + {"$recursiveAnchor": True}, + ) + two = referencing.jsonschema.DRAFT201909.create_resource( + { + "$id": "http://example.com", + "$recursiveAnchor": True, + "$defs": { + "foo": { + "$id": "foo", + "$recursiveAnchor": True, + "$defs": { + "bar": True, + "baz": { + "$recursiveAnchor": True, + "$anchor": "fooAnchor", + }, + }, + }, + }, + }, + ) + three = referencing.jsonschema.DRAFT201909.create_resource( + {"$recursiveAnchor": False}, + ) + resolver = ( + Registry() + .with_resources( + [ + ("http://example.com", three), + ("http://example.com/foo/", two), + ("http://example.com/foo/bar", one), + ], + ) + .resolver() + ) + + first = resolver.lookup("http://example.com") + second = first.resolver.lookup("foo/") + resolver = second.resolver.lookup("bar").resolver + fourth = referencing.jsonschema.lookup_recursive_ref(resolver=resolver) + assert fourth.contents == two.contents + + +def test_empty_registry(): + assert referencing.jsonschema.EMPTY_REGISTRY == Registry() diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_referencing_suite.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_referencing_suite.py new file mode 100644 index 00000000..4b8ae917 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_referencing_suite.py @@ -0,0 +1,66 @@ +from pathlib import Path +import json +import os + +import pytest + +from referencing import Registry +from referencing.exceptions import Unresolvable +import referencing.jsonschema + + +class SuiteNotFound(Exception): + def __str__(self): # pragma: no cover + return ( + "Cannot find the referencing suite. " + "Set the REFERENCING_SUITE environment variable to the path to " + "the suite, or run the test suite from alongside a full checkout " + "of the git repository." + ) + + +if "REFERENCING_SUITE" in os.environ: # pragma: no cover + SUITE = Path(os.environ["REFERENCING_SUITE"]) / "tests" +else: + SUITE = Path(__file__).parent.parent.parent / "suite/tests" +if not SUITE.is_dir(): # pragma: no cover + raise SuiteNotFound() +DIALECT_IDS = json.loads(SUITE.joinpath("specifications.json").read_text()) + + +@pytest.mark.parametrize( + "test_path", + [ + pytest.param(each, id=f"{each.parent.name}-{each.stem}") + for each in SUITE.glob("*/**/*.json") + ], +) +def test_referencing_suite(test_path, subtests): + dialect_id = DIALECT_IDS[test_path.relative_to(SUITE).parts[0]] + specification = referencing.jsonschema.specification_with(dialect_id) + loaded = json.loads(test_path.read_text()) + registry = loaded["registry"] + registry = Registry().with_resources( + (uri, specification.create_resource(contents)) + for uri, contents in loaded["registry"].items() + ) + for test in loaded["tests"]: + with subtests.test(test=test): + if "normalization" in test_path.stem: + pytest.xfail("APIs need to change for proper URL support.") + + resolver = registry.resolver(base_uri=test.get("base_uri", "")) + + if test.get("error"): + with pytest.raises(Unresolvable): + resolver.lookup(test["ref"]) + else: + resolved = resolver.lookup(test["ref"]) + assert resolved.contents == test["target"] + + then = test.get("then") + while then: # pragma: no cover + with subtests.test(test=test, then=then): + resolved = resolved.resolver.lookup(then["ref"]) + assert resolved.contents == then["target"] + then = then.get("then") diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_retrieval.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_retrieval.py new file mode 100644 index 00000000..d0a8f8ad --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/tests/test_retrieval.py @@ -0,0 +1,106 @@ +from functools import lru_cache +import json + +import pytest + +from referencing import Registry, Resource, exceptions +from referencing.jsonschema import DRAFT202012 +from referencing.retrieval import to_cached_resource + + +class TestToCachedResource: + def test_it_caches_retrieved_resources(self): + contents = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + stack = [json.dumps(contents)] + + @to_cached_resource() + def retrieve(uri): + return stack.pop() + + registry = Registry(retrieve=retrieve) + + expected = Resource.from_contents(contents) + + got = registry.get_or_retrieve("urn:example:schema") + assert got.value == expected + + # And a second time we get the same value. + again = registry.get_or_retrieve("urn:example:schema") + assert again.value is got.value + + def test_custom_loader(self): + contents = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + stack = [json.dumps(contents)[::-1]] + + @to_cached_resource(loads=lambda s: json.loads(s[::-1])) + def retrieve(uri): + return stack.pop() + + registry = Registry(retrieve=retrieve) + + expected = Resource.from_contents(contents) + + got = registry.get_or_retrieve("urn:example:schema") + assert got.value == expected + + # And a second time we get the same value. + again = registry.get_or_retrieve("urn:example:schema") + assert again.value is got.value + + def test_custom_from_contents(self): + contents = {} + stack = [json.dumps(contents)] + + @to_cached_resource(from_contents=DRAFT202012.create_resource) + def retrieve(uri): + return stack.pop() + + registry = Registry(retrieve=retrieve) + + expected = DRAFT202012.create_resource(contents) + + got = registry.get_or_retrieve("urn:example:schema") + assert got.value == expected + + # And a second time we get the same value. + again = registry.get_or_retrieve("urn:example:schema") + assert again.value is got.value + + def test_custom_cache(self): + schema = {"$schema": "https://json-schema.org/draft/2020-12/schema"} + mapping = { + "urn:example:1": dict(schema, foo=1), + "urn:example:2": dict(schema, foo=2), + "urn:example:3": dict(schema, foo=3), + } + + resources = { + uri: Resource.from_contents(contents) + for uri, contents in mapping.items() + } + + @to_cached_resource(cache=lru_cache(maxsize=2)) + def retrieve(uri): + return json.dumps(mapping.pop(uri)) + + registry = Registry(retrieve=retrieve) + + got = registry.get_or_retrieve("urn:example:1") + assert got.value == resources["urn:example:1"] + assert registry.get_or_retrieve("urn:example:1").value is got.value + assert registry.get_or_retrieve("urn:example:1").value is got.value + + got = registry.get_or_retrieve("urn:example:2") + assert got.value == resources["urn:example:2"] + assert registry.get_or_retrieve("urn:example:2").value is got.value + assert registry.get_or_retrieve("urn:example:2").value is got.value + + # This still succeeds, but evicts the first URI + got = registry.get_or_retrieve("urn:example:3") + assert got.value == resources["urn:example:3"] + assert registry.get_or_retrieve("urn:example:3").value is got.value + assert registry.get_or_retrieve("urn:example:3").value is got.value + + # And now this fails (as we popped the value out of `mapping`) + with pytest.raises(exceptions.Unretrievable): + registry.get_or_retrieve("urn:example:1") diff --git a/edge-cv-portal/backend/layers/workflow_core/python/referencing/typing.py b/edge-cv-portal/backend/layers/workflow_core/python/referencing/typing.py new file mode 100644 index 00000000..a6144641 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/referencing/typing.py @@ -0,0 +1,61 @@ +""" +Type-annotation related support for the referencing library. +""" + +from __future__ import annotations + +from collections.abc import Mapping as Mapping +from typing import TYPE_CHECKING, Any, Protocol + +try: + from typing_extensions import TypeVar +except ImportError: # pragma: no cover + from typing import TypeVar + +if TYPE_CHECKING: + from referencing._core import Resolved, Resolver, Resource + +#: A URI which identifies a `Resource`. +URI = str + +#: The type of documents within a registry. +D = TypeVar("D", default=Any) + + +class Retrieve(Protocol[D]): + """ + A retrieval callable, usable within a `Registry` for resource retrieval. + + Does not make assumptions about where the resource might be coming from. + """ + + def __call__(self, uri: URI) -> Resource[D]: + """ + Retrieve the resource with the given URI. + + Raise `referencing.exceptions.NoSuchResource` if you wish to indicate + the retriever cannot lookup the given URI. + """ + ... + + +class Anchor(Protocol[D]): + """ + An anchor within a `Resource`. + + Beyond "simple" anchors, some specifications like JSON Schema's 2020 + version have dynamic anchors. + """ + + @property + def name(self) -> str: + """ + Return the name of this anchor. + """ + ... + + def resolve(self, resolver: Resolver[D]) -> Resolved[D]: + """ + Return the resource for this anchor. + """ + ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.py new file mode 100644 index 00000000..257da6a7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.py @@ -0,0 +1,5 @@ +from .rpds import * + +__doc__ = rpds.__doc__ +if hasattr(rpds, "__all__"): + __all__ = rpds.__all__ \ No newline at end of file diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.pyi b/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.pyi new file mode 100644 index 00000000..51f03b0c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds/__init__.pyi @@ -0,0 +1,79 @@ +from collections.abc import ( + ItemsView, + Iterable, + Iterator, + KeysView, + Mapping, + ValuesView, +) +from typing import ( + TypeVar, +) + +_T = TypeVar("_T") +_KT_co = TypeVar("_KT_co", covariant=True) +_VT_co = TypeVar("_VT_co", covariant=True) +_KU_co = TypeVar("_KU_co", covariant=True) +_VU_co = TypeVar("_VU_co", covariant=True) + +class HashTrieMap(Mapping[_KT_co, _VT_co]): + def __init__( + self, + value: Mapping[_KT_co, _VT_co] | Iterable[tuple[_KT_co, _VT_co]] = {}, + **kwds: Mapping[_KT_co, _VT_co], + ): ... + def __getitem__(self, key: _KT_co) -> _VT_co: ... + def __iter__(self) -> Iterator[_KT_co]: ... + def __len__(self) -> int: ... + def discard(self, key: _KT_co) -> HashTrieMap[_KT_co, _VT_co]: ... + def items(self) -> ItemsView[_KT_co, _VT_co]: ... + def keys(self) -> KeysView[_KT_co]: ... + def values(self) -> ValuesView[_VT_co]: ... + def remove(self, key: _KT_co) -> HashTrieMap[_KT_co, _VT_co]: ... + def insert( + self, + key: _KT_co, + val: _VT_co, + ) -> HashTrieMap[_KT_co, _VT_co]: ... + def update( + self, + *args: Mapping[_KU_co, _VU_co] | Iterable[tuple[_KU_co, _VU_co]], + ) -> HashTrieMap[_KT_co | _KU_co, _VT_co | _VU_co]: ... + @classmethod + def convert( + cls, + value: Mapping[_KT_co, _VT_co] | Iterable[tuple[_KT_co, _VT_co]], + ) -> HashTrieMap[_KT_co, _VT_co]: ... + @classmethod + def fromkeys( + cls, + keys: Iterable[_KT_co], + value: _VT_co = None, + ) -> HashTrieMap[_KT_co, _VT_co]: ... + +class HashTrieSet(frozenset[_T]): + def __init__(self, value: Iterable[_T] = ()): ... + def __iter__(self) -> Iterator[_T]: ... + def __len__(self) -> int: ... + def discard(self, value: _T) -> HashTrieSet[_T]: ... + def remove(self, value: _T) -> HashTrieSet[_T]: ... + def insert(self, value: _T) -> HashTrieSet[_T]: ... + def update(self, *args: Iterable[_T]) -> HashTrieSet[_T]: ... + +class List(Iterable[_T]): + def __init__(self, value: Iterable[_T] = (), *more: _T): ... + def __iter__(self) -> Iterator[_T]: ... + def __len__(self) -> int: ... + def push_front(self, value: _T) -> List[_T]: ... + def drop_first(self) -> List[_T]: ... + +class Queue(Iterable[_T]): + def __init__(self, value: Iterable[_T] = (), *more: _T): ... + def __iter__(self) -> Iterator[_T]: ... + def __len__(self) -> int: ... + def enqueue(self, value: _T) -> Queue[_T]: ... + def dequeue(self, value: _T) -> Queue[_T]: ... + @property + def is_empty(self) -> _T: ... + @property + def peek(self) -> _T: ... diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds/py.typed b/edge-cv-portal/backend/layers/workflow_core/python/rpds/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds/rpds.cpython-311-x86_64-linux-gnu.so b/edge-cv-portal/backend/layers/workflow_core/python/rpds/rpds.cpython-311-x86_64-linux-gnu.so new file mode 100755 index 00000000..ed7c2ac9 Binary files /dev/null and b/edge-cv-portal/backend/layers/workflow_core/python/rpds/rpds.cpython-311-x86_64-linux-gnu.so differ diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/METADATA new file mode 100644 index 00000000..bcf64a45 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/METADATA @@ -0,0 +1,99 @@ +Metadata-Version: 2.4 +Name: rpds-py +Version: 2026.6.3 +Classifier: Development Status :: 3 - Alpha +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Rust +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: 3.15 +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +License-File: LICENSE +Summary: Python bindings to Rust's persistent data structures (rpds) +Keywords: data structures,rust,persistent +Author-email: Julian Berman +License-Expression: MIT +Requires-Python: >=3.11 +Description-Content-Type: text/x-rst; charset=UTF-8 +Project-URL: Documentation, https://rpds.readthedocs.io/ +Project-URL: Funding, https://github.com/sponsors/Julian +Project-URL: Homepage, https://github.com/crate-py/rpds +Project-URL: Issues, https://github.com/crate-py/rpds/issues/ +Project-URL: Source, https://github.com/crate-py/rpds +Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-rpds-py?utm_source=pypi-rpds-py&utm_medium=referral&utm_campaign=pypi-link +Project-URL: Upstream, https://github.com/orium/rpds + +=========== +``rpds.py`` +=========== + +|PyPI| |Pythons| |CI| + +.. |PyPI| image:: https://img.shields.io/pypi/v/rpds-py.svg + :alt: PyPI version + :target: https://pypi.org/project/rpds-py/ + +.. |Pythons| image:: https://img.shields.io/pypi/pyversions/rpds-py.svg + :alt: Supported Python versions + :target: https://pypi.org/project/rpds-py/ + +.. |CI| image:: https://github.com/crate-py/rpds/workflows/CI/badge.svg + :alt: Build status + :target: https://github.com/crate-py/rpds/actions?query=workflow%3ACI + +.. |ReadTheDocs| image:: https://readthedocs.org/projects/referencing/badge/?version=stable&style=flat + :alt: ReadTheDocs status + :target: https://referencing.readthedocs.io/en/stable/ + + +Python bindings to the `Rust rpds crate `_ for persistent data structures. + +What's here is quite minimal (in transparency, it was written initially to support replacing ``pyrsistent`` in the `referencing library `_). +If you see something missing (which is very likely), a PR is definitely welcome to add it. + +Installation +------------ + +The distribution on PyPI is named ``rpds.py`` (equivalently ``rpds-py``), and thus can be installed via e.g.: + +.. code:: sh + + $ pip install rpds-py + +Note that if you install ``rpds-py`` from source, you will need a Rust toolchain installed, as it is a build-time dependency. +An example of how to do so in a ``Dockerfile`` can be found `here `_. + +If you believe you are on a common platform which should have wheels built (i.e. and not need to compile from source), feel free to file an issue or pull request modifying the GitHub action used here to build wheels via ``maturin``. + +Usage +----- + +Methods in general are named similarly to their ``rpds`` counterparts (rather than ``pyrsistent``\ 's conventions, though probably a full drop-in ``pyrsistent``\ -compatible wrapper module is a good addition at some point). + +.. code:: python + + >>> from rpds import HashTrieMap, HashTrieSet, List + + >>> m = HashTrieMap({"foo": "bar", "baz": "quux"}) + >>> m.insert("spam", 37) == HashTrieMap({"foo": "bar", "baz": "quux", "spam": 37}) + True + >>> m.remove("foo") == HashTrieMap({"baz": "quux"}) + True + + >>> s = HashTrieSet({"foo", "bar", "baz", "quux"}) + >>> s.insert("spam") == HashTrieSet({"foo", "bar", "baz", "quux", "spam"}) + True + >>> s.remove("foo") == HashTrieSet({"bar", "baz", "quux"}) + True + + >>> L = List([1, 3, 5]) + >>> L.push_front(-1) == List([-1, 1, 3, 5]) + True + >>> L.rest == List([3, 5]) + True + diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/RECORD new file mode 100644 index 00000000..9263be2e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/RECORD @@ -0,0 +1,11 @@ +rpds/__init__.py,sha256=w3MgXW7lpTCICw0KXbw20QX573_kbsEnWIeMsCAugvM,99 +rpds/__init__.pyi,sha256=am7x6oMa_pu_kv1NlolqJbPr6_UvCvoyxGKrDGSMKEk,2602 +rpds/__pycache__/__init__.cpython-39.pyc,, +rpds/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +rpds/rpds.cpython-311-x86_64-linux-gnu.so,sha256=JRHDitWnz3TAiJsXHj5cHhNtol5_0RolWocqrPUzAao,983168 +rpds_py-2026.6.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +rpds_py-2026.6.3.dist-info/METADATA,sha256=-L57F9o5gLLEhZaUFBGkPHCsQbHi1TCqUx8sFeqy6xw,4147 +rpds_py-2026.6.3.dist-info/RECORD,, +rpds_py-2026.6.3.dist-info/WHEEL,sha256=7UOsZdPFMPa8vq7-7LH_ssceoJVSasrIhMrlqpXt6LA,147 +rpds_py-2026.6.3.dist-info/licenses/LICENSE,sha256=MU5Okb47qpPA-0vMyeTpfNZD64ObBlr5IXgsIXX-mQk,1057 +rpds_py-2026.6.3.dist-info/sboms/rpds-py.cyclonedx.json,sha256=gKO_uuFgflGyVpVmpGm4ptmphNSSPAgkCGQYpsij52M,20594 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/WHEEL new file mode 100644 index 00000000..887918e9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: maturin (1.14.1) +Root-Is-Purelib: false +Tag: cp311-cp311-manylinux_2_17_x86_64 +Tag: cp311-cp311-manylinux2014_x86_64 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/licenses/LICENSE b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/licenses/LICENSE new file mode 100644 index 00000000..119a1f20 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/licenses/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023 Julian Berman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/sboms/rpds-py.cyclonedx.json b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/sboms/rpds-py.cyclonedx.json new file mode 100644 index 00000000..87f246bb --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/rpds_py-2026.6.3.dist-info/sboms/rpds-py.cyclonedx.json @@ -0,0 +1,680 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "version": 1, + "serialNumber": "urn:uuid:db798bf9-1726-4cb1-82cb-2a13771d0822", + "metadata": { + "timestamp": "2026-06-30T07:08:42.115208144Z", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cargo-cyclonedx", + "version": "0.5.9" + } + ], + "component": { + "type": "library", + "bom-ref": "path+file:///home/runner/work/rpds/rpds#rpds-py@2026.6.3", + "name": "rpds-py", + "version": "2026.6.3", + "scope": "required", + "purl": "pkg:cargo/rpds-py@2026.6.3?download_url=file://.", + "components": [ + { + "type": "library", + "bom-ref": "path+file:///home/runner/work/rpds/rpds#rpds-py@2026.6.3 bin-target-0", + "name": "rpds", + "version": "2026.6.3", + "purl": "pkg:cargo/rpds-py@2026.6.3?download_url=file://.#src/lib.rs" + } + ] + }, + "properties": [ + { + "name": "cdx:rustc:sbom:target:all_targets", + "value": "true" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#archery@1.2.2", + "author": "Diogo Sousa ", + "name": "archery", + "version": "1.2.2", + "description": "Abstract over the atomicity of reference-counting pointers", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" + } + ], + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/archery@1.2.2", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/archery" + }, + { + "type": "website", + "url": "https://github.com/orium/archery" + }, + { + "type": "vcs", + "url": "https://github.com/orium/archery" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#heck@0.5.0", + "name": "heck", + "version": "0.5.0", + "description": "heck is a case conversion library.", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/heck@0.5.0", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/withoutboats/heck" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.177", + "author": "The Rust Project Developers", + "name": "libc", + "version": "0.2.177", + "description": "Raw FFI bindings to platform libraries like libc.", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/libc@0.2.177", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/rust-lang/libc" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3", + "author": "Aleksey Kladov ", + "name": "once_cell", + "version": "1.21.3", + "description": "Single assignment cells and lazy values.", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/once_cell@1.21.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/once_cell" + }, + { + "type": "vcs", + "url": "https://github.com/matklad/once_cell" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103", + "author": "David Tolnay , Alex Crichton ", + "name": "proc-macro2", + "version": "1.0.103", + "description": "A substitute implementation of the compiler's `proc_macro` API to decouple token-based libraries from the procedural macro use case.", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/proc-macro2@1.0.103", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/proc-macro2" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/proc-macro2" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-build-config@0.29.0", + "author": "PyO3 Project and Contributors ", + "name": "pyo3-build-config", + "version": "0.29.0", + "description": "Build configuration for the PyO3 ecosystem", + "scope": "excluded", + "hashes": [ + { + "alg": "SHA-256", + "content": "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/pyo3-build-config@0.29.0", + "externalReferences": [ + { + "type": "website", + "url": "https://github.com/pyo3/pyo3" + }, + { + "type": "vcs", + "url": "https://github.com/pyo3/pyo3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-ffi@0.29.0", + "author": "PyO3 Project and Contributors ", + "name": "pyo3-ffi", + "version": "0.29.0", + "description": "Python-API bindings for the PyO3 ecosystem", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/pyo3-ffi@0.29.0", + "externalReferences": [ + { + "type": "website", + "url": "https://github.com/pyo3/pyo3" + }, + { + "type": "other", + "url": "python" + }, + { + "type": "vcs", + "url": "https://github.com/pyo3/pyo3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros-backend@0.29.0", + "author": "PyO3 Project and Contributors ", + "name": "pyo3-macros-backend", + "version": "0.29.0", + "description": "Code generation for PyO3 package", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/pyo3-macros-backend@0.29.0", + "externalReferences": [ + { + "type": "website", + "url": "https://github.com/pyo3/pyo3" + }, + { + "type": "vcs", + "url": "https://github.com/pyo3/pyo3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros@0.29.0", + "author": "PyO3 Project and Contributors ", + "name": "pyo3-macros", + "version": "0.29.0", + "description": "Proc macros for PyO3 package", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/pyo3-macros@0.29.0", + "externalReferences": [ + { + "type": "website", + "url": "https://github.com/pyo3/pyo3" + }, + { + "type": "vcs", + "url": "https://github.com/pyo3/pyo3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3@0.29.0", + "author": "PyO3 Project and Contributors ", + "name": "pyo3", + "version": "0.29.0", + "description": "Bindings to Python interpreter", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/pyo3@0.29.0", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/crate/pyo3/" + }, + { + "type": "website", + "url": "https://github.com/pyo3/pyo3" + }, + { + "type": "other", + "url": "pyo3-python" + }, + { + "type": "vcs", + "url": "https://github.com/pyo3/pyo3" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.42", + "author": "David Tolnay ", + "name": "quote", + "version": "1.0.42", + "description": "Quasi-quoting macro quote!(...)", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/quote@1.0.42", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/quote/" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/quote" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#rpds@1.2.1", + "author": "Diogo Sousa ", + "name": "rpds", + "version": "1.2.1", + "description": "Persistent data structures with structural sharing", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "e025feb26210bc196b908e72deb063b1b4000754304341cbc168a1e72c857ebc" + } + ], + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:cargo/rpds@1.2.1", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/rpds" + }, + { + "type": "website", + "url": "https://github.com/orium/rpds" + }, + { + "type": "vcs", + "url": "https://github.com/orium/rpds" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#smallvec@1.15.1", + "author": "The Servo Project Developers", + "name": "smallvec", + "version": "1.15.1", + "description": "'Small vector' optimization: store up to a small number of items on the stack", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/smallvec@1.15.1", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/smallvec/" + }, + { + "type": "vcs", + "url": "https://github.com/servo/rust-smallvec" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.111", + "author": "David Tolnay ", + "name": "syn", + "version": "2.0.111", + "description": "Parser for Rust source code", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/syn@2.0.111", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/syn" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/syn" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#target-lexicon@0.13.3", + "author": "Dan Gohman ", + "name": "target-lexicon", + "version": "0.13.3", + "description": "LLVM target triple types", + "scope": "excluded", + "hashes": [ + { + "alg": "SHA-256", + "content": "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + } + ], + "licenses": [ + { + "expression": "Apache-2.0 WITH LLVM-exception" + } + ], + "purl": "pkg:cargo/target-lexicon@0.13.3", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/target-lexicon/" + }, + { + "type": "vcs", + "url": "https://github.com/bytecodealliance/target-lexicon" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#triomphe@0.1.15", + "author": "Manish Goregaokar , The Servo Project Developers", + "name": "triomphe", + "version": "0.1.15", + "description": "A fork of std::sync::Arc with some extra functionality and without weak references (originally servo_arc)", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" + } + ], + "licenses": [ + { + "expression": "MIT OR Apache-2.0" + } + ], + "purl": "pkg:cargo/triomphe@0.1.15", + "externalReferences": [ + { + "type": "vcs", + "url": "https://github.com/Manishearth/triomphe" + } + ] + }, + { + "type": "library", + "bom-ref": "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.22", + "author": "David Tolnay ", + "name": "unicode-ident", + "version": "1.0.22", + "description": "Determine whether characters have the XID_Start or XID_Continue properties according to Unicode Standard Annex #31", + "scope": "required", + "hashes": [ + { + "alg": "SHA-256", + "content": "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + } + ], + "licenses": [ + { + "expression": "(MIT OR Apache-2.0) AND Unicode-3.0" + } + ], + "purl": "pkg:cargo/unicode-ident@1.0.22", + "externalReferences": [ + { + "type": "documentation", + "url": "https://docs.rs/unicode-ident" + }, + { + "type": "vcs", + "url": "https://github.com/dtolnay/unicode-ident" + } + ] + } + ], + "dependencies": [ + { + "ref": "path+file:///home/runner/work/rpds/rpds#rpds-py@2026.6.3", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#archery@1.2.2", + "registry+https://github.com/rust-lang/crates.io-index#pyo3@0.29.0", + "registry+https://github.com/rust-lang/crates.io-index#rpds@1.2.1" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#archery@1.2.2", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#triomphe@0.1.15" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#heck@0.5.0" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.177" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.22" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-build-config@0.29.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#target-lexicon@0.13.3" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-ffi@0.29.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.177", + "registry+https://github.com/rust-lang/crates.io-index#pyo3-build-config@0.29.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros-backend@0.29.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#heck@0.5.0", + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.42", + "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.111" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros@0.29.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103", + "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros-backend@0.29.0", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.42", + "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.111" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#pyo3@0.29.0", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#libc@0.2.177", + "registry+https://github.com/rust-lang/crates.io-index#once_cell@1.21.3", + "registry+https://github.com/rust-lang/crates.io-index#pyo3-build-config@0.29.0", + "registry+https://github.com/rust-lang/crates.io-index#pyo3-ffi@0.29.0", + "registry+https://github.com/rust-lang/crates.io-index#pyo3-macros@0.29.0" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.42", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#rpds@1.2.1", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#archery@1.2.2", + "registry+https://github.com/rust-lang/crates.io-index#smallvec@1.15.1" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#smallvec@1.15.1" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#syn@2.0.111", + "dependsOn": [ + "registry+https://github.com/rust-lang/crates.io-index#proc-macro2@1.0.103", + "registry+https://github.com/rust-lang/crates.io-index#quote@1.0.42", + "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.22" + ] + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#target-lexicon@0.13.3" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#triomphe@0.1.15" + }, + { + "ref": "registry+https://github.com/rust-lang/crates.io-index#unicode-ident@1.0.22" + } + ] +} \ No newline at end of file diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/INSTALLER b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/INSTALLER new file mode 100644 index 00000000..a1b589e3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/METADATA b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/METADATA new file mode 100644 index 00000000..97fd818b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/METADATA @@ -0,0 +1,73 @@ +Metadata-Version: 2.4 +Name: typing_extensions +Version: 4.16.0 +Summary: Backported and Experimental Type Hints for Python 3.9+ +Keywords: annotations,backport,checker,checking,function,hinting,hints,type,typechecking,typehinting,typehints,typing +Author-email: "Guido van Rossum, Jukka Lehtosalo, Łukasz Langa, Michael Lee" +Requires-Python: >=3.9 +Description-Content-Type: text/markdown +License-Expression: PSF-2.0 +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Console +Classifier: Intended Audience :: Developers +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: 3.15 +Classifier: Topic :: Software Development +License-File: LICENSE +Project-URL: Bug Tracker, https://github.com/python/typing_extensions/issues +Project-URL: Changes, https://github.com/python/typing_extensions/blob/main/CHANGELOG.md +Project-URL: Documentation, https://typing-extensions.readthedocs.io/ +Project-URL: Home, https://github.com/python/typing_extensions +Project-URL: Q & A, https://github.com/python/typing/discussions +Project-URL: Repository, https://github.com/python/typing_extensions + +# Typing Extensions + +[![Chat at https://gitter.im/python/typing](https://badges.gitter.im/python/typing.svg)](https://gitter.im/python/typing) + +[Documentation](https://typing-extensions.readthedocs.io/en/latest/#) – +[PyPI](https://pypi.org/project/typing-extensions/) + +## Overview + +The `typing_extensions` module serves two related purposes: + +- Enable use of new type system features on older Python versions. For example, + `typing.TypeGuard` is new in Python 3.10, but `typing_extensions` allows + users on previous Python versions to use it too. +- Enable experimentation with new type system PEPs before they are accepted and + added to the `typing` module. + +`typing_extensions` is treated specially by static type checkers such as +mypy and pyright. Objects defined in `typing_extensions` are treated the same +way as equivalent forms in `typing`. + +`typing_extensions` uses +[Semantic Versioning](https://semver.org/). The +major version will be incremented only for backwards-incompatible changes. +Therefore, it's safe to depend +on `typing_extensions` like this: `typing_extensions ~=x.y`, +where `x.y` is the first version that includes all features you need. +[This](https://packaging.python.org/en/latest/specifications/version-specifiers/#compatible-release) +is equivalent to `typing_extensions >=x.y, <(x+1)`. Do not depend on `~= x.y.z` +unless you really know what you're doing; that defeats the purpose of +semantic versioning. + +## Included items + +See [the documentation](https://typing-extensions.readthedocs.io/en/latest/#) for a +complete listing of module contents. + +## Contributing + +See [CONTRIBUTING.md](https://github.com/python/typing_extensions/blob/main/CONTRIBUTING.md) +for how to contribute to `typing_extensions`. + diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/RECORD b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/RECORD new file mode 100644 index 00000000..5ee73d64 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/RECORD @@ -0,0 +1,7 @@ +__pycache__/typing_extensions.cpython-39.pyc,, +typing_extensions-4.16.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +typing_extensions-4.16.0.dist-info/METADATA,sha256=sFCEyh1Qh5hlF42f_5-r6rYb37HzYb-96VQh_8j5vkY,3310 +typing_extensions-4.16.0.dist-info/RECORD,, +typing_extensions-4.16.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82 +typing_extensions-4.16.0.dist-info/licenses/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936 +typing_extensions.py,sha256=QEDKGh7L7gDROFwSqTCE0cW9RvC3dPB-WufpHE9V5pY,165012 diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/WHEEL b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/WHEEL new file mode 100644 index 00000000..d8b9936d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: flit 3.12.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/licenses/LICENSE b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/licenses/LICENSE new file mode 100644 index 00000000..f26bcf4d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions-4.16.0.dist-info/licenses/LICENSE @@ -0,0 +1,279 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions.py b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions.py new file mode 100644 index 00000000..ced78373 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/typing_extensions.py @@ -0,0 +1,4422 @@ +import abc +import builtins +import collections +import collections.abc +import contextlib +import enum +import functools +import inspect +import io +import keyword +import operator +import sys +import types as _types +import typing +import warnings + +# Breakpoint: https://github.com/python/cpython/pull/119891 +if sys.version_info >= (3, 14): + import annotationlib + +__all__ = [ + # Super-special typing primitives. + 'Any', + 'ClassVar', + 'Concatenate', + 'Final', + 'LiteralString', + 'ParamSpec', + 'ParamSpecArgs', + 'ParamSpecKwargs', + 'Self', + 'Type', + 'TypeVar', + 'TypeVarTuple', + 'Unpack', + + # ABCs (from collections.abc). + 'Awaitable', + 'AsyncIterator', + 'AsyncIterable', + 'Coroutine', + 'AsyncGenerator', + 'AsyncContextManager', + 'Buffer', + 'ChainMap', + + # Concrete collection types. + 'ContextManager', + 'Counter', + 'Deque', + 'DefaultDict', + 'NamedTuple', + 'OrderedDict', + 'TypedDict', + + # Structural checks, a.k.a. protocols. + 'SupportsAbs', + 'SupportsBytes', + 'SupportsComplex', + 'SupportsFloat', + 'SupportsIndex', + 'SupportsInt', + 'SupportsRound', + 'Reader', + 'Writer', + + # One-off things. + 'Annotated', + 'assert_never', + 'assert_type', + 'clear_overloads', + 'dataclass_transform', + 'deprecated', + 'disjoint_base', + 'Doc', + 'evaluate_forward_ref', + 'get_overloads', + 'final', + 'Format', + 'get_annotations', + 'get_args', + 'get_origin', + 'get_original_bases', + 'get_protocol_members', + 'get_type_hints', + 'IntVar', + 'is_protocol', + 'is_typeddict', + 'Literal', + 'NewType', + 'overload', + 'override', + 'Protocol', + 'sentinel', + 'Sentinel', + 'reveal_type', + 'runtime', + 'runtime_checkable', + 'Text', + 'TypeAlias', + 'TypeAliasType', + 'TypeForm', + 'TypeGuard', + 'TypeIs', + 'TYPE_CHECKING', + 'type_repr', + 'Never', + 'NoReturn', + 'ReadOnly', + 'Required', + 'NotRequired', + 'NoDefault', + 'NoExtraItems', + + # Pure aliases, have always been in typing + 'AbstractSet', + 'AnyStr', + 'BinaryIO', + 'Callable', + 'Collection', + 'Container', + 'Dict', + 'ForwardRef', + 'FrozenSet', + 'Generator', + 'Generic', + 'Hashable', + 'IO', + 'ItemsView', + 'Iterable', + 'Iterator', + 'KeysView', + 'List', + 'Mapping', + 'MappingView', + 'Match', + 'MutableMapping', + 'MutableSequence', + 'MutableSet', + 'Optional', + 'Pattern', + 'Reversible', + 'Sequence', + 'Set', + 'Sized', + 'TextIO', + 'Tuple', + 'Union', + 'ValuesView', + 'cast', + 'no_type_check', +] + +# for backward compatibility +PEP_560 = True +GenericMeta = type +# Breakpoint: https://github.com/python/cpython/pull/116129 +_PEP_696_IMPLEMENTED = sys.version_info >= (3, 13, 0, "beta") + +# Added with bpo-45166 to 3.10.1+ and some 3.9 versions +_FORWARD_REF_HAS_CLASS = "__forward_is_class__" in typing.ForwardRef.__slots__ + + +def _caller(depth=1, default='__main__'): + try: + return sys._getframemodulename(depth + 1) or default + except AttributeError: # For platforms without _getframemodulename() + pass + try: + return sys._getframe(depth + 1).f_globals.get('__name__', default) + except (AttributeError, ValueError): # For platforms without _getframe() + pass + return None + + +# Placeholder for sentinel methods, because sentinels can not have their own sentinels +_sentinel_placeholder = object() + +if hasattr(builtins, "sentinel"): # 3.15+ + sentinel = builtins.sentinel +else: + class sentinel: + """Create a unique sentinel object. + + *name* should be the name of the variable to which the return value + shall be assigned. + """ + + def __init__( + self, + __name: str = _sentinel_placeholder, + __repr: typing.Optional[str] = _sentinel_placeholder, + /, + *, + repr: typing.Optional[str] = None, + name: str = _sentinel_placeholder, + ) -> None: + if name is not _sentinel_placeholder: + warnings.warn( + "Passing 'name' as a keyword argument is deprecated; " + "pass it positionally instead.", + DeprecationWarning, + stacklevel=2, + ) + __name = name + if __name is _sentinel_placeholder: + raise TypeError("First parameter 'name' is required") + if __repr is not _sentinel_placeholder: + warnings.warn( + "Passing 'repr' as a positional argument is deprecated; " + "pass it by keyword instead.", + DeprecationWarning, + stacklevel=2, + ) + repr = __repr + + self._name = __name + self._repr = repr if repr is not None else __name + + # For pickling as a singleton: + self.__module__ = _caller() + + def __init_subclass__(cls): + warnings.warn( + "Subclassing sentinel is deprecated " + "and will be disallowed in Python 3.15", + DeprecationWarning, + stacklevel=2, + ) + super().__init_subclass__() + + def __setattr__(self, attr: str, value: object) -> None: + if attr not in {"_name", "_repr", "__module__"}: + warnings.warn( + f"Setting attribute {attr!r} on sentinel objects is deprecated " + "and will be disallowed in Python 3.15.", + DeprecationWarning, + stacklevel=2, + ) + super().__setattr__(attr, value) + + @property + def __name__(self) -> str: + return self._name + + @__name__.setter + def __name__(self, value: str) -> None: + self._name = value + + def __repr__(self) -> str: + return self._repr + + if sys.version_info < (3, 11): + # The presence of this method convinces typing._type_check + # that Sentinels are types. + def __call__(self, *args, **kwargs): + raise TypeError(f"{type(self).__name__!r} object is not callable") + + # Breakpoint: https://github.com/python/cpython/pull/21515 + if sys.version_info >= (3, 10): + def __or__(self, other): + return typing.Union[self, other] + + def __ror__(self, other): + return typing.Union[other, self] + + def __reduce__(self) -> str: + """Reduce this sentinel to a singleton.""" + return self.__name__ # Module is taken from the __module__ attribute + +Sentinel = sentinel + +_marker = sentinel("sentinel") + + +# The functions below are modified copies of typing internal helpers. +# They are needed by _ProtocolMeta and they provide support for PEP 646. + +# Breakpoint: https://github.com/python/cpython/pull/27342 +if sys.version_info >= (3, 10): + def _should_collect_from_parameters(t): + return isinstance( + t, (typing._GenericAlias, _types.GenericAlias, _types.UnionType) + ) +else: + def _should_collect_from_parameters(t): + return isinstance(t, (typing._GenericAlias, _types.GenericAlias)) + + +NoReturn = typing.NoReturn + +# Some unconstrained type variables. These are used by the container types. +# (These are not for export.) +T = typing.TypeVar('T') # Any type. +KT = typing.TypeVar('KT') # Key type. +VT = typing.TypeVar('VT') # Value type. +T_co = typing.TypeVar('T_co', covariant=True) # Any type covariant containers. +T_contra = typing.TypeVar('T_contra', contravariant=True) # Ditto contravariant. + + +# Breakpoint: https://github.com/python/cpython/pull/31841 +if sys.version_info >= (3, 11): + from typing import Any +else: + + class _AnyMeta(type): + def __instancecheck__(self, obj): + if self is Any: + raise TypeError("typing_extensions.Any cannot be used with isinstance()") + return super().__instancecheck__(obj) + + def __repr__(self): + if self is Any: + return "typing_extensions.Any" + return super().__repr__() + + class Any(metaclass=_AnyMeta): + """Special type indicating an unconstrained type. + - Any is compatible with every type. + - Any assumed to have all methods. + - All values assumed to be instances of Any. + Note that all the above statements are true from the point of view of + static type checkers. At runtime, Any should not be used with instance + checks. + """ + def __new__(cls, *args, **kwargs): + if cls is Any: + raise TypeError("Any cannot be instantiated") + return super().__new__(cls, *args, **kwargs) + + +ClassVar = typing.ClassVar + +# Vendored from cpython typing._SpecialFrom +# Having a separate class means that instances will not be rejected by +# typing._type_check. +class _SpecialForm(typing._Final, _root=True): + __slots__ = ('_name', '__doc__', '_getitem') + + def __init__(self, getitem): + self._getitem = getitem + self._name = getitem.__name__ + self.__doc__ = getitem.__doc__ + + def __getattr__(self, item): + if item in {'__name__', '__qualname__'}: + return self._name + + raise AttributeError(item) + + def __mro_entries__(self, bases): + raise TypeError(f"Cannot subclass {self!r}") + + def __repr__(self): + return f'typing_extensions.{self._name}' + + def __reduce__(self): + return self._name + + def __call__(self, *args, **kwds): + raise TypeError(f"Cannot instantiate {self!r}") + + def __or__(self, other): + return typing.Union[self, other] + + def __ror__(self, other): + return typing.Union[other, self] + + def __instancecheck__(self, obj): + raise TypeError(f"{self} cannot be used with isinstance()") + + def __subclasscheck__(self, cls): + raise TypeError(f"{self} cannot be used with issubclass()") + + @typing._tp_cache + def __getitem__(self, parameters): + return self._getitem(self, parameters) + + +# Note that inheriting from this class means that the object will be +# rejected by typing._type_check, so do not use it if the special form +# is arguably valid as a type by itself. +class _ExtensionsSpecialForm(typing._SpecialForm, _root=True): + def __repr__(self): + return 'typing_extensions.' + self._name + + +Final = typing.Final + +# Breakpoint: https://github.com/python/cpython/pull/30530 +if sys.version_info >= (3, 11): + final = typing.final +else: + # @final exists in 3.8+, but we backport it for all versions + # before 3.11 to keep support for the __final__ attribute. + # See https://bugs.python.org/issue46342 + def final(f): + """This decorator can be used to indicate to type checkers that + the decorated method cannot be overridden, and decorated class + cannot be subclassed. For example: + + class Base: + @final + def done(self) -> None: + ... + class Sub(Base): + def done(self) -> None: # Error reported by type checker + ... + @final + class Leaf: + ... + class Other(Leaf): # Error reported by type checker + ... + + There is no runtime checking of these properties. The decorator + sets the ``__final__`` attribute to ``True`` on the decorated object + to allow runtime introspection. + """ + try: + f.__final__ = True + except (AttributeError, TypeError): + # Skip the attribute silently if it is not writable. + # AttributeError happens if the object has __slots__ or a + # read-only property, TypeError if it's a builtin class. + pass + return f + + +if hasattr(typing, "disjoint_base"): # 3.15 + disjoint_base = typing.disjoint_base +else: + def disjoint_base(cls): + """This decorator marks a class as a disjoint base. + + Child classes of a disjoint base cannot inherit from other disjoint bases that are + not parent classes of the disjoint base. + + For example: + + @disjoint_base + class Disjoint1: pass + + @disjoint_base + class Disjoint2: pass + + class Disjoint3(Disjoint1, Disjoint2): pass # Type checker error + + Type checkers can use knowledge of disjoint bases to detect unreachable code + and determine when two types can overlap. + + See PEP 800.""" + cls.__disjoint_base__ = True + return cls + + +def IntVar(name): + return typing.TypeVar(name) + + +# A Literal bug was fixed in 3.11.0, 3.10.1 and 3.9.8 +# Breakpoint: https://github.com/python/cpython/pull/29334 +if sys.version_info >= (3, 10, 1): + Literal = typing.Literal +else: + def _flatten_literal_params(parameters): + """An internal helper for Literal creation: flatten Literals among parameters""" + params = [] + for p in parameters: + if isinstance(p, _LiteralGenericAlias): + params.extend(p.__args__) + else: + params.append(p) + return tuple(params) + + def _value_and_type_iter(params): + for p in params: + yield p, type(p) + + class _LiteralGenericAlias(typing._GenericAlias, _root=True): + def __eq__(self, other): + if not isinstance(other, _LiteralGenericAlias): + return NotImplemented + these_args_deduped = set(_value_and_type_iter(self.__args__)) + other_args_deduped = set(_value_and_type_iter(other.__args__)) + return these_args_deduped == other_args_deduped + + def __hash__(self): + return hash(frozenset(_value_and_type_iter(self.__args__))) + + class _LiteralForm(_ExtensionsSpecialForm, _root=True): + def __init__(self, doc: str): + self._name = 'Literal' + self._doc = self.__doc__ = doc + + def __getitem__(self, parameters): + if not isinstance(parameters, tuple): + parameters = (parameters,) + + parameters = _flatten_literal_params(parameters) + + val_type_pairs = list(_value_and_type_iter(parameters)) + try: + deduped_pairs = set(val_type_pairs) + except TypeError: + # unhashable parameters + pass + else: + # similar logic to typing._deduplicate on Python 3.9+ + if len(deduped_pairs) < len(val_type_pairs): + new_parameters = [] + for pair in val_type_pairs: + if pair in deduped_pairs: + new_parameters.append(pair[0]) + deduped_pairs.remove(pair) + assert not deduped_pairs, deduped_pairs + parameters = tuple(new_parameters) + + return _LiteralGenericAlias(self, parameters) + + Literal = _LiteralForm(doc="""\ + A type that can be used to indicate to type checkers + that the corresponding value has a value literally equivalent + to the provided parameter. For example: + + var: Literal[4] = 4 + + The type checker understands that 'var' is literally equal to + the value 4 and no other value. + + Literal[...] cannot be subclassed. There is no runtime + checking verifying that the parameter is actually a value + instead of a type.""") + + +_overload_dummy = typing._overload_dummy + + +if hasattr(typing, "get_overloads"): # 3.11+ + overload = typing.overload + get_overloads = typing.get_overloads + clear_overloads = typing.clear_overloads +else: + # {module: {qualname: {firstlineno: func}}} + _overload_registry = collections.defaultdict( + functools.partial(collections.defaultdict, dict) + ) + + def overload(func): + """Decorator for overloaded functions/methods. + + In a stub file, place two or more stub definitions for the same + function in a row, each decorated with @overload. For example: + + @overload + def utf8(value: None) -> None: ... + @overload + def utf8(value: bytes) -> bytes: ... + @overload + def utf8(value: str) -> bytes: ... + + In a non-stub file (i.e. a regular .py file), do the same but + follow it with an implementation. The implementation should *not* + be decorated with @overload. For example: + + @overload + def utf8(value: None) -> None: ... + @overload + def utf8(value: bytes) -> bytes: ... + @overload + def utf8(value: str) -> bytes: ... + def utf8(value): + # implementation goes here + + The overloads for a function can be retrieved at runtime using the + get_overloads() function. + """ + # classmethod and staticmethod + f = getattr(func, "__func__", func) + try: + _overload_registry[f.__module__][f.__qualname__][ + f.__code__.co_firstlineno + ] = func + except AttributeError: + # Not a normal function; ignore. + pass + return _overload_dummy + + def get_overloads(func): + """Return all defined overloads for *func* as a sequence.""" + # classmethod and staticmethod + f = getattr(func, "__func__", func) + if f.__module__ not in _overload_registry: + return [] + mod_dict = _overload_registry[f.__module__] + if f.__qualname__ not in mod_dict: + return [] + return list(mod_dict[f.__qualname__].values()) + + def clear_overloads(): + """Clear all overloads in the registry.""" + _overload_registry.clear() + + +# This is not a real generic class. Don't use outside annotations. +Type = typing.Type + +# Various ABCs mimicking those in collections.abc. +# A few are simply re-exported for completeness. +Awaitable = typing.Awaitable +Coroutine = typing.Coroutine +AsyncIterable = typing.AsyncIterable +AsyncIterator = typing.AsyncIterator +Deque = typing.Deque +DefaultDict = typing.DefaultDict +OrderedDict = typing.OrderedDict +Counter = typing.Counter +ChainMap = typing.ChainMap +Text = typing.Text +TYPE_CHECKING = typing.TYPE_CHECKING + + +# Breakpoint: https://github.com/python/cpython/pull/118681 +if sys.version_info >= (3, 13, 0, "beta"): + from typing import AsyncContextManager, AsyncGenerator, ContextManager, Generator +else: + def _is_dunder(attr): + return attr.startswith('__') and attr.endswith('__') + + + class _SpecialGenericAlias(typing._SpecialGenericAlias, _root=True): + def __init__(self, origin, nparams, *, defaults, inst=True, name=None): + assert nparams > 0, "`nparams` must be a positive integer" + assert defaults, "Must always specify a non-empty sequence for `defaults`" + super().__init__(origin, nparams, inst=inst, name=name) + self._defaults = defaults + + def __setattr__(self, attr, val): + allowed_attrs = {'_name', '_inst', '_nparams', '_defaults'} + if _is_dunder(attr) or attr in allowed_attrs: + object.__setattr__(self, attr, val) + else: + setattr(self.__origin__, attr, val) + + @typing._tp_cache + def __getitem__(self, params): + if not isinstance(params, tuple): + params = (params,) + msg = "Parameters to generic types must be types." + params = tuple(typing._type_check(p, msg) for p in params) + if ( + len(params) < self._nparams + and len(params) + len(self._defaults) >= self._nparams + ): + params = (*params, *self._defaults[len(params) - self._nparams:]) + actual_len = len(params) + + if actual_len != self._nparams: + expected = f"at least {self._nparams - len(self._defaults)}" + raise TypeError( + f"Too {'many' if actual_len > self._nparams else 'few'}" + f" arguments for {self};" + f" actual {actual_len}, expected {expected}" + ) + return self.copy_with(params) + + _NoneType = type(None) + Generator = _SpecialGenericAlias( + collections.abc.Generator, 3, defaults=(_NoneType, _NoneType) + ) + AsyncGenerator = _SpecialGenericAlias( + collections.abc.AsyncGenerator, 2, defaults=(_NoneType,) + ) + ContextManager = _SpecialGenericAlias( + contextlib.AbstractContextManager, + 2, + name="ContextManager", + defaults=(typing.Optional[bool],) + ) + AsyncContextManager = _SpecialGenericAlias( + contextlib.AbstractAsyncContextManager, + 2, + name="AsyncContextManager", + defaults=(typing.Optional[bool],) + ) + + +_PROTO_ALLOWLIST = { + 'collections.abc': [ + 'Callable', 'Awaitable', 'Iterable', 'Iterator', 'AsyncIterable', + 'AsyncIterator', 'Hashable', 'Sized', 'Container', 'Collection', + 'Reversible', 'Buffer', + ], + 'contextlib': ['AbstractContextManager', 'AbstractAsyncContextManager'], + 'io': ['Reader', 'Writer'], + 'typing_extensions': ['Buffer'], + 'os': ['PathLike'], +} + + +_EXCLUDED_ATTRS = frozenset(typing.EXCLUDED_ATTRIBUTES) | { + "__match_args__", "__protocol_attrs__", "__non_callable_proto_members__", + "__final__", +} + + +def _get_protocol_attrs(cls): + attrs = set() + for base in cls.__mro__[:-1]: # without object + if base.__name__ in {'Protocol', 'Generic'}: + continue + annotations = getattr(base, '__annotations__', {}) + for attr in (*base.__dict__, *annotations): + if (not attr.startswith('_abc_') and attr not in _EXCLUDED_ATTRS): + attrs.add(attr) + return attrs + + +# `__match_args__` attribute was removed from protocol members in 3.13, +# we want to backport this change to older Python versions. +# 3.14 additionally added `io.Reader`, `io.Writer` and `os.PathLike` to +# the list of allowed protocol allowlist. +# https://github.com/python/cpython/issues/127647 +if sys.version_info >= (3, 14): + Protocol = typing.Protocol +else: + def _allow_reckless_class_checks(depth=2): + """Allow instance and class checks for special stdlib modules. + The abc and functools modules indiscriminately call isinstance() and + issubclass() on the whole MRO of a user class, which may contain protocols. + """ + return _caller(depth) in {'abc', 'functools', None} + + def _no_init(self, *args, **kwargs): + if type(self)._is_protocol: + raise TypeError('Protocols cannot be instantiated') + + def _type_check_issubclass_arg_1(arg): + """Raise TypeError if `arg` is not an instance of `type` + in `issubclass(arg, )`. + + In most cases, this is verified by type.__subclasscheck__. + Checking it again unnecessarily would slow down issubclass() checks, + so, we don't perform this check unless we absolutely have to. + + For various error paths, however, + we want to ensure that *this* error message is shown to the user + where relevant, rather than a typing.py-specific error message. + """ + if not isinstance(arg, type): + # Same error message as for issubclass(1, int). + raise TypeError('issubclass() arg 1 must be a class') + + # Inheriting from typing._ProtocolMeta isn't actually desirable, + # but is necessary to allow typing.Protocol and typing_extensions.Protocol + # to mix without getting TypeErrors about "metaclass conflict" + class _ProtocolMeta(type(typing.Protocol)): + # This metaclass is somewhat unfortunate, + # but is necessary for several reasons... + # + # NOTE: DO NOT call super() in any methods in this class + # That would call the methods on typing._ProtocolMeta on Python <=3.11 + # and those are slow + def __new__(mcls, name, bases, namespace, **kwargs): + if name == "Protocol" and len(bases) < 2: + pass + elif {Protocol, typing.Protocol} & set(bases): + for base in bases: + if not ( + base in {object, typing.Generic, Protocol, typing.Protocol} + or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) + or is_protocol(base) + ): + raise TypeError( + f"Protocols can only inherit from other protocols, " + f"got {base!r}" + ) + return abc.ABCMeta.__new__(mcls, name, bases, namespace, **kwargs) + + def __init__(cls, *args, **kwargs): + abc.ABCMeta.__init__(cls, *args, **kwargs) + if getattr(cls, "_is_protocol", False): + cls.__protocol_attrs__ = _get_protocol_attrs(cls) + + def __subclasscheck__(cls, other): + if cls is Protocol: + return type.__subclasscheck__(cls, other) + if ( + getattr(cls, '_is_protocol', False) + and not _allow_reckless_class_checks() + ): + if not getattr(cls, '_is_runtime_protocol', False): + _type_check_issubclass_arg_1(other) + raise TypeError( + "Instance and class checks can only be used with " + "@runtime_checkable protocols" + ) + if ( + # this attribute is set by @runtime_checkable: + cls.__non_callable_proto_members__ + and cls.__dict__.get("__subclasshook__") is _proto_hook + ): + _type_check_issubclass_arg_1(other) + non_method_attrs = sorted(cls.__non_callable_proto_members__) + raise TypeError( + "Protocols with non-method members don't support issubclass()." + f" Non-method members: {str(non_method_attrs)[1:-1]}." + ) + return abc.ABCMeta.__subclasscheck__(cls, other) + + def __instancecheck__(cls, instance): + # We need this method for situations where attributes are + # assigned in __init__. + if cls is Protocol: + return type.__instancecheck__(cls, instance) + if not getattr(cls, "_is_protocol", False): + # i.e., it's a concrete subclass of a protocol + return abc.ABCMeta.__instancecheck__(cls, instance) + + if ( + not getattr(cls, '_is_runtime_protocol', False) and + not _allow_reckless_class_checks() + ): + raise TypeError("Instance and class checks can only be used with" + " @runtime_checkable protocols") + + if abc.ABCMeta.__instancecheck__(cls, instance): + return True + + for attr in cls.__protocol_attrs__: + try: + val = inspect.getattr_static(instance, attr) + except AttributeError: + break + # this attribute is set by @runtime_checkable: + if val is None and attr not in cls.__non_callable_proto_members__: + break + else: + return True + + return False + + def __eq__(cls, other): + # Hack so that typing.Generic.__class_getitem__ + # treats typing_extensions.Protocol + # as equivalent to typing.Protocol + if abc.ABCMeta.__eq__(cls, other) is True: + return True + return cls is Protocol and other is typing.Protocol + + # This has to be defined, or the abc-module cache + # complains about classes with this metaclass being unhashable, + # if we define only __eq__! + def __hash__(cls) -> int: + return type.__hash__(cls) + + @classmethod + def _proto_hook(cls, other): + if not cls.__dict__.get('_is_protocol', False): + return NotImplemented + + for attr in cls.__protocol_attrs__: + for base in other.__mro__: + # Check if the members appears in the class dictionary... + if attr in base.__dict__: + if base.__dict__[attr] is None: + return NotImplemented + break + + # ...or in annotations, if it is a sub-protocol. + annotations = getattr(base, '__annotations__', {}) + if ( + isinstance(annotations, collections.abc.Mapping) + and attr in annotations + and is_protocol(other) + ): + break + else: + return NotImplemented + return True + + class Protocol(typing.Generic, metaclass=_ProtocolMeta): + __doc__ = typing.Protocol.__doc__ + __slots__ = () + _is_protocol = True + _is_runtime_protocol = False + + def __init_subclass__(cls, *args, **kwargs): + super().__init_subclass__(*args, **kwargs) + + # Determine if this is a protocol or a concrete subclass. + if not cls.__dict__.get('_is_protocol', False): + cls._is_protocol = any(b is Protocol for b in cls.__bases__) + + # Set (or override) the protocol subclass hook. + if '__subclasshook__' not in cls.__dict__: + cls.__subclasshook__ = _proto_hook + + # Prohibit instantiation for protocol classes + if cls._is_protocol and cls.__init__ is Protocol.__init__: + cls.__init__ = _no_init + + +# Breakpoint: https://github.com/python/cpython/pull/113401 +if sys.version_info >= (3, 13): + runtime_checkable = typing.runtime_checkable +else: + def runtime_checkable(cls): + """Mark a protocol class as a runtime protocol. + + Such protocol can be used with isinstance() and issubclass(). + Raise TypeError if applied to a non-protocol class. + This allows a simple-minded structural check very similar to + one trick ponies in collections.abc such as Iterable. + + For example:: + + @runtime_checkable + class Closable(Protocol): + def close(self): ... + + assert isinstance(open('/some/file'), Closable) + + Warning: this will check only the presence of the required methods, + not their type signatures! + """ + if not issubclass(cls, typing.Generic) or not getattr(cls, '_is_protocol', False): + raise TypeError(f'@runtime_checkable can be only applied to protocol classes,' + f' got {cls!r}') + cls._is_runtime_protocol = True + + # typing.Protocol classes on <=3.11 break if we execute this block, + # because typing.Protocol classes on <=3.11 don't have a + # `__protocol_attrs__` attribute, and this block relies on the + # `__protocol_attrs__` attribute. Meanwhile, typing.Protocol classes on 3.12.2+ + # break if we *don't* execute this block, because *they* assume that all + # protocol classes have a `__non_callable_proto_members__` attribute + # (which this block sets) + if isinstance(cls, _ProtocolMeta) or sys.version_info >= (3, 12, 2): + # PEP 544 prohibits using issubclass() + # with protocols that have non-method members. + # See gh-113320 for why we compute this attribute here, + # rather than in `_ProtocolMeta.__init__` + cls.__non_callable_proto_members__ = set() + for attr in cls.__protocol_attrs__: + try: + is_callable = callable(getattr(cls, attr, None)) + except Exception as e: + raise TypeError( + f"Failed to determine whether protocol member {attr!r} " + "is a method member" + ) from e + else: + if not is_callable: + cls.__non_callable_proto_members__.add(attr) + + return cls + + +# The "runtime" alias exists for backwards compatibility. +runtime = runtime_checkable + + +# Our version of runtime-checkable protocols is faster on Python <=3.11 +# Breakpoint: https://github.com/python/cpython/pull/112717 +if sys.version_info >= (3, 12): + SupportsInt = typing.SupportsInt + SupportsFloat = typing.SupportsFloat + SupportsComplex = typing.SupportsComplex + SupportsBytes = typing.SupportsBytes + SupportsIndex = typing.SupportsIndex + SupportsAbs = typing.SupportsAbs + SupportsRound = typing.SupportsRound +else: + @runtime_checkable + class SupportsInt(Protocol): + """An ABC with one abstract method __int__.""" + __slots__ = () + + @abc.abstractmethod + def __int__(self) -> int: + pass + + @runtime_checkable + class SupportsFloat(Protocol): + """An ABC with one abstract method __float__.""" + __slots__ = () + + @abc.abstractmethod + def __float__(self) -> float: + pass + + @runtime_checkable + class SupportsComplex(Protocol): + """An ABC with one abstract method __complex__.""" + __slots__ = () + + @abc.abstractmethod + def __complex__(self) -> complex: + pass + + @runtime_checkable + class SupportsBytes(Protocol): + """An ABC with one abstract method __bytes__.""" + __slots__ = () + + @abc.abstractmethod + def __bytes__(self) -> bytes: + pass + + @runtime_checkable + class SupportsIndex(Protocol): + __slots__ = () + + @abc.abstractmethod + def __index__(self) -> int: + pass + + @runtime_checkable + class SupportsAbs(Protocol[T_co]): + """ + An ABC with one abstract method __abs__ that is covariant in its return type. + """ + __slots__ = () + + @abc.abstractmethod + def __abs__(self) -> T_co: + pass + + @runtime_checkable + class SupportsRound(Protocol[T_co]): + """ + An ABC with one abstract method __round__ that is covariant in its return type. + """ + __slots__ = () + + @abc.abstractmethod + def __round__(self, ndigits: int = 0) -> T_co: + pass + + +if hasattr(io, "Reader") and hasattr(io, "Writer"): + Reader = io.Reader + Writer = io.Writer +else: + @runtime_checkable + class Reader(Protocol[T_co]): + """Protocol for simple I/O reader instances. + + This protocol only supports blocking I/O. + """ + + __slots__ = () + + @abc.abstractmethod + def read(self, size: int = ..., /) -> T_co: + """Read data from the input stream and return it. + + If *size* is specified, at most *size* items (bytes/characters) will be + read. + """ + + @runtime_checkable + class Writer(Protocol[T_contra]): + """Protocol for simple I/O writer instances. + + This protocol only supports blocking I/O. + """ + + __slots__ = () + + @abc.abstractmethod + def write(self, data: T_contra, /) -> int: + """Write *data* to the output stream and return the number of items written.""" # noqa: E501 + + +_NEEDS_SINGLETONMETA = ( + not hasattr(typing, "NoDefault") or not hasattr(typing, "NoExtraItems") +) + +if _NEEDS_SINGLETONMETA: + class SingletonMeta(type): + def __setattr__(cls, attr, value): + # TypeError is consistent with the behavior of NoneType + raise TypeError( + f"cannot set {attr!r} attribute of immutable type {cls.__name__!r}" + ) + + +if hasattr(typing, "NoDefault"): + NoDefault = typing.NoDefault +else: + class NoDefaultType(metaclass=SingletonMeta): + """The type of the NoDefault singleton.""" + + __slots__ = () + + def __new__(cls): + return globals().get("NoDefault") or object.__new__(cls) + + def __repr__(self): + return "typing_extensions.NoDefault" + + def __reduce__(self): + return "NoDefault" + + NoDefault = NoDefaultType() + del NoDefaultType + +if hasattr(typing, "NoExtraItems"): + NoExtraItems = typing.NoExtraItems +else: + class NoExtraItemsType(metaclass=SingletonMeta): + """The type of the NoExtraItems singleton.""" + + __slots__ = () + + def __new__(cls): + return globals().get("NoExtraItems") or object.__new__(cls) + + def __repr__(self): + return "typing_extensions.NoExtraItems" + + def __reduce__(self): + return "NoExtraItems" + + NoExtraItems = NoExtraItemsType() + del NoExtraItemsType + +if _NEEDS_SINGLETONMETA: + del SingletonMeta + + +# Update this to something like >=3.13.0b1 if and when +# PEP 764 is implemented in CPython +_PEP_764_IMPLEMENTED = False + +if _PEP_764_IMPLEMENTED: + # The standard library TypedDict in Python 3.9.0/1 does not honour the "total" + # keyword with old-style TypedDict(). See https://bugs.python.org/issue42059 + # The standard library TypedDict below Python 3.11 does not store runtime + # information about optional and required keys when using Required or NotRequired. + # Generic TypedDicts are also impossible using typing.TypedDict on Python <3.11. + # Aaaand on 3.12 we add __orig_bases__ to TypedDict + # to enable better runtime introspection. + # On 3.13 we deprecate some odd ways of creating TypedDicts. + # Also on 3.13, PEP 705 adds the ReadOnly[] qualifier. + # PEP 728 (Python 3.15+) adds the `extra_items` and `closed` keywords. + # PEP 764 (still pending) allows the `TypedDict` special form to be subscripted. + TypedDict = typing.TypedDict + _TypedDictMeta = typing._TypedDictMeta + is_typeddict = typing.is_typeddict +else: + # 3.10.0 and later + _TAKES_MODULE = "module" in inspect.signature(typing._type_check).parameters + + def _get_typeddict_qualifiers(annotation_type): + while True: + annotation_origin = get_origin(annotation_type) + if annotation_origin is Annotated: + annotation_args = get_args(annotation_type) + if annotation_args: + annotation_type = annotation_args[0] + else: + break + elif annotation_origin is Required: + yield Required + annotation_type, = get_args(annotation_type) + elif annotation_origin is NotRequired: + yield NotRequired + annotation_type, = get_args(annotation_type) + elif annotation_origin is ReadOnly: + yield ReadOnly + annotation_type, = get_args(annotation_type) + else: + break + + class _TypedDictMeta(type): + + def __new__(cls, name, bases, ns, *, total=True, closed=None, + extra_items=NoExtraItems): + """Create new typed dict class object. + + This method is called when TypedDict is subclassed, + or when TypedDict is instantiated. This way + TypedDict supports all three syntax forms described in its docstring. + Subclasses and instances of TypedDict return actual dictionaries. + """ + for base in bases: + if type(base) is not _TypedDictMeta and base is not typing.Generic: + raise TypeError('cannot inherit from both a TypedDict type ' + 'and a non-TypedDict base class') + if closed is not None and extra_items is not NoExtraItems: + raise TypeError(f"Cannot combine closed={closed!r} and extra_items") + + if any(issubclass(b, typing.Generic) for b in bases): + generic_base = (typing.Generic,) + else: + generic_base = () + + ns_annotations = ns.pop('__annotations__', None) + + # typing.py generally doesn't let you inherit from plain Generic, unless + # the name of the class happens to be "Protocol" + tp_dict = type.__new__(_TypedDictMeta, "Protocol", (*generic_base, dict), ns) + tp_dict.__name__ = name + if tp_dict.__qualname__ == "Protocol": + tp_dict.__qualname__ = name + + if not hasattr(tp_dict, '__orig_bases__'): + tp_dict.__orig_bases__ = bases + + annotations = {} + own_annotate = None + if ns_annotations is not None: + own_annotations = ns_annotations + elif sys.version_info >= (3, 14): + if hasattr(annotationlib, "get_annotate_from_class_namespace"): + own_annotate = annotationlib.get_annotate_from_class_namespace(ns) + else: + # 3.14.0a7 and earlier + own_annotate = ns.get("__annotate__") + if own_annotate is not None: + own_annotations = annotationlib.call_annotate_function( + own_annotate, Format.FORWARDREF, owner=tp_dict + ) + else: + own_annotations = {} + else: + own_annotations = {} + msg = "TypedDict('Name', {f0: t0, f1: t1, ...}); each t must be a type" + if _TAKES_MODULE: + own_checked_annotations = { + n: typing._type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own_annotations.items() + } + else: + own_checked_annotations = { + n: typing._type_check(tp, msg) + for n, tp in own_annotations.items() + } + required_keys = set() + optional_keys = set() + readonly_keys = set() + mutable_keys = set() + extra_items_type = extra_items + + for base in bases: + base_dict = base.__dict__ + + if sys.version_info <= (3, 14): + annotations.update(base_dict.get('__annotations__', {})) + base_required = base_dict.get('__required_keys__', set()) + required_keys |= base_required + optional_keys -= base_required + + base_optional = base_dict.get('__optional_keys__', set()) + required_keys -= base_optional + optional_keys |= base_optional + + readonly_keys.update(base_dict.get('__readonly_keys__', ())) + mutable_keys.update(base_dict.get('__mutable_keys__', ())) + + # This was specified in an earlier version of PEP 728. Support + # is retained for backwards compatibility, but only for Python + # 3.13 and lower. + if (closed and sys.version_info < (3, 14) + and "__extra_items__" in own_checked_annotations): + annotation_type = own_checked_annotations.pop("__extra_items__") + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) + if Required in qualifiers: + raise TypeError( + "Special key __extra_items__ does not support " + "Required" + ) + if NotRequired in qualifiers: + raise TypeError( + "Special key __extra_items__ does not support " + "NotRequired" + ) + extra_items_type = annotation_type + + annotations.update(own_checked_annotations) + for annotation_key, annotation_type in own_checked_annotations.items(): + qualifiers = set(_get_typeddict_qualifiers(annotation_type)) + + if Required in qualifiers: + is_required = True + elif NotRequired in qualifiers: + is_required = False + else: + is_required = total + + if is_required: + required_keys.add(annotation_key) + optional_keys.discard(annotation_key) + else: + optional_keys.add(annotation_key) + required_keys.discard(annotation_key) + + if ReadOnly in qualifiers: + mutable_keys.discard(annotation_key) + readonly_keys.add(annotation_key) + else: + mutable_keys.add(annotation_key) + readonly_keys.discard(annotation_key) + + # Breakpoint: https://github.com/python/cpython/pull/119891 + if sys.version_info >= (3, 14): + def __annotate__(format): + annos = {} + for base in bases: + if base is Generic: + continue + base_annotate = base.__annotate__ + if base_annotate is None: + continue + base_annos = annotationlib.call_annotate_function( + base_annotate, format, owner=base) + annos.update(base_annos) + if own_annotate is not None: + own = annotationlib.call_annotate_function( + own_annotate, format, owner=tp_dict) + if format != Format.STRING: + own = { + n: typing._type_check(tp, msg, module=tp_dict.__module__) + for n, tp in own.items() + } + elif format == Format.STRING: + own = annotationlib.annotations_to_string(own_annotations) + elif format in (Format.FORWARDREF, Format.VALUE): + own = own_checked_annotations + else: + raise NotImplementedError(format) + annos.update(own) + return annos + + tp_dict.__annotate__ = __annotate__ + else: + tp_dict.__annotations__ = annotations + tp_dict.__required_keys__ = frozenset(required_keys) + tp_dict.__optional_keys__ = frozenset(optional_keys) + tp_dict.__readonly_keys__ = frozenset(readonly_keys) + tp_dict.__mutable_keys__ = frozenset(mutable_keys) + tp_dict.__total__ = total + tp_dict.__closed__ = closed + tp_dict.__extra_items__ = extra_items_type + return tp_dict + + __call__ = dict # static method + + def __subclasscheck__(cls, other): + # Typed dicts are only for static structural subtyping. + raise TypeError('TypedDict does not support instance and class checks') + + __instancecheck__ = __subclasscheck__ + + _TypedDict = type.__new__(_TypedDictMeta, 'TypedDict', (), {}) + + def _create_typeddict( + typename, + fields, + /, + *, + typing_is_inline, + total, + closed, + extra_items, + **kwargs, + ): + if fields is _marker or fields is None: + if fields is _marker: + deprecated_thing = ( + "Failing to pass a value for the 'fields' parameter" + ) + else: + deprecated_thing = "Passing `None` as the 'fields' parameter" + + example = f"`{typename} = TypedDict({typename!r}, {{}})`" + deprecation_msg = ( + f"{deprecated_thing} is deprecated and will be disallowed in " + "Python 3.15. To create a TypedDict class with 0 fields " + "using the functional syntax, pass an empty dictionary, e.g. " + ) + example + "." + warnings.warn(deprecation_msg, DeprecationWarning, stacklevel=2) + # Support a field called "closed" + if closed is not False and closed is not True and closed is not None: + kwargs["closed"] = closed + closed = None + # Or "extra_items" + if extra_items is not NoExtraItems: + kwargs["extra_items"] = extra_items + extra_items = NoExtraItems + fields = kwargs + elif kwargs: + raise TypeError("TypedDict takes either a dict or keyword arguments," + " but not both") + if kwargs: + # Breakpoint: https://github.com/python/cpython/pull/104891 + if sys.version_info >= (3, 13): + raise TypeError("TypedDict takes no keyword arguments") + warnings.warn( + "The kwargs-based syntax for TypedDict definitions is deprecated " + "in Python 3.11, will be removed in Python 3.13, and may not be " + "understood by third-party type checkers.", + DeprecationWarning, + stacklevel=2, + ) + + ns = {'__annotations__': dict(fields)} + module = _caller(depth=4 if typing_is_inline else 2) + if module is not None: + # Setting correct module is necessary to make typed dict classes + # pickleable. + ns['__module__'] = module + + td = _TypedDictMeta(typename, (), ns, total=total, closed=closed, + extra_items=extra_items) + td.__orig_bases__ = (TypedDict,) + return td + + class _TypedDictSpecialForm(_SpecialForm, _root=True): + def __call__( + self, + typename, + fields=_marker, + /, + *, + total=True, + closed=None, + extra_items=NoExtraItems, + **kwargs + ): + return _create_typeddict( + typename, + fields, + typing_is_inline=False, + total=total, + closed=closed, + extra_items=extra_items, + **kwargs, + ) + + def __mro_entries__(self, bases): + return (_TypedDict,) + + @_TypedDictSpecialForm + def TypedDict(self, args): + """A simple typed namespace. At runtime it is equivalent to a plain dict. + + TypedDict creates a dictionary type such that a type checker will expect all + instances to have a certain set of keys, where each key is + associated with a value of a consistent type. This expectation + is not checked at runtime. + + Usage:: + + class Point2D(TypedDict): + x: int + y: int + label: str + + a: Point2D = {'x': 1, 'y': 2, 'label': 'good'} # OK + b: Point2D = {'z': 3, 'label': 'bad'} # Fails type check + + assert Point2D(x=1, y=2, label='first') == dict(x=1, y=2, label='first') + + The type info can be accessed via the Point2D.__annotations__ dict, and + the Point2D.__required_keys__ and Point2D.__optional_keys__ frozensets. + TypedDict supports an additional equivalent form:: + + Point2D = TypedDict('Point2D', {'x': int, 'y': int, 'label': str}) + + By default, all keys must be present in a TypedDict. It is possible + to override this by specifying totality:: + + class Point2D(TypedDict, total=False): + x: int + y: int + + This means that a Point2D TypedDict can have any of the keys omitted. A type + checker is only expected to support a literal False or True as the value of + the total argument. True is the default, and makes all items defined in the + class body be required. + + The Required and NotRequired special forms can also be used to mark + individual keys as being required or not required:: + + class Point2D(TypedDict): + x: int # the "x" key must always be present (Required is the default) + y: NotRequired[int] # the "y" key can be omitted + + See PEP 655 for more details on Required and NotRequired. + """ + # This runs when creating inline TypedDicts: + if not isinstance(args, dict): + raise TypeError( + "TypedDict[...] should be used with a single dict argument" + ) + + return _create_typeddict( + "", + args, + typing_is_inline=True, + total=True, + closed=True, + extra_items=NoExtraItems, + ) + + _TYPEDDICT_TYPES = (typing._TypedDictMeta, _TypedDictMeta) + + def is_typeddict(tp): + """Check if an annotation is a TypedDict class + + For example:: + class Film(TypedDict): + title: str + year: int + + is_typeddict(Film) # => True + is_typeddict(Union[list, str]) # => False + """ + return isinstance(tp, _TYPEDDICT_TYPES) + + +if hasattr(typing, "assert_type"): + assert_type = typing.assert_type + +else: + def assert_type(val, typ, /): + """Assert (to the type checker) that the value is of the given type. + + When the type checker encounters a call to assert_type(), it + emits an error if the value is not of the specified type:: + + def greet(name: str) -> None: + assert_type(name, str) # ok + assert_type(name, int) # type checker error + + At runtime this returns the first argument unchanged and otherwise + does nothing. + """ + return val + + +if hasattr(typing, "ReadOnly"): # 3.13+ + get_type_hints = typing.get_type_hints +else: # <=3.13 + # replaces _strip_annotations() + def _strip_extras(t): + """Strips Annotated, Required and NotRequired from a given type.""" + if isinstance(t, typing._AnnotatedAlias): + return _strip_extras(t.__origin__) + if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired, ReadOnly): + return _strip_extras(t.__args__[0]) + if isinstance(t, typing._GenericAlias): + stripped_args = tuple(_strip_extras(a) for a in t.__args__) + if stripped_args == t.__args__: + return t + return t.copy_with(stripped_args) + if hasattr(_types, "GenericAlias") and isinstance(t, _types.GenericAlias): + stripped_args = tuple(_strip_extras(a) for a in t.__args__) + if stripped_args == t.__args__: + return t + return _types.GenericAlias(t.__origin__, stripped_args) + if hasattr(_types, "UnionType") and isinstance(t, _types.UnionType): + stripped_args = tuple(_strip_extras(a) for a in t.__args__) + if stripped_args == t.__args__: + return t + return functools.reduce(operator.or_, stripped_args) + + return t + + def get_type_hints(obj, globalns=None, localns=None, include_extras=False): + """Return type hints for an object. + + This is often the same as obj.__annotations__, but it handles + forward references encoded as string literals, adds Optional[t] if a + default value equal to None is set and recursively replaces all + 'Annotated[T, ...]', 'Required[T]' or 'NotRequired[T]' with 'T' + (unless 'include_extras=True'). + + The argument may be a module, class, method, or function. The annotations + are returned as a dictionary. For classes, annotations include also + inherited members. + + TypeError is raised if the argument is not of a type that can contain + annotations, and an empty dictionary is returned if no annotations are + present. + + BEWARE -- the behavior of globalns and localns is counterintuitive + (unless you are familiar with how eval() and exec() work). The + search order is locals first, then globals. + + - If no dict arguments are passed, an attempt is made to use the + globals from obj (or the respective module's globals for classes), + and these are also used as the locals. If the object does not appear + to have globals, an empty dictionary is used. + + - If one dict argument is passed, it is used for both globals and + locals. + + - If two dict arguments are passed, they specify globals and + locals, respectively. + """ + hint = typing.get_type_hints( + obj, globalns=globalns, localns=localns, include_extras=True + ) + # Breakpoint: https://github.com/python/cpython/pull/30304 + if sys.version_info < (3, 11): + _clean_optional(obj, hint, globalns, localns) + if include_extras: + return hint + return {k: _strip_extras(t) for k, t in hint.items()} + + _NoneType = type(None) + + def _could_be_inserted_optional(t): + """detects Union[..., None] pattern""" + if not isinstance(t, typing._UnionGenericAlias): + return False + # Assume if last argument is not None they are user defined + if t.__args__[-1] is not _NoneType: + return False + return True + + # < 3.11 + def _clean_optional(obj, hints, globalns=None, localns=None): + # reverts injected Union[..., None] cases from typing.get_type_hints + # when a None default value is used. + # see https://github.com/python/typing_extensions/issues/310 + if not hints or isinstance(obj, type): + return + defaults = typing._get_defaults(obj) # avoid accessing __annotations___ + if not defaults: + return + original_hints = obj.__annotations__ + for name, value in hints.items(): + # Not a Union[..., None] or replacement conditions not fullfilled + if (not _could_be_inserted_optional(value) + or name not in defaults + or defaults[name] is not None + ): + continue + original_value = original_hints[name] + # value=NoneType should have caused a skip above but check for safety + if original_value is None: + original_value = _NoneType + # Forward reference + if isinstance(original_value, str): + if globalns is None: + if isinstance(obj, _types.ModuleType): + globalns = obj.__dict__ + else: + nsobj = obj + # Find globalns for the unwrapped object. + while hasattr(nsobj, '__wrapped__'): + nsobj = nsobj.__wrapped__ + globalns = getattr(nsobj, '__globals__', {}) + if localns is None: + localns = globalns + elif localns is None: + localns = globalns + + original_value = ForwardRef( + original_value, + is_argument=not isinstance(obj, _types.ModuleType) + ) + original_evaluated = typing._eval_type(original_value, globalns, localns) + # Compare if values differ. Note that even if equal + # value might be cached by typing._tp_cache contrary to original_evaluated + if original_evaluated != value or ( + # 3.10: ForwardRefs of UnionType might be turned into _UnionGenericAlias + hasattr(_types, "UnionType") + and isinstance(original_evaluated, _types.UnionType) + and not isinstance(value, _types.UnionType) + ): + hints[name] = original_evaluated + +# Python 3.9 has get_origin() and get_args() but those implementations don't support +# ParamSpecArgs and ParamSpecKwargs, so only Python 3.10's versions will do. +# Breakpoint: https://github.com/python/cpython/pull/25298 +if sys.version_info >= (3, 10): + get_origin = typing.get_origin + get_args = typing.get_args +# 3.9 +else: + def get_origin(tp): + """Get the unsubscripted version of a type. + + This supports generic types, Callable, Tuple, Union, Literal, Final, ClassVar + and Annotated. Return None for unsupported types. Examples:: + + get_origin(Literal[42]) is Literal + get_origin(int) is None + get_origin(ClassVar[int]) is ClassVar + get_origin(Generic) is Generic + get_origin(Generic[T]) is Generic + get_origin(Union[T, int]) is Union + get_origin(List[Tuple[T, T]][int]) == list + get_origin(P.args) is P + """ + if isinstance(tp, typing._AnnotatedAlias): + return Annotated + if isinstance(tp, (typing._BaseGenericAlias, _types.GenericAlias, + ParamSpecArgs, ParamSpecKwargs)): + return tp.__origin__ + if tp is typing.Generic: + return typing.Generic + return None + + def get_args(tp): + """Get type arguments with all substitutions performed. + + For unions, basic simplifications used by Union constructor are performed. + Examples:: + get_args(Dict[str, int]) == (str, int) + get_args(int) == () + get_args(Union[int, Union[T, int], str][int]) == (int, str) + get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int]) + get_args(Callable[[], T][int]) == ([], int) + """ + if isinstance(tp, typing._AnnotatedAlias): + return (tp.__origin__, *tp.__metadata__) + if isinstance(tp, (typing._GenericAlias, _types.GenericAlias)): + res = tp.__args__ + if get_origin(tp) is collections.abc.Callable and res[0] is not Ellipsis: + res = (list(res[:-1]), res[-1]) + return res + return () + + +# 3.10+ +if hasattr(typing, 'TypeAlias'): + TypeAlias = typing.TypeAlias +# 3.9 +else: + @_ExtensionsSpecialForm + def TypeAlias(self, parameters): + """Special marker indicating that an assignment should + be recognized as a proper type alias definition by type + checkers. + + For example:: + + Predicate: TypeAlias = Callable[..., bool] + + It's invalid when used anywhere except as in the example above. + """ + raise TypeError(f"{self} is not subscriptable") + + +def _set_default(type_param, default): + type_param.has_default = lambda: default is not NoDefault + type_param.__default__ = default + + +def _set_module(typevarlike): + # for pickling: + def_mod = _caller(depth=2) + if def_mod != 'typing_extensions': + typevarlike.__module__ = def_mod + + +class _DefaultMixin: + """Mixin for TypeVarLike defaults.""" + + __slots__ = () + __init__ = _set_default + + +# Classes using this metaclass must provide a _backported_typevarlike ClassVar +class _TypeVarLikeMeta(type): + def __instancecheck__(cls, __instance: Any) -> bool: + return isinstance(__instance, cls._backported_typevarlike) + + +if _PEP_696_IMPLEMENTED: + from typing import TypeVar +else: + # Add default and infer_variance parameters from PEP 696 and 695 + class TypeVar(metaclass=_TypeVarLikeMeta): + """Type variable.""" + + _backported_typevarlike = typing.TypeVar + + def __new__(cls, name, *constraints, bound=None, + covariant=False, contravariant=False, + default=NoDefault, infer_variance=False): + if hasattr(typing, "TypeAliasType"): + # PEP 695 implemented (3.12+), can pass infer_variance to typing.TypeVar + typevar = typing.TypeVar(name, *constraints, bound=bound, + covariant=covariant, contravariant=contravariant, + infer_variance=infer_variance) + else: + typevar = typing.TypeVar(name, *constraints, bound=bound, + covariant=covariant, contravariant=contravariant) + if infer_variance and (covariant or contravariant): + raise ValueError("Variance cannot be specified with infer_variance.") + typevar.__infer_variance__ = infer_variance + + _set_default(typevar, default) + _set_module(typevar) + + def _tvar_prepare_subst(alias, args): + if ( + typevar.has_default() + and alias.__parameters__.index(typevar) == len(args) + ): + args += (typevar.__default__,) + return args + + typevar.__typing_prepare_subst__ = _tvar_prepare_subst + return typevar + + def __init_subclass__(cls) -> None: + raise TypeError(f"type '{__name__}.TypeVar' is not an acceptable base type") + + +# Python 3.10+ has PEP 612 +if hasattr(typing, 'ParamSpecArgs'): + ParamSpecArgs = typing.ParamSpecArgs + ParamSpecKwargs = typing.ParamSpecKwargs +# 3.9 +else: + class _Immutable: + """Mixin to indicate that object should not be copied.""" + __slots__ = () + + def __copy__(self): + return self + + def __deepcopy__(self, memo): + return self + + class ParamSpecArgs(_Immutable): + """The args for a ParamSpec object. + + Given a ParamSpec object P, P.args is an instance of ParamSpecArgs. + + ParamSpecArgs objects have a reference back to their ParamSpec: + + P.args.__origin__ is P + + This type is meant for runtime introspection and has no special meaning to + static type checkers. + """ + def __init__(self, origin): + self.__origin__ = origin + + def __repr__(self): + return f"{self.__origin__.__name__}.args" + + def __eq__(self, other): + if not isinstance(other, ParamSpecArgs): + return NotImplemented + return self.__origin__ == other.__origin__ + + class ParamSpecKwargs(_Immutable): + """The kwargs for a ParamSpec object. + + Given a ParamSpec object P, P.kwargs is an instance of ParamSpecKwargs. + + ParamSpecKwargs objects have a reference back to their ParamSpec: + + P.kwargs.__origin__ is P + + This type is meant for runtime introspection and has no special meaning to + static type checkers. + """ + def __init__(self, origin): + self.__origin__ = origin + + def __repr__(self): + return f"{self.__origin__.__name__}.kwargs" + + def __eq__(self, other): + if not isinstance(other, ParamSpecKwargs): + return NotImplemented + return self.__origin__ == other.__origin__ + + +if _PEP_696_IMPLEMENTED: + from typing import ParamSpec + +# 3.10+ +elif hasattr(typing, 'ParamSpec'): + + # Add default parameter - PEP 696 + class ParamSpec(metaclass=_TypeVarLikeMeta): + """Parameter specification.""" + + _backported_typevarlike = typing.ParamSpec + + def __new__(cls, name, *, bound=None, + covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): + if hasattr(typing, "TypeAliasType"): + # PEP 695 implemented, can pass infer_variance to typing.TypeVar + paramspec = typing.ParamSpec(name, bound=bound, + covariant=covariant, + contravariant=contravariant, + infer_variance=infer_variance) + else: + paramspec = typing.ParamSpec(name, bound=bound, + covariant=covariant, + contravariant=contravariant) + paramspec.__infer_variance__ = bool(infer_variance) + + _set_default(paramspec, default) + _set_module(paramspec) + + def _paramspec_prepare_subst(alias, args): + params = alias.__parameters__ + i = params.index(paramspec) + if i == len(args) and paramspec.has_default(): + args = [*args, paramspec.__default__] + if i >= len(args): + raise TypeError(f"Too few arguments for {alias}") + # Special case where Z[[int, str, bool]] == Z[int, str, bool] in PEP 612. + if len(params) == 1 and not typing._is_param_expr(args[0]): + assert i == 0 + args = (args,) + # Convert lists to tuples to help other libraries cache the results. + elif isinstance(args[i], list): + args = (*args[:i], tuple(args[i]), *args[i + 1:]) + return args + + paramspec.__typing_prepare_subst__ = _paramspec_prepare_subst + return paramspec + + def __init_subclass__(cls) -> None: + raise TypeError(f"type '{__name__}.ParamSpec' is not an acceptable base type") + +# 3.9 +else: + + # Inherits from list as a workaround for Callable checks in Python < 3.9.2. + class ParamSpec(list, _DefaultMixin): + """Parameter specification variable. + + Usage:: + + P = ParamSpec('P') + + Parameter specification variables exist primarily for the benefit of static + type checkers. They are used to forward the parameter types of one + callable to another callable, a pattern commonly found in higher order + functions and decorators. They are only valid when used in ``Concatenate``, + or s the first argument to ``Callable``. In Python 3.10 and higher, + they are also supported in user-defined Generics at runtime. + See class Generic for more information on generic types. An + example for annotating a decorator:: + + T = TypeVar('T') + P = ParamSpec('P') + + def add_logging(f: Callable[P, T]) -> Callable[P, T]: + '''A type-safe decorator to add logging to a function.''' + def inner(*args: P.args, **kwargs: P.kwargs) -> T: + logging.info(f'{f.__name__} was called') + return f(*args, **kwargs) + return inner + + @add_logging + def add_two(x: float, y: float) -> float: + '''Add two numbers together.''' + return x + y + + Parameter specification variables defined with covariant=True or + contravariant=True can be used to declare covariant or contravariant + generic types. These keyword arguments are valid, but their actual semantics + are yet to be decided. See PEP 612 for details. + + Parameter specification variables can be introspected. e.g.: + + P.__name__ == 'T' + P.__bound__ == None + P.__covariant__ == False + P.__contravariant__ == False + + Note that only parameter specification variables defined in global scope can + be pickled. + """ + + # Trick Generic __parameters__. + __class__ = typing.TypeVar + + @property + def args(self): + return ParamSpecArgs(self) + + @property + def kwargs(self): + return ParamSpecKwargs(self) + + def __init__(self, name, *, bound=None, covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): + list.__init__(self, [self]) + self.__name__ = name + self.__covariant__ = bool(covariant) + self.__contravariant__ = bool(contravariant) + self.__infer_variance__ = bool(infer_variance) + self.__bound__ = bound + _DefaultMixin.__init__(self, default) + + # for pickling: + def_mod = _caller() + if def_mod != 'typing_extensions': + self.__module__ = def_mod + + def __repr__(self): + if self.__infer_variance__: + prefix = '' + elif self.__covariant__: + prefix = '+' + elif self.__contravariant__: + prefix = '-' + else: + prefix = '~' + return prefix + self.__name__ + + def __hash__(self): + return object.__hash__(self) + + def __eq__(self, other): + return self is other + + def __reduce__(self): + return self.__name__ + + # Hack to get typing._type_check to pass. + def __call__(self, *args, **kwargs): + pass + + def __init_subclass__(cls) -> None: + raise TypeError(f"type '{__name__}.ParamSpec' is not an acceptable base type") + + +# 3.9 +if not hasattr(typing, 'Concatenate'): + # Inherits from list as a workaround for Callable checks in Python < 3.9.2. + + # 3.9.0-1 + if not hasattr(typing, '_type_convert'): + def _type_convert(arg, module=None, *, allow_special_forms=False): + """For converting None to type(None), and strings to ForwardRef.""" + if arg is None: + return type(None) + if isinstance(arg, str): + if sys.version_info <= (3, 9, 6): + return ForwardRef(arg) + if sys.version_info <= (3, 9, 7): + return ForwardRef(arg, module=module) + return ForwardRef(arg, module=module, is_class=allow_special_forms) + return arg + else: + _type_convert = typing._type_convert + + class _ConcatenateGenericAlias(list): + + # Trick Generic into looking into this for __parameters__. + __class__ = typing._GenericAlias + + def __init__(self, origin, args): + # Cannot use `super().__init__` here because of the `__class__` assignment + # in the class body (https://github.com/python/typing_extensions/issues/661) + list.__init__(self, args) + self.__origin__ = origin + self.__args__ = args + + def __repr__(self): + _type_repr = typing._type_repr + return (f'{_type_repr(self.__origin__)}' + f'[{", ".join(_type_repr(arg) for arg in self.__args__)}]') + + def __hash__(self): + return hash((self.__origin__, self.__args__)) + + # Hack to get typing._type_check to pass in Generic. + def __call__(self, *args, **kwargs): + pass + + @property + def __parameters__(self): + return tuple( + tp for tp in self.__args__ if isinstance(tp, (typing.TypeVar, ParamSpec)) + ) + + # 3.9 used by __getitem__ below + def copy_with(self, params): + if isinstance(params[-1], _ConcatenateGenericAlias): + params = (*params[:-1], *params[-1].__args__) + elif isinstance(params[-1], (list, tuple)): + return (*params[:-1], *params[-1]) + elif (not (params[-1] is ... or isinstance(params[-1], ParamSpec))): + raise TypeError("The last parameter to Concatenate should be a " + "ParamSpec variable or ellipsis.") + return self.__class__(self.__origin__, params) + + # 3.9; accessed during GenericAlias.__getitem__ when substituting + def __getitem__(self, args): + if self.__origin__ in (Generic, Protocol): + # Can't subscript Generic[...] or Protocol[...]. + raise TypeError(f"Cannot subscript already-subscripted {self}") + if not self.__parameters__: + raise TypeError(f"{self} is not a generic class") + + if not isinstance(args, tuple): + args = (args,) + args = _unpack_args(*(_type_convert(p) for p in args)) + params = self.__parameters__ + for param in params: + prepare = getattr(param, "__typing_prepare_subst__", None) + if prepare is not None: + args = prepare(self, args) + # 3.9 & typing.ParamSpec + elif isinstance(param, ParamSpec): + i = params.index(param) + if ( + i == len(args) + and getattr(param, '__default__', NoDefault) is not NoDefault + ): + args = [*args, param.__default__] + if i >= len(args): + raise TypeError(f"Too few arguments for {self}") + # Special case for Z[[int, str, bool]] == Z[int, str, bool] + if len(params) == 1 and not _is_param_expr(args[0]): + assert i == 0 + args = (args,) + elif ( + isinstance(args[i], list) + # 3.9 + # This class inherits from list do not convert + and not isinstance(args[i], _ConcatenateGenericAlias) + ): + args = (*args[:i], tuple(args[i]), *args[i + 1:]) + + alen = len(args) + plen = len(params) + if alen != plen: + raise TypeError( + f"Too {'many' if alen > plen else 'few'} arguments for {self};" + f" actual {alen}, expected {plen}" + ) + + subst = dict(zip(self.__parameters__, args)) + # determine new args + new_args = [] + for arg in self.__args__: + if isinstance(arg, type): + new_args.append(arg) + continue + if isinstance(arg, TypeVar): + arg = subst[arg] + if ( + (isinstance(arg, typing._GenericAlias) and _is_unpack(arg)) + or ( + hasattr(_types, "GenericAlias") + and isinstance(arg, _types.GenericAlias) + and getattr(arg, "__unpacked__", False) + ) + ): + raise TypeError(f"{arg} is not valid as type argument") + + elif isinstance(arg, + typing._GenericAlias + if not hasattr(_types, "GenericAlias") else + (typing._GenericAlias, _types.GenericAlias) + ): + subparams = arg.__parameters__ + if subparams: + subargs = tuple(subst[x] for x in subparams) + arg = arg[subargs] + new_args.append(arg) + return self.copy_with(tuple(new_args)) + +# 3.10+ +else: + _ConcatenateGenericAlias = typing._ConcatenateGenericAlias + + # 3.10 + if sys.version_info < (3, 11): + + class _ConcatenateGenericAlias(typing._ConcatenateGenericAlias, _root=True): + # needed for checks in collections.abc.Callable to accept this class + __module__ = "typing" + + def copy_with(self, params): + if isinstance(params[-1], (list, tuple)): + return (*params[:-1], *params[-1]) + if isinstance(params[-1], typing._ConcatenateGenericAlias): + params = (*params[:-1], *params[-1].__args__) + elif not (params[-1] is ... or isinstance(params[-1], ParamSpec)): + raise TypeError("The last parameter to Concatenate should be a " + "ParamSpec variable or ellipsis.") + return super(typing._ConcatenateGenericAlias, self).copy_with(params) + + def __getitem__(self, args): + value = super().__getitem__(args) + if isinstance(value, tuple) and any(_is_unpack(t) for t in value): + return tuple(_unpack_args(*(n for n in value))) + return value + + +# 3.9.2 +class _EllipsisDummy: ... + + +# <=3.10 +def _create_concatenate_alias(origin, parameters): + if parameters[-1] is ... and sys.version_info < (3, 9, 2): + # Hack: Arguments must be types, replace it with one. + parameters = (*parameters[:-1], _EllipsisDummy) + if sys.version_info >= (3, 10, 3): + concatenate = _ConcatenateGenericAlias(origin, parameters, + _typevar_types=(TypeVar, ParamSpec), + _paramspec_tvars=True) + else: + concatenate = _ConcatenateGenericAlias(origin, parameters) + if parameters[-1] is not _EllipsisDummy: + return concatenate + # Remove dummy again + concatenate.__args__ = tuple(p if p is not _EllipsisDummy else ... + for p in concatenate.__args__) + if sys.version_info < (3, 10): + # backport needs __args__ adjustment only + return concatenate + concatenate.__parameters__ = tuple(p for p in concatenate.__parameters__ + if p is not _EllipsisDummy) + return concatenate + + +# <=3.10 +@typing._tp_cache +def _concatenate_getitem(self, parameters): + if parameters == (): + raise TypeError("Cannot take a Concatenate of no types.") + if not isinstance(parameters, tuple): + parameters = (parameters,) + if not (parameters[-1] is ... or isinstance(parameters[-1], ParamSpec)): + raise TypeError("The last parameter to Concatenate should be a " + "ParamSpec variable or ellipsis.") + msg = "Concatenate[arg, ...]: each arg must be a type." + parameters = (*(typing._type_check(p, msg) for p in parameters[:-1]), + parameters[-1]) + return _create_concatenate_alias(self, parameters) + + +# 3.11+; Concatenate does not accept ellipsis in 3.10 +# Breakpoint: https://github.com/python/cpython/pull/30969 +if sys.version_info >= (3, 11): + Concatenate = typing.Concatenate +# <=3.10 +else: + @_ExtensionsSpecialForm + def Concatenate(self, parameters): + """Used in conjunction with ``ParamSpec`` and ``Callable`` to represent a + higher order function which adds, removes or transforms parameters of a + callable. + + For example:: + + Callable[Concatenate[int, P], int] + + See PEP 612 for detailed information. + """ + return _concatenate_getitem(self, parameters) + + +# 3.10+ +if hasattr(typing, 'TypeGuard'): + TypeGuard = typing.TypeGuard +# 3.9 +else: + @_ExtensionsSpecialForm + def TypeGuard(self, parameters): + """Special typing form used to annotate the return type of a user-defined + type guard function. ``TypeGuard`` only accepts a single type argument. + At runtime, functions marked this way should return a boolean. + + ``TypeGuard`` aims to benefit *type narrowing* -- a technique used by static + type checkers to determine a more precise type of an expression within a + program's code flow. Usually type narrowing is done by analyzing + conditional code flow and applying the narrowing to a block of code. The + conditional expression here is sometimes referred to as a "type guard". + + Sometimes it would be convenient to use a user-defined boolean function + as a type guard. Such a function should use ``TypeGuard[...]`` as its + return type to alert static type checkers to this intention. + + Using ``-> TypeGuard`` tells the static type checker that for a given + function: + + 1. The return value is a boolean. + 2. If the return value is ``True``, the type of its argument + is the type inside ``TypeGuard``. + + For example:: + + def is_str(val: Union[str, float]): + # "isinstance" type guard + if isinstance(val, str): + # Type of ``val`` is narrowed to ``str`` + ... + else: + # Else, type of ``val`` is narrowed to ``float``. + ... + + Strict type narrowing is not enforced -- ``TypeB`` need not be a narrower + form of ``TypeA`` (it can even be a wider form) and this may lead to + type-unsafe results. The main reason is to allow for things like + narrowing ``List[object]`` to ``List[str]`` even though the latter is not + a subtype of the former, since ``List`` is invariant. The responsibility of + writing type-safe type guards is left to the user. + + ``TypeGuard`` also works with type variables. For more information, see + PEP 647 (User-Defined Type Guards). + """ + item = typing._type_check(parameters, f'{self} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + +# 3.13+ +if hasattr(typing, 'TypeIs'): + TypeIs = typing.TypeIs +# <=3.12 +else: + @_ExtensionsSpecialForm + def TypeIs(self, parameters): + """Special typing form used to annotate the return type of a user-defined + type narrower function. ``TypeIs`` only accepts a single type argument. + At runtime, functions marked this way should return a boolean. + + ``TypeIs`` aims to benefit *type narrowing* -- a technique used by static + type checkers to determine a more precise type of an expression within a + program's code flow. Usually type narrowing is done by analyzing + conditional code flow and applying the narrowing to a block of code. The + conditional expression here is sometimes referred to as a "type guard". + + Sometimes it would be convenient to use a user-defined boolean function + as a type guard. Such a function should use ``TypeIs[...]`` as its + return type to alert static type checkers to this intention. + + Using ``-> TypeIs`` tells the static type checker that for a given + function: + + 1. The return value is a boolean. + 2. If the return value is ``True``, the type of its argument + is the intersection of the type inside ``TypeIs`` and the argument's + previously known type. + + For example:: + + def is_awaitable(val: object) -> TypeIs[Awaitable[Any]]: + return hasattr(val, '__await__') + + def f(val: Union[int, Awaitable[int]]) -> int: + if is_awaitable(val): + assert_type(val, Awaitable[int]) + else: + assert_type(val, int) + + ``TypeIs`` also works with type variables. For more information, see + PEP 742 (Narrowing types with TypeIs). + """ + item = typing._type_check(parameters, f'{self} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + +# 3.15+? +if hasattr(typing, 'TypeForm'): + TypeForm = typing.TypeForm +# <=3.14 +else: + class _TypeFormForm(_ExtensionsSpecialForm, _root=True): + # TypeForm(X) is equivalent to X but indicates to the type checker + # that the object is a TypeForm. + def __call__(self, obj, /): + return obj + + @_TypeFormForm + def TypeForm(self, parameters): + """A special form representing the value that results from the evaluation + of a type expression. This value encodes the information supplied in the + type expression, and it represents the type described by that type expression. + + When used in a type expression, TypeForm describes a set of type form objects. + It accepts a single type argument, which must be a valid type expression. + ``TypeForm[T]`` describes the set of all type form objects that represent + the type T or types that are assignable to T. + + Usage: + + def cast[T](typ: TypeForm[T], value: Any) -> T: ... + + reveal_type(cast(int, "x")) # int + + See PEP 747 for more information. + """ + item = typing._type_check(parameters, f'{self} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + + + +if hasattr(typing, "LiteralString"): # 3.11+ + LiteralString = typing.LiteralString +else: + @_SpecialForm + def LiteralString(self, params): + """Represents an arbitrary literal string. + + Example:: + + from typing_extensions import LiteralString + + def query(sql: LiteralString) -> ...: + ... + + query("SELECT * FROM table") # ok + query(f"SELECT * FROM {input()}") # not ok + + See PEP 675 for details. + + """ + raise TypeError(f"{self} is not subscriptable") + + +if hasattr(typing, "Self"): # 3.11+ + Self = typing.Self +else: + @_SpecialForm + def Self(self, params): + """Used to spell the type of "self" in classes. + + Example:: + + from typing import Self + + class ReturnsSelf: + def parse(self, data: bytes) -> Self: + ... + return self + + """ + + raise TypeError(f"{self} is not subscriptable") + + +if hasattr(typing, "Never"): # 3.11+ + Never = typing.Never +else: + @_SpecialForm + def Never(self, params): + """The bottom type, a type that has no members. + + This can be used to define a function that should never be + called, or a function that never returns:: + + from typing_extensions import Never + + def never_call_me(arg: Never) -> None: + pass + + def int_or_str(arg: int | str) -> None: + never_call_me(arg) # type checker error + match arg: + case int(): + print("It's an int") + case str(): + print("It's a str") + case _: + never_call_me(arg) # ok, arg is of type Never + + """ + + raise TypeError(f"{self} is not subscriptable") + + +if hasattr(typing, 'Required'): # 3.11+ + Required = typing.Required + NotRequired = typing.NotRequired +else: # <=3.10 + @_ExtensionsSpecialForm + def Required(self, parameters): + """A special typing construct to mark a key of a total=False TypedDict + as required. For example: + + class Movie(TypedDict, total=False): + title: Required[str] + year: int + + m = Movie( + title='The Matrix', # typechecker error if key is omitted + year=1999, + ) + + There is no runtime checking that a required key is actually provided + when instantiating a related TypedDict. + """ + item = typing._type_check(parameters, f'{self._name} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + @_ExtensionsSpecialForm + def NotRequired(self, parameters): + """A special typing construct to mark a key of a TypedDict as + potentially missing. For example: + + class Movie(TypedDict): + title: str + year: NotRequired[int] + + m = Movie( + title='The Matrix', # typechecker error if key is omitted + year=1999, + ) + """ + item = typing._type_check(parameters, f'{self._name} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + +if hasattr(typing, 'ReadOnly'): + ReadOnly = typing.ReadOnly +else: # <=3.12 + @_ExtensionsSpecialForm + def ReadOnly(self, parameters): + """A special typing construct to mark an item of a TypedDict as read-only. + + For example: + + class Movie(TypedDict): + title: ReadOnly[str] + year: int + + def mutate_movie(m: Movie) -> None: + m["year"] = 1992 # allowed + m["title"] = "The Matrix" # typechecker error + + There is no runtime checking for this property. + """ + item = typing._type_check(parameters, f'{self._name} accepts only a single type.') + return typing._GenericAlias(self, (item,)) + + +_UNPACK_DOC = """\ +Type unpack operator. + +The type unpack operator takes the child types from some container type, +such as `tuple[int, str]` or a `TypeVarTuple`, and 'pulls them out'. For +example: + + # For some generic class `Foo`: + Foo[Unpack[tuple[int, str]]] # Equivalent to Foo[int, str] + + Ts = TypeVarTuple('Ts') + # Specifies that `Bar` is generic in an arbitrary number of types. + # (Think of `Ts` as a tuple of an arbitrary number of individual + # `TypeVar`s, which the `Unpack` is 'pulling out' directly into the + # `Generic[]`.) + class Bar(Generic[Unpack[Ts]]): ... + Bar[int] # Valid + Bar[int, str] # Also valid + +From Python 3.11, this can also be done using the `*` operator: + + Foo[*tuple[int, str]] + class Bar(Generic[*Ts]): ... + +The operator can also be used along with a `TypedDict` to annotate +`**kwargs` in a function signature. For instance: + + class Movie(TypedDict): + name: str + year: int + + # This function expects two keyword arguments - *name* of type `str` and + # *year* of type `int`. + def foo(**kwargs: Unpack[Movie]): ... + +Note that there is only some runtime checking of this operator. Not +everything the runtime allows may be accepted by static type checkers. + +For more information, see PEP 646 and PEP 692. +""" + + +# PEP 692 changed the repr of Unpack[] +# Breakpoint: https://github.com/python/cpython/pull/104048 +if sys.version_info >= (3, 12): + Unpack = typing.Unpack + + def _is_unpack(obj): + return get_origin(obj) is Unpack + +else: # <=3.11 + class _UnpackSpecialForm(_ExtensionsSpecialForm, _root=True): + def __init__(self, getitem): + super().__init__(getitem) + self.__doc__ = _UNPACK_DOC + + class _UnpackAlias(typing._GenericAlias, _root=True): + if sys.version_info < (3, 11): + # needed for compatibility with Generic[Unpack[Ts]] + __class__ = typing.TypeVar + + @property + def __typing_unpacked_tuple_args__(self): + assert self.__origin__ is Unpack + assert len(self.__args__) == 1 + arg, = self.__args__ + if isinstance(arg, (typing._GenericAlias, _types.GenericAlias)): + if arg.__origin__ is not tuple: + raise TypeError("Unpack[...] must be used with a tuple type") + return arg.__args__ + return None + + @property + def __typing_is_unpacked_typevartuple__(self): + assert self.__origin__ is Unpack + assert len(self.__args__) == 1 + return isinstance(self.__args__[0], TypeVarTuple) + + def __getitem__(self, args): + if self.__typing_is_unpacked_typevartuple__: + return args + # Cannot use `super().__getitem__` here because of the `__class__` assignment + # in the class body on Python <=3.11 + # (https://github.com/python/typing_extensions/issues/661) + return typing._GenericAlias.__getitem__(self, args) + + @_UnpackSpecialForm + def Unpack(self, parameters): + item = typing._type_check(parameters, f'{self._name} accepts only a single type.') + return _UnpackAlias(self, (item,)) + + def _is_unpack(obj): + return isinstance(obj, _UnpackAlias) + + +def _unpack_args(*args): + newargs = [] + for arg in args: + subargs = getattr(arg, '__typing_unpacked_tuple_args__', None) + if subargs is not None and (not (subargs and subargs[-1] is ...)): + newargs.extend(subargs) + else: + newargs.append(arg) + return newargs + + +if sys.version_info >= (3, 15): + from typing import TypeVarTuple + +elif hasattr(typing, "TypeVarTuple"): # 3.11+ + + # Add default parameter - PEP 696 and bound/variance parameters + class TypeVarTuple(metaclass=_TypeVarLikeMeta): + """Type variable tuple.""" + + _backported_typevarlike = typing.TypeVarTuple + + def __new__(cls, name, *, bound=None, + covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): + + if _PEP_696_IMPLEMENTED: + # can pass default argument + tvt = typing.TypeVarTuple(name, default=default) + else: + tvt = typing.TypeVarTuple(name) + _set_default(tvt, default) + + tvt.__bound__ = bound + tvt.__covariant__ = bool(covariant) + tvt.__contravariant__ = bool(contravariant) + tvt.__infer_variance__ = bool(infer_variance) + + _set_module(tvt) + + def _typevartuple_prepare_subst(alias, args): + params = alias.__parameters__ + typevartuple_index = params.index(tvt) + for param in params[typevartuple_index + 1:]: + if isinstance(param, TypeVarTuple): + raise TypeError( + f"More than one TypeVarTuple parameter in {alias}" + ) + + alen = len(args) + plen = len(params) + left = typevartuple_index + right = plen - typevartuple_index - 1 + var_tuple_index = None + fillarg = None + for k, arg in enumerate(args): + if not isinstance(arg, type): + subargs = getattr(arg, '__typing_unpacked_tuple_args__', None) + if subargs and len(subargs) == 2 and subargs[-1] is ...: + if var_tuple_index is not None: + raise TypeError( + "More than one unpacked " + "arbitrary-length tuple argument" + ) + var_tuple_index = k + fillarg = subargs[0] + if var_tuple_index is not None: + left = min(left, var_tuple_index) + right = min(right, alen - var_tuple_index - 1) + elif left + right > alen: + raise TypeError(f"Too few arguments for {alias};" + f" actual {alen}, expected at least {plen - 1}") + if left == alen - right and tvt.has_default(): + replacement = _unpack_args(tvt.__default__) + else: + replacement = args[left: alen - right] + + return ( + *args[:left], + *([fillarg] * (typevartuple_index - left)), + replacement, + *([fillarg] * (plen - right - left - typevartuple_index - 1)), + *args[alen - right:], + ) + + tvt.__typing_prepare_subst__ = _typevartuple_prepare_subst + return tvt + + def __init_subclass__(self, *args, **kwds): + raise TypeError("Cannot subclass special typing classes") + +else: # <=3.10 + class TypeVarTuple(_DefaultMixin): + """Type variable tuple. + + Usage:: + + Ts = TypeVarTuple('Ts') + + In the same way that a normal type variable is a stand-in for a single + type such as ``int``, a type variable *tuple* is a stand-in for a *tuple* + type such as ``Tuple[int, str]``. + + Type variable tuples can be used in ``Generic`` declarations. + Consider the following example:: + + class Array(Generic[*Ts]): ... + + The ``Ts`` type variable tuple here behaves like ``tuple[T1, T2]``, + where ``T1`` and ``T2`` are type variables. To use these type variables + as type parameters of ``Array``, we must *unpack* the type variable tuple using + the star operator: ``*Ts``. The signature of ``Array`` then behaves + as if we had simply written ``class Array(Generic[T1, T2]): ...``. + In contrast to ``Generic[T1, T2]``, however, ``Generic[*Shape]`` allows + us to parameterise the class with an *arbitrary* number of type parameters. + + Type variable tuples can be used anywhere a normal ``TypeVar`` can. + This includes class definitions, as shown above, as well as function + signatures and variable annotations:: + + class Array(Generic[*Ts]): + + def __init__(self, shape: Tuple[*Ts]): + self._shape: Tuple[*Ts] = shape + + def get_shape(self) -> Tuple[*Ts]: + return self._shape + + shape = (Height(480), Width(640)) + x: Array[Height, Width] = Array(shape) + y = abs(x) # Inferred type is Array[Height, Width] + z = x + x # ... is Array[Height, Width] + x.get_shape() # ... is tuple[Height, Width] + + """ + + # Trick Generic __parameters__. + __class__ = typing.TypeVar + + def __iter__(self): + yield self.__unpacked__ + + def __init__(self, name, *, bound=None, covariant=False, contravariant=False, + infer_variance=False, default=NoDefault): + self.__name__ = name + self.__covariant__ = bool(covariant) + self.__contravariant__ = bool(contravariant) + self.__infer_variance__ = bool(infer_variance) + self.__bound__ = bound + _DefaultMixin.__init__(self, default) + + # for pickling: + def_mod = _caller() + if def_mod != 'typing_extensions': + self.__module__ = def_mod + + self.__unpacked__ = Unpack[self] + + def __repr__(self): + if self.__infer_variance__: + prefix = '' + elif self.__covariant__: + prefix = '+' + elif self.__contravariant__: + prefix = '-' + else: + prefix = '~' + return prefix + self.__name__ + + def __hash__(self): + return object.__hash__(self) + + def __eq__(self, other): + return self is other + + def __reduce__(self): + return self.__name__ + + def __init_subclass__(self, *args, **kwds): + if '_root' not in kwds: + raise TypeError("Cannot subclass special typing classes") + + +if hasattr(typing, "reveal_type"): # 3.11+ + reveal_type = typing.reveal_type +else: # <=3.10 + def reveal_type(obj: T, /) -> T: + """Reveal the inferred type of a variable. + + When a static type checker encounters a call to ``reveal_type()``, + it will emit the inferred type of the argument:: + + x: int = 1 + reveal_type(x) + + Running a static type checker (e.g., ``mypy``) on this example + will produce output similar to 'Revealed type is "builtins.int"'. + + At runtime, the function prints the runtime type of the + argument and returns it unchanged. + + """ + print(f"Runtime type is {type(obj).__name__!r}", file=sys.stderr) + return obj + + +if hasattr(typing, "_ASSERT_NEVER_REPR_MAX_LENGTH"): # 3.11+ + _ASSERT_NEVER_REPR_MAX_LENGTH = typing._ASSERT_NEVER_REPR_MAX_LENGTH +else: # <=3.10 + _ASSERT_NEVER_REPR_MAX_LENGTH = 100 + + +if hasattr(typing, "assert_never"): # 3.11+ + assert_never = typing.assert_never +else: # <=3.10 + def assert_never(arg: Never, /) -> Never: + """Assert to the type checker that a line of code is unreachable. + + Example:: + + def int_or_str(arg: int | str) -> None: + match arg: + case int(): + print("It's an int") + case str(): + print("It's a str") + case _: + assert_never(arg) + + If a type checker finds that a call to assert_never() is + reachable, it will emit an error. + + At runtime, this throws an exception when called. + + """ + value = repr(arg) + if len(value) > _ASSERT_NEVER_REPR_MAX_LENGTH: + value = value[:_ASSERT_NEVER_REPR_MAX_LENGTH] + '...' + raise AssertionError(f"Expected code to be unreachable, but got: {value}") + + +# dataclass_transform exists in 3.11 but lacks the frozen_default parameter +# Breakpoint: https://github.com/python/cpython/pull/99958 +if sys.version_info >= (3, 12): # 3.12+ + dataclass_transform = typing.dataclass_transform +else: # <=3.11 + def dataclass_transform( + *, + eq_default: bool = True, + order_default: bool = False, + kw_only_default: bool = False, + frozen_default: bool = False, + field_specifiers: typing.Tuple[ + typing.Union[typing.Type[typing.Any], typing.Callable[..., typing.Any]], + ... + ] = (), + **kwargs: typing.Any, + ) -> typing.Callable[[T], T]: + """Decorator that marks a function, class, or metaclass as providing + dataclass-like behavior. + + Example: + + from typing_extensions import dataclass_transform + + _T = TypeVar("_T") + + # Used on a decorator function + @dataclass_transform() + def create_model(cls: type[_T]) -> type[_T]: + ... + return cls + + @create_model + class CustomerModel: + id: int + name: str + + # Used on a base class + @dataclass_transform() + class ModelBase: ... + + class CustomerModel(ModelBase): + id: int + name: str + + # Used on a metaclass + @dataclass_transform() + class ModelMeta(type): ... + + class ModelBase(metaclass=ModelMeta): ... + + class CustomerModel(ModelBase): + id: int + name: str + + Each of the ``CustomerModel`` classes defined in this example will now + behave similarly to a dataclass created with the ``@dataclasses.dataclass`` + decorator. For example, the type checker will synthesize an ``__init__`` + method. + + The arguments to this decorator can be used to customize this behavior: + - ``eq_default`` indicates whether the ``eq`` parameter is assumed to be + True or False if it is omitted by the caller. + - ``order_default`` indicates whether the ``order`` parameter is + assumed to be True or False if it is omitted by the caller. + - ``kw_only_default`` indicates whether the ``kw_only`` parameter is + assumed to be True or False if it is omitted by the caller. + - ``frozen_default`` indicates whether the ``frozen`` parameter is + assumed to be True or False if it is omitted by the caller. + - ``field_specifiers`` specifies a static list of supported classes + or functions that describe fields, similar to ``dataclasses.field()``. + + At runtime, this decorator records its arguments in the + ``__dataclass_transform__`` attribute on the decorated object. + + See PEP 681 for details. + + """ + def decorator(cls_or_fn): + cls_or_fn.__dataclass_transform__ = { + "eq_default": eq_default, + "order_default": order_default, + "kw_only_default": kw_only_default, + "frozen_default": frozen_default, + "field_specifiers": field_specifiers, + "kwargs": kwargs, + } + return cls_or_fn + return decorator + + +if hasattr(typing, "override"): # 3.12+ + override = typing.override +else: # <=3.11 + _F = typing.TypeVar("_F", bound=typing.Callable[..., typing.Any]) + + def override(arg: _F, /) -> _F: + """Indicate that a method is intended to override a method in a base class. + + Usage: + + class Base: + def method(self) -> None: + pass + + class Child(Base): + @override + def method(self) -> None: + super().method() + + When this decorator is applied to a method, the type checker will + validate that it overrides a method with the same name on a base class. + This helps prevent bugs that may occur when a base class is changed + without an equivalent change to a child class. + + There is no runtime checking of these properties. The decorator + sets the ``__override__`` attribute to ``True`` on the decorated object + to allow runtime introspection. + + See PEP 698 for details. + + """ + try: + arg.__override__ = True + except (AttributeError, TypeError): + # Skip the attribute silently if it is not writable. + # AttributeError happens if the object has __slots__ or a + # read-only property, TypeError if it's a builtin class. + pass + return arg + + +# Python 3.13.8+ and 3.14.1+ contain a fix for the wrapped __init_subclass__ +# Breakpoint: https://github.com/python/cpython/pull/138210 +if ((3, 13, 8) <= sys.version_info < (3, 14)) or sys.version_info >= (3, 14, 1): + deprecated = warnings.deprecated +else: + _T = typing.TypeVar("_T") + + class deprecated: + """Indicate that a class, function or overload is deprecated. + + When this decorator is applied to an object, the type checker + will generate a diagnostic on usage of the deprecated object. + + Usage: + + @deprecated("Use B instead") + class A: + pass + + @deprecated("Use g instead") + def f(): + pass + + @overload + @deprecated("int support is deprecated") + def g(x: int) -> int: ... + @overload + def g(x: str) -> int: ... + + The warning specified by *category* will be emitted at runtime + on use of deprecated objects. For functions, that happens on calls; + for classes, on instantiation and on creation of subclasses. + If the *category* is ``None``, no warning is emitted at runtime. + The *stacklevel* determines where the + warning is emitted. If it is ``1`` (the default), the warning + is emitted at the direct caller of the deprecated object; if it + is higher, it is emitted further up the stack. + Static type checker behavior is not affected by the *category* + and *stacklevel* arguments. + + The deprecation message passed to the decorator is saved in the + ``__deprecated__`` attribute on the decorated object. + If applied to an overload, the decorator + must be after the ``@overload`` decorator for the attribute to + exist on the overload as returned by ``get_overloads()``. + + See PEP 702 for details. + + """ + def __init__( + self, + message: str, + /, + *, + category: typing.Optional[typing.Type[Warning]] = DeprecationWarning, + stacklevel: int = 1, + ) -> None: + if not isinstance(message, str): + raise TypeError( + "Expected an object of type str for 'message', not " + f"{type(message).__name__!r}" + ) + self.message = message + self.category = category + self.stacklevel = stacklevel + + def __call__(self, arg: _T, /) -> _T: + # Make sure the inner functions created below don't + # retain a reference to self. + msg = self.message + category = self.category + stacklevel = self.stacklevel + if category is None: + arg.__deprecated__ = msg + return arg + elif isinstance(arg, type): + import functools + from types import MethodType + + original_new = arg.__new__ + + @functools.wraps(original_new) + def __new__(cls, /, *args, **kwargs): + if cls is arg: + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + if original_new is not object.__new__: + return original_new(cls, *args, **kwargs) + # Mirrors a similar check in object.__new__. + elif cls.__init__ is object.__init__ and (args or kwargs): + raise TypeError(f"{cls.__name__}() takes no arguments") + else: + return original_new(cls) + + arg.__new__ = staticmethod(__new__) + + if "__init_subclass__" in arg.__dict__: + # __init_subclass__ is directly present on the decorated class. + # Synthesize a wrapper that calls this method directly. + original_init_subclass = arg.__init_subclass__ + # We need slightly different behavior if __init_subclass__ + # is a bound method (likely if it was implemented in Python). + # Otherwise, it likely means it's a builtin such as + # object's implementation of __init_subclass__. + if isinstance(original_init_subclass, MethodType): + original_init_subclass = original_init_subclass.__func__ + + @functools.wraps(original_init_subclass) + def __init_subclass__(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return original_init_subclass(*args, **kwargs) + else: + def __init_subclass__(cls, *args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return super(arg, cls).__init_subclass__(*args, **kwargs) + + arg.__init_subclass__ = classmethod(__init_subclass__) + + arg.__deprecated__ = __new__.__deprecated__ = msg + __init_subclass__.__deprecated__ = msg + return arg + elif callable(arg): + import functools + import inspect + + @functools.wraps(arg) + def wrapper(*args, **kwargs): + warnings.warn(msg, category=category, stacklevel=stacklevel + 1) + return arg(*args, **kwargs) + + if inspect.iscoroutinefunction(arg): + # Breakpoint: https://github.com/python/cpython/pull/99247 + if sys.version_info >= (3, 12): + wrapper = inspect.markcoroutinefunction(wrapper) + else: + import asyncio.coroutines + + wrapper._is_coroutine = asyncio.coroutines._is_coroutine + + arg.__deprecated__ = wrapper.__deprecated__ = msg + return wrapper + else: + raise TypeError( + "@deprecated decorator with non-None category must be applied to " + f"a class or callable, not {arg!r}" + ) + +# Breakpoint: https://github.com/python/cpython/pull/23702 +if sys.version_info < (3, 10): + def _is_param_expr(arg): + return arg is ... or isinstance( + arg, (tuple, list, ParamSpec, _ConcatenateGenericAlias) + ) +else: + def _is_param_expr(arg): + return arg is ... or isinstance( + arg, + ( + tuple, + list, + ParamSpec, + _ConcatenateGenericAlias, + typing._ConcatenateGenericAlias, + ), + ) + + +# We have to do some monkey patching to deal with the dual nature of +# Unpack/TypeVarTuple: +# - We want Unpack to be a kind of TypeVar so it gets accepted in +# Generic[Unpack[Ts]] +# - We want it to *not* be treated as a TypeVar for the purposes of +# counting generic parameters, so that when we subscript a generic, +# the runtime doesn't try to substitute the Unpack with the subscripted type. +if not hasattr(typing, "TypeVarTuple"): + def _check_generic(cls, parameters, elen=_marker): + """Check correct count for parameters of a generic cls (internal helper). + + This gives a nice error message in case of count mismatch. + """ + # If substituting a single ParamSpec with multiple arguments + # we do not check the count + if (inspect.isclass(cls) and issubclass(cls, typing.Generic) + and len(cls.__parameters__) == 1 + and isinstance(cls.__parameters__[0], ParamSpec) + and parameters + and not _is_param_expr(parameters[0]) + ): + # Generic modifies parameters variable, but here we cannot do this + return + + if not elen: + raise TypeError(f"{cls} is not a generic class") + if elen is _marker: + if not hasattr(cls, "__parameters__") or not cls.__parameters__: + raise TypeError(f"{cls} is not a generic class") + elen = len(cls.__parameters__) + alen = len(parameters) + if alen != elen: + expect_val = elen + if hasattr(cls, "__parameters__"): + parameters = [p for p in cls.__parameters__ if not _is_unpack(p)] + num_tv_tuples = sum(isinstance(p, TypeVarTuple) for p in parameters) + if (num_tv_tuples > 0) and (alen >= elen - num_tv_tuples): + return + + # deal with TypeVarLike defaults + # required TypeVarLikes cannot appear after a defaulted one. + if alen < elen: + # since we validate TypeVarLike default in _collect_type_vars + # or _collect_parameters we can safely check parameters[alen] + if ( + getattr(parameters[alen], '__default__', NoDefault) + is not NoDefault + ): + return + + num_default_tv = sum(getattr(p, '__default__', NoDefault) + is not NoDefault for p in parameters) + + elen -= num_default_tv + + expect_val = f"at least {elen}" + + # Breakpoint: https://github.com/python/cpython/pull/27515 + things = "arguments" if sys.version_info >= (3, 10) else "parameters" + raise TypeError(f"Too {'many' if alen > elen else 'few'} {things}" + f" for {cls}; actual {alen}, expected {expect_val}") +else: + # Python 3.11+ + + def _check_generic(cls, parameters, elen): + """Check correct count for parameters of a generic cls (internal helper). + + This gives a nice error message in case of count mismatch. + """ + if not elen: + raise TypeError(f"{cls} is not a generic class") + alen = len(parameters) + if alen != elen: + expect_val = elen + if hasattr(cls, "__parameters__"): + parameters = [p for p in cls.__parameters__ if not _is_unpack(p)] + + # deal with TypeVarLike defaults + # required TypeVarLikes cannot appear after a defaulted one. + if alen < elen: + # since we validate TypeVarLike default in _collect_type_vars + # or _collect_parameters we can safely check parameters[alen] + if ( + getattr(parameters[alen], '__default__', NoDefault) + is not NoDefault + ): + return + + num_default_tv = sum(getattr(p, '__default__', NoDefault) + is not NoDefault for p in parameters) + + elen -= num_default_tv + + expect_val = f"at least {elen}" + + raise TypeError(f"Too {'many' if alen > elen else 'few'} arguments" + f" for {cls}; actual {alen}, expected {expect_val}") + +if not _PEP_696_IMPLEMENTED: + typing._check_generic = _check_generic + + +def _has_generic_or_protocol_as_origin() -> bool: + try: + frame = sys._getframe(2) + # - Catch AttributeError: not all Python implementations have sys._getframe() + # - Catch ValueError: maybe we're called from an unexpected module + # and the call stack isn't deep enough + except (AttributeError, ValueError): + return False # err on the side of leniency + else: + # If we somehow get invoked from outside typing.py, + # also err on the side of leniency + if frame.f_globals.get("__name__") != "typing": + return False + origin = frame.f_locals.get("origin") + # Cannot use "in" because origin may be an object with a buggy __eq__ that + # throws an error. + return origin is typing.Generic or origin is Protocol or origin is typing.Protocol + + +_TYPEVARTUPLE_TYPES = {TypeVarTuple, getattr(typing, "TypeVarTuple", None)} + + +def _is_unpacked_typevartuple(x) -> bool: + if get_origin(x) is not Unpack: + return False + args = get_args(x) + return ( + bool(args) + and len(args) == 1 + and type(args[0]) in _TYPEVARTUPLE_TYPES + ) + + +# Python 3.11+ _collect_type_vars was renamed to _collect_parameters +if hasattr(typing, '_collect_type_vars'): + def _collect_type_vars(types, typevar_types=None): + """Collect all type variable contained in types in order of + first appearance (lexicographic order). For example:: + + _collect_type_vars((T, List[S, T])) == (T, S) + """ + if typevar_types is None: + typevar_types = typing.TypeVar + tvars = [] + + # A required TypeVarLike cannot appear after a TypeVarLike with a default + # if it was a direct call to `Generic[]` or `Protocol[]` + enforce_default_ordering = _has_generic_or_protocol_as_origin() + default_encountered = False + + # Also, a TypeVarLike with a default cannot appear after a TypeVarTuple + type_var_tuple_encountered = False + + for t in types: + if _is_unpacked_typevartuple(t): + type_var_tuple_encountered = True + elif ( + isinstance(t, typevar_types) and not isinstance(t, _UnpackAlias) + and t not in tvars + ): + if enforce_default_ordering: + has_default = getattr(t, '__default__', NoDefault) is not NoDefault + if has_default: + if type_var_tuple_encountered: + raise TypeError('Type parameter with a default' + ' follows TypeVarTuple') + default_encountered = True + elif default_encountered: + raise TypeError(f'Type parameter {t!r} without a default' + ' follows type parameter with a default') + + tvars.append(t) + if _should_collect_from_parameters(t): + tvars.extend([t for t in t.__parameters__ if t not in tvars]) + elif isinstance(t, tuple): + # Collect nested type_vars + # tuple wrapped by _prepare_paramspec_params(cls, params) + for x in t: + for collected in _collect_type_vars([x]): + if collected not in tvars: + tvars.append(collected) + return tuple(tvars) + + typing._collect_type_vars = _collect_type_vars +else: + def _collect_parameters(args): + """Collect all type variables and parameter specifications in args + in order of first appearance (lexicographic order). + + For example:: + + assert _collect_parameters((T, Callable[P, T])) == (T, P) + """ + parameters = [] + + # A required TypeVarLike cannot appear after a TypeVarLike with default + # if it was a direct call to `Generic[]` or `Protocol[]` + enforce_default_ordering = _has_generic_or_protocol_as_origin() + default_encountered = False + + # Also, a TypeVarLike with a default cannot appear after a TypeVarTuple + type_var_tuple_encountered = False + + for t in args: + if isinstance(t, type): + # We don't want __parameters__ descriptor of a bare Python class. + pass + elif isinstance(t, tuple): + # `t` might be a tuple, when `ParamSpec` is substituted with + # `[T, int]`, or `[int, *Ts]`, etc. + for x in t: + for collected in _collect_parameters([x]): + if collected not in parameters: + parameters.append(collected) + elif hasattr(t, '__typing_subst__'): + if t not in parameters: + if enforce_default_ordering: + has_default = ( + getattr(t, '__default__', NoDefault) is not NoDefault + ) + + if type_var_tuple_encountered and has_default: + raise TypeError('Type parameter with a default' + ' follows TypeVarTuple') + + if has_default: + default_encountered = True + elif default_encountered: + raise TypeError(f'Type parameter {t!r} without a default' + ' follows type parameter with a default') + + parameters.append(t) + else: + if _is_unpacked_typevartuple(t): + type_var_tuple_encountered = True + for x in getattr(t, '__parameters__', ()): + if x not in parameters: + parameters.append(x) + + return tuple(parameters) + + if not _PEP_696_IMPLEMENTED: + typing._collect_parameters = _collect_parameters + +# Backport typing.NamedTuple as it exists in Python 3.13. +# In 3.11, the ability to define generic `NamedTuple`s was supported. +# This was explicitly disallowed in 3.9-3.10, and only half-worked in <=3.8. +# On 3.12, we added __orig_bases__ to call-based NamedTuples +# On 3.13, we deprecated kwargs-based NamedTuples +# Breakpoint: https://github.com/python/cpython/pull/105609 +if sys.version_info >= (3, 13): + NamedTuple = typing.NamedTuple +else: + def _make_nmtuple(name, types, module, defaults=()): + fields = [n for n, t in types] + annotations = {n: typing._type_check(t, f"field {n} annotation must be a type") + for n, t in types} + nm_tpl = collections.namedtuple(name, fields, + defaults=defaults, module=module) + nm_tpl.__annotations__ = nm_tpl.__new__.__annotations__ = annotations + return nm_tpl + + _prohibited_namedtuple_fields = typing._prohibited + _special_namedtuple_fields = frozenset({'__module__', '__name__', '__annotations__'}) + + class _NamedTupleMeta(type): + def __new__(cls, typename, bases, ns): + assert _NamedTuple in bases + for base in bases: + if base is not _NamedTuple and base is not typing.Generic: + raise TypeError( + 'can only inherit from a NamedTuple type and Generic') + bases = tuple(tuple if base is _NamedTuple else base for base in bases) + if "__annotations__" in ns: + types = ns["__annotations__"] + elif "__annotate__" in ns: + # TODO: Use inspect.VALUE here, and make the annotations lazily evaluated + types = ns["__annotate__"](1) + else: + types = {} + default_names = [] + for field_name in types: + if field_name in ns: + default_names.append(field_name) + elif default_names: + raise TypeError(f"Non-default namedtuple field {field_name} " + f"cannot follow default field" + f"{'s' if len(default_names) > 1 else ''} " + f"{', '.join(default_names)}") + nm_tpl = _make_nmtuple( + typename, types.items(), + defaults=[ns[n] for n in default_names], + module=ns['__module__'] + ) + nm_tpl.__bases__ = bases + if typing.Generic in bases: + if hasattr(typing, '_generic_class_getitem'): # 3.12+ + nm_tpl.__class_getitem__ = classmethod(typing._generic_class_getitem) + else: + class_getitem = typing.Generic.__class_getitem__.__func__ + nm_tpl.__class_getitem__ = classmethod(class_getitem) + # update from user namespace without overriding special namedtuple attributes + for key, val in ns.items(): + if key in _prohibited_namedtuple_fields: + raise AttributeError("Cannot overwrite NamedTuple attribute " + key) + elif key not in _special_namedtuple_fields: + if key not in nm_tpl._fields: + setattr(nm_tpl, key, ns[key]) + try: + set_name = type(val).__set_name__ + except AttributeError: + pass + else: + try: + set_name(val, nm_tpl, key) + except BaseException as e: + msg = ( + f"Error calling __set_name__ on {type(val).__name__!r} " + f"instance {key!r} in {typename!r}" + ) + # BaseException.add_note() existed on py311, + # but the __set_name__ machinery didn't start + # using add_note() until py312. + # Making sure exceptions are raised in the same way + # as in "normal" classes seems most important here. + # Breakpoint: https://github.com/python/cpython/pull/95915 + if sys.version_info >= (3, 12): + e.add_note(msg) + raise + else: + raise RuntimeError(msg) from e + + if typing.Generic in bases: + nm_tpl.__init_subclass__() + return nm_tpl + + _NamedTuple = type.__new__(_NamedTupleMeta, 'NamedTuple', (), {}) + + def _namedtuple_mro_entries(bases): + assert NamedTuple in bases + return (_NamedTuple,) + + def NamedTuple(typename, fields=_marker, /, **kwargs): + """Typed version of namedtuple. + + Usage:: + + class Employee(NamedTuple): + name: str + id: int + + This is equivalent to:: + + Employee = collections.namedtuple('Employee', ['name', 'id']) + + The resulting class has an extra __annotations__ attribute, giving a + dict that maps field names to types. (The field names are also in + the _fields attribute, which is part of the namedtuple API.) + An alternative equivalent functional syntax is also accepted:: + + Employee = NamedTuple('Employee', [('name', str), ('id', int)]) + """ + if fields is _marker: + if kwargs: + deprecated_thing = "Creating NamedTuple classes using keyword arguments" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "Use the class-based or functional syntax instead." + ) + else: + deprecated_thing = "Failing to pass a value for the 'fields' parameter" + example = f"`{typename} = NamedTuple({typename!r}, [])`" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. " + ) + example + "." + elif fields is None: + if kwargs: + raise TypeError( + "Cannot pass `None` as the 'fields' parameter " + "and also specify fields using keyword arguments" + ) + else: + deprecated_thing = "Passing `None` as the 'fields' parameter" + example = f"`{typename} = NamedTuple({typename!r}, [])`" + deprecation_msg = ( + "{name} is deprecated and will be disallowed in Python {remove}. " + "To create a NamedTuple class with 0 fields " + "using the functional syntax, " + "pass an empty list, e.g. " + ) + example + "." + elif kwargs: + raise TypeError("Either list of fields or keywords" + " can be provided to NamedTuple, not both") + if fields is _marker or fields is None: + warnings.warn( + deprecation_msg.format(name=deprecated_thing, remove="3.15"), + DeprecationWarning, + stacklevel=2, + ) + fields = kwargs.items() + nt = _make_nmtuple(typename, fields, module=_caller()) + nt.__orig_bases__ = (NamedTuple,) + return nt + + NamedTuple.__mro_entries__ = _namedtuple_mro_entries + + +if hasattr(collections.abc, "Buffer"): + Buffer = collections.abc.Buffer +else: + class Buffer(abc.ABC): # noqa: B024 + """Base class for classes that implement the buffer protocol. + + The buffer protocol allows Python objects to expose a low-level + memory buffer interface. Before Python 3.12, it is not possible + to implement the buffer protocol in pure Python code, or even + to check whether a class implements the buffer protocol. In + Python 3.12 and higher, the ``__buffer__`` method allows access + to the buffer protocol from Python code, and the + ``collections.abc.Buffer`` ABC allows checking whether a class + implements the buffer protocol. + + To indicate support for the buffer protocol in earlier versions, + inherit from this ABC, either in a stub file or at runtime, + or use ABC registration. This ABC provides no methods, because + there is no Python-accessible methods shared by pre-3.12 buffer + classes. It is useful primarily for static checks. + + """ + + # As a courtesy, register the most common stdlib buffer classes. + Buffer.register(memoryview) + Buffer.register(bytearray) + Buffer.register(bytes) + + +# Backport of types.get_original_bases, available on 3.12+ in CPython +if hasattr(_types, "get_original_bases"): + get_original_bases = _types.get_original_bases +else: + def get_original_bases(cls, /): + """Return the class's "original" bases prior to modification by `__mro_entries__`. + + Examples:: + + from typing import TypeVar, Generic + from typing_extensions import NamedTuple, TypedDict + + T = TypeVar("T") + class Foo(Generic[T]): ... + class Bar(Foo[int], float): ... + class Baz(list[str]): ... + Eggs = NamedTuple("Eggs", [("a", int), ("b", str)]) + Spam = TypedDict("Spam", {"a": int, "b": str}) + + assert get_original_bases(Bar) == (Foo[int], float) + assert get_original_bases(Baz) == (list[str],) + assert get_original_bases(Eggs) == (NamedTuple,) + assert get_original_bases(Spam) == (TypedDict,) + assert get_original_bases(int) == (object,) + """ + try: + return cls.__dict__.get("__orig_bases__", cls.__bases__) + except AttributeError: + raise TypeError( + f'Expected an instance of type, not {type(cls).__name__!r}' + ) from None + + +# NewType is a class on Python 3.10+, making it pickleable +# The error message for subclassing instances of NewType was improved on 3.11+ +# Breakpoint: https://github.com/python/cpython/pull/30268 +if sys.version_info >= (3, 11): + NewType = typing.NewType +else: + class NewType: + """NewType creates simple unique types with almost zero + runtime overhead. NewType(name, tp) is considered a subtype of tp + by static type checkers. At runtime, NewType(name, tp) returns + a dummy callable that simply returns its argument. Usage:: + UserId = NewType('UserId', int) + def name_by_id(user_id: UserId) -> str: + ... + UserId('user') # Fails type check + name_by_id(42) # Fails type check + name_by_id(UserId(42)) # OK + num = UserId(5) + 1 # type: int + """ + + def __call__(self, obj, /): + return obj + + def __init__(self, name, tp): + self.__qualname__ = name + if '.' in name: + name = name.rpartition('.')[-1] + self.__name__ = name + self.__supertype__ = tp + def_mod = _caller() + if def_mod != 'typing_extensions': + self.__module__ = def_mod + + def __mro_entries__(self, bases): + # We defined __mro_entries__ to get a better error message + # if a user attempts to subclass a NewType instance. bpo-46170 + supercls_name = self.__name__ + + class Dummy: + def __init_subclass__(cls): + subcls_name = cls.__name__ + raise TypeError( + f"Cannot subclass an instance of NewType. " + f"Perhaps you were looking for: " + f"`{subcls_name} = NewType({subcls_name!r}, {supercls_name})`" + ) + + return (Dummy,) + + def __repr__(self): + return f'{self.__module__}.{self.__qualname__}' + + def __reduce__(self): + return self.__qualname__ + + # Breakpoint: https://github.com/python/cpython/pull/21515 + if sys.version_info >= (3, 10): + # PEP 604 methods + # It doesn't make sense to have these methods on Python <3.10 + + def __or__(self, other): + return typing.Union[self, other] + + def __ror__(self, other): + return typing.Union[other, self] + + +# Breakpoint: https://github.com/python/cpython/pull/149172 +if sys.version_info >= (3, 15): + TypeAliasType = typing.TypeAliasType +# <=3.14 +else: + # Breakpoint: https://github.com/python/cpython/pull/103764 + if sys.version_info >= (3, 12): + # 3.12-3.14 + def _is_unionable(obj): + """Corresponds to is_unionable() in unionobject.c in CPython.""" + return obj is None or isinstance(obj, ( + type, + _types.GenericAlias, + _types.UnionType, + typing.TypeAliasType, + TypeAliasType, + )) + else: + # <=3.11 + def _is_unionable(obj): + """Corresponds to is_unionable() in unionobject.c in CPython.""" + return obj is None or isinstance(obj, ( + type, + _types.GenericAlias, + _types.UnionType, + TypeAliasType, + )) + + if sys.version_info < (3, 10): + # Copied and pasted from https://github.com/python/cpython/blob/986a4e1b6fcae7fe7a1d0a26aea446107dd58dd2/Objects/genericaliasobject.c#L568-L582, + # so that we emulate the behaviour of `types.GenericAlias` + # on the latest versions of CPython + _ATTRIBUTE_DELEGATION_EXCLUSIONS = frozenset({ + "__class__", + "__bases__", + "__origin__", + "__args__", + "__unpacked__", + "__parameters__", + "__typing_unpacked_tuple_args__", + "__mro_entries__", + "__reduce_ex__", + "__reduce__", + "__copy__", + "__deepcopy__", + }) + + class _TypeAliasGenericAlias(typing._GenericAlias, _root=True): + def __getattr__(self, attr): + if attr in _ATTRIBUTE_DELEGATION_EXCLUSIONS: + return object.__getattr__(self, attr) + return getattr(self.__origin__, attr) + + + class TypeAliasType: + """Create named, parameterized type aliases. + + This provides a backport of the new `type` statement in Python 3.12: + + type ListOrSet[T] = list[T] | set[T] + + is equivalent to: + + T = TypeVar("T") + ListOrSet = TypeAliasType("ListOrSet", list[T] | set[T], type_params=(T,)) + + The name ListOrSet can then be used as an alias for the type it refers to. + + The type_params argument should contain all the type parameters used + in the value of the type alias. If the alias is not generic, this + argument is omitted. + + Static type checkers should only support type aliases declared using + TypeAliasType that follow these rules: + + - The first argument (the name) must be a string literal. + - The TypeAliasType instance must be immediately assigned to a variable + of the same name. (For example, 'X = TypeAliasType("Y", int)' is invalid, + as is 'X, Y = TypeAliasType("X", int), TypeAliasType("Y", int)'). + + """ + + def __init__(self, name: str, value, *, type_params=()): + if not isinstance(name, str): + raise TypeError("TypeAliasType name must be a string") + if not isinstance(type_params, tuple): + raise TypeError("type_params must be a tuple") + self.__value__ = value + self.__type_params__ = type_params + + default_value_encountered = False + parameters = [] + for type_param in type_params: + if ( + not isinstance(type_param, (TypeVar, TypeVarTuple, ParamSpec)) + # <=3.11 + # Unpack Backport passes isinstance(type_param, TypeVar) + or _is_unpack(type_param) + ): + raise TypeError(f"Expected a type param, got {type_param!r}") + has_default = ( + getattr(type_param, '__default__', NoDefault) is not NoDefault + ) + if default_value_encountered and not has_default: + raise TypeError(f"non-default type parameter '{type_param!r}'" + " follows default type parameter") + if has_default: + default_value_encountered = True + if isinstance(type_param, TypeVarTuple): + parameters.extend(type_param) + else: + parameters.append(type_param) + self.__parameters__ = tuple(parameters) + def_mod = _caller() + if def_mod != 'typing_extensions': + self.__module__ = def_mod + # Setting this attribute closes the TypeAliasType from further modification + self.__name__ = name + + def __setattr__(self, name: str, value: object, /) -> None: + if hasattr(self, "__name__") and name != "__module__": + self._raise_attribute_error(name) + super().__setattr__(name, value) + + def __delattr__(self, name: str, /) -> Never: + self._raise_attribute_error(name) + + def _raise_attribute_error(self, name: str) -> Never: + # Match the Python 3.12 error messages exactly + if name == "__name__": + raise AttributeError("readonly attribute") + elif name in {"__value__", "__type_params__", "__parameters__"}: + raise AttributeError( + f"attribute '{name}' of 'typing.TypeAliasType' objects " + "is not writable" + ) + else: + raise AttributeError( + f"'typing.TypeAliasType' object has no attribute '{name}'" + ) + + def __repr__(self) -> str: + return self.__name__ + + if sys.version_info < (3, 11): + def _check_single_param(self, param, recursion=0): + # Allow [], [int], [int, str], [int, ...], [int, T] + if param is ...: + return ... + if param is None: + return None + # Note in <= 3.9 _ConcatenateGenericAlias inherits from list + if isinstance(param, list) and recursion == 0: + return [self._check_single_param(arg, recursion+1) + for arg in param] + return typing._type_check( + param, f'Subscripting {self.__name__} requires a type.' + ) + + def _check_parameters(self, parameters): + if sys.version_info < (3, 11): + return tuple( + self._check_single_param(item) + for item in parameters + ) + return tuple(typing._type_check( + item, f'Subscripting {self.__name__} requires a type.' + ) + for item in parameters + ) + + def __getitem__(self, parameters): + if not self.__type_params__: + raise TypeError("Only generic type aliases are subscriptable") + if not isinstance(parameters, tuple): + parameters = (parameters,) + # Using 3.9 here will create problems with Concatenate + if sys.version_info >= (3, 10): + return _types.GenericAlias(self, parameters) + type_vars = _collect_type_vars(parameters) + parameters = self._check_parameters(parameters) + alias = _TypeAliasGenericAlias(self, parameters) + # alias.__parameters__ is not complete if Concatenate is present + # as it is converted to a list from which no parameters are extracted. + if alias.__parameters__ != type_vars: + alias.__parameters__ = type_vars + return alias + + def __reduce__(self): + return self.__name__ + + def __init_subclass__(cls, *args, **kwargs): + raise TypeError( + "type 'typing_extensions.TypeAliasType' is not an acceptable base type" + ) + + # The presence of this method convinces typing._type_check + # that TypeAliasTypes are types. + def __call__(self): + raise TypeError("Type alias is not callable") + + # Breakpoint: https://github.com/python/cpython/pull/21515 + if sys.version_info >= (3, 10): + def __or__(self, right): + # For forward compatibility with 3.12, reject Unions + # that are not accepted by the built-in Union. + if not _is_unionable(right): + return NotImplemented + return typing.Union[self, right] + + def __ror__(self, left): + if not _is_unionable(left): + return NotImplemented + return typing.Union[left, self] + + +if hasattr(typing, "is_protocol"): + is_protocol = typing.is_protocol + get_protocol_members = typing.get_protocol_members +else: + def is_protocol(tp: type, /) -> bool: + """Return True if the given type is a Protocol. + + Example:: + + >>> from typing_extensions import Protocol, is_protocol + >>> class P(Protocol): + ... def a(self) -> str: ... + ... b: int + >>> is_protocol(P) + True + >>> is_protocol(int) + False + """ + return ( + isinstance(tp, type) + and getattr(tp, '_is_protocol', False) + and tp is not Protocol + and tp is not typing.Protocol + ) + + def get_protocol_members(tp: type, /) -> typing.FrozenSet[str]: + """Return the set of members defined in a Protocol. + + Example:: + + >>> from typing_extensions import Protocol, get_protocol_members + >>> class P(Protocol): + ... def a(self) -> str: ... + ... b: int + >>> get_protocol_members(P) == frozenset({'a', 'b'}) + True + + Raise a TypeError for arguments that are not Protocols. + """ + if not is_protocol(tp): + raise TypeError(f'{tp!r} is not a Protocol') + if hasattr(tp, '__protocol_attrs__'): + return frozenset(tp.__protocol_attrs__) + return frozenset(_get_protocol_attrs(tp)) + + +if hasattr(typing, "Doc"): + Doc = typing.Doc +else: + class Doc: + """Define the documentation of a type annotation using ``Annotated``, to be + used in class attributes, function and method parameters, return values, + and variables. + + The value should be a positional-only string literal to allow static tools + like editors and documentation generators to use it. + + This complements docstrings. + + The string value passed is available in the attribute ``documentation``. + + Example:: + + >>> from typing_extensions import Annotated, Doc + >>> def hi(to: Annotated[str, Doc("Who to say hi to")]) -> None: ... + """ + def __init__(self, documentation: str, /) -> None: + self.documentation = documentation + + def __repr__(self) -> str: + return f"Doc({self.documentation!r})" + + def __hash__(self) -> int: + return hash(self.documentation) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Doc): + return NotImplemented + return self.documentation == other.documentation + + +_CapsuleType = getattr(_types, "CapsuleType", None) + +if _CapsuleType is None: + try: + import _socket + except ImportError: + pass + else: + _CAPI = getattr(_socket, "CAPI", None) + if _CAPI is not None: + _CapsuleType = type(_CAPI) + +if _CapsuleType is not None: + CapsuleType = _CapsuleType + __all__.append("CapsuleType") + + +if sys.version_info >= (3, 14): + from annotationlib import Format, get_annotations +else: + # Available since Python 3.14.0a3 + # PR: https://github.com/python/cpython/pull/124415 + class Format(enum.IntEnum): + VALUE = 1 + VALUE_WITH_FAKE_GLOBALS = 2 + FORWARDREF = 3 + STRING = 4 + + # Available since Python 3.14.0a1 + # PR: https://github.com/python/cpython/pull/119891 + def get_annotations(obj, *, globals=None, locals=None, eval_str=False, + format=Format.VALUE): + """Compute the annotations dict for an object. + + obj may be a callable, class, or module. + Passing in an object of any other type raises TypeError. + + Returns a dict. get_annotations() returns a new dict every time + it's called; calling it twice on the same object will return two + different but equivalent dicts. + + This is a backport of `inspect.get_annotations`, which has been + in the standard library since Python 3.10. See the standard library + documentation for more: + + https://docs.python.org/3/library/inspect.html#inspect.get_annotations + + This backport adds the *format* argument introduced by PEP 649. The + three formats supported are: + * VALUE: the annotations are returned as-is. This is the default and + it is compatible with the behavior on previous Python versions. + * FORWARDREF: return annotations as-is if possible, but replace any + undefined names with ForwardRef objects. The implementation proposed by + PEP 649 relies on language changes that cannot be backported; the + typing-extensions implementation simply returns the same result as VALUE. + * STRING: return annotations as strings, in a format close to the original + source. Again, this behavior cannot be replicated directly in a backport. + As an approximation, typing-extensions retrieves the annotations under + VALUE semantics and then stringifies them. + + The purpose of this backport is to allow users who would like to use + FORWARDREF or STRING semantics once PEP 649 is implemented, but who also + want to support earlier Python versions, to simply write: + + typing_extensions.get_annotations(obj, format=Format.FORWARDREF) + + """ + format = Format(format) + if format is Format.VALUE_WITH_FAKE_GLOBALS: + raise ValueError( + "The VALUE_WITH_FAKE_GLOBALS format is for internal use only" + ) + + if eval_str and format is not Format.VALUE: + raise ValueError("eval_str=True is only supported with format=Format.VALUE") + + if isinstance(obj, type): + # class + obj_dict = getattr(obj, '__dict__', None) + if obj_dict and hasattr(obj_dict, 'get'): + ann = obj_dict.get('__annotations__', None) + if isinstance(ann, _types.GetSetDescriptorType): + ann = None + else: + ann = None + + obj_globals = None + module_name = getattr(obj, '__module__', None) + if module_name: + module = sys.modules.get(module_name, None) + if module: + obj_globals = getattr(module, '__dict__', None) + obj_locals = dict(vars(obj)) + unwrap = obj + elif isinstance(obj, _types.ModuleType): + # module + ann = getattr(obj, '__annotations__', None) + obj_globals = obj.__dict__ + obj_locals = None + unwrap = None + elif callable(obj): + # this includes types.Function, types.BuiltinFunctionType, + # types.BuiltinMethodType, functools.partial, functools.singledispatch, + # "class funclike" from Lib/test/test_inspect... on and on it goes. + ann = getattr(obj, '__annotations__', None) + obj_globals = getattr(obj, '__globals__', None) + obj_locals = None + unwrap = obj + elif hasattr(obj, '__annotations__'): + ann = obj.__annotations__ + obj_globals = obj_locals = unwrap = None + else: + raise TypeError(f"{obj!r} is not a module, class, or callable.") + + if ann is None: + return {} + + if not isinstance(ann, dict): + raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") + + if not ann: + return {} + + if not eval_str: + if format is Format.STRING: + return { + key: value if isinstance(value, str) else typing._type_repr(value) + for key, value in ann.items() + } + return dict(ann) + + if unwrap is not None: + while True: + if hasattr(unwrap, '__wrapped__'): + unwrap = unwrap.__wrapped__ + continue + if isinstance(unwrap, functools.partial): + unwrap = unwrap.func + continue + break + if hasattr(unwrap, "__globals__"): + obj_globals = unwrap.__globals__ + + if globals is None: + globals = obj_globals + if locals is None: + locals = obj_locals or {} + + # "Inject" type parameters into the local namespace + # (unless they are shadowed by assignments *in* the local namespace), + # as a way of emulating annotation scopes when calling `eval()` + if type_params := getattr(obj, "__type_params__", ()): + locals = {param.__name__: param for param in type_params} | locals + + return_value = {key: + value if not isinstance(value, str) else eval(value, globals, locals) + for key, value in ann.items() } + return return_value + + +if hasattr(typing, "evaluate_forward_ref"): + evaluate_forward_ref = typing.evaluate_forward_ref +else: + # Implements annotationlib.ForwardRef.evaluate + def _eval_with_owner( + forward_ref, *, owner=None, globals=None, locals=None, type_params=None + ): + if forward_ref.__forward_evaluated__: + return forward_ref.__forward_value__ + if getattr(forward_ref, "__cell__", None) is not None: + try: + value = forward_ref.__cell__.cell_contents + except ValueError: + pass + else: + forward_ref.__forward_evaluated__ = True + forward_ref.__forward_value__ = value + return value + if owner is None: + owner = getattr(forward_ref, "__owner__", None) + + if ( + globals is None + and getattr(forward_ref, "__forward_module__", None) is not None + ): + globals = getattr( + sys.modules.get(forward_ref.__forward_module__, None), "__dict__", None + ) + if globals is None: + globals = getattr(forward_ref, "__globals__", None) + if globals is None: + if isinstance(owner, type): + module_name = getattr(owner, "__module__", None) + if module_name: + module = sys.modules.get(module_name, None) + if module: + globals = getattr(module, "__dict__", None) + elif isinstance(owner, _types.ModuleType): + globals = getattr(owner, "__dict__", None) + elif callable(owner): + globals = getattr(owner, "__globals__", None) + + # If we pass None to eval() below, the globals of this module are used. + if globals is None: + globals = {} + + if locals is None: + locals = {} + if isinstance(owner, type): + locals.update(vars(owner)) + + if type_params is None and owner is not None: + # "Inject" type parameters into the local namespace + # (unless they are shadowed by assignments *in* the local namespace), + # as a way of emulating annotation scopes when calling `eval()` + type_params = getattr(owner, "__type_params__", None) + + # Type parameters exist in their own scope, which is logically + # between the locals and the globals. We simulate this by adding + # them to the globals. + if type_params is not None: + globals = dict(globals) + for param in type_params: + globals[param.__name__] = param + + arg = forward_ref.__forward_arg__ + if arg.isidentifier() and not keyword.iskeyword(arg): + if arg in locals: + value = locals[arg] + elif arg in globals: + value = globals[arg] + elif hasattr(builtins, arg): + return getattr(builtins, arg) + else: + raise NameError(arg) + else: + code = forward_ref.__forward_code__ + value = eval(code, globals, locals) + forward_ref.__forward_evaluated__ = True + forward_ref.__forward_value__ = value + return value + + def evaluate_forward_ref( + forward_ref, + *, + owner=None, + globals=None, + locals=None, + type_params=None, + format=None, + _recursive_guard=frozenset(), + ): + """Evaluate a forward reference as a type hint. + + This is similar to calling the ForwardRef.evaluate() method, + but unlike that method, evaluate_forward_ref() also: + + * Recursively evaluates forward references nested within the type hint. + * Rejects certain objects that are not valid type hints. + * Replaces type hints that evaluate to None with types.NoneType. + * Supports the *FORWARDREF* and *STRING* formats. + + *forward_ref* must be an instance of ForwardRef. *owner*, if given, + should be the object that holds the annotations that the forward reference + derived from, such as a module, class object, or function. It is used to + infer the namespaces to use for looking up names. *globals* and *locals* + can also be explicitly given to provide the global and local namespaces. + *type_params* is a tuple of type parameters that are in scope when + evaluating the forward reference. This parameter must be provided (though + it may be an empty tuple) if *owner* is not given and the forward reference + does not already have an owner set. *format* specifies the format of the + annotation and is a member of the annotationlib.Format enum. + + """ + if format == Format.STRING: + return forward_ref.__forward_arg__ + if forward_ref.__forward_arg__ in _recursive_guard: + return forward_ref + + # Evaluate the forward reference + try: + value = _eval_with_owner( + forward_ref, + owner=owner, + globals=globals, + locals=locals, + type_params=type_params, + ) + except NameError: + if format == Format.FORWARDREF: + return forward_ref + else: + raise + + if isinstance(value, str): + value = ForwardRef(value) + + # Recursively evaluate the type + if isinstance(value, ForwardRef): + if getattr(value, "__forward_module__", True) is not None: + globals = None + return evaluate_forward_ref( + value, + globals=globals, + locals=locals, + type_params=type_params, owner=owner, + _recursive_guard=_recursive_guard, format=format + ) + if sys.version_info < (3, 12, 5) and type_params: + # Make use of type_params + locals = dict(locals) if locals else {} + for tvar in type_params: + if tvar.__name__ not in locals: # lets not overwrite something present + locals[tvar.__name__] = tvar + if sys.version_info < (3, 12, 5): + return typing._eval_type( + value, + globals, + locals, + recursive_guard=_recursive_guard | {forward_ref.__forward_arg__}, + ) + else: + return typing._eval_type( + value, + globals, + locals, + type_params, + recursive_guard=_recursive_guard | {forward_ref.__forward_arg__}, + ) + + +if sys.version_info >= (3, 14, 0, "beta"): + type_repr = annotationlib.type_repr +else: + def type_repr(value): + """Convert a Python value to a format suitable for use with the STRING format. + + This is intended as a helper for tools that support the STRING format but do + not have access to the code that originally produced the annotations. It uses + repr() for most objects. + + """ + if isinstance(value, (type, _types.FunctionType, _types.BuiltinFunctionType)): + if value.__module__ == "builtins": + return value.__qualname__ + return f"{value.__module__}.{value.__qualname__}" + if value is ...: + return "..." + return repr(value) + + +# Aliases for items that are in typing in all supported versions. +# We use hasattr() checks so this library will continue to import on +# future versions of Python that may remove these names. +_typing_names = [ + "AbstractSet", + "AnyStr", + "BinaryIO", + "Callable", + "Collection", + "Container", + "Dict", + "FrozenSet", + "Hashable", + "IO", + "ItemsView", + "Iterable", + "Iterator", + "KeysView", + "List", + "Mapping", + "MappingView", + "Match", + "MutableMapping", + "MutableSequence", + "MutableSet", + "Optional", + "Pattern", + "Reversible", + "Sequence", + "Set", + "Sized", + "TextIO", + "Tuple", + "Union", + "ValuesView", + "cast", + "no_type_check", + # This is private, but it was defined by typing_extensions for a long time + # and some users rely on it. + "_AnnotatedAlias", +] + +# Breakpoint: https://github.com/python/cpython/pull/133602 +if sys.version_info < (3, 15, 0): + _typing_names.append("no_type_check_decorator") + __all__.append("no_type_check_decorator") + +globals().update( + {name: getattr(typing, name) for name in _typing_names if hasattr(typing, name)} +) +# These are defined unconditionally because they are used in +# typing-extensions itself. +Generic = typing.Generic +ForwardRef = typing.ForwardRef +Annotated = typing.Annotated diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/__init__.py new file mode 100644 index 00000000..c75ab6ea --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/__init__.py @@ -0,0 +1,8 @@ +"""workflow_core: shared Workflow Manager core. + +Contains the node catalog, workflow serializer, validator, and compiler +shared by the portal Lambdas, the cloud test sandbox, and the LocalServer +workflow engine. +""" + +__version__ = "0.1.0" diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/__init__.py new file mode 100644 index 00000000..7c0ba3cb --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/__init__.py @@ -0,0 +1,120 @@ +"""Node type catalog: descriptors for every workflow node type. + +Declares ports, port types, parameters (types, defaults, constraints), +per-architecture GStreamer mappings, executor bindings, plugin +dependencies, and hardware-dependence flags (Requirement 2.8), plus the +port-type compatibility rules and the per-arch LocalServer-bundled +plugin manifest. +""" + +from .models import ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + ARCH_SIM, + ARCH_X86_64, + ARCH_X86_64_NVIDIA, + ARCHITECTURES, + CATEGORIES, + CATEGORY_INFERENCE, + CATEGORY_INPUT, + CATEGORY_OUTPUT, + CATEGORY_POST_PROCESSING, + CATEGORY_PREPROCESSING, + DEVICE_ARCHITECTURES, + SIM_RECORDING_BINDING_PREFIX, + GstMapping, + NodeTypeDescriptor, + PARAMETER_TYPES, + ParameterDescriptor, + PortDescriptor, + PORT_TYPE_EVENT_SIGNAL, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES, + PORT_TYPES, +) +from .compatibility import ( + PORT_TYPE_COERCIONS, + are_port_types_compatible, + incompatibility_reason, +) +from .bundled_plugins import ( + LOCALSERVER_BUNDLED_PLUGINS, + bundled_plugins_for, +) +from .nodes import ( + CONDITION_EXAMPLES, + CONDITION_LANGUAGE_DESCRIPTION, + NODE_CATALOG, + get_node_type, + nodes_by_category, +) +from .classification import ( + CLASSIFICATION_BAD, + CLASSIFICATION_GOOD, + CLASSIFICATION_UGLY, + CLASSIFICATION_UNCLASSIFIED, + CLASSIFICATIONS, + EXPLANATIONS, + classify_plugin_set, +) +from .custom import ( + DEEPSTREAM_ARCHITECTURES, + DeclarationError, + descriptor_from_declaration, + resolve_catalog, +) + +__all__ = [ + # models + "PortDescriptor", + "ParameterDescriptor", + "GstMapping", + "NodeTypeDescriptor", + "PORT_TYPES", + "PORT_TYPE_VIDEO_FRAMES", + "PORT_TYPE_INFERENCE_META", + "PORT_TYPE_EVENT_SIGNAL", + "CATEGORIES", + "CATEGORY_INPUT", + "CATEGORY_PREPROCESSING", + "CATEGORY_INFERENCE", + "CATEGORY_POST_PROCESSING", + "CATEGORY_OUTPUT", + "ARCHITECTURES", + "DEVICE_ARCHITECTURES", + "ARCH_X86_64", + "ARCH_X86_64_NVIDIA", + "ARCH_ARM64_JP4", + "ARCH_ARM64_JP5", + "ARCH_ARM64_JP6", + "ARCH_SIM", + "SIM_RECORDING_BINDING_PREFIX", + "PARAMETER_TYPES", + # compatibility rules + "PORT_TYPE_COERCIONS", + "are_port_types_compatible", + "incompatibility_reason", + # bundled plugin manifest + "LOCALSERVER_BUNDLED_PLUGINS", + "bundled_plugins_for", + # catalog + "NODE_CATALOG", + "CONDITION_EXAMPLES", + "CONDITION_LANGUAGE_DESCRIPTION", + "get_node_type", + "nodes_by_category", + # plugin-set classification + "CLASSIFICATION_GOOD", + "CLASSIFICATION_BAD", + "CLASSIFICATION_UGLY", + "CLASSIFICATION_UNCLASSIFIED", + "CLASSIFICATIONS", + "EXPLANATIONS", + "classify_plugin_set", + # custom node type declarations + "DEEPSTREAM_ARCHITECTURES", + "DeclarationError", + "descriptor_from_declaration", + "resolve_catalog", +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/bundled_plugins.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/bundled_plugins.py new file mode 100644 index 00000000..c0f154ce --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/bundled_plugins.py @@ -0,0 +1,81 @@ +"""Per-architecture manifest of GStreamer plugins bundled with LocalServer. + +The Workflow_Compiler computes a compiled pipeline's external plugin +dependencies as the union of the catalog-declared dependencies of its +node mappings minus this bundled set for the target architecture +(Requirement 6.4). Entries prefixed ``python:`` are Python runtime +packages available in the LocalServer environment rather than GStreamer +plugins. +""" + +from __future__ import annotations + +from .models import ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + ARCH_SIM, + ARCH_X86_64, + ARCH_X86_64_NVIDIA, +) + +# Core GStreamer plugins present in every LocalServer image (base, good, +# bad-but-shipped sets used by the existing pipeline_builder paths). +_GST_CORE = frozenset( + { + "coreelements", # filesrc, filesink, fakesink, tee, queue, capsfilter + "app", # appsrc, appsink + "videoconvertscale", # videoconvert, videoscale + "videocrop", # videocrop + "videofilter", # videoflip + "jpeg", # jpegenc, jpegdec, jpegparse + "png", # pngdec, pngenc + "bayer", # bayer2rgb + "video4linux2", # v4l2src + "multifile", # multifilesrc (used by the sim harness) + } +) + +# DDA edgemlsdk plugins shipped inside every LocalServer component. +_DDA_ELEMENTS = frozenset( + { + "emltriton", + "emlcapture", + "emoutputevent", + "emexifextract", + } +) + +# Python runtime packages available inside LocalServer (used by +# executor-level bindings). +_LOCALSERVER_PYTHON = frozenset( + { + "python:paho-mqtt", # existing mqtt/ client + "python:pillow", # JP6 PNG staging path + } +) + +_COMMON = _GST_CORE | _DDA_ELEMENTS | _LOCALSERVER_PYTHON + +#: arch -> frozenset of plugin names bundled with the LocalServer build +#: for that architecture. ``sim`` mirrors the x86_64 sandbox image. +#: ``x86_64_nvidia`` mirrors ``x86_64`` — the LocalServer amd64 GPU build +#: bundles the same base plugin set. +LOCALSERVER_BUNDLED_PLUGINS = { + ARCH_X86_64: _COMMON, + ARCH_X86_64_NVIDIA: _COMMON, + ARCH_ARM64_JP4: _COMMON | frozenset({"nvvideo4linux2"}), + ARCH_ARM64_JP5: _COMMON | frozenset({"nvvideo4linux2"}), + ARCH_ARM64_JP6: _COMMON | frozenset({"nvvideo4linux2"}), + ARCH_SIM: _COMMON, +} + + +def bundled_plugins_for(arch: str) -> frozenset: + """The LocalServer-bundled plugin set for ``arch``. + + Raises KeyError for unknown architectures so compiler callers surface + unsupported-architecture errors instead of silently packaging + everything. + """ + return LOCALSERVER_BUNDLED_PLUGINS[arch] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/classification.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/classification.py new file mode 100644 index 00000000..b09142d9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/classification.py @@ -0,0 +1,145 @@ +"""Upstream Plugin_Set_Classification for public GStreamer plugins. + +Pure derivation of the upstream good/bad/ugly quality taxonomy from a +module's identity: the official plugin-set module names +(``gst-plugins-good``, ``gst-plugins-bad``, ``gst-plugins-ugly``) and +their known freedesktop.org repository locations. Anything else — +including arbitrary public repositories — is never guessed into an +official set and classifies as ``unclassified`` (Requirements 15.3, +15.4). +""" + +from __future__ import annotations + +from typing import Optional +from urllib.parse import urlsplit + +#: The four Plugin_Set_Classification values (upstream's own taxonomy). +CLASSIFICATION_GOOD = "good" +CLASSIFICATION_BAD = "bad" +CLASSIFICATION_UGLY = "ugly" +CLASSIFICATION_UNCLASSIFIED = "unclassified" + +CLASSIFICATIONS = ( + CLASSIFICATION_GOOD, + CLASSIFICATION_BAD, + CLASSIFICATION_UGLY, + CLASSIFICATION_UNCLASSIFIED, +) + +#: Fixed plain-language explanation for each classification value, +#: presented verbatim alongside the classification (Requirement 15.3). +EXPLANATIONS = { + CLASSIFICATION_GOOD: ( + "good indicates a well-maintained, well-tested, properly " + "licensed plugin set" + ), + CLASSIFICATION_BAD: ( + "bad indicates a plugin set lacking upstream review, testing, " + "or active maintenance" + ), + CLASSIFICATION_UGLY: ( + "ugly indicates a plugin set of good quality that carries " + "licensing or distribution concerns" + ), + CLASSIFICATION_UNCLASSIFIED: ( + "unclassified indicates a plugin outside the official GStreamer " + "plugin sets that warrants the highest caution" + ), +} + +#: Official plugin-set module names -> classification. +_OFFICIAL_SET_MODULES = { + "gst-plugins-good": CLASSIFICATION_GOOD, + "gst-plugins-bad": CLASSIFICATION_BAD, + "gst-plugins-ugly": CLASSIFICATION_UGLY, +} + +# Known freedesktop.org hosts that publish the official plugin sets: +# the GitLab instance (per-set repos and the monorepo subprojects), the +# historical cgit/anongit mirrors, and the gstreamer.freedesktop.org +# source-release tree. +_GITLAB_HOST = "gitlab.freedesktop.org" +_LEGACY_GIT_HOSTS = frozenset({"cgit.freedesktop.org", "anongit.freedesktop.org"}) +_RELEASE_HOST = "gstreamer.freedesktop.org" +_URL_SCHEMES = frozenset({"http", "https", "git"}) + + +def _path_segments(path: str) -> list: + """URL path split into segments with any ``.git`` suffix stripped.""" + segments = [] + for raw in path.split("/"): + if not raw: + continue + if raw.endswith(".git"): + raw = raw[: -len(".git")] + segments.append(raw) + return segments + + +def _classify_repo_url(repo_url: str) -> str: + """Classification for a repository URL, ``unclassified`` unless the + URL is a known freedesktop.org location of an official plugin set.""" + try: + parts = urlsplit(repo_url.strip()) + except ValueError: + return CLASSIFICATION_UNCLASSIFIED + + if parts.scheme.lower() not in _URL_SCHEMES: + return CLASSIFICATION_UNCLASSIFIED + + host = (parts.hostname or "").lower() + segments = _path_segments(parts.path) + + if host == _GITLAB_HOST: + # https://gitlab.freedesktop.org/gstreamer/gst-plugins-good[.git] + # and monorepo subproject paths such as + # https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/ + # main/subprojects/gst-plugins-good + if segments and segments[0] == "gstreamer": + for segment in segments[1:]: + if segment in _OFFICIAL_SET_MODULES: + return _OFFICIAL_SET_MODULES[segment] + return CLASSIFICATION_UNCLASSIFIED + + if host in _LEGACY_GIT_HOSTS: + # https://cgit.freedesktop.org/gstreamer/gst-plugins-good/ + # git://anongit.freedesktop.org/gstreamer/gst-plugins-good + if ( + len(segments) >= 2 + and segments[0] == "gstreamer" + and segments[1] in _OFFICIAL_SET_MODULES + ): + return _OFFICIAL_SET_MODULES[segments[1]] + return CLASSIFICATION_UNCLASSIFIED + + if host == _RELEASE_HOST: + # https://gstreamer.freedesktop.org/src/gst-plugins-good/... + for segment in segments: + if segment in _OFFICIAL_SET_MODULES: + return _OFFICIAL_SET_MODULES[segment] + return CLASSIFICATION_UNCLASSIFIED + + return CLASSIFICATION_UNCLASSIFIED + + +def classify_plugin_set( + module_name: Optional[str], repo_url: Optional[str] +) -> str: + """The Plugin_Set_Classification for a module. + + ``good``/``bad``/``ugly`` exactly when the module name is one of the + official plugin-set module names or the repository URL is a known + freedesktop.org location of an official set; ``unclassified`` + otherwise (Requirement 15.4). Pure and deterministic — no network + access, no guessing. + """ + if module_name: + name = module_name.strip() + if name in _OFFICIAL_SET_MODULES: + return _OFFICIAL_SET_MODULES[name] + + if repo_url and repo_url.strip(): + return _classify_repo_url(repo_url) + + return CLASSIFICATION_UNCLASSIFIED diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/compatibility.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/compatibility.py new file mode 100644 index 00000000..74184c52 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/compatibility.py @@ -0,0 +1,46 @@ +"""Port-type compatibility rules: exact match plus declared coercions. + +A connection joins an output port (source) to an input port (target). +Compatibility is exact type match, plus an explicit coercion table +(Requirement 1.4, 4.2). The only declared coercion: ``InferenceMeta`` +flows over the same GStreamer buffer stream as ``VideoFrames`` with +attached metadata, so an ``InferenceMeta`` output may feed a +``VideoFrames`` input (e.g. ``capture`` accepts both). +""" + +from __future__ import annotations + +from .models import ( + PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES, + PORT_TYPES, +) + +#: Declared coercions: (source output type) -> set of additionally +#: acceptable target input types. +PORT_TYPE_COERCIONS = { + PORT_TYPE_INFERENCE_META: frozenset({PORT_TYPE_VIDEO_FRAMES}), +} + + +def are_port_types_compatible(source_type: str, target_type: str) -> bool: + """True when an output of ``source_type`` may connect to an input of + ``target_type``: exact match or a declared coercion.""" + if source_type == target_type: + return True + return target_type in PORT_TYPE_COERCIONS.get(source_type, frozenset()) + + +def incompatibility_reason(source_type: str, target_type: str) -> str | None: + """A human-readable rejection reason, or None when compatible. + + Used by the Workflow_Builder to explain rejected connections + (Requirement 1.4) and by validator check V2 (Requirement 4.2). + """ + if source_type not in PORT_TYPES: + return f"Unknown source port type '{source_type}'" + if target_type not in PORT_TYPES: + return f"Unknown target port type '{target_type}'" + if are_port_types_compatible(source_type, target_type): + return None + return f"Cannot connect {source_type} output to {target_type} input" diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/custom.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/custom.py new file mode 100644 index 00000000..ad7058d2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/custom.py @@ -0,0 +1,420 @@ +"""Custom node type declarations: conversion and catalog resolution. + +Makes the Node_Type_Catalog composable (custom-node-designer design, +"Dynamic Node_Type_Catalog extension"): built-in descriptors stay the +static frozen ``NODE_CATALOG``; Custom_Node_Type declarations — stored +per Use_Case in the identical camelCase wire shape the node-catalog +endpoint already serves — are converted into frozen +``NodeTypeDescriptor`` instances by :func:`descriptor_from_declaration` +and merged into a per-request effective catalog by +:func:`resolve_catalog`. + +Validation (Requirements 5.3, 8.2, 8.5): + - port types against ``PORT_TYPES``, + - categories against ``CATEGORIES``, + - parameter descriptors against ``PARAMETER_TYPES`` (including + constraint satisfiability, defaults and examples satisfying the + parameter's own type and constraints, exactly like the built-in + catalog well-formedness predicate), + - mappings against ``ARCHITECTURES``, + - DeepStream-flagged declarations restricted to the JetPack + architectures (``arm64_jp4/jp5/jp6``) — a DeepStream-backed type is + unavailable on architectures without a matching runtime (5.3). + +Invalid declarations raise :class:`DeclarationError` identifying the +offending field (8.5). +""" + +from __future__ import annotations + +import re +from typing import Any, Sequence + +from .models import ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + ARCHITECTURES, + CATEGORIES, + PARAM_TYPE_ENUM, + PARAMETER_TYPES, + PORT_TYPES, + GstMapping, + NodeTypeDescriptor, + ParameterDescriptor, + PortDescriptor, +) +from .nodes import NODE_CATALOG + +#: Architectures with a DeepStream runtime (Requirement 5.1/5.3): +#: DeepStream targets Jetson, so DeepStream-flagged declarations may only +#: declare mappings for the JetPack builds. +DEEPSTREAM_ARCHITECTURES = ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, +) + +#: Wire constraint keys -> ParameterDescriptor.constraints keys. +#: Mirrors (inverts) ``_CONSTRAINT_KEY_MAP`` in workflow_validation.py; +#: min/max/regex/values pass through unchanged. +_WIRE_CONSTRAINT_KEY_MAP = { + "minLength": "min_length", + "maxLength": "max_length", +} + + +class DeclarationError(ValueError): + """A Custom_Node_Type declaration is invalid. + + ``field`` identifies the offending field using a JSON-path-like + notation over the wire declaration (e.g. ``inputs[0].portType``, + ``parameters[2].default``, ``mappings[1].arch``) so the caller can + surface exactly the invalid declaration entry (Requirement 8.5). + """ + + def __init__(self, field: str, message: str): + self.field = field + super().__init__("{0}: {1}".format(field, message)) + + +# -------------------------------------------------------------------------- +# Small field validators +# -------------------------------------------------------------------------- + +def _require_dict(value: Any, field: str) -> dict: + if not isinstance(value, dict): + raise DeclarationError(field, "must be an object, got {0}".format(type(value).__name__)) + return value + + +def _require_list(value: Any, field: str) -> list: + if not isinstance(value, list): + raise DeclarationError(field, "must be a list, got {0}".format(type(value).__name__)) + return value + + +def _require_str(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise DeclarationError(field, "must be a non-empty string, got {0!r}".format(value)) + return value + + +def _require_bool(value: Any, field: str) -> bool: + if not isinstance(value, bool): + raise DeclarationError(field, "must be a boolean, got {0!r}".format(value)) + return value + + +# -------------------------------------------------------------------------- +# Ports +# -------------------------------------------------------------------------- + +def _port_from_wire(port: Any, field: str) -> PortDescriptor: + port = _require_dict(port, field) + name = _require_str(port.get("name"), field + ".name") + port_type = port.get("portType") + if port_type not in PORT_TYPES: + raise DeclarationError( + field + ".portType", + "unknown port type {0!r}; must be one of {1}".format(port_type, list(PORT_TYPES)), + ) + return PortDescriptor(name=name, port_type=port_type) + + +# -------------------------------------------------------------------------- +# Parameters +# -------------------------------------------------------------------------- + +def _constraints_from_wire(constraints: Any, field: str) -> dict: + if constraints is None: + return {} + constraints = _require_dict(constraints, field) + return {_WIRE_CONSTRAINT_KEY_MAP.get(k, k): v for k, v in constraints.items()} + + +def _check_constraints_satisfiable(param_type: str, constraints: dict, field: str) -> None: + """Constraints must admit at least one value (mirrors the built-in + catalog well-formedness predicate).""" + if "min" in constraints and "max" in constraints: + if constraints["min"] > constraints["max"]: + raise DeclarationError(field, "empty numeric range: min > max") + if "min_length" in constraints and constraints["min_length"] < 0: + raise DeclarationError(field + ".minLength", "must be non-negative") + if "min_length" in constraints and "max_length" in constraints: + if constraints["min_length"] > constraints["max_length"]: + raise DeclarationError(field, "empty length range: minLength > maxLength") + if "values" in constraints: + values = constraints["values"] + if not isinstance(values, list) or not values: + raise DeclarationError(field + ".values", "must be a non-empty list") + if param_type == PARAM_TYPE_ENUM and not constraints.get("values"): + raise DeclarationError( + field + ".values", + "enum parameters require a non-empty 'values' constraint", + ) + if "regex" in constraints: + pattern = constraints["regex"] + if not isinstance(pattern, str): + raise DeclarationError(field + ".regex", "must be a string pattern") + try: + re.compile(pattern) + except re.error as exc: + raise DeclarationError(field + ".regex", "invalid pattern: {0}".format(exc)) + + +def _parameter_from_wire(parameter: Any, field: str) -> ParameterDescriptor: + # Imported at call time: the validator package imports back into + # workflow_core.catalog, so a module-level import here would recurse + # through a partially initialized catalog package. + from ..validator.parameters import check_parameter_value + + parameter = _require_dict(parameter, field) + name = _require_str(parameter.get("name"), field + ".name") + + param_type = parameter.get("paramType") + if param_type not in PARAMETER_TYPES: + raise DeclarationError( + field + ".paramType", + "unknown parameter type {0!r}; must be one of {1}".format( + param_type, list(PARAMETER_TYPES) + ), + ) + + required = _require_bool(parameter.get("required", False), field + ".required") + + constraints = _constraints_from_wire(parameter.get("constraints"), field + ".constraints") + _check_constraints_satisfiable(param_type, constraints, field + ".constraints") + + # Field-level help is part of the registration declaration + # (Requirement 8.1) and of the catalog well-formedness predicate the + # built-in node types satisfy: a non-empty description and at least + # one example value satisfying the parameter's own constraints. + description = _require_str(parameter.get("description"), field + ".description") + examples = _require_list(parameter.get("examples"), field + ".examples") + if not examples: + raise DeclarationError(field + ".examples", "must contain at least one example value") + + depends_on = parameter.get("dependsOn") + if depends_on is not None: + depends_on = _require_str(depends_on, field + ".dependsOn") + + descriptor = ParameterDescriptor( + name=name, + param_type=param_type, + required=required, + default=parameter.get("default"), + constraints=constraints, + depends_on=depends_on, + description=description, + examples=list(examples), + ) + + # A declared default must satisfy the parameter's own type and + # constraints (None means "no default": required parameters the + # user must set). + default = descriptor.default + if default is not None: + violation = check_parameter_value(descriptor, default) + if violation is not None: + raise DeclarationError( + field + ".default", + "default {0!r} violates the parameter's own declaration: {1}".format( + default, violation.message + ), + ) + + for index, example in enumerate(examples): + example_field = "{0}.examples[{1}]".format(field, index) + if example is None: + raise DeclarationError(example_field, "example values must not be null") + violation = check_parameter_value(descriptor, example) + if violation is not None: + raise DeclarationError( + example_field, + "example {0!r} violates the parameter's own declaration: {1}".format( + example, violation.message + ), + ) + + return descriptor + + +def _check_depends_on(parameters: Sequence[ParameterDescriptor], field: str) -> None: + """``dependsOn`` must name a bool parameter on the same node type.""" + bool_params = {p.name for p in parameters if p.param_type == "bool"} + for index, parameter in enumerate(parameters): + if parameter.depends_on is None: + continue + if parameter.depends_on not in bool_params or parameter.depends_on == parameter.name: + raise DeclarationError( + "{0}[{1}].dependsOn".format(field, index), + "{0!r} must name a bool parameter declared on the same node type".format( + parameter.depends_on + ), + ) + + +# -------------------------------------------------------------------------- +# Mappings +# -------------------------------------------------------------------------- + +def _element_from_wire(element: Any, field: str) -> dict: + element = _require_dict(element, field) + factory = _require_str(element.get("factory"), field + ".factory") + args_template = element.get("argsTemplate") + if args_template is None: + args_template = {} + args_template = _require_dict(args_template, field + ".argsTemplate") + return {"factory": factory, "args_template": dict(args_template)} + + +def _mapping_from_wire(mapping: Any, field: str, deepstream: bool) -> GstMapping: + mapping = _require_dict(mapping, field) + + arch = mapping.get("arch") + if arch not in ARCHITECTURES: + raise DeclarationError( + field + ".arch", + "unknown architecture {0!r}; must be one of {1}".format(arch, list(ARCHITECTURES)), + ) + if deepstream and arch not in DEEPSTREAM_ARCHITECTURES: + raise DeclarationError( + field + ".arch", + "DeepStream-flagged declarations may only map the JetPack " + "architectures {0}; got {1!r}".format(list(DEEPSTREAM_ARCHITECTURES), arch), + ) + + element_chain = _require_list(mapping.get("elementChain", []), field + ".elementChain") + elements = [ + _element_from_wire(element, "{0}.elementChain[{1}]".format(field, index)) + for index, element in enumerate(element_chain) + ] + + executor_binding = mapping.get("executorBinding") + if executor_binding is not None: + executor_binding = _require_str(executor_binding, field + ".executorBinding") + + plugin_dependencies = _require_list( + mapping.get("pluginDependencies", []), field + ".pluginDependencies" + ) + for index, dependency in enumerate(plugin_dependencies): + _require_str(dependency, "{0}.pluginDependencies[{1}]".format(field, index)) + + return GstMapping( + arch=arch, + element_chain=elements, + executor_binding=executor_binding, + plugin_dependencies=list(plugin_dependencies), + ) + + +# -------------------------------------------------------------------------- +# Public API +# -------------------------------------------------------------------------- + +def descriptor_from_declaration(decl: Any) -> NodeTypeDescriptor: + """Convert a stored Custom_Node_Type declaration into a frozen descriptor. + + ``decl`` is the node-catalog wire shape (camelCase — identical to what + ``descriptor_to_wire`` in workflow_validation.py serves): ``typeId``, + ``displayName``, ``category``, ``inputs``/``outputs`` (``[{name, + portType}]``), ``parameters`` (``[{name, paramType, required, default, + constraints, dependsOn, description, examples}]``), ``mappings`` + (``[{arch, elementChain: [{factory, argsTemplate}], executorBinding, + pluginDependencies}]``), and ``hardwareDependent``. An optional + ``deepstream`` flag restricts the declarable mapping architectures to + ``arm64_jp4/jp5/jp6`` (Requirement 5.3). Extra keys (``typeVersion``, + ``lifecycleState``) are ignored. + + Raises :class:`DeclarationError` identifying the offending field for + any invalid declaration (Requirement 8.5). + """ + decl = _require_dict(decl, "declaration") + + type_id = _require_str(decl.get("typeId"), "typeId") + display_name = _require_str(decl.get("displayName"), "displayName") + + category = decl.get("category") + if category not in CATEGORIES: + raise DeclarationError( + "category", + "unknown category {0!r}; must be one of {1}".format(category, list(CATEGORIES)), + ) + + deepstream = _require_bool(decl.get("deepstream", False), "deepstream") + hardware_dependent = _require_bool( + decl.get("hardwareDependent", False), "hardwareDependent" + ) + + inputs = [ + _port_from_wire(port, "inputs[{0}]".format(index)) + for index, port in enumerate(_require_list(decl.get("inputs", []), "inputs")) + ] + outputs = [ + _port_from_wire(port, "outputs[{0}]".format(index)) + for index, port in enumerate(_require_list(decl.get("outputs", []), "outputs")) + ] + + parameters = [ + _parameter_from_wire(parameter, "parameters[{0}]".format(index)) + for index, parameter in enumerate( + _require_list(decl.get("parameters", []), "parameters") + ) + ] + _check_parameter_names_unique(parameters) + _check_depends_on(parameters, "parameters") + + mappings = [] + seen_archs = set() + for index, mapping in enumerate(_require_list(decl.get("mappings", []), "mappings")): + field = "mappings[{0}]".format(index) + converted = _mapping_from_wire(mapping, field, deepstream) + if converted.arch in seen_archs: + raise DeclarationError( + field + ".arch", "duplicate mapping for architecture {0!r}".format(converted.arch) + ) + seen_archs.add(converted.arch) + mappings.append(converted) + + return NodeTypeDescriptor( + type_id=type_id, + category=category, + display_name=display_name, + inputs=inputs, + outputs=outputs, + parameters=parameters, + mappings=mappings, + hardware_dependent=hardware_dependent, + ) + + +def _check_parameter_names_unique(parameters: Sequence[ParameterDescriptor]) -> None: + seen = set() + for index, parameter in enumerate(parameters): + if parameter.name in seen: + raise DeclarationError( + "parameters[{0}].name".format(index), + "duplicate parameter name {0!r}".format(parameter.name), + ) + seen.add(parameter.name) + + +def resolve_catalog(custom_descriptors: Sequence[NodeTypeDescriptor]) -> tuple: + """Merge custom descriptors into the built-in catalog (Requirement 8.2). + + Returns ``NODE_CATALOG + tuple(custom_descriptors)`` preserving order: + built-in descriptors first (unchanged), then the custom descriptors in + the given order. Duplicate ``type_id`` entries are rejected from the + result — a custom descriptor colliding with a built-in type id never + displaces the built-in (built-ins always win), and a custom descriptor + colliding with an earlier custom descriptor is dropped (first wins). + """ + resolved = list(NODE_CATALOG) + seen = {descriptor.type_id for descriptor in NODE_CATALOG} + for descriptor in custom_descriptors: + if descriptor.type_id in seen: + continue + seen.add(descriptor.type_id) + resolved.append(descriptor) + return tuple(resolved) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/models.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/models.py new file mode 100644 index 00000000..0b7dd891 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/models.py @@ -0,0 +1,200 @@ +"""Data models and constants for the node type catalog. + +These dataclasses are the shared vocabulary between the portal Lambdas, +the cloud test sandbox, and the LocalServer workflow engine: every node +type declares its ports, parameters, per-architecture GStreamer mappings, +executor bindings, plugin dependencies, and hardware-dependence flag +(Requirement 2.8). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +# -------------------------------------------------------------------------- +# Port types (Requirement 2.8) +# -------------------------------------------------------------------------- + +PORT_TYPE_VIDEO_FRAMES = "VideoFrames" +PORT_TYPE_INFERENCE_META = "InferenceMeta" +PORT_TYPE_EVENT_SIGNAL = "EventSignal" + +PORT_TYPES = ( + PORT_TYPE_VIDEO_FRAMES, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_EVENT_SIGNAL, +) + +# -------------------------------------------------------------------------- +# Node categories (Requirements 2.1-2.5) +# -------------------------------------------------------------------------- + +CATEGORY_INPUT = "input" +CATEGORY_PREPROCESSING = "preprocessing" +CATEGORY_INFERENCE = "inference" +CATEGORY_POST_PROCESSING = "post_processing" +CATEGORY_OUTPUT = "output" + +CATEGORIES = ( + CATEGORY_INPUT, + CATEGORY_PREPROCESSING, + CATEGORY_INFERENCE, + CATEGORY_POST_PROCESSING, + CATEGORY_OUTPUT, +) + +# -------------------------------------------------------------------------- +# Target architectures +# -------------------------------------------------------------------------- + +ARCH_X86_64 = "x86_64" +ARCH_X86_64_NVIDIA = "x86_64_nvidia" +ARCH_ARM64_JP4 = "arm64_jp4" +ARCH_ARM64_JP5 = "arm64_jp5" +ARCH_ARM64_JP6 = "arm64_jp6" +ARCH_SIM = "sim" + +#: All architectures a workflow can be compiled for. ``sim`` is the +#: cloud-side test sandbox (x86_64 container, CPU Triton). +#: ``x86_64_nvidia`` is an x86_64 device with the NVIDIA GPU runtime. +ARCHITECTURES = ( + ARCH_X86_64, + ARCH_X86_64_NVIDIA, + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + ARCH_SIM, +) + +#: Architectures that correspond to physical edge devices. +DEVICE_ARCHITECTURES = ( + ARCH_X86_64, + ARCH_X86_64_NVIDIA, + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, +) + +#: Executor-binding name prefix for simulation recording stubs: in +#: simulation-mode compilation, hardware output nodes (digital output, +#: MQTT publish, OPC UA write) bind to ``recording_`` bindings that +#: log would-be actuations instead of touching any endpoint +#: (Requirement 12.6). +SIM_RECORDING_BINDING_PREFIX = "recording_" + +# -------------------------------------------------------------------------- +# Parameter types +# -------------------------------------------------------------------------- + +PARAM_TYPE_STRING = "string" +PARAM_TYPE_INT = "int" +PARAM_TYPE_FLOAT = "float" +PARAM_TYPE_BOOL = "bool" +PARAM_TYPE_ENUM = "enum" +PARAM_TYPE_CODE = "code" +PARAM_TYPE_MODEL_REF = "model_ref" + +PARAMETER_TYPES = ( + PARAM_TYPE_STRING, + PARAM_TYPE_INT, + PARAM_TYPE_FLOAT, + PARAM_TYPE_BOOL, + PARAM_TYPE_ENUM, + PARAM_TYPE_CODE, + PARAM_TYPE_MODEL_REF, +) + + +@dataclass(frozen=True) +class PortDescriptor: + """A typed attachment point on a node where a connection begins or ends.""" + + name: str # e.g. "in", "out" + port_type: str # one of PORT_TYPES + + +@dataclass(frozen=True) +class ParameterDescriptor: + """A configurable node parameter with type, default, and constraints. + + ``constraints`` keys by parameter type: + - int/float: ``min``, ``max`` + - string/code: ``min_length``, ``max_length``, ``regex`` + - enum: ``values`` (list of allowed values) + - int with a discrete value set: ``values`` + + ``depends_on`` declares conditional visibility: the name of a bool + parameter on the same node type. While that parameter's effective + value is false (or absent), the configuration UI hides this + parameter's control. None (the default) means always visible, so + existing node types are unaffected. + + ``description`` is a concise human-readable explanation of the + parameter — what it is, the expected format, and a short example + value where useful — rendered by the configuration UI as field-level + help. None (the default) keeps older descriptors backward + compatible. + + ``examples`` is a list of working example values for the parameter: + each entry satisfies the parameter's own type and constraints and + can be used verbatim. The configuration UI may offer them as + fill-in suggestions next to the field help. None (the default) + keeps older descriptors backward compatible. + """ + + name: str + param_type: str # one of PARAMETER_TYPES + required: bool + default: Any | None = None + constraints: dict = field(default_factory=dict) + depends_on: str | None = None + description: str | None = None + examples: list | None = None + + +@dataclass(frozen=True) +class GstMapping: + """How a node type is realized on one target architecture. + + ``element_chain`` is an ordered list of ``{"factory": str, + "args_template": dict}`` entries. Argument template values may contain + ``{placeholder}`` tokens resolved by the compiler from node parameter + values or the compile context (e.g. ``{triton_model_repo}``, + ``{dio_script_path}``, ``{dataset_location}``). + + Executor-level nodes (no GStreamer element) use an empty + ``element_chain`` and set ``executor_binding`` to the binding name the + LocalServer WorkflowExecutor processes (e.g. ``"mqtt_publish"``). + + ``plugin_dependencies`` names every GStreamer plugin (or ``python:`` + runtime dependency) the mapping relies on; the compiler subtracts the + per-arch LocalServer-bundled manifest to obtain the set that must be + packaged with the Workflow_Component (Requirement 6.4). + """ + + arch: str # one of ARCHITECTURES + element_chain: list = field(default_factory=list) + executor_binding: str | None = None + plugin_dependencies: list = field(default_factory=list) + + +@dataclass(frozen=True) +class NodeTypeDescriptor: + """Full declaration of a workflow node type (Requirement 2.8).""" + + type_id: str + category: str # one of CATEGORIES + display_name: str + inputs: list # list[PortDescriptor] + outputs: list # list[PortDescriptor] + parameters: list # list[ParameterDescriptor] + mappings: list # list[GstMapping] + hardware_dependent: bool # drives test-runner stubbing (Requirement 12.6) + + def mapping_for(self, arch: str) -> GstMapping | None: + """Return this node type's mapping for ``arch``, or None.""" + for mapping in self.mappings: + if mapping.arch == arch: + return mapping + return None diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py new file mode 100644 index 00000000..023a1825 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/catalog/nodes.py @@ -0,0 +1,1059 @@ +"""The initial node type catalog (Requirements 2.1-2.5, 2.8). + +The catalog is data, not code: a list of NodeTypeDescriptor records. +GStreamer factories and argument shapes mirror the existing LocalServer +builder (src/backend/gstreamer/pipeline_builder.py) so compiled pipelines +run through the same element dialect GstPipelineManager already executes. + +Argument templates use ``{placeholder}`` tokens resolved at compile time: + - node parameter names (e.g. ``{device}``, ``{modelName}``) + - compile context values (``{triton_model_repo}``, ``{triton_server_path}``, + ``{dio_script_path}``, ``{dio_config_json}``, ``{python_handler_path}``, + ``{dataset_location}``, ``{capture_meta}``) +""" + +from __future__ import annotations + +from .models import ( + ARCH_ARM64_JP4, + ARCH_ARM64_JP5, + ARCH_ARM64_JP6, + ARCH_SIM, + ARCH_X86_64, + ARCH_X86_64_NVIDIA, + ARCHITECTURES, + DEVICE_ARCHITECTURES, + SIM_RECORDING_BINDING_PREFIX, + CATEGORY_INFERENCE, + CATEGORY_INPUT, + CATEGORY_OUTPUT, + CATEGORY_POST_PROCESSING, + CATEGORY_PREPROCESSING, + GstMapping, + NodeTypeDescriptor, + ParameterDescriptor, + PortDescriptor, + PORT_TYPE_EVENT_SIGNAL, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES, +) + + +def _element(factory, **args_template): + return {"factory": factory, "args_template": dict(args_template)} + + +#: The rule-expression language shared by every executor-evaluated +#: ``condition`` parameter (inference_filter, conditional, +#: digital_output). This documents exactly what the shared condition +#: evaluator (LocalServer workflow engine and the test sandbox mirror) +#: supports — keep in sync with ``output_bindings.evaluate_condition``. +CONDITION_LANGUAGE_DESCRIPTION = ( + "Rule expression over the inference metadata fields is_anomalous " + "(true/false; for object-detection models true when at least one " + "object was detected) and confidence (number; the model's " + "confidence score, for object detection the top detection's " + "confidence). Supports the comparisons ==, !=, >=, <=, >, <, the " + "logic operators && (and), || (or), ! (not), and parentheses; " + "values are numbers (e.g. 0.8), 'quoted strings', or true/false; " + "a bare field name is tested for truth. " + "E.g. is_anomalous == true && confidence >= 0.8" +) + +#: Working example expressions for every ``condition`` parameter, using +#: only the metadata fields the shared evaluator resolves (is_anomalous, +#: confidence). For object-detection (e.g. YOLO) models, is_anomalous is +#: true when at least one object was detected and confidence is the top +#: detection's confidence, so the same expressions cover detected / +#: not-detected routing. +CONDITION_EXAMPLES = ( + "is_anomalous == true", + "confidence >= 0.8 && is_anomalous == true", + "is_anomalous == true || confidence < 0.5", + "!(is_anomalous == true)", +) + + +def _same_on_all_archs(element_chain=None, executor_binding=None, plugin_dependencies=None): + """One identical GstMapping per architecture.""" + return [ + GstMapping( + arch=arch, + element_chain=list(element_chain or []), + executor_binding=executor_binding, + plugin_dependencies=list(plugin_dependencies or []), + ) + for arch in ARCHITECTURES + ] + + +def _same_on_device_archs(element_chain=None, executor_binding=None, plugin_dependencies=None): + """One identical GstMapping per physical device architecture. + + Used by hardware-dependent node types whose ``sim`` mapping is a + recording stub rather than the device realization (Requirement 12.6). + """ + return [ + GstMapping( + arch=arch, + element_chain=list(element_chain or []), + executor_binding=executor_binding, + plugin_dependencies=list(plugin_dependencies or []), + ) + for arch in DEVICE_ARCHITECTURES + ] + + +def _recording_binding(node_type_id): + """The sim-architecture recording stub for a hardware output node: + an executor binding that records would-be actuations to the test + run's recording log instead of any endpoint (Requirement 12.6).""" + return GstMapping( + arch=ARCH_SIM, + executor_binding=SIM_RECORDING_BINDING_PREFIX + node_type_id, + ) + + +def _dataset_fed_sim_source(): + """The sim-architecture stub for a hardware frame source: fed from + the Test_Dataset via ``multifilesrc location={dataset_location}`` + (resolved by the test harness), decoding with stock GStreamer + elements only — no device paths, no DDA elements (Requirement 12.6). + Shared by camera_source and folder_source.""" + return GstMapping( + arch=ARCH_SIM, + element_chain=[ + _element("multifilesrc", location="{dataset_location}"), + _element("jpegparse"), + _element("jpegdec", **{"idct-method": 2}), + _element("videoconvert"), + ], + plugin_dependencies=["multifile", "jpeg", "videoconvertscale"], + ) + + +# -------------------------------------------------------------------------- +# Shared element chains +# -------------------------------------------------------------------------- + +# Standard JPEG file decode chain used by the existing builder +# (filesrc ! emexifextract ! jpegparse ! jpegdec ! videoconvert ! videoflip). +def _jpeg_file_chain(location_template): + return [ + _element("filesrc", blocksize=-1, location=location_template), + _element("emexifextract"), + _element("jpegparse"), + _element("jpegdec", **{"idct-method": 2}), + _element("videoconvert"), + _element("videoflip", method="automatic"), + ] + + +# JetPack 6 avoids GStreamer's libjpeg-based jpegdec (libdlr.so libjpeg +# collision); the executor stages a Pillow-decoded PNG and reads it via +# pngdec, matching pipeline_builder._add_file_image_source's JP6 path. +def _jp6_png_staged_chain(location_template): + return [ + _element("filesrc", blocksize=-1, location=location_template), + _element("pngdec"), + _element("videoconvert"), + ] + + +# -------------------------------------------------------------------------- +# Input node types (Requirement 2.1) +# -------------------------------------------------------------------------- + +CAMERA_SOURCE = NodeTypeDescriptor( + type_id="camera_source", + category=CATEGORY_INPUT, + display_name="Camera Source", + inputs=[], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("device", "string", required=False, default="/dev/video0", + constraints={"min_length": 1}, + description="Camera device path on the edge " + "device, e.g. /dev/video0.", + examples=["/dev/video0", "/dev/video1"]), + ParameterDescriptor("gain", "int", required=False, default=4, + constraints={"min": 0, "max": 100}, + description="Sensor gain (0-100). Higher values " + "brighten the image but add noise; " + "e.g. 4.", + examples=[4, 10]), + ParameterDescriptor("exposure", "int", required=False, default=5000000, + constraints={"min": 0}, + description="Sensor exposure time in " + "nanoseconds, e.g. 5000000 (5 ms).", + examples=[5000000, 16000000]), + ], + mappings=[ + # USB/V4L2 camera on generic x86_64 devices. + GstMapping( + arch=ARCH_X86_64, + element_chain=[ + _element("v4l2src", device="{device}"), + _element("videoconvert"), + ], + plugin_dependencies=["video4linux2", "videoconvertscale"], + ), + # x86_64 with the NVIDIA GPU runtime: same V4L2 capture path as + # plain x86_64 (an NVIDIA-accelerated chain may be declared later). + GstMapping( + arch=ARCH_X86_64_NVIDIA, + element_chain=[ + _element("v4l2src", device="{device}"), + _element("videoconvert"), + ], + plugin_dependencies=["video4linux2", "videoconvertscale"], + ), + # Existing appsrc-fed camera path on JetPack 4/5 (frames pushed by + # the LocalServer camera adapter, as in _add_camera_image_source). + GstMapping( + arch=ARCH_ARM64_JP4, + element_chain=[ + _element("appsrc", name="appsrc"), + _element("videoconvert"), + ], + plugin_dependencies=["app", "videoconvertscale"], + ), + GstMapping( + arch=ARCH_ARM64_JP5, + element_chain=[ + _element("appsrc", name="appsrc"), + _element("videoconvert"), + ], + plugin_dependencies=["app", "videoconvertscale"], + ), + # JP6 NVIDIA CSI host-service file capture path (PNG staged). + GstMapping( + arch=ARCH_ARM64_JP6, + element_chain=_jp6_png_staged_chain("/aws_dda/nvidia-csi-capture/latest.jpg.dda_decoded.png"), + plugin_dependencies=["coreelements", "png", "videoconvertscale", "python:pillow"], + ), + # Simulation: fed from the Test_Dataset (Requirement 12.6). + _dataset_fed_sim_source(), + ], + hardware_dependent=True, +) + +ARAVIS_CAMERA_SOURCE = NodeTypeDescriptor( + type_id="aravis_camera_source", + category=CATEGORY_INPUT, + display_name="Aravis Camera Source", + inputs=[], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("camera_id", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Aravis (GenICam) camera identifier " + "as enumerated on the edge device, " + "e.g. Aravis-Fake-GV01 or " + "Basler-12345678.", + examples=["Aravis-Fake-GV01", "Basler-12345678"]), + ParameterDescriptor("gain", "int", required=False, default=4, + constraints={"min": 0, "max": 100}, + description="Sensor gain (0-100) applied through " + "the camera manager. Higher values " + "brighten the image but add noise; " + "e.g. 4.", + examples=[4, 10]), + ParameterDescriptor("exposure", "int", required=False, default=5000000, + constraints={"min": 0}, + description="Sensor exposure time in nanoseconds " + "applied through the camera manager, " + "e.g. 5000000 (5 ms).", + examples=[5000000, 16000000]), + ], + # Aravis acquisition happens in the LocalServer process through the + # camera manager (no aravissrc element ships in the DDA images): + # every physical architecture compiles an appsrc-headed chain the + # executor feeds a camera-manager-grabbed frame into, the classic + # Camera-type Frame_Feed model. The appsrc name is compile-time + # rendered per node ({nodeId} derived by the compiler) so + # multi-camera documents stay addressable. Simulation: fed from the + # Test_Dataset like camera_source (Requirement 12.6). + mappings=_same_on_device_archs( + element_chain=[ + _element("appsrc", name="appsrc_{nodeId}"), + _element("videoconvert"), + ], + plugin_dependencies=["app", "videoconvertscale"], + ) + [_dataset_fed_sim_source()], + hardware_dependent=True, +) + +FOLDER_SOURCE = NodeTypeDescriptor( + type_id="folder_source", + category=CATEGORY_INPUT, + display_name="Folder Source", + inputs=[], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("location", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Path of the image file (or folder " + "of files) to read on the device, " + "e.g. /aws_dda/images/latest.jpg.", + examples=["/aws_dda/images/latest.jpg", + "/aws_dda/captures"]), + ParameterDescriptor("file_pattern", "string", required=False, default="*.jpg", + constraints={"min_length": 1}, + description="Glob pattern selecting which files " + "in the folder are read, e.g. *.jpg.", + examples=["*.jpg", "line1_*.jpg"]), + ], + mappings=[ + GstMapping(arch=ARCH_X86_64, element_chain=_jpeg_file_chain("{location}"), + plugin_dependencies=["coreelements", "emexifextract", "jpeg", + "videoconvertscale", "videofilter"]), + # x86_64 with the NVIDIA GPU runtime mirrors the plain x86_64 chain. + GstMapping(arch=ARCH_X86_64_NVIDIA, element_chain=_jpeg_file_chain("{location}"), + plugin_dependencies=["coreelements", "emexifextract", "jpeg", + "videoconvertscale", "videofilter"]), + GstMapping(arch=ARCH_ARM64_JP4, element_chain=_jpeg_file_chain("{location}"), + plugin_dependencies=["coreelements", "emexifextract", "jpeg", + "videoconvertscale", "videofilter"]), + GstMapping(arch=ARCH_ARM64_JP5, element_chain=_jpeg_file_chain("{location}"), + plugin_dependencies=["coreelements", "emexifextract", "jpeg", + "videoconvertscale", "videofilter"]), + # JP6 PNG staging path (see _jp6_png_staged_chain). + GstMapping(arch=ARCH_ARM64_JP6, element_chain=_jp6_png_staged_chain("{location}"), + plugin_dependencies=["coreelements", "png", "videoconvertscale", + "python:pillow"]), + # Simulation: fed from the Test_Dataset like camera_source, never + # from the device file system — the location path and the DDA + # decode elements (emexifextract) do not exist in the sandbox + # (Requirement 12.6). + _dataset_fed_sim_source(), + ], + # Reads the device-local file system (a camera adapter drop folder in + # practice), which is absent in the cloud sandbox: test runs feed it + # from the Test_Dataset instead, so it is hardware-dependent for + # simulation purposes (Requirement 12.6). + hardware_dependent=True, +) + +DIGITAL_INPUT = NodeTypeDescriptor( + type_id="digital_input", + category=CATEGORY_INPUT, + display_name="Digital Input", + inputs=[], + outputs=[PortDescriptor("out", PORT_TYPE_EVENT_SIGNAL)], + parameters=[ + ParameterDescriptor("pin", "int", required=True, default=None, + constraints={"min": 0, "max": 255}, + description="GPIO input pin number to watch " + "(0-255), e.g. 7.", + examples=[7, 18]), + ParameterDescriptor("trigger_edge", "enum", required=False, default="rising", + constraints={"values": ["rising", "falling", "both"]}, + description="Signal edge that fires the trigger: " + "rising (low to high), falling (high " + "to low), or both.", + examples=["rising", "falling"]), + ParameterDescriptor("poll_interval_ms", "int", required=False, default=100, + constraints={"min": 10, "max": 60000}, + description="How often the pin is sampled, in " + "milliseconds (10-60000), e.g. 100.", + examples=[100, 500]), + ], + # GPIO poll adapter runs at the executor level, not as a GStreamer + # element. Simulation: an appsrc event source the test harness feeds + # from the Test_Dataset instead of polling GPIO (Requirement 12.6). + mappings=_same_on_device_archs(executor_binding="digital_input") + [ + GstMapping( + arch=ARCH_SIM, + element_chain=[_element("appsrc", name="{sim_source_name}")], + plugin_dependencies=["app"], + ), + ], + hardware_dependent=True, +) + +# -------------------------------------------------------------------------- +# Preprocessing node types (Requirement 2.2) +# -------------------------------------------------------------------------- + +DEWARP = NodeTypeDescriptor( + type_id="dewarp", + category=CATEGORY_PREPROCESSING, + display_name="Dewarp", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("mode", "enum", required=False, default="fisheye", + constraints={"values": ["fisheye", "barrel", "perspective"]}, + description="Lens distortion model to correct: " + "fisheye, barrel, or perspective.", + examples=["fisheye", "barrel"]), + ParameterDescriptor("strength", "float", required=False, default=0.5, + constraints={"min": 0.0, "max": 1.0}, + description="Correction strength from 0.0 (none) " + "to 1.0 (maximum), e.g. 0.5.", + examples=[0.5, 0.8]), + ], + # OpenCV-based dewarp plugin delivered as a packaged dependency + # (not in the LocalServer-bundled manifest). + mappings=_same_on_all_archs( + element_chain=[_element("dewarp", mode="{mode}", strength="{strength}")], + plugin_dependencies=["dda-dewarp"], + ), + hardware_dependent=False, +) + +ROTATE = NodeTypeDescriptor( + type_id="rotate", + category=CATEGORY_PREPROCESSING, + display_name="Rotate / Flip", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("method", "enum", required=True, default="clockwise", + constraints={"values": ["clockwise", "rotate-180", + "counterclockwise", "horizontal-flip", + "vertical-flip", "automatic"]}, + description="Rotation or flip applied to every " + "frame; automatic follows the image " + "orientation metadata.", + examples=["clockwise", "rotate-180", "automatic"]), + ], + mappings=_same_on_all_archs( + element_chain=[_element("videoflip", method="{method}")], + plugin_dependencies=["videofilter"], + ), + hardware_dependent=False, +) + +CROP = NodeTypeDescriptor( + type_id="crop", + category=CATEGORY_PREPROCESSING, + display_name="Crop", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("top", "int", required=False, default=0, constraints={"min": 0}, + description="Pixels cropped from the top edge, " + "e.g. 100.", + examples=[100]), + ParameterDescriptor("bottom", "int", required=False, default=0, constraints={"min": 0}, + description="Pixels cropped from the bottom " + "edge, e.g. 100.", + examples=[100]), + ParameterDescriptor("left", "int", required=False, default=0, constraints={"min": 0}, + description="Pixels cropped from the left edge, " + "e.g. 50.", + examples=[50]), + ParameterDescriptor("right", "int", required=False, default=0, constraints={"min": 0}, + description="Pixels cropped from the right edge, " + "e.g. 50.", + examples=[50]), + ], + mappings=_same_on_all_archs( + element_chain=[_element("videocrop", top="{top}", bottom="{bottom}", + left="{left}", right="{right}")], + plugin_dependencies=["videocrop"], + ), + hardware_dependent=False, +) + +FORMAT_CONVERT = NodeTypeDescriptor( + type_id="format_convert", + category=CATEGORY_PREPROCESSING, + display_name="Format Convert", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("format", "enum", required=True, default="RGB", + constraints={"values": ["RGB", "RGBA", "BGR", "GRAY8", + "NV12", "I420"]}, + description="Pixel format frames are converted " + "to, e.g. RGB (what most models " + "expect).", + examples=["RGB", "GRAY8"]), + ], + mappings=_same_on_all_archs( + element_chain=[ + _element("videoconvert"), + _element("capsfilter", caps="video/x-raw,format={format}"), + ], + plugin_dependencies=["videoconvertscale", "coreelements"], + ), + hardware_dependent=False, +) + +CUSTOM_PYTHON_PREPROCESS = NodeTypeDescriptor( + type_id="custom_python_preprocess", + category=CATEGORY_PREPROCESSING, + display_name="Custom Python (Frames)", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_VIDEO_FRAMES)], + parameters=[ + ParameterDescriptor("code", "code", required=True, default=None, + constraints={"min_length": 1}, + description="Python run for every video frame. " + "Define process_frame(frame, " + "metadata) and return the processed " + "frame; frame is a NumPy uint8 array " + "(rows x cols x channels) and " + "cv2/np are pre-imported. Return " + "None to pass the frame through. " + "Helpers: import dda_frames for " + "frame_info(), load_image(path or " + "s3:// URI), to_array(), to_bytes().", + examples=["def process_frame(frame, metadata):\n" + " return cv2.GaussianBlur(frame, (5, 5), 0)"]), + ParameterDescriptor("requirements", "string", required=False, default="", + constraints={}, + description="Extra pip packages the code needs, " + "one per line in requirements.txt " + "form.", + examples=["scikit-image==0.24.0"]), + ], + # Same emlpython bridge element and packaged plugin dependency as the + # custom_python post-processing node (Requirement 1.4); the compiler + # derives {python_handler_path} per node id, so no compiler changes. + mappings=_same_on_all_archs( + element_chain=[_element("emlpython", **{"handler-path": "{python_handler_path}"})], + plugin_dependencies=["dda-emlpython"], + ), + hardware_dependent=False, +) + +# -------------------------------------------------------------------------- +# Model inference node type (Requirement 2.3) +# -------------------------------------------------------------------------- + +MODEL_INFERENCE = NodeTypeDescriptor( + type_id="model_inference", + category=CATEGORY_INFERENCE, + display_name="Model Inference", + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[PortDescriptor("out", PORT_TYPE_INFERENCE_META)], + parameters=[ + # Populated from the portal model registry for the selected + # Use_Case (Requirement 2.6). + ParameterDescriptor("modelName", "model_ref", required=True, default=None, + constraints={"min_length": 1}, + description="Model to run on each frame, chosen " + "from the models registered for the " + "selected use case.", + examples=["widget-anomaly-v3"]), + ], + # Mirrors pipeline_builder._add_inference_plugins: preceding RGB + # capsfilter, then emltriton with the Triton repo/server paths + # LocalServer uses (Requirement 6.2). Device architectures only: + # the proprietary emltriton plugin does not exist in the cloud test + # sandbox and registered models are device-compiled, so simulation + # stubs the node with a pass-through chain (the RGB capsfilter plus + # an identity element named ``sim_inference_`` the test + # harness recognizes) that keeps the stream flowing while the + # configured simulated inference outcome is injected as the node's + # metadata (Requirement 12.6). + mappings=_same_on_device_archs( + element_chain=[ + _element("capsfilter", caps="video/x-raw,format=RGB"), + _element("emltriton", + **{"model-repo": "{triton_model_repo}", + "server-path": "{triton_server_path}", + "model": "{modelName}"}), + ], + plugin_dependencies=["coreelements", "emltriton"], + ) + [ + GstMapping( + arch=ARCH_SIM, + element_chain=[ + _element("capsfilter", caps="video/x-raw,format=RGB"), + _element("identity", name="{sim_inference_name}"), + ], + plugin_dependencies=["coreelements"], + ), + ], + # The model executes only on a device (Jetson-compiled artifacts, + # proprietary Triton plugin): simulation compiles the sim stub above. + hardware_dependent=True, +) + +#: Default comparison prompt for the Bedrock Inference node. The model +#: must answer with the JSON shape the shared condition evaluator +#: consumes ({is_anomalous, confidence}). +BEDROCK_DEFAULT_PROMPT = ( + "Compare the input image to the reference image. Respond with JSON: " + '{"is_anomalous": true|false, "confidence": 0..1} where is_anomalous ' + "is true when the input meaningfully differs from the reference." +) + +BEDROCK_INFERENCE = NodeTypeDescriptor( + type_id="bedrock_inference", + category=CATEGORY_INFERENCE, + display_name="Bedrock Inference", + # Two VideoFrames inputs: the frame under inspection and a reference + # image the model compares it against per the configured prompt. + inputs=[ + PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES), + PortDescriptor("reference", PORT_TYPE_VIDEO_FRAMES), + ], + outputs=[PortDescriptor("out", PORT_TYPE_INFERENCE_META)], + parameters=[ + ParameterDescriptor("model", "enum", required=False, + default="us.amazon.nova-lite-v1:0", + constraints={"values": [ + "us.amazon.nova-pro-v1:0", + "us.amazon.nova-lite-v1:0", + "qwen.qwen3-vl-235b-a22b", + "moonshotai.kimi-k2.5", + ]}, + description="Bedrock multimodal model invoked " + "with the input frame and the " + "reference image, e.g. " + "us.amazon.nova-lite-v1:0.", + examples=["us.amazon.nova-lite-v1:0", + "us.amazon.nova-pro-v1:0"]), + ParameterDescriptor("prompt", "string", required=True, + default=BEDROCK_DEFAULT_PROMPT, + constraints={"min_length": 1}, + description="Instruction sent to the model with " + "both images. The model must answer " + "with JSON of the shape " + '{"is_anomalous": true|false, ' + '"confidence": 0..1}; those fields ' + "become the inference metadata " + "(is_anomalous, confidence) driving " + "downstream filters, conditionals, " + "and outputs.", + examples=[BEDROCK_DEFAULT_PROMPT]), + ParameterDescriptor("region", "string", required=False, + default="us-east-1", + constraints={"min_length": 1}, + description="AWS region of the Bedrock runtime " + "endpoint the device calls, e.g. " + "us-east-1.", + examples=["us-east-1", "us-west-2"]), + ParameterDescriptor("max_tokens", "int", required=False, default=256, + constraints={"min": 1, "max": 4096}, + description="Maximum tokens the model may " + "generate for its JSON answer, " + "e.g. 256.", + examples=[256, 512]), + ], + # Executor-level on every physical device architecture: the node has + # no GStreamer element of its own. The compiler terminates each + # VideoFrames input branch in a synthetic frame-capture sink chain + # (videoconvert ! jpegenc ! multifilesink location={work_dir}/...) + # and emits a "bedrock_inference" executor binding carrying the + # parameters plus the per-port capture file paths; after a successful + # pipeline run the LocalServer executor reads the two captured + # frames, calls the Bedrock runtime (network + AWS credentials + # required on the device), and merges the parsed {is_anomalous, + # confidence} into the run's inference metadata. Because frames stop + # at the capture sinks, the node's InferenceMeta output must feed + # only executor-level consumers (filters, conditionals, hardware + # outputs) on device architectures. + # + # Simulation: the cloud sandbox VPC has no internet, so the node is + # stubbed exactly like model_inference — a pass-through chain (RGB + # capsfilter + identity named ``sim_inference_``) the test + # harness recognizes; the configured simulated inference outcome is + # injected as the node's metadata and the model is never invoked + # (Requirement 12.6). + mappings=_same_on_device_archs( + executor_binding="bedrock_inference", + plugin_dependencies=["videoconvertscale", "jpeg", "multifile", + "python:boto3"], + ) + [ + GstMapping( + arch=ARCH_SIM, + element_chain=[ + _element("capsfilter", caps="video/x-raw,format=RGB"), + _element("identity", name="{sim_inference_name}"), + ], + plugin_dependencies=["coreelements"], + ), + ], + # Needs device-side network access and AWS credentials; simulation + # compiles the sim stub above (Requirement 12.6). + hardware_dependent=True, +) + +# -------------------------------------------------------------------------- +# Post-processing node types (Requirement 2.4) +# -------------------------------------------------------------------------- + +CUSTOM_PYTHON = NodeTypeDescriptor( + type_id="custom_python", + category=CATEGORY_POST_PROCESSING, + display_name="Custom Python", + # Default port typing; overridden per node instance via the declared + # input/output port type parameters (Requirement 2.7). + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + outputs=[PortDescriptor("out", PORT_TYPE_INFERENCE_META)], + parameters=[ + ParameterDescriptor("code", "code", required=True, default=None, + constraints={"min_length": 1}, + description="Python run for every item passing " + "through the node. Define " + "process_frame(frame, metadata) to " + "work with video frames as NumPy " + "arrays (cv2/np pre-imported; import " + "dda_frames for helpers), or " + "handle(frame_bytes, metadata) -> " + "(frame_bytes, metadata) to work " + "with raw bytes.", + examples=["def process_frame(frame, metadata):\n" + " return frame", + "def handle(frame_bytes, metadata):\n" + " return frame_bytes, metadata"]), + ParameterDescriptor("requirements", "string", required=False, default="", + constraints={}, + description="Extra pip packages the code needs, " + "one per line in requirements.txt " + "form, e.g. numpy==1.24.0.", + examples=["numpy==1.24.0", + "numpy==1.24.0\nopencv-python-headless==4.8.0.74"]), + ParameterDescriptor("input_port_type", "enum", required=True, + default=PORT_TYPE_INFERENCE_META, + constraints={"values": [PORT_TYPE_VIDEO_FRAMES, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_EVENT_SIGNAL]}, + description="Type of data this node accepts on " + "its input port: VideoFrames, " + "InferenceMeta, or EventSignal.", + examples=[PORT_TYPE_INFERENCE_META]), + ParameterDescriptor("output_port_type", "enum", required=True, + default=PORT_TYPE_INFERENCE_META, + constraints={"values": [PORT_TYPE_VIDEO_FRAMES, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_EVENT_SIGNAL]}, + description="Type of data this node emits on its " + "output port: VideoFrames, " + "InferenceMeta, or EventSignal.", + examples=[PORT_TYPE_INFERENCE_META]), + ], + # emlpython bridge element invoking user code (appsink/appsrc pair + # managed by the executor); delivered as a packaged dependency. + mappings=_same_on_all_archs( + element_chain=[_element("emlpython", **{"handler-path": "{python_handler_path}"})], + plugin_dependencies=["dda-emlpython"], + ), + hardware_dependent=False, +) + +INFERENCE_FILTER = NodeTypeDescriptor( + type_id="inference_filter", + category=CATEGORY_POST_PROCESSING, + display_name="Inference Filter", + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + outputs=[PortDescriptor("out", PORT_TYPE_INFERENCE_META)], + parameters=[ + # Rule expression over inference metadata, e.g. + # "is_anomalous == true && confidence >= 0.8". + ParameterDescriptor("condition", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Inference results continue " + "downstream only while this " + "condition holds. " + + CONDITION_LANGUAGE_DESCRIPTION, + examples=list(CONDITION_EXAMPLES)), + ], + # Executor-evaluated condition over inference metadata. + mappings=_same_on_all_archs(executor_binding="inference_filter"), + hardware_dependent=False, +) + +CONDITIONAL = NodeTypeDescriptor( + type_id="conditional", + category=CATEGORY_POST_PROCESSING, + display_name="Conditional", + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + # Two-path routing over the same condition grammar as + # inference_filter: the "true" output receives the inference + # metadata when the condition holds, the "false" output when it does + # not — e.g. a green andon light on normal results and a red one on + # anomalies. + outputs=[ + PortDescriptor("true", PORT_TYPE_INFERENCE_META), + PortDescriptor("false", PORT_TYPE_INFERENCE_META), + ], + parameters=[ + # Rule expression over inference metadata (same grammar as + # inference_filter), e.g. "is_anomalous == true". + ParameterDescriptor("condition", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Routes each inference result to " + "one of the two outputs: the 'true' " + "output receives it when this " + "condition holds, the 'false' " + "output when it does not. " + + CONDITION_LANGUAGE_DESCRIPTION, + examples=list(CONDITION_EXAMPLES)), + ], + # Executor-evaluated condition over inference metadata: downstream of + # the "true" output is gated by the condition, downstream of the + # "false" output by its negation (compiler portConditions). + mappings=_same_on_all_archs(executor_binding="conditional"), + hardware_dependent=False, +) + +# -------------------------------------------------------------------------- +# Output node types (Requirement 2.5) +# -------------------------------------------------------------------------- + +DIGITAL_OUTPUT = NodeTypeDescriptor( + type_id="digital_output", + category=CATEGORY_OUTPUT, + display_name="Digital Output", + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + outputs=[], + parameters=[ + ParameterDescriptor("pin", "int", required=True, default=None, + constraints={"min": 0, "max": 255}, + description="GPIO output pin number to actuate " + "(0-255), e.g. 5.", + examples=[5, 12]), + ParameterDescriptor("signal_type", "enum", required=True, default="pulse", + constraints={"values": ["high", "low", "pulse"]}, + description="Signal written to the pin: high or " + "low latches the level; pulse sets " + "high then resets after the pulse " + "width.", + examples=["pulse", "high"]), + ParameterDescriptor("pulse_width_ms", "int", required=False, default=100, + constraints={"min": 1, "max": 60000}, + description="Pulse duration in milliseconds " + "(used with signal type pulse), " + "e.g. 100.", + examples=[100, 250]), + # Rule over inference metadata gating actuation (Requirement 9.4). + ParameterDescriptor("condition", "string", required=True, default=None, + constraints={"min_length": 1}, + description="The pin is actuated only when this " + "condition holds. " + + CONDITION_LANGUAGE_DESCRIPTION, + examples=list(CONDITION_EXAMPLES)), + ], + # Existing emoutputevent element (see pipeline_builder._add_output_plugins). + # Simulation: recording binding, no GPIO actuation (Requirement 12.6). + mappings=_same_on_device_archs( + element_chain=[_element("emoutputevent", + **{"script-path": "{dio_script_path}", + "config": "{dio_config_json}"})], + plugin_dependencies=["emoutputevent"], + ) + [_recording_binding("digital_output")], + hardware_dependent=True, +) + +MQTT_PUBLISH = NodeTypeDescriptor( + type_id="mqtt_publish", + category=CATEGORY_OUTPUT, + display_name="MQTT Publish", + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + outputs=[], + parameters=[ + ParameterDescriptor("broker_host", "string", required=True, default=None, + constraints={"min_length": 1}, + description="MQTT broker hostname or IP, e.g. " + "10.0.0.12 or broker.local.", + examples=["10.0.0.12", "broker.local"]), + ParameterDescriptor("broker_port", "int", required=False, default=1883, + constraints={"min": 1, "max": 65535}, + description="MQTT broker TCP port, e.g. 1883 " + "(plain MQTT) or 8883 (TLS).", + examples=[1883, 8883]), + ParameterDescriptor("topic", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Topic the message is published to, " + "e.g. factory/line1/inspection.", + examples=["factory/line1/inspection"]), + ParameterDescriptor("payload_template", "string", required=False, + default="{inference_json}", constraints={}, + description="Message payload. Placeholders in " + "curly braces are replaced from the " + "inference metadata: " + "{inference_json} (full metadata as " + "JSON), {is_anomalous}, and " + "{confidence}.", + examples=["{inference_json}", + "anomaly={is_anomalous} " + "confidence={confidence}"]), + ParameterDescriptor("qos", "enum", required=False, default=0, + constraints={"values": [0, 1, 2]}, + description="MQTT quality of service: 0 (at " + "most once), 1 (at least once), or " + "2 (exactly once; AWS IoT Core " + "supports up to 1).", + examples=[0, 1]), + # AWS IoT Core publishing over mutual TLS. Certificates are + # referenced by file paths on the device (e.g. /greengrass/v2/...), + # never uploaded to the portal. The iot_* fields are shown in the + # config panel only while aws_iot is enabled (depends_on). + ParameterDescriptor("aws_iot", "bool", required=False, default=False, + constraints={}, + description="Publish to AWS IoT Core over " + "mutual TLS instead of a plain MQTT " + "broker; enables the IoT thing name " + "and certificate path fields.", + examples=[True]), + ParameterDescriptor("iot_thing_name", "string", required=False, + default=None, constraints={"min_length": 1}, + depends_on="aws_iot", + description="AWS IoT thing name used as the " + "MQTT client id, e.g. " + "dda-edge-device-01.", + examples=["dda-edge-device-01"]), + # Amazon root CA path on the device. + ParameterDescriptor("iot_ca_cert_path", "string", required=False, + default=None, constraints={"min_length": 1}, + depends_on="aws_iot", + description="Path of the Amazon root CA " + "certificate on the device, e.g. " + "/greengrass/v2/rootCA.pem.", + examples=["/greengrass/v2/rootCA.pem"]), + ParameterDescriptor("iot_client_cert_path", "string", required=False, + default=None, constraints={"min_length": 1}, + depends_on="aws_iot", + description="Path of the device client " + "certificate on the device, e.g. " + "/greengrass/v2/thingCert.crt.", + examples=["/greengrass/v2/thingCert.crt"]), + ParameterDescriptor("iot_private_key_path", "string", required=False, + default=None, constraints={"min_length": 1}, + depends_on="aws_iot", + description="Path of the device private key on " + "the device, e.g. " + "/greengrass/v2/privKey.key.", + examples=["/greengrass/v2/privKey.key"]), + ], + # Executor-level MQTT client publish on pipeline completion. + # Simulation: recording binding, no broker contact (Requirement 12.6). + mappings=_same_on_device_archs( + executor_binding="mqtt_publish", + plugin_dependencies=["python:paho-mqtt"], + ) + [_recording_binding("mqtt_publish")], + hardware_dependent=True, +) + +OPCUA_WRITE = NodeTypeDescriptor( + type_id="opcua_write", + category=CATEGORY_OUTPUT, + display_name="OPC UA Write", + inputs=[PortDescriptor("in", PORT_TYPE_INFERENCE_META)], + outputs=[], + parameters=[ + ParameterDescriptor("endpoint", "string", required=True, default=None, + constraints={"min_length": 1, + "regex": r"^opc\.tcp://.+"}, + description="OPC UA server endpoint URL, e.g. " + "opc.tcp://192.168.1.20:4840.", + examples=["opc.tcp://192.168.1.20:4840"]), + ParameterDescriptor("node_id", "string", required=True, default=None, + constraints={"min_length": 1}, + description="OPC UA node id the value is " + "written to, e.g. " + "ns=2;s=Machine1.Reject.", + examples=["ns=2;s=Machine1.Reject"]), + ParameterDescriptor("value_template", "string", required=False, + default="{is_anomalous}", constraints={}, + description="Value written to the node. " + "Placeholders in curly braces are " + "replaced from the inference " + "metadata: {is_anomalous}, " + "{confidence}, or {inference_json}; " + "a single placeholder keeps its " + "native type.", + examples=["{is_anomalous}", "{confidence}"]), + ], + # Executor-level OPC UA client write; the opcua Python lib is a + # packaged dependency (not bundled with LocalServer). + # Simulation: recording binding, no server contact (Requirement 12.6). + mappings=_same_on_device_archs( + executor_binding="opcua_write", + plugin_dependencies=["python:opcua"], + ) + [_recording_binding("opcua_write")], + hardware_dependent=True, +) + +CAPTURE = NodeTypeDescriptor( + type_id="capture", + category=CATEGORY_OUTPUT, + display_name="Capture to File System", + # VideoFrames input; InferenceMeta outputs also connect here via the + # declared InferenceMeta -> VideoFrames coercion (same buffer stream + # with attached metadata). + inputs=[PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES)], + outputs=[], + parameters=[ + ParameterDescriptor("output_path", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Folder on the device file system " + "where captured JPEG images are " + "written, e.g. /aws_dda/captures.", + examples=["/aws_dda/captures"]), + ParameterDescriptor("interval", "int", required=False, default=0, + constraints={"min": 0}, + description="Capture every Nth frame; 0 " + "captures every frame, e.g. 10 " + "keeps one frame in ten.", + examples=[0, 10]), + ParameterDescriptor("quality", "int", required=False, default=100, + constraints={"min": 1, "max": 100}, + description="JPEG encoding quality from 1 " + "(smallest file) to 100 (best " + "image), e.g. 85.", + examples=[85, 100]), + ], + # Existing jpegenc ! emlcapture chain + # (see pipeline_builder._add_post_processing_plugins). + mappings=_same_on_all_archs( + element_chain=[ + _element("jpegenc", **{"idct-method": 2, "quality": "{quality}"}), + _element("emlcapture", + **{"buffer-message-id": "file-target_{output_path}-jpg", + "interval": "{interval}", + "meta": "{capture_meta}"}), + ], + plugin_dependencies=["jpeg", "emlcapture"], + ), + hardware_dependent=False, +) + +# -------------------------------------------------------------------------- +# Catalog access +# -------------------------------------------------------------------------- + +NODE_CATALOG = ( + CAMERA_SOURCE, + ARAVIS_CAMERA_SOURCE, + FOLDER_SOURCE, + DIGITAL_INPUT, + DEWARP, + ROTATE, + CROP, + FORMAT_CONVERT, + CUSTOM_PYTHON_PREPROCESS, + MODEL_INFERENCE, + BEDROCK_INFERENCE, + CUSTOM_PYTHON, + INFERENCE_FILTER, + CONDITIONAL, + DIGITAL_OUTPUT, + MQTT_PUBLISH, + OPCUA_WRITE, + CAPTURE, +) + +_CATALOG_BY_ID = {descriptor.type_id: descriptor for descriptor in NODE_CATALOG} + + +def get_node_type(type_id: str) -> NodeTypeDescriptor | None: + """Look up a node type descriptor by its type id, or None.""" + return _CATALOG_BY_ID.get(type_id) + + +def nodes_by_category() -> dict: + """Catalog grouped by category, preserving catalog order — the shape + the Node_Palette consumes (Requirement 1.1).""" + grouped = {} + for descriptor in NODE_CATALOG: + grouped.setdefault(descriptor.category, []).append(descriptor) + return grouped diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/__init__.py new file mode 100644 index 00000000..43223571 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/__init__.py @@ -0,0 +1,38 @@ +"""Workflow_Compiler: compiles validated graphs to Compiled Pipeline Documents. + +Topologically sorts the DAG, emits one element chain (or executor binding) +per node, linearizes connections with tee/queue for fan-out, and computes +plugin dependencies per target architecture (Requirements 6.1-6.6). + +``compile(graph, target_arch, context, simulation)`` returns a +:class:`CompiledPipelineDocument` on success or the complete list of +:class:`CompileError` records on failure. + +With ``simulation=True``, hardware-dependent nodes (per the catalog flag) +map to recording stubs — dataset-fed sources via ``multifilesrc``/``appsrc`` +and ``recording_*`` executor bindings for hardware outputs — while +non-hardware nodes compile identically to non-simulation output +(Requirement 12.6). +""" + +from .models import ( + CODE_UNMAPPED_ARCHITECTURE, + CODE_VALIDATION_ERROR, + COMPILED_DOCUMENT_SCHEMA_VERSION, + CompileContext, + CompiledPipelineDocument, + CompileError, + DEFAULT_CONTEXT_VALUES, +) +from .compiler import compile + +__all__ = [ + "compile", + "CompileContext", + "CompiledPipelineDocument", + "CompileError", + "CODE_VALIDATION_ERROR", + "CODE_UNMAPPED_ARCHITECTURE", + "COMPILED_DOCUMENT_SCHEMA_VERSION", + "DEFAULT_CONTEXT_VALUES", +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/compiler.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/compiler.py new file mode 100644 index 00000000..5ca14603 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/compiler.py @@ -0,0 +1,665 @@ +"""compile(): valid workflow graphs -> Compiled Pipeline Documents. + +Algorithm (design section 5, Requirements 6.1-6.6): + +1. Re-run the validator; refuse to compile when any error-severity + finding exists. +2. Resolve every node's GstMapping for the target architecture; node + types lacking a usable mapping yield CompileError{nodeId, arch} + (Requirement 6.5). +3. Topologically sort the stream DAG (GStreamer-mapped nodes; executor + -level nodes are collapsed out of the stream but kept as executor + bindings). +4. Emit one element chain per GStreamer node, tagged with its nodeId, + contiguously in exactly one segment (Requirements 6.1, 6.6). Model + inference nodes carry the emltriton chain configured with the model + name and the Triton repo/server paths (Requirement 6.2). +5. Linearize connections: fan-out gets ``tee name=t`` with a + ``queue`` at the head of each branch (Requirement 6.3); fan-in gets a + named ``funnel`` the converging branches link into. +6. pluginDependencies = union of the used mappings' declared + dependencies minus the per-arch LocalServer-bundled set + (Requirement 6.4). + +Simulation mode (Requirement 12.6): ``compile(..., simulation=True)`` +resolves hardware-dependent node types (per the catalog flag) to their +``sim``-architecture recording stubs — dataset-fed sources +(``multifilesrc``/``appsrc``) for hardware inputs and ``recording_*`` +executor bindings for hardware outputs — while every other node resolves +its ``target_arch`` mapping exactly as in non-simulation compilation, so +non-hardware nodes compile identically. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Sequence, Union + +from ..catalog import ARCH_SIM, ARCHITECTURES, NODE_CATALOG, bundled_plugins_for +from ..catalog.models import GstMapping, NodeTypeDescriptor +from ..serializer.models import Node, WorkflowGraph +from ..validator import SEVERITY_ERROR, validate +from .models import ( + CODE_UNMAPPED_ARCHITECTURE, + CODE_VALIDATION_ERROR, + CompileContext, + CompiledPipelineDocument, + CompileError, + DEFAULT_CONTEXT_VALUES, +) + +__all__ = ["compile"] + +#: The two-path routing executor binding (the conditional node): the +#: compiler emits per-port gate conditions for it ("true" = the +#: configured condition, "false" = its negation), so downstream executor +#: bindings of each output port are gated exactly like downstream of an +#: inference_filter. +BINDING_CONDITIONAL = "conditional" + +#: The Bedrock comparison-inference executor binding (the +#: bedrock_inference node on device architectures). Unlike other +#: executor-level nodes, its two VideoFrames input branches TERMINATE in +#: the pipeline: the compiler appends one synthetic frame-capture sink +#: chain (``videoconvert ! jpegenc ! multifilesink``) per feeding +#: GStreamer branch so each branch ends in a real sink that persists the +#: latest frame, and the emitted binding carries the per-input-port +#: capture file paths (``capturePaths``). The paths are rooted at the +#: ``{work_dir}`` placeholder, resolved by the LocalServer executor per +#: run (the same lenient-placeholder mechanism as ``{dataset_location}`` +#: for the test harness). Frames do NOT flow through the node, so +#: pipeline-element consumers downstream of its InferenceMeta output are +#: never fed on device architectures (metadata-level executor consumers +#: — filters, conditionals, hardware outputs — are the supported +#: downstream). In simulation the node resolves to its sim stub chain +#: (identity pass-through) and none of this applies. +BINDING_BEDROCK_INFERENCE = "bedrock_inference" + + +def compile( + graph: WorkflowGraph, + target_arch: str, + context: Optional[CompileContext] = None, + simulation: bool = False, + catalog: Sequence[NodeTypeDescriptor] = NODE_CATALOG, +) -> Union[CompiledPipelineDocument, List[CompileError]]: + """Compile ``graph`` for ``target_arch``. + + With ``simulation=True``, hardware-dependent node types resolve their + ``sim``-architecture recording stubs instead of the ``target_arch`` + mapping; all other nodes compile identically to non-simulation output + (Requirement 12.6). + + ``catalog`` is the effective Node_Type_Catalog to compile against — + by default the built-in ``NODE_CATALOG``; portal callers pass the + merged tuple from ``resolve_catalog`` so workflows may use registered + Custom_Node_Types (custom-node-designer Requirements 5.4, 8.6). The + compiler treats built-in and custom descriptors identically: custom + plugin dependencies flow into ``pluginDependencies``, and a node type + without a mapping for ``target_arch`` yields the standard + ``CompileError{nodeId, arch}``. + + Returns a :class:`CompiledPipelineDocument` on success, or the + complete list of :class:`CompileError` records on failure (validation + errors, or nodes without a mapping for the architecture). + """ + context = context or CompileContext() + descriptors_by_id: Dict[str, NodeTypeDescriptor] = { + descriptor.type_id: descriptor for descriptor in catalog + } + + # 1. Re-run validation; refuse to compile on errors (Requirement 6.1). + validation_errors = [ + finding for finding in validate(graph, catalog) + if finding.severity == SEVERITY_ERROR + ] + if validation_errors: + return [ + CompileError( + CODE_VALIDATION_ERROR, + finding.message, + node_id=finding.node_id, + connection_id=finding.connection_id, + ) + for finding in validation_errors + ] + + # 2. Resolve mappings; collect every unmapped node (Requirement 6.5). + # In simulation mode, hardware-dependent node types resolve their + # sim-architecture recording stubs instead (Requirement 12.6); an + # unknown target architecture stays an error for every node. + stub_hardware = simulation and target_arch in ARCHITECTURES + mappings: Dict[str, GstMapping] = {} + unmapped: List[CompileError] = [] + for node in graph.nodes: + descriptor = descriptors_by_id[node.type] # known: validation passed + node_arch = ( + ARCH_SIM + if stub_hardware and descriptor.hardware_dependent + else target_arch + ) + mapping = descriptor.mapping_for(node_arch) + if mapping is None or (not mapping.element_chain and not mapping.executor_binding): + unmapped.append(CompileError( + CODE_UNMAPPED_ARCHITECTURE, + "Node '{0}' (type '{1}') has no GStreamer mapping for " + "architecture '{2}'".format(node.id, node.type, node_arch), + node_id=node.id, + arch=node_arch, + )) + else: + mappings[node.id] = mapping + if unmapped: + return unmapped + + nodes_by_id = {node.id: node for node in graph.nodes} + gst_node_ids = [n.id for n in graph.nodes if mappings[n.id].element_chain] + executor_node_ids = [n.id for n in graph.nodes if not mappings[n.id].element_chain] + + # Node-level adjacency from connections (deduplicated, order-stable). + successors = _node_successors(graph) + + # 3. Collapse executor-level nodes out of the stream and + # topologically sort the remaining GStreamer nodes. + # Bedrock inference nodes (executor-level realization) are opaque: + # frames terminate at their synthetic capture sinks instead of + # flowing through to downstream pipeline elements. + gst_set = set(gst_node_ids) + bedrock_node_ids = [ + n.id for n in graph.nodes + if mappings[n.id].executor_binding == BINDING_BEDROCK_INFERENCE + ] + opaque = set(bedrock_node_ids) + stream_out = { + node_id: _stream_successors(node_id, successors, gst_set, opaque) + for node_id in gst_node_ids + } + stream_in: Dict[str, List[str]] = {node_id: [] for node_id in gst_node_ids} + for source, targets in stream_out.items(): + for target in targets: + stream_in[target].append(source) + topo_order = _topological_sort(gst_node_ids, stream_out, stream_in) + + # Bedrock frame captures: one synthetic sink chain per GStreamer + # branch feeding a bedrock_inference input port, plus the per-port + # capture file paths for each node's executor binding. + feeder_captures, bedrock_capture_paths = _bedrock_capture_plan( + graph, bedrock_node_ids, gst_set, opaque, descriptors_by_id + ) + + # 4./5. Emit tagged element chains linearized into segments. + chains = { + node_id: _resolve_chain( + nodes_by_id[node_id], + descriptors_by_id[nodes_by_id[node_id].type], + mappings[node_id], + context, + ) + for node_id in gst_node_ids + } + segments = _build_segments( + topo_order, stream_out, stream_in, chains, feeder_captures + ) + + # Executor bindings, one entry per executor-level node (Requirement 6.6). + predecessors = _node_predecessors(graph) + port_successors = _node_port_successors(graph) + executor_bindings = [] + for node_id in executor_node_ids: + node = nodes_by_id[node_id] + descriptor = descriptors_by_id[node.type] + parameters = _effective_parameters(node, descriptor) + entry = { + "nodeId": node_id, + "binding": mappings[node_id].executor_binding, + "parameters": parameters, + "upstreamNodeIds": predecessors.get(node_id, []), + "downstreamNodeIds": successors.get(node_id, []), + } + # Multi-output executor nodes additionally record which + # downstream nodes hang off which output port, so the executors + # can gate each side independently (the conditional node). + if len(descriptor.outputs) > 1: + by_port = port_successors.get(node_id, {}) + entry["downstreamNodeIdsByPort"] = { + port.name: by_port.get(port.name, []) + for port in descriptor.outputs + } + # The conditional node gates each output port with a condition: + # the "true" port with the configured condition, the "false" port + # with its negation — composed exactly as the shared condition + # evaluator parses it (unary '!'). + if mappings[node_id].executor_binding == BINDING_CONDITIONAL: + condition = str(parameters.get("condition") or "") + entry["portConditions"] = { + "true": condition, + "false": "!({0})".format(condition), + } + # Bedrock inference bindings carry the per-input-port capture + # file paths their frame branches sink to ({work_dir}-rooted, + # resolved by the LocalServer executor per run). + if mappings[node_id].executor_binding == BINDING_BEDROCK_INFERENCE: + entry["capturePaths"] = bedrock_capture_paths.get(node_id, {}) + executor_bindings.append(entry) + + # 6. Plugin dependencies beyond the LocalServer-bundled set + # (Requirement 6.4). + declared = set() + for mapping in mappings.values(): + declared.update(mapping.plugin_dependencies) + plugin_dependencies = sorted(declared - bundled_plugins_for(target_arch)) + + return CompiledPipelineDocument( + workflow_id=context.workflow_id, + workflow_version=context.workflow_version, + target_arch=target_arch, + segments=segments, + executor_bindings=executor_bindings, + plugin_dependencies=plugin_dependencies, + ) + + +# -------------------------------------------------------------------------- +# Graph adjacency +# -------------------------------------------------------------------------- + +def _node_successors(graph: WorkflowGraph) -> Dict[str, List[str]]: + """Node id -> deduplicated, connection-ordered successor node ids.""" + successors: Dict[str, List[str]] = {node.id: [] for node in graph.nodes} + for connection in graph.connections: + source, target = connection.source.node, connection.target.node + if source in successors and target in successors: + if target not in successors[source]: + successors[source].append(target) + return successors + + +def _node_port_successors(graph: WorkflowGraph) -> Dict[str, Dict[str, List[str]]]: + """Node id -> output port name -> deduplicated, connection-ordered + successor node ids (the per-port refinement of _node_successors, + used for multi-output executor nodes).""" + known = {node.id for node in graph.nodes} + by_port: Dict[str, Dict[str, List[str]]] = {} + for connection in graph.connections: + source, target = connection.source.node, connection.target.node + if source in known and target in known: + targets = by_port.setdefault(source, {}).setdefault( + connection.source.port, []) + if target not in targets: + targets.append(target) + return by_port + + +def _node_predecessors(graph: WorkflowGraph) -> Dict[str, List[str]]: + """Node id -> deduplicated, connection-ordered predecessor node ids.""" + predecessors: Dict[str, List[str]] = {node.id: [] for node in graph.nodes} + for connection in graph.connections: + source, target = connection.source.node, connection.target.node + if source in predecessors and target in predecessors: + if source not in predecessors[target]: + predecessors[target].append(source) + return predecessors + + +def _stream_successors( + node_id: str, + successors: Dict[str, List[str]], + gst_set: set, + opaque: Optional[set] = None, +) -> List[str]: + """The GStreamer nodes ``node_id`` streams into, looking through + executor-level nodes (which have no pipeline elements) — except + ``opaque`` nodes (bedrock_inference), where the frame stream + terminates in the node's synthetic capture sink.""" + opaque = opaque or set() + result: List[str] = [] + seen = {node_id} + frontier = list(successors.get(node_id, [])) + while frontier: + current = frontier.pop(0) + if current in seen: + continue + seen.add(current) + if current in gst_set: + if current not in result: + result.append(current) + elif current not in opaque: + frontier.extend(successors.get(current, [])) + return result + + +# -------------------------------------------------------------------------- +# Bedrock inference frame captures +# -------------------------------------------------------------------------- + +_UNSAFE_PATH_CHARS = re.compile(r"[^A-Za-z0-9_.-]") + + +def _frame_feeders( + graph: WorkflowGraph, + node_id: str, + port_name: str, + gst_set: set, + opaque: set, +) -> List[str]: + """The GStreamer nodes whose frames reach input port ``port_name`` + of ``node_id``, looking upstream through executor-level nodes (the + buffer stream passes through them) but never through other opaque + frame-terminating nodes.""" + frontier = [ + connection.source.node for connection in graph.connections + if connection.target.node == node_id + and connection.target.port == port_name + ] + feeders: List[str] = [] + seen = set() + while frontier: + current = frontier.pop(0) + if current in seen or current == node_id: + continue + seen.add(current) + if current in gst_set: + if current not in feeders: + feeders.append(current) + elif current not in opaque: + frontier.extend( + connection.source.node for connection in graph.connections + if connection.target.node == current + ) + return feeders + + +def _bedrock_capture_plan( + graph: WorkflowGraph, + bedrock_node_ids: List[str], + gst_set: set, + opaque: set, + descriptors_by_id: Dict[str, NodeTypeDescriptor], +): + """Plan the synthetic frame-capture sinks for bedrock_inference nodes. + + Returns ``(feeder_captures, capture_paths_by_node)``: + + - ``feeder_captures``: GStreamer feeder node id -> capture file path. + Every branch feeding any bedrock input port ends in exactly one + capture sink chain persisting its latest frame; a feeder serving + several ports (or several bedrock nodes) shares its single file — + the frames are the same stream. + - ``capture_paths_by_node``: bedrock node id -> {input port name: + capture path, or None when nothing feeds the port}. When several + branches feed one port, the first (connection-ordered) feeder's + file is used. + + Paths are rooted at the ``{work_dir}`` placeholder the LocalServer + executor resolves per run; feeder node ids are sanitized to a safe + file-name form (collisions disambiguated with a numeric suffix). + """ + feeder_captures: Dict[str, str] = {} + used_names: set = set() + capture_paths_by_node: Dict[str, Dict[str, Optional[str]]] = {} + + def path_for(feeder_id: str) -> str: + if feeder_id not in feeder_captures: + base = _UNSAFE_PATH_CHARS.sub("_", feeder_id) or "node" + name = base + suffix = 0 + while name in used_names: + suffix += 1 + name = "{0}_{1}".format(base, suffix) + used_names.add(name) + feeder_captures[feeder_id] = ( + "{work_dir}/bedrock_frame_" + name + ".jpg" + ) + return feeder_captures[feeder_id] + + for node_id in bedrock_node_ids: + descriptor = descriptors_by_id[graph.node_by_id(node_id).type] + ports: Dict[str, Optional[str]] = {} + for port in descriptor.inputs: + feeders = _frame_feeders(graph, node_id, port.name, gst_set, opaque) + ports[port.name] = path_for(feeders[0]) if feeders else None + capture_paths_by_node[node_id] = ports + + return feeder_captures, capture_paths_by_node + + +def _capture_chain(path: str) -> List[dict]: + """The synthetic frame-capture sink chain terminating a bedrock + feeder branch: raw frames converted, JPEG-encoded, and written to + ``path`` (multifilesink without a printf index rewrites the same + file per buffer, so the latest frame persists for the executor).""" + return [ + _synthetic("videoconvert"), + _synthetic("jpegenc"), + _synthetic("multifilesink", {"location": path}), + ] + + +def _topological_sort( + node_ids: List[str], + stream_out: Dict[str, List[str]], + stream_in: Dict[str, List[str]], +) -> List[str]: + """Kahn's algorithm; deterministic (seeded in graph node order).""" + in_degree = {node_id: len(stream_in[node_id]) for node_id in node_ids} + ready = [node_id for node_id in node_ids if in_degree[node_id] == 0] + order: List[str] = [] + while ready: + current = ready.pop(0) + order.append(current) + for target in stream_out[current]: + in_degree[target] -= 1 + if in_degree[target] == 0: + ready.append(target) + # The validator guarantees acyclicity (V3), so everything is ordered. + return order + + +# -------------------------------------------------------------------------- +# Element chain resolution +# -------------------------------------------------------------------------- + +_SINGLE_PLACEHOLDER = re.compile(r"^\{(\w+)\}$") + + +class _LenientDict(dict): + """Leaves unknown ``{placeholder}`` tokens intact for later + resolution (e.g. device-local paths supplied by the edge renderer).""" + + def __missing__(self, key: str) -> str: + return "{" + key + "}" + + +def _effective_parameters(node: Node, descriptor: NodeTypeDescriptor) -> Dict[str, Any]: + """Declared defaults overlaid with the node's explicit values (the + same effective-value rule the validator's V4 check applies).""" + values = { + parameter.name: parameter.default for parameter in descriptor.parameters + } + values.update(node.parameters) + return values + + +def _derived_values(node: Node, parameters: Dict[str, Any]) -> Dict[str, Any]: + """Placeholder values the compiler derives per node.""" + derived: Dict[str, Any] = { + # The node's own id, for per-node element naming in mapping + # templates (e.g. aravis_camera_source's appsrc name + # ``appsrc_{nodeId}``) so multi-node documents render unique + # element names. + "nodeId": node.id, + # Custom Python handler artifact path (packaging layout, task 7.1). + "python_handler_path": "python/{0}/handler.py".format(node.id), + # Per-node element name for simulation stub sources so the test + # harness can feed each one from the Test_Dataset (task 4.2). + "sim_source_name": "sim_source_{0}".format(node.id), + # Per-node element name for the simulation model-inference stub + # (an identity pass-through) so the test harness can identify + # stubbed inference nodes and inject the configured simulated + # inference outcome as their metadata (Requirement 12.6). + "sim_inference_name": "sim_inference_{0}".format(node.id), + # Per-node element name for the custom-node pass-through recording + # stub substituted when a Custom_Node_Type has no x86_64 + # Plugin_Artifact, so the test harness can identify the stubbed + # nodes in the test run report (custom-node-designer + # Requirement 12.2; the stub mapping is built by the test-runner + # compile step in workflow_test_steps.py). + "custom_stub_name": "custom_stub_{0}".format(node.id), + } + dio_keys = ("pin", "signal_type", "pulse_width_ms", "condition") + dio_config = {key: parameters[key] for key in dio_keys if key in parameters} + if dio_config: + derived["dio_config_json"] = json.dumps( + dio_config, sort_keys=True, separators=(",", ":") + ) + return derived + + +def _resolve_chain( + node: Node, + descriptor: NodeTypeDescriptor, + mapping: GstMapping, + context: CompileContext, +) -> List[dict]: + """Resolve a mapping's element chain templates into concrete + elements, each tagged with the originating nodeId (Requirement 6.6).""" + parameters = _effective_parameters(node, descriptor) + resolution = _LenientDict(DEFAULT_CONTEXT_VALUES) + resolution.update(_derived_values(node, parameters)) + resolution.update(context.values) + resolution.update(parameters) + + elements = [] + for template in mapping.element_chain: + args = { + name: _resolve_value(value, resolution) + for name, value in template["args_template"].items() + } + elements.append({ + "nodeId": node.id, + "factory": template["factory"], + "args": args, + }) + return elements + + +def _resolve_value(value: Any, resolution: Dict[str, Any]) -> Any: + """Resolve one argument template value. + + A template that is exactly one placeholder keeps the referenced + value's native type (ints stay ints); mixed templates are formatted + as strings. Non-string values pass through unchanged. + """ + if not isinstance(value, str): + return value + match = _SINGLE_PLACEHOLDER.match(value) + if match and match.group(1) in resolution: + return resolution[match.group(1)] + return value.format_map(resolution) + + +def _synthetic(factory: str, args: Optional[dict] = None) -> dict: + """A linking element (tee/queue/funnel) not originating from any node.""" + return {"nodeId": None, "factory": factory, "args": dict(args or {})} + + +# -------------------------------------------------------------------------- +# Segment linearization (Requirements 6.1, 6.3) +# -------------------------------------------------------------------------- + +def _build_segments( + topo_order: List[str], + stream_out: Dict[str, List[str]], + stream_in: Dict[str, List[str]], + chains: Dict[str, List[dict]], + feeder_captures: Optional[Dict[str, str]] = None, +) -> List[dict]: + """Linearize the stream DAG into named segments. + + - A run of single-in/single-out nodes shares one segment. + - Fan-out appends ``tee name=t``; each branch is a new segment + referencing the tee (``"from"``) and starting with ``queue`` + (Requirement 6.3). + - Fan-in nodes start their own segment headed by a named ``funnel``; + converging branches carry ``"linkTo"`` naming that funnel. + - A node in ``feeder_captures`` (it feeds a bedrock_inference input + port) additionally sinks its frames to the planned capture file: + inline at the end of its branch when the branch otherwise + terminates there, or as an extra queue-headed tee branch when the + stream also continues downstream. + """ + feeder_captures = feeder_captures or {} + segments: List[dict] = [] + placed = set() + counters = {"tee": 0, "segment": 0} + + # Fan-in nodes get a funnel name up front so any branch can link to it. + funnel_names = {} + for node_id in topo_order: + if len(stream_in[node_id]) > 1: + funnel_names[node_id] = "f{0}".format(len(funnel_names)) + + def new_segment(from_ref: Optional[str] = None) -> dict: + segment = { + "name": "s{0}".format(counters["segment"]), + "from": from_ref, + "linkTo": None, + "elements": [], + } + counters["segment"] += 1 + segments.append(segment) + return segment + + def extend(node_id: str, segment: dict) -> None: + """Append ``node_id``'s chain and continue downstream.""" + placed.add(node_id) + segment["elements"].extend(chains[node_id]) + downstream = stream_out[node_id] + capture_path = feeder_captures.get(node_id) + if not downstream: + # A bedrock feeder whose branch ends here: the capture sink + # chain terminates the branch inline (no tee). + if capture_path: + segment["elements"].extend(_capture_chain(capture_path)) + return + if len(downstream) == 1 and not capture_path: + target = downstream[0] + if target in funnel_names: + segment["linkTo"] = funnel_names[target] + else: + extend(target, segment) + return + # Fan-out (or downstream continuation plus a bedrock capture): + # tee, then one queue-headed branch segment per target. + tee_name = "t{0}".format(counters["tee"]) + counters["tee"] += 1 + segment["elements"].append(_synthetic("tee", {"name": tee_name})) + for target in downstream: + branch = new_segment(from_ref=tee_name) + branch["elements"].append(_synthetic("queue")) + if target in funnel_names: + branch["linkTo"] = funnel_names[target] + else: + extend(target, branch) + if capture_path: + branch = new_segment(from_ref=tee_name) + branch["elements"].append(_synthetic("queue")) + branch["elements"].extend(_capture_chain(capture_path)) + + for node_id in topo_order: + if node_id in placed: + continue + fan_in = len(stream_in[node_id]) + if fan_in == 0: + extend(node_id, new_segment()) + elif fan_in > 1: + segment = new_segment() + segment["elements"].append( + _synthetic("funnel", {"name": funnel_names[node_id]}) + ) + extend(node_id, segment) + # fan_in == 1 nodes are placed while extending their upstream, + # which precedes them in topological order. + + return segments diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/models.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/models.py new file mode 100644 index 00000000..d47ae978 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/compiler/models.py @@ -0,0 +1,159 @@ +"""Data models for the Workflow_Compiler output. + +``compile()`` returns either a :class:`CompiledPipelineDocument` or a +list of :class:`CompileError` records (validation failures or node types +lacking a GStreamer mapping on the target architecture — Requirement 6.5). + +The Compiled Pipeline Document is the JSON artifact LocalServer renders +into a ``gst-launch``-style string: + +- ``segments``: ordered element runs. Each segment's elements are joined + with ``" ! "``; a segment with ``"from": "t0"`` hangs off the tee named + ``t0`` (rendered as ``t0. ! ...``); a segment with ``"linkTo": "f1"`` + feeds the funnel named ``f1`` (rendered as ``... ! f1.``). +- Every element carries the ``nodeId`` of the workflow node it realizes; + synthetic linking elements (``tee``, ``queue``, ``funnel``) carry + ``nodeId: null``. Each node's element chain appears contiguously in + exactly one segment (Requirement 6.6). +- ``executorBindings``: entries for executor-level nodes (digital input, + MQTT publish, OPC UA write, inference filter, conditional) that have no + GStreamer elements; each node appears exactly once here instead. + Multi-output executor nodes additionally carry + ``downstreamNodeIdsByPort`` (output port name -> downstream node ids); + the ``conditional`` binding also carries ``portConditions`` — the gate + condition per output port ("true" = the configured condition, + "false" = its negation ``!(condition)``). +- ``pluginDependencies``: GStreamer plugins / Python runtime packages the + pipeline needs beyond those bundled with LocalServer (Requirement 6.4). +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +#: Schema version of the Compiled Pipeline Document format. +COMPILED_DOCUMENT_SCHEMA_VERSION = 1 + +# -------------------------------------------------------------------------- +# Compile error codes +# -------------------------------------------------------------------------- + +#: The graph has validation errors; compilation is refused (Requirement 6.1). +CODE_VALIDATION_ERROR = "VALIDATION_ERROR" + +#: A node type has no usable GStreamer mapping for the target +#: architecture (Requirement 6.5). +CODE_UNMAPPED_ARCHITECTURE = "UNMAPPED_ARCHITECTURE" + + +@dataclass(frozen=True) +class CompileError: + """One compilation error. + + ``node_id`` and ``arch`` identify the failing node and the unsupported + architecture for :data:`CODE_UNMAPPED_ARCHITECTURE` errors + (Requirement 6.5). Validation-derived errors carry the offending + node or connection id from the underlying finding. + """ + + code: str + message: str + node_id: Optional[str] = None + connection_id: Optional[str] = None + arch: Optional[str] = None + + def to_dict(self) -> dict: + return { + "code": self.code, + "message": self.message, + "nodeId": self.node_id, + "connectionId": self.connection_id, + "arch": self.arch, + } + + +# -------------------------------------------------------------------------- +# Compile context +# -------------------------------------------------------------------------- + +#: Default placeholder values resolved into element argument templates. +#: ``triton_model_repo`` / ``triton_server_path`` mirror the paths the +#: existing LocalServer builder passes to emltriton +#: (src/backend/dda_triton/constants.py) — Requirement 6.2. +DEFAULT_CONTEXT_VALUES = { + "triton_model_repo": "/aws_dda/dda_triton/triton_model_repo", + "triton_server_path": "/opt/tritonserver", + "capture_meta": "", +} + + +@dataclass(frozen=True) +class CompileContext: + """Ambient values for compilation. + + ``values`` supplies (or overrides) ``{placeholder}`` tokens used by + catalog argument templates beyond node parameters, e.g. + ``triton_model_repo``, ``dio_script_path``, ``dataset_location``. + Placeholders with no value available are left untouched in the output + so the edge renderer / test harness can resolve device-local paths. + """ + + workflow_id: str = "" + workflow_version: str = "" + values: Dict[str, Any] = field(default_factory=dict) + + +# -------------------------------------------------------------------------- +# Compiled Pipeline Document +# -------------------------------------------------------------------------- + +@dataclass +class CompiledPipelineDocument: + """The compiler's JSON output (Requirements 6.1-6.6).""" + + workflow_id: str + workflow_version: str + target_arch: str + segments: List[dict] = field(default_factory=list) + executor_bindings: List[dict] = field(default_factory=list) + plugin_dependencies: List[str] = field(default_factory=list) + schema_version: int = COMPILED_DOCUMENT_SCHEMA_VERSION + + def to_dict(self) -> dict: + return { + "schemaVersion": self.schema_version, + "workflowId": self.workflow_id, + "workflowVersion": self.workflow_version, + "targetArch": self.target_arch, + "segments": self.segments, + "executorBindings": self.executor_bindings, + "pluginDependencies": list(self.plugin_dependencies), + } + + def to_json(self) -> str: + """Canonical JSON form (sorted keys, 2-space indent, ASCII).""" + return json.dumps(self.to_dict(), sort_keys=True, indent=2, ensure_ascii=True) + + def referenced_node_ids(self) -> List[str]: + """Every node reference in the document, one entry per element + chain occurrence or executor binding. + + Because each node's chain is emitted contiguously in exactly one + segment, contiguous runs of the same ``nodeId`` count as a single + reference; synthetic elements (``nodeId: null``) are skipped. A + correct document lists every graph node exactly once + (Requirement 6.6). + """ + references: List[str] = [] + for segment in self.segments: + previous: Any = object() + for element in segment["elements"]: + node_id = element["nodeId"] + if node_id is not None and node_id != previous: + references.append(node_id) + previous = node_id + for binding in self.executor_bindings: + references.append(binding["nodeId"]) + return references diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/scaffold.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/scaffold.py new file mode 100644 index 00000000..48c6d90e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/scaffold.py @@ -0,0 +1,833 @@ +"""Plugin_Scaffold rendering and validation (custom-node-designer). + +Pure template rendering, usable in tests without AWS (design, "Plugin_Scaffold +and Node_Generator"): given a validated Custom_Node_Type declaration (name, +category, ports, parameters, architectures), :func:`render_scaffold` renders a +GStreamer plugin project as a file map ``{path: content}``: + +- a C skeleton element (``plugin/gst.c``) wrapping an embedded Python + Frame_Processing_Hook via an internal appsink/appsrc bridge — the same + bridge approach as the existing ``emlpython`` custom-python element + (Requirement 1.3), +- the Frame_Processing_Hook file (``plugin/frame_processing_hook.py``) + exposing ``process_frame(frame, params) -> frame`` where the user writes + the per-frame logic (Requirements 1.2, 1.3), +- one ``meson.build`` build configuration per selected Target_Architecture + (``builds//meson.build``, Requirement 1.2), +- a ``README.md`` describing the project layout, the hook contract, and the + per-architecture build instructions. + +Declared parameters surface as GObject properties on the C element and are +plumbed into the ``params`` dict handed to the hook, keyed by their declared +names (Requirement 1.4). + +:func:`validate_scaffold` rejects non-buildable scaffold source — a missing +Frame_Processing_Hook file, missing build configurations, or empty required +files — with a description of the failure, and accepts every scaffold +produced by :func:`render_scaffold` unchanged (Requirements 1.7, 2.6). + +The declaration is the same node-catalog wire shape accepted by +:func:`workflow_core.catalog.custom.descriptor_from_declaration`, plus an +``architectures`` list naming the selected Target_Architectures (falling back +to the architectures of the declaration's ``mappings`` when absent). +""" + +from __future__ import annotations + +from string import Template +from typing import Any, Mapping, Sequence + +from .catalog.custom import DeclarationError, descriptor_from_declaration +from .catalog.models import ( + DEVICE_ARCHITECTURES, + PARAM_TYPE_BOOL, + PARAM_TYPE_FLOAT, + PARAM_TYPE_INT, + NodeTypeDescriptor, + ParameterDescriptor, +) + +# -------------------------------------------------------------------------- +# Scaffold layout +# -------------------------------------------------------------------------- + +#: Path of the Frame_Processing_Hook file inside every Plugin_Scaffold. +HOOK_FILE = "plugin/frame_processing_hook.py" + +#: Path of the scaffold README. +README_FILE = "README.md" + + +def c_source_path(declaration: Any) -> str: + """Path of the C skeleton element source for ``declaration``.""" + return "plugin/gst{0}.c".format(element_name_for(declaration)) + + +def build_config_path(arch: str) -> str: + """Path of the ``meson.build`` build configuration for ``arch``.""" + return "builds/{0}/meson.build".format(arch) + + +class ScaffoldError(ValueError): + """A Plugin_Scaffold could not be generated or is not buildable. + + ``field`` identifies the failing declaration input when the failure is + an invalid declaration (Requirement 1.7); it is None for scaffold + source validation failures. ``defects`` lists every described failure + found by :func:`validate_scaffold` (Requirement 2.6). + """ + + def __init__(self, message: str, field: str | None = None, + defects: Sequence[str] | None = None): + self.field = field + self.defects = list(defects) if defects is not None else [] + if field is not None: + message = "{0}: {1}".format(field, message) + super().__init__(message) + + +# -------------------------------------------------------------------------- +# Declaration handling +# -------------------------------------------------------------------------- + +def element_name_for(declaration: Any) -> str: + """The GStreamer element (factory) name derived from the declaration. + + Lower-cases the declaration's ``typeId`` and strips everything outside + ``[a-z0-9]``; a leading non-letter is prefixed so the name is a valid + C identifier and element factory name. + """ + if not isinstance(declaration, dict): + raise ScaffoldError("must be an object, got {0}".format( + type(declaration).__name__), field="declaration") + type_id = declaration.get("typeId") + if not isinstance(type_id, str) or not type_id.strip(): + raise ScaffoldError( + "must be a non-empty string, got {0!r}".format(type_id), + field="typeId") + name = "".join(ch for ch in type_id.lower() if ch.isascii() and ch.isalnum()) + if not name: + raise ScaffoldError( + "{0!r} contains no usable characters for a GStreamer element " + "name".format(type_id), field="typeId") + if not name[0].isalpha(): + name = "x" + name + return name + + +def _validated(declaration: Any) -> tuple: + """Validate the declaration, returning ``(descriptor, architectures)``. + + Raises :class:`ScaffoldError` identifying the failing input + (Requirement 1.7). + """ + try: + descriptor = descriptor_from_declaration(declaration) + except DeclarationError as exc: + raise ScaffoldError(str(exc).split(": ", 1)[-1], field=exc.field) + + architectures = declaration.get("architectures") + if architectures is None: + architectures = [m.arch for m in descriptor.mappings + if m.arch in DEVICE_ARCHITECTURES] + if not isinstance(architectures, list): + raise ScaffoldError( + "must be a list, got {0}".format(type(architectures).__name__), + field="architectures") + if not architectures: + raise ScaffoldError( + "at least one Target_Architecture must be selected", + field="architectures") + seen = set() + for index, arch in enumerate(architectures): + field = "architectures[{0}]".format(index) + if arch not in DEVICE_ARCHITECTURES: + raise ScaffoldError( + "unknown Target_Architecture {0!r}; must be one of {1}".format( + arch, list(DEVICE_ARCHITECTURES)), field=field) + if arch in seen: + raise ScaffoldError( + "duplicate Target_Architecture {0!r}".format(arch), field=field) + seen.add(arch) + + # element name derivability is part of declaration validity + element_name_for(declaration) + + return descriptor, list(architectures) + + +# -------------------------------------------------------------------------- +# Public API +# -------------------------------------------------------------------------- + +def render_scaffold(declaration: Any) -> dict: + """Render the Plugin_Scaffold for ``declaration`` as ``{path: content}``. + + Raises :class:`ScaffoldError` identifying the failing input when the + declaration is invalid (Requirement 1.7). + """ + descriptor, architectures = _validated(declaration) + element = element_name_for(declaration) + description = declaration.get("description") or descriptor.display_name + + files = { + HOOK_FILE: _render_hook(descriptor), + c_source_path(declaration): _render_c_source( + descriptor, element, description), + README_FILE: _render_readme( + descriptor, element, description, architectures), + } + for arch in architectures: + files[build_config_path(arch)] = _render_meson(element, arch) + return files + + +def scaffold_defects(files: Any, declaration: Any) -> list: + """Every buildability defect in ``files``, each as a description. + + Checks: presence of the Frame_Processing_Hook file, presence of the + build configuration for every selected Target_Architecture, and + non-emptiness of every required file (hook, C skeleton, build + configurations). An empty list means the scaffold is buildable. + """ + descriptor, architectures = _validated(declaration) + del descriptor + if not isinstance(files, Mapping): + return ["scaffold source must be a file map {path: content}, got " + + type(files).__name__] + + defects = [] + + def _check_required(path: str, label: str, missing_message: str) -> None: + if path not in files: + defects.append(missing_message) + return + content = files[path] + if not isinstance(content, str) or not content.strip(): + defects.append( + "required file '{0}' ({1}) is empty".format(path, label)) + + _check_required( + HOOK_FILE, "Frame_Processing_Hook", + "missing Frame_Processing_Hook file '{0}'".format(HOOK_FILE)) + _check_required( + c_source_path(declaration), "C skeleton element", + "missing C skeleton element source '{0}'".format( + c_source_path(declaration))) + for arch in architectures: + _check_required( + build_config_path(arch), + "build configuration for {0}".format(arch), + "missing build configuration for Target_Architecture '{0}' " + "('{1}')".format(arch, build_config_path(arch))) + return defects + + +def validate_scaffold(files: Any, declaration: Any) -> None: + """Reject non-buildable scaffold source (Requirements 1.7, 2.6). + + Raises :class:`ScaffoldError` describing every failure found; returns + None when ``files`` is a buildable scaffold for ``declaration``. + """ + defects = scaffold_defects(files, declaration) + if defects: + raise ScaffoldError("; ".join(defects), defects=defects) + + +# -------------------------------------------------------------------------- +# Escaping and naming helpers +# -------------------------------------------------------------------------- + +def _c_escape(text: str) -> str: + """Escape ``text`` for use inside a C string literal.""" + out = [] + for ch in str(text): + if ch == "\\": + out.append("\\\\") + elif ch == '"': + out.append('\\"') + elif ch == "\n": + out.append("\\n") + elif ch == "\t": + out.append("\\t") + elif ch == "\r": + out.append("\\r") + elif ord(ch) < 0x20: + continue # other control characters have no place in metadata + else: + out.append(ch) + return "".join(out) + + +def _c_identifiers(parameters: Sequence[ParameterDescriptor]) -> list: + """A unique, valid C identifier per parameter (declaration order).""" + identifiers = [] + seen = set() + for parameter in parameters: + base = "".join( + ch if (ch.isascii() and ch.isalnum()) else "_" + for ch in parameter.name.lower()) + if not base or not (base[0].isalpha() or base[0] == "_"): + base = "p_" + base + candidate = base + suffix = 2 + while candidate in seen: + candidate = "{0}_{1}".format(base, suffix) + suffix += 1 + seen.add(candidate) + identifiers.append(candidate) + return identifiers + + +def _int_default(parameter: ParameterDescriptor) -> int: + return parameter.default if isinstance(parameter.default, int) and not isinstance(parameter.default, bool) else 0 + + +def _float_default(parameter: ParameterDescriptor) -> float: + if isinstance(parameter.default, (int, float)) and not isinstance(parameter.default, bool): + return float(parameter.default) + return 0.0 + + +# -------------------------------------------------------------------------- +# Frame_Processing_Hook template (Requirements 1.2, 1.3, 1.4) +# -------------------------------------------------------------------------- + +_HOOK_TEMPLATE = Template('''\ +"""Frame_Processing_Hook for ${display_name}. + +Write your per-frame processing logic in :func:`process_frame` below. The +plugin element calls it once for every frame arriving at the node's input +port and emits the returned frame content on the node's output port. + +``frame`` is the raw frame payload (``bytes``); ``params`` is a dict +carrying the current value of every parameter declared on this node type, +keyed by the declared parameter name. +""" + +#: The parameters declared on this node type: every key below is present +#: in the ``params`` dict handed to process_frame (name -> parameter type). +DECLARED_PARAMETERS = { +${declared_parameters} +} + + +def process_frame(frame, params): + """Process one frame and return the frame content to emit. + + Args: + frame: the raw frame bytes arriving at the input port. + params: dict of declared parameter values, keyed by parameter + name${param_hint}. + + Returns: + The frame bytes to emit on the output port. + """ + # TODO: replace this pass-through with your processing logic. + return frame +''') + + +def _render_hook(descriptor: NodeTypeDescriptor) -> str: + declared = "\n".join( + " {0}: {1},".format(repr(parameter.name), repr(parameter.param_type)) + for parameter in descriptor.parameters + ) or " # (no parameters declared)" + param_hint = "" + if descriptor.parameters: + param_hint = " (see DECLARED_PARAMETERS)" + return _HOOK_TEMPLATE.substitute( + display_name=descriptor.display_name, + declared_parameters=declared, + param_hint=param_hint, + ) + + +# -------------------------------------------------------------------------- +# C skeleton element template (Requirements 1.2, 1.3, 1.4) +# -------------------------------------------------------------------------- + +_C_TEMPLATE = Template('''\ +/* gst${element}.c - ${display_name} + * + * Generated by the DDA Node_Designer. Skeleton GStreamer element wrapping + * the embedded Python Frame_Processing_Hook (plugin/frame_processing_hook.py) + * behind an internal appsink/appsrc bridge, the same bridge approach as the + * existing emlpython custom-python element: every buffer arriving on the + * sink pad is pulled from the internal appsink, handed to + * process_frame(frame, params), and the returned frame content is pushed + * on the internal appsrc feeding the source pad. + * + * You normally only need to edit plugin/frame_processing_hook.py; this file + * carries the element boilerplate, the GObject properties for the declared + * parameters, and the params-dict plumbing into the hook. + */ + +#include +#include +#include +#include + +#define PACKAGE "${element}" + +#define GST_TYPE_${element_upper} (gst_${element}_get_type ()) + +typedef struct _Gst${element_camel} +{ + GstBin parent; + + GstElement *appsink; /* internal frame tap (input side) */ + GstElement *appsrc; /* internal frame feed (output side) */ + PyObject *hook_module; /* imported frame_processing_hook */ + + /* Declared parameters, exposed as GObject properties. */ +${property_fields} +} Gst${element_camel}; + +typedef struct _Gst${element_camel}Class +{ + GstBinClass parent_class; +} Gst${element_camel}Class; + +enum +{ + PROP_0${property_enum} +}; + +G_DEFINE_TYPE (Gst${element_camel}, gst_${element}, GST_TYPE_BIN); + +/* -------------------------------------------------------------------- + * Parameter plumbing: build the params dict handed to + * process_frame(frame, params) - one entry per declared parameter, + * keyed by its declared name (Requirement 1.4). + * -------------------------------------------------------------------- */ +static PyObject * +gst_${element}_params_dict (Gst${element_camel} * self) +{ + PyObject *params = PyDict_New (); +${params_dict_body} + return params; +} + +static void +gst_${element}_set_property (GObject * object, guint prop_id, + const GValue * value, GParamSpec * pspec) +{ + Gst${element_camel} *self = (Gst${element_camel} *) object; + + switch (prop_id) { +${set_property_cases} + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +static void +gst_${element}_get_property (GObject * object, guint prop_id, + GValue * value, GParamSpec * pspec) +{ + Gst${element_camel} *self = (Gst${element_camel} *) object; + + switch (prop_id) { +${get_property_cases} + default: + G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec); + break; + } +} + +/* -------------------------------------------------------------------- + * Frame_Processing_Hook bridge: appsink -> Python -> appsrc + * (Requirement 1.3). + * -------------------------------------------------------------------- */ +static GstFlowReturn +gst_${element}_on_new_sample (GstElement * sink, gpointer user_data) +{ + Gst${element_camel} *self = (Gst${element_camel} *) user_data; + GstSample *sample = gst_app_sink_pull_sample (GST_APP_SINK (sink)); + GstBuffer *buffer; + GstMapInfo info; + GstFlowReturn ret = GST_FLOW_ERROR; + + if (sample == NULL) + return GST_FLOW_EOS; + + buffer = gst_sample_get_buffer (sample); + if (buffer != NULL && gst_buffer_map (buffer, &info, GST_MAP_READ)) { + PyGILState_STATE gil = PyGILState_Ensure (); + PyObject *frame = PyBytes_FromStringAndSize ((const char *) info.data, + (Py_ssize_t) info.size); + PyObject *params = gst_${element}_params_dict (self); + PyObject *result = PyObject_CallMethod (self->hook_module, + "process_frame", "OO", frame, params); + + if (result != NULL && PyBytes_Check (result)) { + char *out_data = NULL; + Py_ssize_t out_size = 0; + if (PyBytes_AsStringAndSize (result, &out_data, &out_size) == 0) { + /* allocate + fill (both GStreamer 1.0 APIs) rather than the + * newer one-shot memdup helper, so the plugin links on every + * supported device stack (JetPack 4 ships GStreamer 1.14, + * JetPack 5 ships 1.16). */ + GstBuffer *out = gst_buffer_new_allocate (NULL, (gsize) out_size, NULL); + gst_buffer_fill (out, 0, out_data, (gsize) out_size); + gst_buffer_copy_into (out, buffer, GST_BUFFER_COPY_TIMESTAMPS, 0, -1); + ret = gst_app_src_push_buffer (GST_APP_SRC (self->appsrc), out); + } + } else { + GST_ELEMENT_ERROR (self, STREAM, FAILED, + ("frame_processing_hook.process_frame failed"), (NULL)); + if (PyErr_Occurred ()) + PyErr_Print (); + } + + Py_XDECREF (result); + Py_XDECREF (params); + Py_XDECREF (frame); + PyGILState_Release (gil); + gst_buffer_unmap (buffer, &info); + } + + gst_sample_unref (sample); + return ret; +} + +static void +gst_${element}_class_init (Gst${element_camel}Class * klass) +{ + GObjectClass *gobject_class = G_OBJECT_CLASS (klass); + GstElementClass *element_class = GST_ELEMENT_CLASS (klass); + + gobject_class->set_property = gst_${element}_set_property; + gobject_class->get_property = gst_${element}_get_property; + +${install_properties} + gst_element_class_set_static_metadata (element_class, + "${display_name_c}", "${category_c}", + "${description_c}", + "DDA Node_Designer "); +} + +static void +gst_${element}_init (Gst${element_camel} * self) +{ + GstPad *pad; + + if (!Py_IsInitialized ()) + Py_InitializeEx (0); + + { + PyGILState_STATE gil = PyGILState_Ensure (); + self->hook_module = PyImport_ImportModule ("frame_processing_hook"); + if (self->hook_module == NULL && PyErr_Occurred ()) + PyErr_Print (); + PyGILState_Release (gil); + } + + self->appsink = gst_element_factory_make ("appsink", NULL); + self->appsrc = gst_element_factory_make ("appsrc", NULL); + g_object_set (self->appsink, "emit-signals", TRUE, "sync", FALSE, NULL); + g_signal_connect (self->appsink, "new-sample", + G_CALLBACK (gst_${element}_on_new_sample), self); + + gst_bin_add_many (GST_BIN (self), self->appsink, self->appsrc, NULL); + + pad = gst_element_get_static_pad (self->appsink, "sink"); + gst_element_add_pad (GST_ELEMENT (self), + gst_ghost_pad_new ("sink", pad)); + gst_object_unref (pad); + + pad = gst_element_get_static_pad (self->appsrc, "src"); + gst_element_add_pad (GST_ELEMENT (self), + gst_ghost_pad_new ("src", pad)); + gst_object_unref (pad); +${init_defaults} +} + +static gboolean +plugin_init (GstPlugin * plugin) +{ + return gst_element_register (plugin, "${element}", GST_RANK_NONE, + GST_TYPE_${element_upper}); +} + +GST_PLUGIN_DEFINE (GST_VERSION_MAJOR, GST_VERSION_MINOR, + ${element}, + "${description_c}", + plugin_init, "1.0.0", "LGPL", "${element}", + "https://github.com/awslabs/defect-detection-application") +''') + + +def _property_snippets(parameters: Sequence[ParameterDescriptor]) -> dict: + """Per-parameter C snippets: fields, enum entries, param specs, + set/get cases, params-dict plumbing, and init defaults.""" + identifiers = _c_identifiers(parameters) + fields = [] + enum_entries = [] + installs = [] + set_cases = [] + get_cases = [] + dict_lines = [] + init_defaults = [] + + for parameter, cname in zip(parameters, identifiers): + prop_enum = "PROP_" + cname.upper() + gname = _c_escape(cname.replace("_", "-")) + name_c = _c_escape(parameter.name) + blurb = _c_escape(parameter.description or parameter.name) + enum_entries.append(",\n " + prop_enum) + + if parameter.param_type == PARAM_TYPE_INT: + fields.append(" gint {0};".format(cname)) + installs.append( + " g_object_class_install_property (gobject_class, {0},\n" + " g_param_spec_int (\"{1}\", \"{2}\", \"{3}\",\n" + " G_MININT, G_MAXINT, {4},\n" + " G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));\n" + .format(prop_enum, gname, name_c, blurb, + _int_default(parameter))) + set_cases.append( + " case {0}:\n self->{1} = g_value_get_int (value);\n" + " break;".format(prop_enum, cname)) + get_cases.append( + " case {0}:\n g_value_set_int (value, self->{1});\n" + " break;".format(prop_enum, cname)) + py_value = "PyLong_FromLong (self->{0})".format(cname) + init_defaults.append( + " self->{0} = {1};".format(cname, _int_default(parameter))) + elif parameter.param_type == PARAM_TYPE_FLOAT: + fields.append(" gdouble {0};".format(cname)) + installs.append( + " g_object_class_install_property (gobject_class, {0},\n" + " g_param_spec_double (\"{1}\", \"{2}\", \"{3}\",\n" + " -G_MAXDOUBLE, G_MAXDOUBLE, {4},\n" + " G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));\n" + .format(prop_enum, gname, name_c, blurb, + repr(_float_default(parameter)))) + set_cases.append( + " case {0}:\n self->{1} = g_value_get_double (value);\n" + " break;".format(prop_enum, cname)) + get_cases.append( + " case {0}:\n g_value_set_double (value, self->{1});\n" + " break;".format(prop_enum, cname)) + py_value = "PyFloat_FromDouble (self->{0})".format(cname) + init_defaults.append( + " self->{0} = {1};".format( + cname, repr(_float_default(parameter)))) + elif parameter.param_type == PARAM_TYPE_BOOL: + default = "TRUE" if parameter.default is True else "FALSE" + fields.append(" gboolean {0};".format(cname)) + installs.append( + " g_object_class_install_property (gobject_class, {0},\n" + " g_param_spec_boolean (\"{1}\", \"{2}\", \"{3}\",\n" + " {4},\n" + " G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));\n" + .format(prop_enum, gname, name_c, blurb, default)) + set_cases.append( + " case {0}:\n self->{1} = g_value_get_boolean (value);\n" + " break;".format(prop_enum, cname)) + get_cases.append( + " case {0}:\n g_value_set_boolean (value, self->{1});\n" + " break;".format(prop_enum, cname)) + py_value = "PyBool_FromLong (self->{0})".format(cname) + init_defaults.append( + " self->{0} = {1};".format(cname, default)) + else: # string, enum, code, model_ref: stored as strings + default = ("\"{0}\"".format(_c_escape(parameter.default)) + if isinstance(parameter.default, str) else "NULL") + fields.append(" gchar *{0};".format(cname)) + installs.append( + " g_object_class_install_property (gobject_class, {0},\n" + " g_param_spec_string (\"{1}\", \"{2}\", \"{3}\",\n" + " {4},\n" + " G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));\n" + .format(prop_enum, gname, name_c, blurb, default)) + set_cases.append( + " case {0}:\n g_free (self->{1});\n" + " self->{1} = g_value_dup_string (value);\n" + " break;".format(prop_enum, cname)) + get_cases.append( + " case {0}:\n g_value_set_string (value, self->{1});\n" + " break;".format(prop_enum, cname)) + py_value = ("PyUnicode_FromString (self->{0} ? self->{0} : \"\")" + .format(cname)) + init_defaults.append( + " self->{0} = {1};".format( + cname, + "g_strdup ({0})".format(default) if default != "NULL" + else "NULL")) + + dict_lines.append( + " {{\n" + " /* declared parameter \"{name}\" */\n" + " PyObject *value = {py_value};\n" + " PyDict_SetItemString (params, \"{name}\", value);\n" + " Py_XDECREF (value);\n" + " }}".format(name=name_c, py_value=py_value)) + + return { + "property_fields": "\n".join(fields) or " /* (no parameters declared) */", + "property_enum": "".join(enum_entries), + "install_properties": "\n".join(installs), + "set_property_cases": "\n".join(set_cases) or " /* no properties */", + "get_property_cases": "\n".join(get_cases) or " /* no properties */", + "params_dict_body": "\n".join(dict_lines) or " /* no declared parameters */", + "init_defaults": ("\n\n" + "\n".join(init_defaults)) if init_defaults else "", + } + + +def _render_c_source(descriptor: NodeTypeDescriptor, element: str, + description: str) -> str: + snippets = _property_snippets(descriptor.parameters) + return _C_TEMPLATE.substitute( + element=element, + element_upper=element.upper(), + element_camel=element[:1].upper() + element[1:], + display_name=descriptor.display_name, + display_name_c=_c_escape(descriptor.display_name), + category_c=_c_escape("Filter/Effect/" + descriptor.category), + description_c=_c_escape(description), + **snippets) + + +# -------------------------------------------------------------------------- +# Build configuration template (one per Target_Architecture, Requirement 1.2) +# -------------------------------------------------------------------------- + +_ARCH_NOTES = { + "x86_64": "# Native x86_64 build (matches the cloud sandbox and the\n" + "# Plugin_Simulator runtime).", + "x86_64_nvidia": "# x86_64 build with the NVIDIA GPU runtime available at\n" + "# run time; add CUDA dependencies here if your hook's\n" + "# native side needs them.", + "arm64_jp4": "# Cross build for arm64 Jetson JetPack 4; built with the\n" + "# JetPack 4 cross toolchain image.", + "arm64_jp5": "# Cross build for arm64 Jetson JetPack 5; built with the\n" + "# JetPack 5 cross toolchain image.", + "arm64_jp6": "# Cross build for arm64 Jetson JetPack 6; built with the\n" + "# JetPack 6 cross toolchain image.", +} + +_MESON_TEMPLATE = Template('''\ +# meson.build - ${arch} build configuration for the '${element}' plugin. +${arch_note} + +project('gst-${element}', 'c', + version : '1.0.0', + meson_version : '>= 0.60', + default_options : ['warning_level=1', 'buildtype=debugoptimized']) + +target_architecture = '${arch}' + +gst_dep = dependency('gstreamer-1.0') +gst_app_dep = dependency('gstreamer-app-1.0') +python_dep = dependency('python3-embed') + +plugins_install_dir = join_paths(get_option('libdir'), 'gstreamer-1.0') + +shared_library('gst${element}', + '../../plugin/gst${element}.c', + dependencies : [gst_dep, gst_app_dep, python_dep], + install : true, + install_dir : plugins_install_dir, +) + +# The Frame_Processing_Hook travels with the plugin: installed beside the +# shared library so the embedded interpreter can import it. +install_data('../../plugin/frame_processing_hook.py', + install_dir : plugins_install_dir) +''') + + +def _render_meson(element: str, arch: str) -> str: + return _MESON_TEMPLATE.substitute( + element=element, arch=arch, arch_note=_ARCH_NOTES.get(arch, "#")) + + +# -------------------------------------------------------------------------- +# README template +# -------------------------------------------------------------------------- + +_README_TEMPLATE = Template('''\ +# ${display_name} + +${description} + +This Plugin_Scaffold was generated by the DDA Node_Designer. It builds a +GStreamer plugin exposing the `${element}` element, which hands every frame +arriving at the node's input port to your Frame_Processing_Hook and emits +the returned frame content on the node's output port. + +## Where to write your code + +Edit `plugin/frame_processing_hook.py`: + +```python +def process_frame(frame, params): + return frame +``` + +- `frame` is the raw frame payload (`bytes`) for one frame. +- `params` is a dict with the current value of every declared parameter, + keyed by the declared parameter name. +- Return the frame bytes to emit on the output port. + +${parameters_section} +## Project layout + +| Path | Purpose | +|---|---| +| `plugin/frame_processing_hook.py` | Frame_Processing_Hook - your per-frame logic | +| `plugin/gst${element}.c` | C skeleton element (appsink/appsrc bridge, embedded Python) | +${build_rows}| `README.md` | this file | + +## Building + +One `meson.build` is provided per selected Target_Architecture. To build +for one architecture locally: + +```sh +meson setup build- builds/ +meson compile -C build- +``` + +Selected Target_Architectures: ${arch_list}. + +Submitting this scaffold in the portal builds every selected architecture +in the isolated Plugin_Build_Service and signs the resulting artifacts. +''') + + +def _render_readme(descriptor: NodeTypeDescriptor, element: str, + description: str, architectures: Sequence[str]) -> str: + if descriptor.parameters: + rows = "\n".join( + "| `{0}` | {1} | {2} | {3} |".format( + parameter.name.replace("|", "\\|"), + parameter.param_type, + "yes" if parameter.required else "no", + (parameter.description or "").replace("|", "\\|").replace("\n", " ")) + for parameter in descriptor.parameters) + parameters_section = ( + "## Declared parameters\n\n" + "Each parameter is a GObject property on the `{0}` element and\n" + "arrives in the hook's `params` dict under its declared name.\n\n" + "| Name | Type | Required | Description |\n" + "|---|---|---|---|\n{1}\n\n".format(element, rows)) + else: + parameters_section = ( + "## Declared parameters\n\nThis node type declares no " + "parameters; `params` is an empty dict.\n\n") + build_rows = "".join( + "| `{0}` | {1} build configuration |\n".format( + build_config_path(arch), arch) + for arch in architectures) + return _README_TEMPLATE.substitute( + display_name=descriptor.display_name, + description=description, + element=element, + parameters_section=parameters_section, + build_rows=build_rows, + arch_list=", ".join(architectures)) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/__init__.py new file mode 100644 index 00000000..7d07c0cd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/__init__.py @@ -0,0 +1,78 @@ +"""Workflow_Serializer: canonical JSON serialization and parsing. + +Serializes WorkflowGraph objects to canonical Workflow_Definition JSON +documents and parses documents back into graphs, with JSON-Schema +validation, descriptive first-violation errors, and stepwise schema +migration. + +Task 2.1 implements the graph model, the Workflow_Definition JSON Schema +(schemaVersion 1), and canonical ``serialize``. Task 2.2 adds ``parse`` +with descriptive errors and schema migration. +""" + +from .models import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) +from .parse import ( + ERROR_DUPLICATE_ID, + ERROR_INVALID_JSON, + ERROR_MIGRATION_FAILED, + ERROR_SCHEMA_VIOLATION, + ERROR_UNKNOWN_NODE_REFERENCE, + ERROR_UNSUPPORTED_SCHEMA_VERSION, + Migration, + ParseError, + ParseResult, + parse, + register_migration, + registered_migrations, + unregister_migration, +) +from .schema import ( + SCHEMA_VERSION, + SCHEMAS_BY_VERSION, + WORKFLOW_DEFINITION_SCHEMA, + WORKFLOW_DEFINITION_SCHEMA_V1, +) +from .serialize import ( + SerializationError, + graph_to_document, + serialize, +) + +__all__ = [ + # graph model + "Position", + "PortEndpoint", + "Node", + "Connection", + "WorkflowGraph", + # JSON Schema + "SCHEMA_VERSION", + "SCHEMAS_BY_VERSION", + "WORKFLOW_DEFINITION_SCHEMA", + "WORKFLOW_DEFINITION_SCHEMA_V1", + # serialization + "serialize", + "graph_to_document", + "SerializationError", + # parsing + "parse", + "ParseResult", + "ParseError", + "ERROR_INVALID_JSON", + "ERROR_SCHEMA_VIOLATION", + "ERROR_UNSUPPORTED_SCHEMA_VERSION", + "ERROR_DUPLICATE_ID", + "ERROR_UNKNOWN_NODE_REFERENCE", + "ERROR_MIGRATION_FAILED", + # migration registry + "Migration", + "register_migration", + "unregister_migration", + "registered_migrations", +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/models.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/models.py new file mode 100644 index 00000000..e0e7821b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/models.py @@ -0,0 +1,110 @@ +"""Graph model for Workflow_Definitions. + +A :class:`WorkflowGraph` is the in-memory form of a Workflow_Definition: +nodes (id, type, canvas position, parameter values) and directed +connections between typed port endpoints. The serializer converts graphs +to canonical JSON documents (task 2.1) and parses documents back into +graphs (task 2.2). + +Graph equivalence is order-insensitive: two graphs are equivalent when +they contain the same nodes and the same connections regardless of list +ordering (the round-trip property of Requirement 3.4 is stated in these +terms). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass(frozen=True) +class Position: + """Canvas position of a node (Requirement 3.1: positions are persisted).""" + + x: float + y: float + + +@dataclass(frozen=True) +class PortEndpoint: + """One end of a connection: a port on a specific node.""" + + node: str # node id + port: str # port name on that node (e.g. "in", "out") + + +@dataclass +class Node: + """A single processing stage placed on the canvas. + + ``type`` is a node type id from the catalog (``workflow_core.catalog``); + ``parameters`` maps parameter names to JSON-representable values. + + ``data`` is optional advisory node data (e.g. the camera picker's + ``cameraBindingHint``). It is preserved through parse/serialize round + trips but carries no workflow semantics: validation and compilation + ignore it, and it is excluded from node equality / graph equivalence + (``compare=False``) so hinted and hint-stripped definitions remain + equivalent (Requirements 7.5, 11.5). + """ + + id: str + type: str + position: Position + parameters: Dict[str, Any] = field(default_factory=dict) + data: Dict[str, Any] = field(default_factory=dict, compare=False) + + +@dataclass +class Connection: + """A directed edge from an output port to an input port. + + The ``source`` endpoint corresponds to the document's ``from`` key + (``from`` is a Python keyword) and ``target`` to ``to``. + """ + + id: str + source: PortEndpoint + target: PortEndpoint + + +@dataclass +class WorkflowGraph: + """The full workflow graph: nodes plus connections.""" + + nodes: List[Node] = field(default_factory=list) + connections: List[Connection] = field(default_factory=list) + + def node_by_id(self, node_id: str) -> Optional[Node]: + """Return the node with ``node_id``, or None.""" + for node in self.nodes: + if node.id == node_id: + return node + return None + + def connection_by_id(self, connection_id: str) -> Optional[Connection]: + """Return the connection with ``connection_id``, or None.""" + for connection in self.connections: + if connection.id == connection_id: + return connection + return None + + def is_equivalent_to(self, other: "WorkflowGraph") -> bool: + """Order-insensitive graph equivalence (Requirement 3.4). + + True when both graphs contain the same set of nodes (by full + content, excluding advisory ``data``) and the same set of + connections, regardless of list ordering. + """ + if not isinstance(other, WorkflowGraph): + return False + return ( + _by_id(self.nodes) == _by_id(other.nodes) + and _by_id(self.connections) == _by_id(other.connections) + ) + + +def _by_id(items: list) -> dict: + """Index items by ``.id``; duplicate ids collapse (serialize rejects them).""" + return {item.id: item for item in items} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/parse.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/parse.py new file mode 100644 index 00000000..cc3f2e96 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/parse.py @@ -0,0 +1,355 @@ +"""Parsing of Workflow_Definition JSON documents into WorkflowGraphs. + +``parse`` runs JSON-Schema validation first, reporting the first +violation encountered with a JSON-pointer path (Requirement 3.3), then +constructs the graph (Requirement 3.2). + +Documents declaring an older supported ``schemaVersion`` are upgraded +stepwise through the migration registry and the :class:`ParseResult` +reports ``migrations: [from, to]``; documents declaring a version with +no registered migration path return ``UNSUPPORTED_SCHEMA_VERSION`` +(Requirement 3.5). + +``parse`` never raises on malformed input: every failure mode is +reported as a :class:`ParseError` carrying an error code, a descriptive +message, and a JSON-pointer ``path`` locating the violation. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Tuple + +import jsonschema + +from .models import Connection, Node, PortEndpoint, Position, WorkflowGraph +from .schema import SCHEMA_VERSION, SCHEMAS_BY_VERSION + +#: The document is not valid JSON at all. +ERROR_INVALID_JSON = "INVALID_JSON" +#: The document violates the JSON Schema for its declared version. +ERROR_SCHEMA_VIOLATION = "SCHEMA_VIOLATION" +#: The declared schemaVersion is neither current nor migratable. +ERROR_UNSUPPORTED_SCHEMA_VERSION = "UNSUPPORTED_SCHEMA_VERSION" +#: Two nodes or two connections share an id. +ERROR_DUPLICATE_ID = "DUPLICATE_ID" +#: A connection endpoint references a node id not present in the document. +ERROR_UNKNOWN_NODE_REFERENCE = "UNKNOWN_NODE_REFERENCE" +#: A registered migration failed or produced an invalid document. +ERROR_MIGRATION_FAILED = "MIGRATION_FAILED" + + +@dataclass(frozen=True) +class ParseError: + """A descriptive parse failure (Requirement 3.3). + + ``path`` is a JSON pointer (RFC 6901) locating the first violation + encountered; the empty string denotes the document root. + """ + + code: str + message: str + path: str = "" + + def __str__(self) -> str: + return "{} at {!r}: {}".format(self.code, self.path or "/", self.message) + + +@dataclass(frozen=True) +class ParseResult: + """Outcome of :func:`parse`: a graph or a descriptive error. + + ``migrations`` is ``[from, to]`` when the document was upgraded from + an older supported schema version (Requirement 3.5), else ``None``. + """ + + graph: Optional[WorkflowGraph] = None + error: Optional[ParseError] = None + migrations: Optional[List[int]] = None + + @property + def ok(self) -> bool: + return self.error is None + + +# --------------------------------------------------------------------------- +# Migration registry +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Migration: + """A single-step schema upgrade: ``from_version`` -> ``from_version + 1``. + + ``schema`` is the JSON Schema documents at ``from_version`` must + satisfy before ``upgrade`` runs. + """ + + from_version: int + schema: Dict[str, Any] + upgrade: Callable[[Dict[str, Any]], Dict[str, Any]] + + +#: Registered single-step migrations keyed by from-version. +_MIGRATIONS: Dict[int, Migration] = {} + +#: Schema versions whose entry in SCHEMAS_BY_VERSION was added by +#: register_migration (so unregister_migration removes only those). +_REGISTERED_SCHEMAS: set = set() + + +def register_migration( + from_version: int, + schema: Dict[str, Any], + upgrade: Callable[[Dict[str, Any]], Dict[str, Any]], +) -> None: + """Register the stepwise migration ``from_version -> from_version + 1``. + + ``schema`` is added to ``SCHEMAS_BY_VERSION`` so parse can validate + older documents before upgrading them. + """ + if type(from_version) is not int: + raise TypeError("from_version must be an int, got {!r}".format(from_version)) + if from_version >= SCHEMA_VERSION: + raise ValueError( + "from_version must be older than the current schema version " + "{}, got {}".format(SCHEMA_VERSION, from_version) + ) + if from_version in _MIGRATIONS: + raise ValueError("a migration from version {} is already registered".format(from_version)) + _MIGRATIONS[from_version] = Migration(from_version, schema, upgrade) + if from_version not in SCHEMAS_BY_VERSION: + SCHEMAS_BY_VERSION[from_version] = schema + _REGISTERED_SCHEMAS.add(from_version) + + +def unregister_migration(from_version: int) -> None: + """Remove a registered migration (primarily for tests).""" + _MIGRATIONS.pop(from_version, None) + if from_version in _REGISTERED_SCHEMAS: + SCHEMAS_BY_VERSION.pop(from_version, None) + _REGISTERED_SCHEMAS.discard(from_version) + + +def registered_migrations() -> Dict[int, Migration]: + """A snapshot of the migration registry.""" + return dict(_MIGRATIONS) + + +def _has_migration_path(version: int) -> bool: + """True when stepwise migrations cover every version up to current.""" + if version >= SCHEMA_VERSION or version not in SCHEMAS_BY_VERSION: + return False + return all(v in _MIGRATIONS for v in range(version, SCHEMA_VERSION)) + + +# --------------------------------------------------------------------------- +# parse +# --------------------------------------------------------------------------- + + +def parse(doc: str) -> ParseResult: + """Parse a Workflow_Definition JSON document into a WorkflowGraph. + + Runs JSON-Schema validation first (the first violation is reported + with a JSON-pointer path, Requirement 3.3), migrates older supported + schema versions stepwise to the current version (Requirement 3.5), + then constructs the graph (Requirement 3.2). + """ + try: + document = json.loads(doc) + except json.JSONDecodeError as exc: + return ParseResult( + error=ParseError( + code=ERROR_INVALID_JSON, + message="invalid JSON: {} (line {}, column {})".format(exc.msg, exc.lineno, exc.colno), + ) + ) + + current_schema = SCHEMAS_BY_VERSION[SCHEMA_VERSION] + + if not isinstance(document, dict): + violation = _first_violation(document, current_schema) + return ParseResult(error=violation) + + version = document.get("schemaVersion") + migrations: Optional[List[int]] = None + + # bool is a subclass of int; exclude it explicitly. + if type(version) is int and version == SCHEMA_VERSION: + violation = _first_violation(document, current_schema) + if violation is not None: + return ParseResult(error=violation) + elif type(version) is int and _has_migration_path(version): + # Validate against the declared version's schema first ... + violation = _first_violation(document, SCHEMAS_BY_VERSION[version]) + if violation is not None: + return ParseResult(error=violation) + # ... then upgrade stepwise to the current version. + document, error = _migrate(document, version) + if error is not None: + return ParseResult(error=error) + migrations = [version, SCHEMA_VERSION] + elif type(version) is int: + return ParseResult(error=_unsupported_version(version)) + else: + # schemaVersion missing or not an integer: report the schema + # violation (missing/const mismatch) against the current schema. + violation = _first_violation(document, current_schema) + if violation is not None: + return ParseResult(error=violation) + # Defensive fallback (e.g. schemaVersion: true satisfying + # "const: 1" under loose equality in some validator versions). + return ParseResult(error=_unsupported_version(version)) + + graph, error = _build_graph(document) + if error is not None: + return ParseResult(error=error) + return ParseResult(graph=graph, migrations=migrations) + + +# --------------------------------------------------------------------------- +# Internals +# --------------------------------------------------------------------------- + + +def _unsupported_version(version: Any) -> ParseError: + supported = sorted(v for v in SCHEMAS_BY_VERSION if v == SCHEMA_VERSION or _has_migration_path(v)) + return ParseError( + code=ERROR_UNSUPPORTED_SCHEMA_VERSION, + message="unsupported schemaVersion {!r}; supported versions: {}".format(version, supported), + path="/schemaVersion", + ) + + +def _escape_pointer_token(token: str) -> str: + """Escape a JSON-pointer reference token (RFC 6901).""" + return token.replace("~", "~0").replace("/", "~1") + + +def _json_pointer(parts: List[Any]) -> str: + return "".join("/" + _escape_pointer_token(str(part)) for part in parts) + + +def _first_violation(document: Any, schema: Dict[str, Any]) -> Optional[ParseError]: + """The first schema violation encountered, or None when valid. + + ``iter_errors`` traverses the document deterministically (dicts + preserve document order), so the first yielded error is the first + violation encountered (Requirement 3.3) and is stable for a given + document. Leaf errors are preferred over their derived parent + errors (e.g. the failing item inside an array rather than the + enclosing "items" error). + """ + validator = jsonschema.Draft7Validator(schema) + error = next(iter(validator.iter_errors(document)), None) + if error is None: + return None + # Descend to the deepest context error along the first branch so the + # pointer identifies the actual violating value. + while error.context: + error = error.context[0] + path_parts: List[Any] = list(error.absolute_path) + # For "required" violations, extend the pointer with the missing + # property name for a more precise location. + if error.validator == "required" and isinstance(error.instance, dict): + for prop in error.validator_value: + if prop not in error.instance: + path_parts.append(prop) + break + return ParseError( + code=ERROR_SCHEMA_VIOLATION, + message=error.message, + path=_json_pointer(path_parts), + ) + + +def _migrate( + document: Dict[str, Any], from_version: int +) -> Tuple[Optional[Dict[str, Any]], Optional[ParseError]]: + """Apply registered migrations stepwise from ``from_version`` to current.""" + upgraded = document + for step in range(from_version, SCHEMA_VERSION): + migration = _MIGRATIONS[step] + try: + upgraded = migration.upgrade(upgraded) + except Exception as exc: # noqa: BLE001 - migration code is registered externally + return None, ParseError( + code=ERROR_MIGRATION_FAILED, + message="migration from version {} to {} failed: {}".format(step, step + 1, exc), + ) + # A migration must produce a document valid at the current version. + violation = _first_violation(upgraded, SCHEMAS_BY_VERSION[SCHEMA_VERSION]) + if violation is not None: + return None, ParseError( + code=ERROR_MIGRATION_FAILED, + message="migrated document is invalid at version {}: {}".format( + SCHEMA_VERSION, violation.message + ), + path=violation.path, + ) + return upgraded, None + + +def _build_graph( + document: Dict[str, Any] +) -> Tuple[Optional[WorkflowGraph], Optional[ParseError]]: + """Construct the graph, enforcing structural rules the schema cannot. + + Checks unique node/connection ids and that connection endpoints + reference nodes present in the document. Port-level checks (port + existence, direction, type compatibility) belong to the + Workflow_Validator. + """ + node_ids = set() + for index, node_doc in enumerate(document["nodes"]): + if node_doc["id"] in node_ids: + return None, ParseError( + code=ERROR_DUPLICATE_ID, + message="duplicate node id {!r}".format(node_doc["id"]), + path=_json_pointer(["nodes", index, "id"]), + ) + node_ids.add(node_doc["id"]) + + connection_ids = set() + for index, conn_doc in enumerate(document["connections"]): + if conn_doc["id"] in connection_ids: + return None, ParseError( + code=ERROR_DUPLICATE_ID, + message="duplicate connection id {!r}".format(conn_doc["id"]), + path=_json_pointer(["connections", index, "id"]), + ) + connection_ids.add(conn_doc["id"]) + for key in ("from", "to"): + referenced = conn_doc[key]["node"] + if referenced not in node_ids: + return None, ParseError( + code=ERROR_UNKNOWN_NODE_REFERENCE, + message="connection {!r} references unknown node {!r}".format( + conn_doc["id"], referenced + ), + path=_json_pointer(["connections", index, key, "node"]), + ) + + graph = WorkflowGraph( + nodes=[ + Node( + id=node_doc["id"], + type=node_doc["type"], + position=Position(x=node_doc["position"]["x"], y=node_doc["position"]["y"]), + parameters=dict(node_doc["parameters"]), + data=dict(node_doc.get("data", {})), + ) + for node_doc in document["nodes"] + ], + connections=[ + Connection( + id=conn_doc["id"], + source=PortEndpoint(node=conn_doc["from"]["node"], port=conn_doc["from"]["port"]), + target=PortEndpoint(node=conn_doc["to"]["node"], port=conn_doc["to"]["port"]), + ) + for conn_doc in document["connections"] + ], + ) + return graph, None diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/schema.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/schema.py new file mode 100644 index 00000000..11e7d961 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/schema.py @@ -0,0 +1,105 @@ +"""Workflow_Definition JSON Schema (draft-07). + +Authoritative schema for the Workflow_Definition interchange document +(Requirement 3.1). ``SCHEMA_VERSION`` is the current version; parsing +(task 2.2) validates documents against the schema for their declared +version and migrates older supported versions stepwise to the current +one, so schemas are kept per-version in ``SCHEMAS_BY_VERSION``. + +Structural rules that JSON Schema cannot express conveniently (unique +node/connection ids, connection endpoints referencing nodes present in +the document) are enforced during graph construction in ``parse``; +port-level checks belong to the Workflow_Validator. +""" + +from __future__ import annotations + +#: Current Workflow_Definition schema version. +SCHEMA_VERSION = 1 + +_POSITION_SCHEMA = { + "type": "object", + "description": "Canvas position of the node.", + "required": ["x", "y"], + "additionalProperties": False, + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, +} + +_NODE_SCHEMA = { + "type": "object", + "description": "A single processing stage: id, catalog type, canvas position, and parameter values.", + "required": ["id", "type", "position", "parameters"], + "additionalProperties": False, + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "position": _POSITION_SCHEMA, + "parameters": { + "type": "object", + "description": "Parameter name to JSON value; keys and value types are declared by the node type's catalog descriptor.", + }, + "data": { + "type": "object", + "description": ( + "Optional advisory node data (e.g. cameraBindingHint recorded " + "by the Workflow_Builder camera picker). Preserved through " + "parse/serialize round trips but ignored by validation and " + "compilation (Requirements 7.5, 11.5)." + ), + }, + }, +} + +_PORT_ENDPOINT_SCHEMA = { + "type": "object", + "description": "A typed port endpoint: a port name on a specific node.", + "required": ["node", "port"], + "additionalProperties": False, + "properties": { + "node": {"type": "string", "minLength": 1}, + "port": {"type": "string", "minLength": 1}, + }, +} + +_CONNECTION_SCHEMA = { + "type": "object", + "description": "A directed edge from an output port ('from') to an input port ('to').", + "required": ["id", "from", "to"], + "additionalProperties": False, + "properties": { + "id": {"type": "string", "minLength": 1}, + "from": _PORT_ENDPOINT_SCHEMA, + "to": _PORT_ENDPOINT_SCHEMA, + }, +} + +#: JSON Schema for Workflow_Definition documents at schemaVersion 1. +WORKFLOW_DEFINITION_SCHEMA_V1 = { + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://aws-samples.github.io/defect-detection/schemas/workflow-definition-v1.json", + "title": "Workflow_Definition", + "description": ( + "Serializable workflow graph document: all nodes with their " + "configurations and canvas positions, all connections, and a " + "schema version identifier (Requirement 3.1)." + ), + "type": "object", + "required": ["schemaVersion", "nodes", "connections"], + "additionalProperties": False, + "properties": { + "schemaVersion": {"const": 1}, + "nodes": {"type": "array", "items": _NODE_SCHEMA}, + "connections": {"type": "array", "items": _CONNECTION_SCHEMA}, + }, +} + +#: Schema for the current version. +WORKFLOW_DEFINITION_SCHEMA = WORKFLOW_DEFINITION_SCHEMA_V1 + +#: Per-version schemas; parse (task 2.2) selects by declared schemaVersion. +SCHEMAS_BY_VERSION = { + 1: WORKFLOW_DEFINITION_SCHEMA_V1, +} diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/serialize.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/serialize.py new file mode 100644 index 00000000..889ff8b3 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/serializer/serialize.py @@ -0,0 +1,95 @@ +"""Canonical serialization of WorkflowGraph to Workflow_Definition JSON. + +``serialize`` emits a canonical document (Requirement 3.1): + +- object keys sorted at every level, +- ``nodes`` ordered by node id, ``connections`` ordered by connection id, +- fixed whitespace (2-space indent), ASCII-escaped output. + +Canonical output makes the round-trip property "identical JSON structure" +achievable (Requirement 3.4): serializing a parsed serialization is +byte-identical to the original serialization. Advisory node ``data`` +(excluded from graph equivalence) is preserved verbatim and omitted when +empty, so definitions without node data serialize exactly as before the +field existed (Requirement 11.5). +""" + +from __future__ import annotations + +import json +from typing import Any, Dict + +from .models import WorkflowGraph +from .schema import SCHEMA_VERSION + + +class SerializationError(ValueError): + """A graph cannot be serialized to a well-formed Workflow_Definition.""" + + +def graph_to_document(graph: WorkflowGraph) -> Dict[str, Any]: + """Convert a graph to its Workflow_Definition document (plain dict). + + Nodes and connections are ordered by id. Raises + :class:`SerializationError` on empty or duplicate ids, which would + make the canonical form ambiguous. + """ + _check_ids("node", [node.id for node in graph.nodes]) + _check_ids("connection", [connection.id for connection in graph.connections]) + + return { + "schemaVersion": SCHEMA_VERSION, + "nodes": [ + _node_to_document(node) + for node in sorted(graph.nodes, key=lambda n: n.id) + ], + "connections": [ + { + "id": connection.id, + "from": {"node": connection.source.node, "port": connection.source.port}, + "to": {"node": connection.target.node, "port": connection.target.port}, + } + for connection in sorted(graph.connections, key=lambda c: c.id) + ], + } + + +def _node_to_document(node) -> Dict[str, Any]: + """A node's document form; advisory ``data`` is emitted only when + non-empty so definitions without node data serialize byte-identically + to before the field existed (Requirement 11.5).""" + document = { + "id": node.id, + "type": node.type, + "position": {"x": node.position.x, "y": node.position.y}, + "parameters": dict(node.parameters), + } + if node.data: + document["data"] = dict(node.data) + return document + + +def serialize(graph: WorkflowGraph) -> str: + """Serialize a graph to its canonical Workflow_Definition JSON string. + + The output contains all nodes, node configurations, node positions, + connections, and the schema version identifier (Requirement 3.1). + """ + document = graph_to_document(graph) + try: + return json.dumps(document, sort_keys=True, indent=2, ensure_ascii=True) + except (TypeError, ValueError) as exc: + raise SerializationError( + "graph contains a parameter value that is not JSON-representable: {}".format(exc) + ) from exc + + +def _check_ids(kind: str, ids: list) -> None: + """Reject empty and duplicate ids so canonical ordering is well-defined.""" + seen = set() + for item_id in ids: + if not isinstance(item_id, str) or not item_id: + raise SerializationError("{} id must be a non-empty string, got {!r}".format(kind, item_id)) + if item_id in seen: + raise SerializationError("duplicate {} id: {!r}".format(kind, item_id)) + seen.add(item_id) diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/__init__.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/__init__.py new file mode 100644 index 00000000..2ce40ff6 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/__init__.py @@ -0,0 +1,76 @@ +"""Workflow_Validator: structural and semantic graph validation. + +Runs all checks (input/output presence, connection port compatibility, +cycle detection, required parameters, reachability, warnings) and returns +the complete list of ValidationFinding records. +""" + +from .checks import ( + CODE_UNKNOWN_NODE_TYPE, + CODE_V1_NO_INPUT_NODE, + CODE_V1_NO_OUTPUT_NODE, + CODE_V2_INCOMPATIBLE_TYPES, + CODE_V2_SOURCE_NOT_OUTPUT, + CODE_V2_TARGET_NOT_INPUT, + CODE_V2_UNKNOWN_NODE, + CODE_V2_UNKNOWN_PORT, + CODE_V3_CYCLE, + CODE_V4_INVALID_PARAMETER_VALUE, + CODE_V4_MISSING_REQUIRED_PARAMETER, + CODE_V5_UNREACHABLE_NODE, + CODE_W1_OUTPUT_NODE_NO_INPUT, + CODE_W1_UNUSED_OUTPUT_PORT, + SEVERITY_ERROR, + SEVERITY_WARNING, + ValidationFinding, + validate, +) +from .parameters import ( + ParameterViolation, + VIOLATION_MAX, + VIOLATION_MAX_LENGTH, + VIOLATION_MIN, + VIOLATION_MIN_LENGTH, + VIOLATION_REGEX, + VIOLATION_REQUIRED, + VIOLATION_TYPE, + VIOLATION_UNKNOWN_TYPE, + VIOLATION_VALUES, + check_parameter_value, + is_parameter_value_valid, +) + +__all__ = [ + # validate() and findings + "validate", + "ValidationFinding", + "SEVERITY_ERROR", + "SEVERITY_WARNING", + "CODE_UNKNOWN_NODE_TYPE", + "CODE_V1_NO_INPUT_NODE", + "CODE_V1_NO_OUTPUT_NODE", + "CODE_V2_UNKNOWN_NODE", + "CODE_V2_UNKNOWN_PORT", + "CODE_V2_SOURCE_NOT_OUTPUT", + "CODE_V2_TARGET_NOT_INPUT", + "CODE_V2_INCOMPATIBLE_TYPES", + "CODE_V3_CYCLE", + "CODE_V4_MISSING_REQUIRED_PARAMETER", + "CODE_V4_INVALID_PARAMETER_VALUE", + "CODE_V5_UNREACHABLE_NODE", + "CODE_W1_OUTPUT_NODE_NO_INPUT", + "CODE_W1_UNUSED_OUTPUT_PORT", + # parameter predicate + "ParameterViolation", + "check_parameter_value", + "is_parameter_value_valid", + "VIOLATION_REQUIRED", + "VIOLATION_TYPE", + "VIOLATION_MIN", + "VIOLATION_MAX", + "VIOLATION_MIN_LENGTH", + "VIOLATION_MAX_LENGTH", + "VIOLATION_REGEX", + "VIOLATION_VALUES", + "VIOLATION_UNKNOWN_TYPE", +] diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/checks.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/checks.py new file mode 100644 index 00000000..8c8aac26 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/checks.py @@ -0,0 +1,491 @@ +"""Workflow_Validator checks V1-V5 and W1 (Requirements 4.1-4.6). + +``validate(graph, catalog)`` is a pure function: it always runs every +check (no short-circuiting) and returns the complete list of +:class:`ValidationFinding` records, each carrying severity, a stable +code, a human-readable message, and the associated node or connection +identifier (Requirement 4.6). + +| Check | Rule | Requirement | +|-------|-------------------------------------------------------------|-------------| +| V1 | >=1 input node and >=1 output node | 4.1 | +| V2 | every connection joins an output port to an input port with | 4.2 | +| | compatible types | | +| V3 | no cycles; report the nodes in each cycle (Tarjan SCC) | 4.3 | +| V4 | required parameters have values satisfying constraints | 4.4 | +| V5 | every node reachable from some input node (forward BFS) | 4.5 | +| W1 | warnings: output node with no incoming connection, unused | 4.6 | +| | output ports | | +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Sequence + +from ..catalog import NODE_CATALOG +from ..catalog.compatibility import incompatibility_reason +from ..catalog.models import ( + CATEGORY_INPUT, + CATEGORY_OUTPUT, + NodeTypeDescriptor, + PORT_TYPES, +) +from ..serializer.models import Node, WorkflowGraph +from .parameters import VIOLATION_REQUIRED, check_parameter_value + +# -------------------------------------------------------------------------- +# Severities +# -------------------------------------------------------------------------- + +SEVERITY_ERROR = "error" +SEVERITY_WARNING = "warning" + +# -------------------------------------------------------------------------- +# Finding codes (stable identifiers shared with the frontend mirror) +# -------------------------------------------------------------------------- + +# Structural precondition: a node whose type is not in the catalog cannot +# be checked for ports or parameters, so it gets its own error finding. +CODE_UNKNOWN_NODE_TYPE = "UNKNOWN_NODE_TYPE" + +# V1 (Requirement 4.1) +CODE_V1_NO_INPUT_NODE = "V1_NO_INPUT_NODE" +CODE_V1_NO_OUTPUT_NODE = "V1_NO_OUTPUT_NODE" + +# V2 (Requirement 4.2) +CODE_V2_UNKNOWN_NODE = "V2_UNKNOWN_NODE" +CODE_V2_UNKNOWN_PORT = "V2_UNKNOWN_PORT" +CODE_V2_SOURCE_NOT_OUTPUT = "V2_SOURCE_NOT_OUTPUT" +CODE_V2_TARGET_NOT_INPUT = "V2_TARGET_NOT_INPUT" +CODE_V2_INCOMPATIBLE_TYPES = "V2_INCOMPATIBLE_TYPES" + +# V3 (Requirement 4.3) +CODE_V3_CYCLE = "V3_CYCLE" + +# V4 (Requirement 4.4) +CODE_V4_MISSING_REQUIRED_PARAMETER = "V4_MISSING_REQUIRED_PARAMETER" +CODE_V4_INVALID_PARAMETER_VALUE = "V4_INVALID_PARAMETER_VALUE" + +# V5 (Requirement 4.5) +CODE_V5_UNREACHABLE_NODE = "V5_UNREACHABLE_NODE" + +# W1 warnings (Requirement 4.6) +CODE_W1_OUTPUT_NODE_NO_INPUT = "W1_OUTPUT_NODE_NO_INPUT" +CODE_W1_UNUSED_OUTPUT_PORT = "W1_UNUSED_OUTPUT_PORT" + + +@dataclass(frozen=True) +class ValidationFinding: + """One validation error or warning (Requirement 4.6). + + ``node_id`` / ``connection_id`` identify the associated graph element + (at most one is set; both are None only for graph-level findings such + as V1). ``to_dict`` emits the wire form with camelCase keys. + """ + + severity: str # SEVERITY_ERROR | SEVERITY_WARNING + code: str + message: str + node_id: Optional[str] = None + connection_id: Optional[str] = None + + def to_dict(self) -> dict: + return { + "severity": self.severity, + "code": self.code, + "message": self.message, + "nodeId": self.node_id, + "connectionId": self.connection_id, + } + + +def validate( + graph: WorkflowGraph, + catalog: Sequence[NodeTypeDescriptor] = NODE_CATALOG, +) -> List[ValidationFinding]: + """Run all validator checks on ``graph`` and return every finding. + + All checks always run; nothing short-circuits. The result is the + complete list of errors and warnings, each with the associated node + or connection identifier (Requirement 4.6). + """ + descriptors: Dict[str, NodeTypeDescriptor] = {d.type_id: d for d in catalog} + + findings: List[ValidationFinding] = [] + + # Node id -> descriptor for nodes whose type is known; unknown types + # get an error finding and are excluded from port/parameter checks. + typed_nodes: Dict[str, NodeTypeDescriptor] = {} + for node in graph.nodes: + descriptor = descriptors.get(node.type) + if descriptor is None: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_UNKNOWN_NODE_TYPE, + "Node '{0}' has unknown type '{1}'".format(node.id, node.type), + node_id=node.id, + )) + else: + typed_nodes[node.id] = descriptor + + findings.extend(_check_v1(graph, typed_nodes)) + findings.extend(_check_v2(graph, typed_nodes)) + findings.extend(_check_v3(graph)) + findings.extend(_check_v4(graph, typed_nodes)) + findings.extend(_check_v5(graph, typed_nodes)) + findings.extend(_check_w1(graph, typed_nodes)) + + return findings + + +# -------------------------------------------------------------------------- +# Port resolution +# -------------------------------------------------------------------------- + +def _resolved_ports(node: Node, descriptor: NodeTypeDescriptor) -> tuple: + """Return ``(inputs, outputs)`` as ``{port_name: port_type}`` maps. + + Custom Python nodes declare their input and output port types per + node instance (Requirement 2.7): when the descriptor exposes + ``input_port_type`` / ``output_port_type`` parameters and the node + carries a known port type value, that value overrides the declared + default type of the node's ports. + """ + inputs = {port.name: port.port_type for port in descriptor.inputs} + outputs = {port.name: port.port_type for port in descriptor.outputs} + + parameter_names = {parameter.name for parameter in descriptor.parameters} + + if "input_port_type" in parameter_names: + override = node.parameters.get("input_port_type") + if override in PORT_TYPES: + inputs = {name: override for name in inputs} + if "output_port_type" in parameter_names: + override = node.parameters.get("output_port_type") + if override in PORT_TYPES: + outputs = {name: override for name in outputs} + + return inputs, outputs + + +# -------------------------------------------------------------------------- +# V1: at least one input node and one output node (Requirement 4.1) +# -------------------------------------------------------------------------- + +def _check_v1(graph: WorkflowGraph, typed_nodes: Dict[str, NodeTypeDescriptor]) -> List[ValidationFinding]: + findings = [] + categories = {descriptor.category for descriptor in typed_nodes.values()} + if CATEGORY_INPUT not in categories: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V1_NO_INPUT_NODE, + "Workflow must contain at least one input node", + )) + if CATEGORY_OUTPUT not in categories: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V1_NO_OUTPUT_NODE, + "Workflow must contain at least one output node", + )) + return findings + + +# -------------------------------------------------------------------------- +# V2: connection port direction and type compatibility (Requirement 4.2) +# -------------------------------------------------------------------------- + +def _check_v2(graph: WorkflowGraph, typed_nodes: Dict[str, NodeTypeDescriptor]) -> List[ValidationFinding]: + findings = [] + for connection in graph.connections: + source_type = _endpoint_port_type( + graph, typed_nodes, connection, connection.source.node, + connection.source.port, is_source=True, findings=findings, + ) + target_type = _endpoint_port_type( + graph, typed_nodes, connection, connection.target.node, + connection.target.port, is_source=False, findings=findings, + ) + if source_type is None or target_type is None: + continue + + reason = incompatibility_reason(source_type, target_type) + if reason is not None: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V2_INCOMPATIBLE_TYPES, + "Connection '{0}': {1}".format(connection.id, reason), + connection_id=connection.id, + )) + return findings + + +def _endpoint_port_type( + graph: WorkflowGraph, + typed_nodes: Dict[str, NodeTypeDescriptor], + connection, + node_id: str, + port_name: str, + is_source: bool, + findings: List[ValidationFinding], +) -> Optional[str]: + """Resolve the port type of one connection endpoint, appending V2 + findings for unknown nodes/ports and wrong port direction.""" + role = "source" if is_source else "target" + + node = graph.node_by_id(node_id) + if node is None or node_id not in typed_nodes: + # Missing node, or a node whose type is unknown (already reported + # by UNKNOWN_NODE_TYPE) — the endpoint cannot be resolved. + if node is None: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V2_UNKNOWN_NODE, + "Connection '{0}' {1} references unknown node '{2}'".format( + connection.id, role, node_id + ), + connection_id=connection.id, + )) + return None + + inputs, outputs = _resolved_ports(node, typed_nodes[node_id]) + expected = outputs if is_source else inputs + opposite = inputs if is_source else outputs + + if port_name in expected: + return expected[port_name] + + if port_name in opposite: + # The port exists but has the wrong direction: a connection must + # start at an output port and end at an input port. + code = CODE_V2_SOURCE_NOT_OUTPUT if is_source else CODE_V2_TARGET_NOT_INPUT + direction = "an output" if is_source else "an input" + findings.append(ValidationFinding( + SEVERITY_ERROR, + code, + "Connection '{0}' {1} port '{2}' on node '{3}' is not {4} port".format( + connection.id, role, port_name, node_id, direction + ), + connection_id=connection.id, + )) + return None + + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V2_UNKNOWN_PORT, + "Connection '{0}' {1} references unknown port '{2}' on node '{3}'".format( + connection.id, role, port_name, node_id + ), + connection_id=connection.id, + )) + return None + + +# -------------------------------------------------------------------------- +# V3: cycle detection via Tarjan SCC (Requirement 4.3) +# -------------------------------------------------------------------------- + +def _check_v3(graph: WorkflowGraph) -> List[ValidationFinding]: + node_ids = [node.id for node in graph.nodes] + known = set(node_ids) + + successors: Dict[str, List[str]] = {node_id: [] for node_id in node_ids} + self_loops = set() + for connection in graph.connections: + source = connection.source.node + target = connection.target.node + if source in known and target in known: + if target not in successors[source]: + successors[source].append(target) + if source == target: + self_loops.add(source) + + findings = [] + for scc in _tarjan_sccs(node_ids, successors): + is_cycle = len(scc) > 1 or (len(scc) == 1 and scc[0] in self_loops) + if not is_cycle: + continue + # Report every node participating in the cycle, each finding + # naming the full cycle membership (Requirement 4.3). + members = ", ".join(sorted(scc)) + for node_id in sorted(scc): + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V3_CYCLE, + "Node '{0}' participates in a cycle with nodes: {1}".format( + node_id, members + ), + node_id=node_id, + )) + return findings + + +def _tarjan_sccs(node_ids: List[str], successors: Dict[str, List[str]]) -> List[List[str]]: + """Iterative Tarjan strongly-connected-components.""" + index_counter = 0 + index: Dict[str, int] = {} + lowlink: Dict[str, int] = {} + stack: List[str] = [] + on_stack = set() + sccs: List[List[str]] = [] + + for root in node_ids: + if root in index: + continue + work = [(root, 0)] + while work: + node, next_child = work[-1] + if node not in index: + index[node] = lowlink[node] = index_counter + index_counter += 1 + stack.append(node) + on_stack.add(node) + + descended = False + children = successors.get(node, []) + for position in range(next_child, len(children)): + child = children[position] + if child not in index: + work[-1] = (node, position + 1) + work.append((child, 0)) + descended = True + break + if child in on_stack: + lowlink[node] = min(lowlink[node], index[child]) + if descended: + continue + + if lowlink[node] == index[node]: + scc = [] + while True: + member = stack.pop() + on_stack.discard(member) + scc.append(member) + if member == node: + break + sccs.append(scc) + + work.pop() + if work: + parent = work[-1][0] + lowlink[parent] = min(lowlink[parent], lowlink[node]) + + return sccs + + +# -------------------------------------------------------------------------- +# V4: required parameters satisfy constraints (Requirement 4.4) +# -------------------------------------------------------------------------- + +def _check_v4(graph: WorkflowGraph, typed_nodes: Dict[str, NodeTypeDescriptor]) -> List[ValidationFinding]: + findings = [] + for node in graph.nodes: + descriptor = typed_nodes.get(node.id) + if descriptor is None: + continue + for parameter in descriptor.parameters: + value = _effective_value(node, parameter) + violation = check_parameter_value(parameter, value) + if violation is None: + continue + code = ( + CODE_V4_MISSING_REQUIRED_PARAMETER + if violation.code == VIOLATION_REQUIRED + else CODE_V4_INVALID_PARAMETER_VALUE + ) + findings.append(ValidationFinding( + SEVERITY_ERROR, + code, + "Node '{0}': {1}".format(node.id, violation.message), + node_id=node.id, + )) + return findings + + +def _effective_value(node: Node, parameter) -> Any: + """The value V4 validates: the explicitly set value when the key is + present (an explicit null counts as cleared), else the declared + default (a default is a value, so required parameters with defaults + are satisfied when omitted).""" + if parameter.name in node.parameters: + return node.parameters[parameter.name] + return parameter.default + + +# -------------------------------------------------------------------------- +# V5: reachability from input nodes via forward BFS (Requirement 4.5) +# -------------------------------------------------------------------------- + +def _check_v5(graph: WorkflowGraph, typed_nodes: Dict[str, NodeTypeDescriptor]) -> List[ValidationFinding]: + known = {node.id for node in graph.nodes} + successors: Dict[str, List[str]] = {node_id: [] for node_id in known} + for connection in graph.connections: + source = connection.source.node + target = connection.target.node + if source in known and target in known: + successors[source].append(target) + + roots = [ + node.id for node in graph.nodes + if typed_nodes.get(node.id) is not None + and typed_nodes[node.id].category == CATEGORY_INPUT + ] + + visited = set(roots) + frontier = list(roots) + while frontier: + current = frontier.pop() + for child in successors.get(current, []): + if child not in visited: + visited.add(child) + frontier.append(child) + + findings = [] + for node in graph.nodes: + if node.id not in visited: + findings.append(ValidationFinding( + SEVERITY_ERROR, + CODE_V5_UNREACHABLE_NODE, + "Node '{0}' is not reachable from any input node".format(node.id), + node_id=node.id, + )) + return findings + + +# -------------------------------------------------------------------------- +# W1: warnings (Requirement 4.6) +# -------------------------------------------------------------------------- + +def _check_w1(graph: WorkflowGraph, typed_nodes: Dict[str, NodeTypeDescriptor]) -> List[ValidationFinding]: + incoming = set() # node ids with at least one incoming connection + used_output_ports = set() # (node id, port name) pairs feeding a connection + for connection in graph.connections: + incoming.add(connection.target.node) + used_output_ports.add((connection.source.node, connection.source.port)) + + findings = [] + for node in graph.nodes: + descriptor = typed_nodes.get(node.id) + if descriptor is None: + continue + + if descriptor.category == CATEGORY_OUTPUT and node.id not in incoming: + findings.append(ValidationFinding( + SEVERITY_WARNING, + CODE_W1_OUTPUT_NODE_NO_INPUT, + "Output node '{0}' has no incoming connection".format(node.id), + node_id=node.id, + )) + + _, outputs = _resolved_ports(node, descriptor) + for port_name in outputs: + if (node.id, port_name) not in used_output_ports: + findings.append(ValidationFinding( + SEVERITY_WARNING, + CODE_W1_UNUSED_OUTPUT_PORT, + "Output port '{0}' of node '{1}' is not connected".format( + port_name, node.id + ), + node_id=node.id, + )) + return findings diff --git a/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/parameters.py b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/parameters.py new file mode 100644 index 00000000..912f3e0d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/python/workflow_core/validator/parameters.py @@ -0,0 +1,219 @@ +"""Shared parameter constraint predicate (Requirements 1.8, 4.4). + +Validates a single parameter value against its ``ParameterDescriptor``: +declared type check plus the declared constraints (min/max for numeric +types, min_length/max_length/regex for string-like types, ``values`` +membership for enums and discrete value sets). + +This predicate is the single source of truth for parameter validation. +It is used by: + - the Workflow_Validator check V4 (required parameters satisfy their + constraints, Requirement 4.4), and + - the frontend configuration panel, which mirrors this logic in + TypeScript for inline validation errors (Requirement 1.8). + +Keep the semantics here in sync with the TypeScript mirror +(``arePortsCompatible``'s sibling in the frontend validator package). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from ..catalog.models import ( + PARAM_TYPE_BOOL, + PARAM_TYPE_CODE, + PARAM_TYPE_ENUM, + PARAM_TYPE_FLOAT, + PARAM_TYPE_INT, + PARAM_TYPE_MODEL_REF, + PARAM_TYPE_STRING, + ParameterDescriptor, +) + +# -------------------------------------------------------------------------- +# Violation codes (stable identifiers shared with the frontend mirror) +# -------------------------------------------------------------------------- + +VIOLATION_REQUIRED = "PARAM_REQUIRED" +VIOLATION_TYPE = "PARAM_TYPE" +VIOLATION_MIN = "PARAM_MIN" +VIOLATION_MAX = "PARAM_MAX" +VIOLATION_MIN_LENGTH = "PARAM_MIN_LENGTH" +VIOLATION_MAX_LENGTH = "PARAM_MAX_LENGTH" +VIOLATION_REGEX = "PARAM_REGEX" +VIOLATION_VALUES = "PARAM_VALUES" +VIOLATION_UNKNOWN_TYPE = "PARAM_UNKNOWN_TYPE" + +#: Parameter types whose values are strings. +_STRING_LIKE_TYPES = (PARAM_TYPE_STRING, PARAM_TYPE_CODE, PARAM_TYPE_MODEL_REF) + + +@dataclass(frozen=True) +class ParameterViolation: + """Why a parameter value fails validation. + + ``code`` is a stable machine-readable identifier; ``message`` is the + human-readable reason displayed by the configuration panel + (Requirement 1.8) and embedded in ValidationFinding records + (Requirement 4.4). + """ + + code: str + message: str + + +def check_parameter_value(descriptor: ParameterDescriptor, value: Any) -> ParameterViolation | None: + """Validate ``value`` against ``descriptor``. + + Returns None when the value is valid, otherwise a ParameterViolation + describing the first constraint violated. A missing value (None) is a + violation only for required parameters. + """ + name = descriptor.name + + if value is None: + if descriptor.required: + return ParameterViolation( + VIOLATION_REQUIRED, + "Required parameter '{0}' has no value".format(name), + ) + return None + + type_violation = _check_type(descriptor, value) + if type_violation is not None: + return type_violation + + return _check_constraints(descriptor, value) + + +def is_parameter_value_valid(descriptor: ParameterDescriptor, value: Any) -> bool: + """Boolean form of :func:`check_parameter_value`.""" + return check_parameter_value(descriptor, value) is None + + +# -------------------------------------------------------------------------- +# Type check +# -------------------------------------------------------------------------- + +def _check_type(descriptor: ParameterDescriptor, value: Any) -> ParameterViolation | None: + param_type = descriptor.param_type + name = descriptor.name + + if param_type in _STRING_LIKE_TYPES: + if not isinstance(value, str): + return _type_violation(name, param_type, value) + return None + + if param_type == PARAM_TYPE_INT: + # bool is a subclass of int in Python; reject it explicitly. + if not isinstance(value, int) or isinstance(value, bool): + return _type_violation(name, param_type, value) + return None + + if param_type == PARAM_TYPE_FLOAT: + # Accept ints for float parameters (JSON number semantics). + if not isinstance(value, (int, float)) or isinstance(value, bool): + return _type_violation(name, param_type, value) + return None + + if param_type == PARAM_TYPE_BOOL: + if not isinstance(value, bool): + return _type_violation(name, param_type, value) + return None + + if param_type == PARAM_TYPE_ENUM: + # An enum value's "type" is membership in the declared value set; + # the membership itself is checked with the constraints below. + return None + + return ParameterViolation( + VIOLATION_UNKNOWN_TYPE, + "Parameter '{0}' has unknown declared type '{1}'".format(name, param_type), + ) + + +def _type_violation(name: str, param_type: str, value: Any) -> ParameterViolation: + return ParameterViolation( + VIOLATION_TYPE, + "Parameter '{0}' expects type '{1}' but got {2}".format( + name, param_type, type(value).__name__ + ), + ) + + +# -------------------------------------------------------------------------- +# Constraint checks +# -------------------------------------------------------------------------- + +def _check_constraints(descriptor: ParameterDescriptor, value: Any) -> ParameterViolation | None: + constraints = descriptor.constraints or {} + name = descriptor.name + + # ``values`` membership: enum parameters and discrete value sets on + # other types (e.g. an int parameter restricted to specific values). + if "values" in constraints: + allowed = constraints["values"] + if not any(_matches_member(value, member) for member in allowed): + return ParameterViolation( + VIOLATION_VALUES, + "Parameter '{0}' value {1!r} is not one of {2!r}".format(name, value, allowed), + ) + + # Numeric range. Negated comparisons so NaN fails bounded ranges. + if isinstance(value, (int, float)) and not isinstance(value, bool): + minimum = constraints.get("min") + if minimum is not None and not value >= minimum: + return ParameterViolation( + VIOLATION_MIN, + "Parameter '{0}' value {1!r} is below the minimum {2!r}".format( + name, value, minimum + ), + ) + maximum = constraints.get("max") + if maximum is not None and not value <= maximum: + return ParameterViolation( + VIOLATION_MAX, + "Parameter '{0}' value {1!r} is above the maximum {2!r}".format( + name, value, maximum + ), + ) + + # String length and pattern. + if isinstance(value, str): + min_length = constraints.get("min_length") + if min_length is not None and len(value) < min_length: + return ParameterViolation( + VIOLATION_MIN_LENGTH, + "Parameter '{0}' must be at least {1} character(s) long".format( + name, min_length + ), + ) + max_length = constraints.get("max_length") + if max_length is not None and len(value) > max_length: + return ParameterViolation( + VIOLATION_MAX_LENGTH, + "Parameter '{0}' must be at most {1} character(s) long".format( + name, max_length + ), + ) + pattern = constraints.get("regex") + if pattern is not None and re.search(pattern, value) is None: + return ParameterViolation( + VIOLATION_REGEX, + "Parameter '{0}' value does not match the required pattern {1!r}".format( + name, pattern + ), + ) + + return None + + +def _matches_member(value: Any, member: Any) -> bool: + """Equality for ``values`` membership that never conflates bools with + the numerically equal ints (True == 1, False == 0 in Python).""" + if isinstance(value, bool) or isinstance(member, bool): + return value is member + return value == member diff --git a/edge-cv-portal/backend/layers/workflow_core/requirements.txt b/edge-cv-portal/backend/layers/workflow_core/requirements.txt new file mode 100644 index 00000000..9460aafe --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/requirements.txt @@ -0,0 +1,2 @@ +# Runtime dependencies for the workflow_core Lambda layer. +jsonschema>=3.2,<5 diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/__init__.py b/edge-cv-portal/backend/layers/workflow_core/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/conftest.py b/edge-cv-portal/backend/layers/workflow_core/tests/conftest.py new file mode 100644 index 00000000..3a10387c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/conftest.py @@ -0,0 +1,34 @@ +"""Shared pytest/hypothesis configuration for workflow_core tests. + +Registers and loads a hypothesis profile that caps property tests at +25 examples for fast local runs (HYPOTHESIS_PROFILE=ci for larger runs). +""" + +import os +import sys + +from hypothesis import HealthCheck, settings + +# Make the layer's package importable without installation +# (mirrors how the Lambda layer exposes it on sys.path under python/). +# Appended rather than prepended: python/ also carries the layer's +# vendored Lambda-runtime dependencies (CPython 3.11 manylinux wheels, +# e.g. jsonschema's rpds), which must not shadow the host interpreter's +# own packages when the tests run locally. +_PACKAGE_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "python") +if _PACKAGE_ROOT not in sys.path: + sys.path.append(_PACKAGE_ROOT) + +# Default example budget for property tests. Reduced from the original +# 100-example profile to keep local suite runtime low; use the "ci" +# profile (HYPOTHESIS_PROFILE=ci) for a more exhaustive run. +settings.register_profile( + "workflow-manager", + max_examples=25, + suppress_health_check=[HealthCheck.too_slow], +) + +# Allow overriding via HYPOTHESIS_PROFILE (e.g. a larger "ci" run), +# defaulting to the fast 25-example profile. +settings.register_profile("ci", max_examples=500) +settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "workflow-manager")) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/generators.py b/edge-cv-portal/backend/layers/workflow_core/tests/generators.py new file mode 100644 index 00000000..897e3513 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/generators.py @@ -0,0 +1,781 @@ +"""Shared hypothesis generators for workflow_core property tests (task 2.3). + +Three families of generators, used by the serializer, validator, and +compiler property tests (tasks 2.4, 2.5, 3.4, 4.3-4.8): + +1. ``graph_strategy`` - random *valid* Workflow_Definitions built from the + node catalog: random node subsets, valid parameter values, + type-compatible DAG wiring, and optional fan-out (a drawn "hub" mode + funnels every consumer onto the first compatible source, producing + maximal fan-out). Edge cases covered: empty/whitespace/unicode strings + in parameter values and node/connection ids, minimal two-node graphs, + and maximal fan-out. Every generated graph passes ``validate()`` with + no error-severity findings (warnings such as unused output ports may + be present). + + ``single_node_graph_strategy`` complements it with well-formed + single-node graphs (serializable, parseable) for serializer edge + cases; a single node can never satisfy validator check V1, so these + are intentionally *not* validator-valid. + +2. Defect-seeding combinators - ``seeded_graph_strategy`` produces + controlled *invalid* graphs for a drawn (or caller-fixed) set of + defect classes: missing input/output nodes, incompatible-port + connections, injected cycles, cleared required parameters, and + detached unreachable nodes. Each :class:`SeededGraph` carries the + exact set of error-severity findings ``validate()`` must return + (including findings implied by a seeding, e.g. removing all input + nodes necessarily makes every remaining node unreachable), so the + finding-set exactness property (Property 3) can compare directly. + +3. Schema-corrupting document mutators - ``corrupted_document_strategy`` + serializes a well-formed graph and applies one drawn corruption + (dropped required keys, wrong types, bad schema versions, extra + properties, duplicate ids, dangling node references). Every produced + document is rejected by ``parse()`` with a descriptive error. + +**Validates: Requirements 3.4, 4.6, 6.6** +""" + +from __future__ import annotations + +import copy +import json +from dataclasses import dataclass +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Sequence, Tuple + +from hypothesis import strategies as st + +from workflow_core.catalog import ( + NODE_CATALOG, + PORT_TYPES, + are_port_types_compatible, + get_node_type, +) +from workflow_core.catalog.models import ParameterDescriptor +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, + graph_to_document, +) +from workflow_core.validator import ( + CODE_V1_NO_INPUT_NODE, + CODE_V1_NO_OUTPUT_NODE, + CODE_V2_INCOMPATIBLE_TYPES, + CODE_V2_SOURCE_NOT_OUTPUT, + CODE_V2_TARGET_NOT_INPUT, + CODE_V3_CYCLE, + CODE_V4_MISSING_REQUIRED_PARAMETER, + CODE_V5_UNREACHABLE_NODE, + check_parameter_value, + is_parameter_value_valid, +) + +__all__ = [ + "graph_strategy", + "single_node_graph_strategy", + "valid_parameter_value_strategy", + "node_parameters_strategy", + "DEFECT_MISSING_INPUT_NODE", + "DEFECT_MISSING_OUTPUT_NODE", + "DEFECT_INCOMPATIBLE_CONNECTION", + "DEFECT_CYCLE", + "DEFECT_CLEARED_REQUIRED_PARAMETER", + "DEFECT_UNREACHABLE_NODE", + "ALL_DEFECT_CLASSES", + "ExpectedFinding", + "SeededGraph", + "seeded_graph_strategy", + "corrupted_document_strategy", +] + +# --------------------------------------------------------------------------- +# Catalog groupings used for wiring-feasible type selection +# --------------------------------------------------------------------------- + +#: Input node types that emit VideoFrames (a graph always gets one so a +#: downstream chain is guaranteed to be wireable). +_VIDEO_INPUT_TYPES = ("camera_source", "aravis_camera_source", "folder_source") + +#: All input-category node types. +_INPUT_TYPES = ("camera_source", "aravis_camera_source", "folder_source", + "digital_input") + +#: Intermediate (non-input, non-output) node types. ``conditional`` is the +#: multi-output executor node: both of its output ports register as +#: available sources, so downstream consumers may wire to either path. +_INTERMEDIATE_TYPES = ( + "dewarp", + "rotate", + "crop", + "format_convert", + "model_inference", + "custom_python", + "inference_filter", + "conditional", +) + +#: Intermediate types safe to place without any connection (used when a +#: seeded graph deliberately has no input nodes): no per-instance port +#: typing to coordinate, and all parameters can be generated valid. +_DETACHED_SAFE_INTERMEDIATE_TYPES = ( + "dewarp", + "rotate", + "crop", + "format_convert", + "model_inference", + "inference_filter", + "conditional", +) + +#: Output-category node types. +_OUTPUT_TYPES = ("digital_output", "mqtt_publish", "opcua_write", "capture") + +#: Per-instance port typing parameters (custom_python). Clearing these +#: would change port resolution and cascade into V2 findings, so the +#: cleared-required-parameter combinator never touches them. +_PORT_TYPING_PARAMETER_NAMES = frozenset({"input_port_type", "output_port_type"}) + +# --------------------------------------------------------------------------- +# Parameter value strategies (valid values only) +# --------------------------------------------------------------------------- + +#: Curated strings covering the required edge cases: empty, whitespace-only, +#: unicode, and embedded/surrounding whitespace. Filtered per-descriptor +#: against length constraints below. +_CURATED_STRINGS = ( + "", + " ", + "\t", + " \n\t ", + "0", + "with space", + " padded ", + "naïve", + "ノード-Ω✓", + "définition-ワークフロー", +) + +_STRING_LIKE_TYPES = ("string", "code", "model_ref") + + +def valid_parameter_value_strategy(descriptor: ParameterDescriptor) -> st.SearchStrategy: + """A strategy of values that satisfy ``descriptor``'s type and constraints.""" + constraints = descriptor.constraints or {} + + if "values" in constraints: + # Enums and discrete value sets: membership is the whole rule. + return st.sampled_from(list(constraints["values"])) + + if descriptor.param_type in _STRING_LIKE_TYPES: + return _string_value_strategy(constraints) + if descriptor.param_type == "int": + return st.integers( + min_value=constraints.get("min"), max_value=constraints.get("max") + ) + if descriptor.param_type == "float": + return st.floats( + min_value=constraints.get("min"), + max_value=constraints.get("max"), + allow_nan=False, + allow_infinity=False, + ) + if descriptor.param_type == "bool": + return st.booleans() + + raise ValueError( + "no value strategy for parameter type {!r}".format(descriptor.param_type) + ) + + +def _string_value_strategy(constraints: Dict[str, Any]) -> st.SearchStrategy: + min_length = constraints.get("min_length", 0) + max_length = constraints.get("max_length") + pattern = constraints.get("regex") + + if pattern is not None: + base = st.from_regex(pattern) + else: + base = st.one_of( + st.sampled_from(_CURATED_STRINGS), + st.text(max_size=max_length if max_length is not None else 24), + ) + + def satisfies_lengths(value: str) -> bool: + if len(value) < min_length: + return False + if max_length is not None and len(value) > max_length: + return False + return True + + return base.filter(satisfies_lengths) + + +@st.composite +def node_parameters_strategy(draw, descriptor, forced: Optional[Dict[str, Any]] = None): + """Valid parameter values for one node of type ``descriptor``. + + ``forced`` entries are used verbatim (the graph builders force + custom_python's per-instance port types to match the wiring). Other + parameters are either omitted (when omission is valid, i.e. the + parameter is optional or its default satisfies its constraints) or + given a drawn valid value. + """ + forced = dict(forced or {}) + parameters: Dict[str, Any] = {} + for parameter in descriptor.parameters: + if parameter.name in forced: + parameters[parameter.name] = forced[parameter.name] + continue + omission_valid = check_parameter_value(parameter, parameter.default) is None + if omission_valid and draw(st.booleans()): + continue + value = draw(valid_parameter_value_strategy(parameter)) + assert is_parameter_value_valid(parameter, value) + parameters[parameter.name] = value + return parameters + + +# --------------------------------------------------------------------------- +# Graph builder (shared by the valid and defect-seeded strategies) +# --------------------------------------------------------------------------- + +_POSITION_COORDINATE = st.floats( + min_value=-5000, max_value=5000, allow_nan=False, allow_infinity=False +) + +#: Id prefixes exercise unicode and embedded whitespace in identifiers. +_NODE_ID_PREFIXES = ("n", "node-", "ノード", "n ") +_CONNECTION_ID_PREFIXES = ("c", "conn-", "接続 ") + + +class _GraphBuilder: + """Accumulates nodes/connections while tracking available output ports.""" + + def __init__(self, draw): + self._draw = draw + self._node_prefix = draw(st.sampled_from(_NODE_ID_PREFIXES)) + self._connection_prefix = draw(st.sampled_from(_CONNECTION_ID_PREFIXES)) + self._node_counter = 0 + self._connection_counter = 0 + self.nodes: List[Node] = [] + self.connections: List[Connection] = [] + #: (node_id, port_name, effective_port_type) for every output port. + self.sources: List[Tuple[str, str, str]] = [] + + def add_node(self, type_id: str, forced_params: Optional[Dict[str, Any]] = None, + register_outputs: bool = True) -> Node: + descriptor = get_node_type(type_id) + assert descriptor is not None, type_id + self._node_counter += 1 + node = Node( + id="{}{}".format(self._node_prefix, self._node_counter), + type=type_id, + position=Position( + x=self._draw(_POSITION_COORDINATE), + y=self._draw(_POSITION_COORDINATE), + ), + parameters=self._draw(node_parameters_strategy(descriptor, forced_params)), + ) + self.nodes.append(node) + + if not register_outputs: + return node + output_override = None + parameter_names = {p.name for p in descriptor.parameters} + if "output_port_type" in parameter_names: + candidate = node.parameters.get("output_port_type") + if candidate in PORT_TYPES: + output_override = candidate + for port in descriptor.outputs: + self.sources.append( + (node.id, port.name, output_override or port.port_type) + ) + return node + + def add_bedrock_node(self) -> Node: + """Add a two-input bedrock_inference node fed by dedicated fresh + VideoFrames sources (Requirement: two-input inference wiring). + + The feeders' output ports are deliberately NOT registered as + wiring sources for other consumers, and neither is the bedrock + node's InferenceMeta output: on device architectures the + compiler terminates each feeding branch in a frame-capture sink + (frames do not flow through the node), so generated graphs keep + bedrock branches self-contained — matching the topology the + compiler supports while still exercising the two-input node + through every property. A drawn boolean shares one feeder + between both input ports (same-source comparison) or gives each + port its own feeder. + """ + shared_feeder = self._draw(st.booleans()) + first = self.add_node( + self._draw(st.sampled_from(_VIDEO_INPUT_TYPES)), + register_outputs=False, + ) + second = ( + first if shared_feeder + else self.add_node( + self._draw(st.sampled_from(_VIDEO_INPUT_TYPES)), + register_outputs=False, + ) + ) + bedrock = self.add_node("bedrock_inference", register_outputs=False) + self.connect((first.id, "out"), bedrock.id, "in") + self.connect((second.id, "out"), bedrock.id, "reference") + return bedrock + + def connect(self, source: Tuple[str, str], target_node_id: str, target_port: str) -> Connection: + self._connection_counter += 1 + connection = Connection( + id="{}{}".format(self._connection_prefix, self._connection_counter), + source=PortEndpoint(node=source[0], port=source[1]), + target=PortEndpoint(node=target_node_id, port=target_port), + ) + self.connections.append(connection) + return connection + + def compatible_sources(self, input_type: str) -> List[Tuple[str, str, str]]: + return [ + source for source in self.sources + if are_port_types_compatible(source[2], input_type) + ] + + def add_wired_consumer(self, type_id: str, hub: bool) -> Node: + """Add a node of ``type_id`` fed from a type-compatible existing + output port; ``hub`` mode always picks the first compatible source, + producing maximal fan-out on that source.""" + descriptor = get_node_type(type_id) + input_port = descriptor.inputs[0] + + if type_id == "custom_python": + # Per-instance port typing (Requirement 2.7): declare the input + # type to exactly match the chosen source's output type. + candidates = self.sources + source = candidates[0] if hub else self._draw(st.sampled_from(candidates)) + forced = { + "input_port_type": source[2], + "output_port_type": self._draw(st.sampled_from(PORT_TYPES)), + } + else: + candidates = self.compatible_sources(input_port.port_type) + source = candidates[0] if hub else self._draw(st.sampled_from(candidates)) + forced = None + + node = self.add_node(type_id, forced) + self.connect((source[0], source[1]), node.id, input_port.name) + return node + + def feasible_consumer_types(self, type_ids: Sequence[str]) -> List[str]: + """The subset of ``type_ids`` whose single input port can be fed by + some currently available output port.""" + available_types = {source[2] for source in self.sources} + feasible = [] + for type_id in type_ids: + if type_id == "custom_python": + if self.sources: + feasible.append(type_id) + continue + descriptor = get_node_type(type_id) + input_type = descriptor.inputs[0].port_type + if any(are_port_types_compatible(t, input_type) for t in available_types): + feasible.append(type_id) + return feasible + + def build(self) -> WorkflowGraph: + return WorkflowGraph(nodes=self.nodes, connections=self.connections) + + +# --------------------------------------------------------------------------- +# 1. Valid workflow graphs +# --------------------------------------------------------------------------- + +@st.composite +def graph_strategy( + draw, + max_intermediates: int = 4, + max_extra_inputs: int = 2, + max_extra_outputs: int = 2, +): + """Random *valid* Workflow_Definition graphs from the node catalog. + + Guarantees (checked by the smoke tests in ``test_generators.py``): + ``validate(graph)`` returns no error-severity findings, and the graph + serializes canonically. Structure: one guaranteed VideoFrames input, + optional extra inputs, 0..max_intermediates wired intermediate nodes, + and 1..1+max_extra_outputs wired output nodes; wiring is always + forward (DAG) and type-compatible. A drawn hub mode funnels all + consumers onto the first compatible source (maximal fan-out). + """ + builder = _GraphBuilder(draw) + hub = draw(st.booleans()) + + # V1 + wiring feasibility: at least one VideoFrames-producing input. + builder.add_node(draw(st.sampled_from(_VIDEO_INPUT_TYPES))) + for _ in range(draw(st.integers(min_value=0, max_value=max_extra_inputs))): + builder.add_node(draw(st.sampled_from(_INPUT_TYPES))) + + for _ in range(draw(st.integers(min_value=0, max_value=max_intermediates))): + feasible = builder.feasible_consumer_types(_INTERMEDIATE_TYPES) + builder.add_wired_consumer(draw(st.sampled_from(feasible)), hub) + + # Optionally exercise the two-input Bedrock inference node with its + # dedicated feeder sources (see add_bedrock_node). + if draw(st.booleans()): + builder.add_bedrock_node() + + for _ in range(1 + draw(st.integers(min_value=0, max_value=max_extra_outputs))): + feasible = builder.feasible_consumer_types(_OUTPUT_TYPES) + builder.add_wired_consumer(draw(st.sampled_from(feasible)), hub) + + return builder.build() + + +@st.composite +def single_node_graph_strategy(draw): + """Well-formed single-node graphs (serializer edge case). + + Serializable and parseable, with valid parameter values — but a single + node can never satisfy validator check V1 (a valid workflow needs both + an input and an output node), so these graphs are not validator-valid. + """ + builder = _GraphBuilder(draw) + builder.add_node(draw(st.sampled_from([d.type_id for d in NODE_CATALOG]))) + return builder.build() + + +# --------------------------------------------------------------------------- +# 2. Defect-seeding combinators +# --------------------------------------------------------------------------- + +DEFECT_MISSING_INPUT_NODE = "missing_input_node" +DEFECT_MISSING_OUTPUT_NODE = "missing_output_node" +DEFECT_INCOMPATIBLE_CONNECTION = "incompatible_connection" +DEFECT_CYCLE = "cycle" +DEFECT_CLEARED_REQUIRED_PARAMETER = "cleared_required_parameter" +DEFECT_UNREACHABLE_NODE = "unreachable_node" + +ALL_DEFECT_CLASSES = ( + DEFECT_MISSING_INPUT_NODE, + DEFECT_MISSING_OUTPUT_NODE, + DEFECT_INCOMPATIBLE_CONNECTION, + DEFECT_CYCLE, + DEFECT_CLEARED_REQUIRED_PARAMETER, + DEFECT_UNREACHABLE_NODE, +) + + +@dataclass(frozen=True) +class ExpectedFinding: + """One error-severity finding ``validate()`` must return.""" + + code: str + node_id: Optional[str] = None + connection_id: Optional[str] = None + + +@dataclass(frozen=True, eq=False) +class SeededGraph: + """A deliberately invalid graph plus its exact expected error findings. + + ``expected`` is the complete set of *error-severity* findings + ``validate(graph)`` must return — the seeded defects and their implied + consequences (e.g. with no input nodes, every node is unreachable), and + nothing else. Warning-severity findings are not constrained. + """ + + graph: WorkflowGraph + defects: FrozenSet[str] + expected: FrozenSet[ExpectedFinding] + + +@st.composite +def seeded_graph_strategy(draw, defect_classes: Optional[Sequence[str]] = None): + """Controlled invalid graphs for a set of defect classes. + + Draws a nonempty subset of :data:`ALL_DEFECT_CLASSES` (or uses the + caller-fixed ``defect_classes``) and constructs a graph containing + exactly those defect classes. The returned :class:`SeededGraph` lists + the exact expected error findings, so the validator finding-set + exactness property can assert equality. + """ + if defect_classes is None: + defects = frozenset( + draw(st.sets(st.sampled_from(ALL_DEFECT_CLASSES), min_size=1)) + ) + else: + defects = frozenset(defect_classes) + unknown = defects - set(ALL_DEFECT_CLASSES) + if unknown: + raise ValueError("unknown defect classes: {}".format(sorted(unknown))) + if not defects: + raise ValueError("at least one defect class is required") + + include_inputs = DEFECT_MISSING_INPUT_NODE not in defects + include_outputs = DEFECT_MISSING_OUTPUT_NODE not in defects + + builder = _GraphBuilder(draw) + hub = draw(st.booleans()) + expected: set = set() + #: Nodes deliberately left unreachable while input nodes exist. + detached_node_ids: List[str] = [] + + # --- valid base structure (minus the classes seeded by omission) ------ + if include_inputs: + builder.add_node(draw(st.sampled_from(_VIDEO_INPUT_TYPES))) + for _ in range(draw(st.integers(min_value=0, max_value=1))): + builder.add_node(draw(st.sampled_from(_INPUT_TYPES))) + for _ in range(draw(st.integers(min_value=0, max_value=2))): + feasible = builder.feasible_consumer_types(_INTERMEDIATE_TYPES) + builder.add_wired_consumer(draw(st.sampled_from(feasible)), hub) + else: + # No input nodes: intermediates are placed detached (every node + # will be unreachable anyway; see the V5 expectation below). + for _ in range(draw(st.integers(min_value=0, max_value=2))): + builder.add_node( + draw(st.sampled_from(_DETACHED_SAFE_INTERMEDIATE_TYPES)) + ) + + if include_outputs: + for _ in range(1 + draw(st.integers(min_value=0, max_value=1))): + if include_inputs: + feasible = builder.feasible_consumer_types(_OUTPUT_TYPES) + builder.add_wired_consumer(draw(st.sampled_from(feasible)), hub) + else: + builder.add_node(draw(st.sampled_from(_OUTPUT_TYPES))) + + # --- injected cycle (V3) ---------------------------------------------- + if DEFECT_CYCLE in defects: + cycle_length = draw(st.integers(min_value=1, max_value=3)) + if include_inputs: + # Choose the reachability feed *before* adding the cycle nodes + # so the cycle is never "fed" from inside itself. + feed = draw(st.sampled_from(builder.compatible_sources("VideoFrames"))) + cycle_nodes = [builder.add_node("rotate") for _ in range(cycle_length)] + if include_inputs: + # Keep the cycle reachable so no V5 findings are implied. + builder.connect((feed[0], feed[1]), cycle_nodes[0].id, "in") + for position, node in enumerate(cycle_nodes): + successor = cycle_nodes[(position + 1) % cycle_length] + builder.connect((node.id, "out"), successor.id, "in") + for node in cycle_nodes: + expected.add(ExpectedFinding(CODE_V3_CYCLE, node_id=node.id)) + + # --- incompatible-port connection (V2) -------------------------------- + if DEFECT_INCOMPATIBLE_CONNECTION in defects: + # A fresh VideoFrames-typed feeder, wired for reachability when + # possible; the bad connection then targets a fresh node so it can + # never create a cycle or unseat other invariants. + if include_inputs: + feeder = builder.add_wired_consumer("rotate", hub) + else: + feeder = builder.add_node("rotate") + variant = draw( + st.sampled_from(("incompatible_types", "source_not_output", "target_not_input")) + ) + if variant == "incompatible_types": + # VideoFrames output -> InferenceMeta input: no coercion exists. + target = builder.add_node("inference_filter") + bad = builder.connect((feeder.id, "out"), target.id, "in") + expected.add( + ExpectedFinding(CODE_V2_INCOMPATIBLE_TYPES, connection_id=bad.id) + ) + elif variant == "source_not_output": + target = builder.add_node("rotate") + bad = builder.connect((feeder.id, "in"), target.id, "in") + expected.add( + ExpectedFinding(CODE_V2_SOURCE_NOT_OUTPUT, connection_id=bad.id) + ) + else: # target_not_input + target = builder.add_node("rotate") + bad = builder.connect((feeder.id, "out"), target.id, "out") + expected.add( + ExpectedFinding(CODE_V2_TARGET_NOT_INPUT, connection_id=bad.id) + ) + # Reachability note: V5's BFS follows connections regardless of + # port validity, so `target` is reachable through the bad edge + # whenever `feeder` is. + + # --- detached unreachable node (V5) ------------------------------------ + if DEFECT_UNREACHABLE_NODE in defects: + detached = builder.add_node( + draw(st.sampled_from(_DETACHED_SAFE_INTERMEDIATE_TYPES)) + ) + detached_node_ids.append(detached.id) + + # --- cleared required parameter (V4) ------------------------------------ + if DEFECT_CLEARED_REQUIRED_PARAMETER in defects: + candidates = [] + for node in builder.nodes: + descriptor = get_node_type(node.type) + for parameter in descriptor.parameters: + if parameter.required and parameter.name not in _PORT_TYPING_PARAMETER_NAMES: + candidates.append((node, parameter.name)) + if not candidates: + # Guarantee a target: a fresh rotate node ('method' is required), + # wired for reachability when inputs exist. + if include_inputs: + fresh = builder.add_wired_consumer("rotate", hub) + else: + fresh = builder.add_node("rotate") + candidates = [(fresh, "method")] + node, parameter_name = draw(st.sampled_from(candidates)) + # An explicit null counts as cleared (validator V4 semantics). + node.parameters[parameter_name] = None + expected.add( + ExpectedFinding(CODE_V4_MISSING_REQUIRED_PARAMETER, node_id=node.id) + ) + + # --- V1 and V5 expectations --------------------------------------------- + if not include_inputs: + expected.add(ExpectedFinding(CODE_V1_NO_INPUT_NODE)) + # With no input nodes there are no BFS roots: every node in the + # final graph is unreachable. + for node in builder.nodes: + expected.add(ExpectedFinding(CODE_V5_UNREACHABLE_NODE, node_id=node.id)) + else: + for node_id in detached_node_ids: + expected.add(ExpectedFinding(CODE_V5_UNREACHABLE_NODE, node_id=node_id)) + if not include_outputs: + expected.add(ExpectedFinding(CODE_V1_NO_OUTPUT_NODE)) + + return SeededGraph( + graph=builder.build(), defects=defects, expected=frozenset(expected) + ) + + +# --------------------------------------------------------------------------- +# 3. Schema-corrupting document mutators +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class _DocumentMutation: + name: str + applicable: Callable[[Dict[str, Any]], bool] + apply: Callable[..., None] # (draw, document) -> None, mutates in place + + +def _always(_document: Dict[str, Any]) -> bool: + return True + + +def _has_nodes(document: Dict[str, Any]) -> bool: + return bool(document.get("nodes")) + + +def _has_connections(document: Dict[str, Any]) -> bool: + return bool(document.get("connections")) + + +def _pick_node(draw, document): + nodes = document["nodes"] + return nodes[draw(st.integers(min_value=0, max_value=len(nodes) - 1))] + + +def _pick_connection(draw, document): + connections = document["connections"] + return connections[draw(st.integers(min_value=0, max_value=len(connections) - 1))] + + +def _drop_top_level_key(draw, document): + del document[draw(st.sampled_from(("schemaVersion", "nodes", "connections")))] + + +def _bad_schema_version(draw, document): + document["schemaVersion"] = draw(st.sampled_from((0, -1, 2, 99, "1", None, True))) + + +def _wrong_top_level_type(draw, document): + key = draw(st.sampled_from(("nodes", "connections"))) + document[key] = draw(st.sampled_from(({}, "not-a-list", 5))) + + +def _extra_top_level_property(_draw, document): + document["unexpectedProperty"] = 1 + + +def _drop_node_key(draw, document): + node = _pick_node(draw, document) + del node[draw(st.sampled_from(("id", "type", "position", "parameters")))] + + +def _bad_node_value(draw, document): + node = _pick_node(draw, document) + key, values = draw(st.sampled_from(( + ("id", ("", 7, None)), + ("type", ("", 3)), + ("position", ([1, 2], "here", {"x": 0}, {"x": "a", "y": 0})), + ("parameters", ([], "params", 3)), + ))) + node[key] = draw(st.sampled_from(values)) + + +def _duplicate_node_id(draw, document): + nodes = document["nodes"] + nodes.append(copy.deepcopy(_pick_node(draw, document))) + + +def _drop_connection_key(draw, document): + connection = _pick_connection(draw, document) + del connection[draw(st.sampled_from(("id", "from", "to")))] + + +def _bad_connection_value(draw, document): + connection = _pick_connection(draw, document) + bad_endpoints = ( + "n1", + 5, + {"node": "x"}, # missing "port" + {"node": "", "port": "out"}, # empty node id + {"node": "a", "port": "b", "extra": 1}, # additional property + ) + key, values = draw(st.sampled_from(( + ("id", ("", 3)), + ("from", bad_endpoints), + ("to", bad_endpoints), + ))) + connection[key] = draw(st.sampled_from(values)) + + +def _unknown_node_reference(draw, document): + connection = _pick_connection(draw, document) + existing_ids = {node["id"] for node in document["nodes"]} + missing = "__missing-node__" + while missing in existing_ids: + missing += "_" + connection[draw(st.sampled_from(("from", "to")))]["node"] = missing + + +_DOCUMENT_MUTATIONS = ( + _DocumentMutation("drop_top_level_key", _always, _drop_top_level_key), + _DocumentMutation("bad_schema_version", _always, _bad_schema_version), + _DocumentMutation("wrong_top_level_type", _always, _wrong_top_level_type), + _DocumentMutation("extra_top_level_property", _always, _extra_top_level_property), + _DocumentMutation("drop_node_key", _has_nodes, _drop_node_key), + _DocumentMutation("bad_node_value", _has_nodes, _bad_node_value), + _DocumentMutation("duplicate_node_id", _has_nodes, _duplicate_node_id), + _DocumentMutation("drop_connection_key", _has_connections, _drop_connection_key), + _DocumentMutation("bad_connection_value", _has_connections, _bad_connection_value), + _DocumentMutation("unknown_node_reference", _has_connections, _unknown_node_reference), +) + + +@st.composite +def corrupted_document_strategy(draw, graphs: Optional[st.SearchStrategy] = None): + """Workflow_Definition JSON documents corrupted to violate the schema + (or the parse-level structural rules: duplicate ids, dangling node + references). Every produced document string is rejected by ``parse()`` + with a descriptive error and never yields a graph. + """ + graph = draw( + graphs if graphs is not None + else st.one_of(graph_strategy(), single_node_graph_strategy()) + ) + document = copy.deepcopy(graph_to_document(graph)) + applicable = [m for m in _DOCUMENT_MUTATIONS if m.applicable(document)] + mutation = draw(st.sampled_from(applicable)) + mutation.apply(draw, document) + return json.dumps(document) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_classification.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_classification.py new file mode 100644 index 00000000..ba2ebaa2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_classification.py @@ -0,0 +1,166 @@ +"""Unit tests for workflow_core.catalog.classification. + +Covers each official plugin set by module name and by known repository +URL forms, arbitrary URLs classifying as unclassified, and the fixed +plain-language explanations (Requirements 15.3, 15.4). +""" + +import pytest + +from workflow_core.catalog.classification import ( + CLASSIFICATION_BAD, + CLASSIFICATION_GOOD, + CLASSIFICATION_UGLY, + CLASSIFICATION_UNCLASSIFIED, + CLASSIFICATIONS, + EXPLANATIONS, + classify_plugin_set, +) + + +class TestClassifyByModuleName: + @pytest.mark.parametrize( + "name,expected", + [ + ("gst-plugins-good", CLASSIFICATION_GOOD), + ("gst-plugins-bad", CLASSIFICATION_BAD), + ("gst-plugins-ugly", CLASSIFICATION_UGLY), + ], + ) + def test_official_set_names(self, name, expected): + assert classify_plugin_set(name, None) == expected + + def test_name_wins_without_url(self): + assert classify_plugin_set("gst-plugins-good", "") == CLASSIFICATION_GOOD + + def test_surrounding_whitespace_is_ignored(self): + assert classify_plugin_set(" gst-plugins-bad ", None) == CLASSIFICATION_BAD + + @pytest.mark.parametrize( + "name", + [ + "gst-plugins-base", + "gstreamer", + "gst-plugins-goodish", + "my-gst-plugins-good", + "gst-libav", + "some-random-plugin", + "", + ], + ) + def test_other_names_are_unclassified(self, name): + assert classify_plugin_set(name, None) == CLASSIFICATION_UNCLASSIFIED + + def test_none_inputs_are_unclassified(self): + assert classify_plugin_set(None, None) == CLASSIFICATION_UNCLASSIFIED + + +class TestClassifyByRepoUrl: + @pytest.mark.parametrize( + "url,expected", + [ + # Per-set GitLab repos (with and without .git / trailing slash) + ( + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good", + CLASSIFICATION_GOOD, + ), + ( + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-bad.git", + CLASSIFICATION_BAD, + ), + ( + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-ugly/", + CLASSIFICATION_UGLY, + ), + # Monorepo subproject paths + ( + "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/" + "main/subprojects/gst-plugins-good", + CLASSIFICATION_GOOD, + ), + ( + "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/" + "main/subprojects/gst-plugins-bad", + CLASSIFICATION_BAD, + ), + ( + "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/" + "main/subprojects/gst-plugins-ugly", + CLASSIFICATION_UGLY, + ), + # Historical cgit / anongit mirrors + ( + "https://cgit.freedesktop.org/gstreamer/gst-plugins-good/", + CLASSIFICATION_GOOD, + ), + ( + "git://anongit.freedesktop.org/gstreamer/gst-plugins-ugly", + CLASSIFICATION_UGLY, + ), + # Source release tree + ( + "https://gstreamer.freedesktop.org/src/gst-plugins-bad/", + CLASSIFICATION_BAD, + ), + ], + ) + def test_known_official_urls(self, url, expected): + assert classify_plugin_set(None, url) == expected + + @pytest.mark.parametrize( + "url", + [ + # Arbitrary public repositories are never guessed into a set, + # even when named like one (Requirement 15.4). + "https://github.com/someone/gst-plugins-good", + "https://gitlab.com/gstreamer/gst-plugins-good", + "https://example.com/gstreamer/gst-plugins-bad", + # freedesktop hosts without an official set path + "https://gitlab.freedesktop.org/gstreamer/gstreamer", + "https://gitlab.freedesktop.org/someuser/gst-plugins-good", + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-base", + "https://cgit.freedesktop.org/mesa/mesa", + # Malformed / odd inputs + "not a url", + "ftp://gitlab.freedesktop.org/gstreamer/gst-plugins-good", + "", + ], + ) + def test_everything_else_is_unclassified(self, url): + assert classify_plugin_set(None, url) == CLASSIFICATION_UNCLASSIFIED + + def test_module_selected_from_listing_matches_by_name_and_url(self): + # A Module_Listing entry carries both the name and the repo URL. + assert ( + classify_plugin_set( + "gst-plugins-ugly", + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-ugly.git", + ) + == CLASSIFICATION_UGLY + ) + + +class TestExplanations: + def test_every_classification_has_a_non_empty_explanation(self): + assert set(EXPLANATIONS) == set(CLASSIFICATIONS) + for value in CLASSIFICATIONS: + assert isinstance(EXPLANATIONS[value], str) + assert EXPLANATIONS[value].strip() + + def test_explanation_texts_match_requirement_15_3(self): + assert EXPLANATIONS[CLASSIFICATION_GOOD] == ( + "good indicates a well-maintained, well-tested, properly " + "licensed plugin set" + ) + assert EXPLANATIONS[CLASSIFICATION_BAD] == ( + "bad indicates a plugin set lacking upstream review, testing, " + "or active maintenance" + ) + assert EXPLANATIONS[CLASSIFICATION_UGLY] == ( + "ugly indicates a plugin set of good quality that carries " + "licensing or distribution concerns" + ) + assert EXPLANATIONS[CLASSIFICATION_UNCLASSIFIED] == ( + "unclassified indicates a plugin outside the official GStreamer " + "plugin sets that warrants the highest caution" + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_content.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_content.py new file mode 100644 index 00000000..799aa0c7 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_content.py @@ -0,0 +1,770 @@ +"""Unit tests for catalog content (task 1.4). + +Asserts presence and parameterization of all required input, +preprocessing, inference, post-processing, and output node types. + +Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5 + +Aravis camera input additions (aravis-camera-input task 1.3): +descriptor identity, parameters, mappings, the camera_source pin, and +the catalog mirror-equality check. + +Validates: aravis-camera-input Requirements 1.1, 1.2, 1.3, 1.4, 1.6, 7.4 +""" + +import os + +from workflow_core.catalog import ( + ARCHITECTURES, + DEVICE_ARCHITECTURES, + CATEGORY_INFERENCE, + CATEGORY_INPUT, + CATEGORY_OUTPUT, + CATEGORY_POST_PROCESSING, + CATEGORY_PREPROCESSING, + NODE_CATALOG, + PORT_TYPE_EVENT_SIGNAL, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES, + get_node_type, + nodes_by_category, +) + + +def _params_by_name(descriptor): + return {param.name: param for param in descriptor.parameters} + + +def _port_types(ports): + return [port.port_type for port in ports] + + +# -------------------------------------------------------------------------- +# Requirement 2.1: input node types +# -------------------------------------------------------------------------- + +class TestInputNodeTypes: + def test_required_input_types_present(self): + for type_id in ("camera_source", "folder_source", "digital_input"): + descriptor = get_node_type(type_id) + assert descriptor is not None, type_id + assert descriptor.category == CATEGORY_INPUT + # Input nodes originate data: no input ports, at least one output. + assert descriptor.inputs == [] + assert len(descriptor.outputs) >= 1 + + def test_camera_source_parameterization(self): + descriptor = get_node_type("camera_source") + assert _port_types(descriptor.outputs) == [PORT_TYPE_VIDEO_FRAMES] + params = _params_by_name(descriptor) + assert "device" in params + assert params["device"].param_type == "string" + assert params["device"].default == "/dev/video0" + assert "gain" in params + assert params["gain"].param_type == "int" + assert params["gain"].constraints.get("min") == 0 + assert params["gain"].constraints.get("max") == 100 + assert "exposure" in params + assert params["exposure"].param_type == "int" + assert descriptor.hardware_dependent is True + + def test_folder_source_parameterization(self): + descriptor = get_node_type("folder_source") + assert _port_types(descriptor.outputs) == [PORT_TYPE_VIDEO_FRAMES] + params = _params_by_name(descriptor) + assert params["location"].required is True + assert params["location"].param_type == "string" + assert params["file_pattern"].required is False + assert params["file_pattern"].default == "*.jpg" + # Reads the device-local file system, which the cloud sandbox does + # not have: hardware-dependent so simulation feeds it from the + # Test_Dataset (Requirement 12.6). + assert descriptor.hardware_dependent is True + + def test_folder_source_sim_mapping_is_the_dataset_fed_stub(self): + # The sim mapping is the same dataset-fed recording stub + # camera_source uses: multifilesrc on the {dataset_location} + # placeholder with stock decode elements — never the device + # filesrc/emexifextract chain (Requirement 12.6). + folder = get_node_type("folder_source").mapping_for("sim") + camera = get_node_type("camera_source").mapping_for("sim") + assert folder == camera + factories = [entry["factory"] for entry in folder.element_chain] + assert factories == ["multifilesrc", "jpegparse", "jpegdec", + "videoconvert"] + multifilesrc = folder.element_chain[0] + assert multifilesrc["args_template"]["location"] == "{dataset_location}" + assert "emexifextract" not in folder.plugin_dependencies + + def test_digital_input_parameterization(self): + descriptor = get_node_type("digital_input") + assert _port_types(descriptor.outputs) == [PORT_TYPE_EVENT_SIGNAL] + params = _params_by_name(descriptor) + assert params["pin"].required is True + assert params["pin"].param_type == "int" + assert params["pin"].constraints == {"min": 0, "max": 255} + assert params["trigger_edge"].param_type == "enum" + assert set(params["trigger_edge"].constraints["values"]) == { + "rising", "falling", "both"} + assert params["trigger_edge"].default == "rising" + assert params["poll_interval_ms"].param_type == "int" + assert descriptor.hardware_dependent is True + + +# -------------------------------------------------------------------------- +# Aravis camera input node type +# (aravis-camera-input Requirements 1.1-1.4, 1.6, 7.4) +# -------------------------------------------------------------------------- + +class TestAravisCameraSourceNodeType: + def test_descriptor_identity(self): + # Requirement 1.1: type id, category, display name, no inputs, + # exactly one VideoFrames output port named "out". + descriptor = get_node_type("aravis_camera_source") + assert descriptor is not None + assert descriptor.type_id == "aravis_camera_source" + assert descriptor.category == CATEGORY_INPUT + assert descriptor.display_name == "Aravis Camera Source" + assert descriptor.inputs == [] + assert [(port.name, port.port_type) for port in descriptor.outputs] == [ + ("out", PORT_TYPE_VIDEO_FRAMES)] + + def test_camera_id_parameterization(self): + # Requirement 1.2: required string camera_id, min_length 1, + # documented with at least one working example. + params = _params_by_name(get_node_type("aravis_camera_source")) + assert list(params) == ["camera_id", "gain", "exposure"] + camera_id = params["camera_id"] + assert camera_id.required is True + assert camera_id.param_type == "string" + assert camera_id.default is None + assert camera_id.constraints == {"min_length": 1} + assert camera_id.description.strip() + assert isinstance(camera_id.examples, list) and camera_id.examples + + def test_gain_and_exposure_parameterization(self): + # Requirement 1.3: optional acquisition settings mirroring what + # the Camera_Manager applies, each documented with examples. + params = _params_by_name(get_node_type("aravis_camera_source")) + gain = params["gain"] + assert gain.required is False + assert gain.param_type == "int" + assert gain.default == 4 + assert gain.constraints == {"min": 0, "max": 100} + assert gain.description.strip() + assert isinstance(gain.examples, list) and gain.examples + exposure = params["exposure"] + assert exposure.required is False + assert exposure.param_type == "int" + assert exposure.default == 5000000 + assert exposure.constraints == {"min": 0} + assert exposure.description.strip() + assert isinstance(exposure.examples, list) and exposure.examples + + def test_hardware_dependent(self): + # Requirement 1.4: the node is hardware dependent. + assert get_node_type("aravis_camera_source").hardware_dependent is True + + def test_device_arch_mappings_are_the_appsrc_chain(self): + # Requirement 1.4: every physical device architecture renders + # appsrc name=appsrc_{nodeId} ! videoconvert with the app and + # videoconvertscale plugin dependencies. The appsrc name embeds + # the {nodeId} token so multi-camera documents stay addressable. + descriptor = get_node_type("aravis_camera_source") + assert {m.arch for m in descriptor.mappings} == set(ARCHITECTURES) + for arch in DEVICE_ARCHITECTURES: + mapping = descriptor.mapping_for(arch) + assert mapping is not None, arch + assert mapping.element_chain == [ + {"factory": "appsrc", + "args_template": {"name": "appsrc_{nodeId}"}}, + {"factory": "videoconvert", "args_template": {}}, + ], arch + assert mapping.plugin_dependencies == [ + "app", "videoconvertscale"], arch + assert mapping.executor_binding is None, arch + + def test_sim_mapping_is_the_dataset_fed_stub(self): + # Requirement 1.4: the sim mapping is the shared dataset-fed + # stub, byte-equal to camera_source's sim mapping. + aravis_sim = get_node_type("aravis_camera_source").mapping_for("sim") + camera_sim = get_node_type("camera_source").mapping_for("sim") + assert aravis_sim == camera_sim + + +class TestCameraSourceUnchanged: + """Pin `camera_source`'s pre-Aravis descriptor shape. + + Requirement 7.4: the existing camera_source node type keeps its + current parameters and mappings unchanged by the Aravis addition. + """ + + def test_identity_and_parameters_pinned(self): + descriptor = get_node_type("camera_source") + assert descriptor.type_id == "camera_source" + assert descriptor.category == CATEGORY_INPUT + assert descriptor.display_name == "Camera Source" + assert descriptor.inputs == [] + assert [(port.name, port.port_type) for port in descriptor.outputs] == [ + ("out", PORT_TYPE_VIDEO_FRAMES)] + assert descriptor.hardware_dependent is True + params = _params_by_name(descriptor) + assert list(params) == ["device", "gain", "exposure"] + device = params["device"] + assert device.required is False + assert device.param_type == "string" + assert device.default == "/dev/video0" + assert device.constraints == {"min_length": 1} + gain = params["gain"] + assert (gain.required, gain.param_type, gain.default) == ( + False, "int", 4) + assert gain.constraints == {"min": 0, "max": 100} + exposure = params["exposure"] + assert (exposure.required, exposure.param_type, exposure.default) == ( + False, "int", 5000000) + assert exposure.constraints == {"min": 0} + + def test_mappings_pinned(self): + descriptor = get_node_type("camera_source") + assert {m.arch for m in descriptor.mappings} == set(ARCHITECTURES) + # x86 architectures: V4L2 capture, never appsrc. + for arch in ("x86_64", "x86_64_nvidia"): + mapping = descriptor.mapping_for(arch) + assert mapping.element_chain == [ + {"factory": "v4l2src", "args_template": {"device": "{device}"}}, + {"factory": "videoconvert", "args_template": {}}, + ], arch + assert mapping.plugin_dependencies == [ + "video4linux2", "videoconvertscale"], arch + # JP4/JP5: the classic adapter-fed appsrc named plain "appsrc" + # (never the per-node appsrc_{nodeId} the Aravis node uses). + for arch in ("arm64_jp4", "arm64_jp5"): + mapping = descriptor.mapping_for(arch) + assert mapping.element_chain == [ + {"factory": "appsrc", "args_template": {"name": "appsrc"}}, + {"factory": "videoconvert", "args_template": {}}, + ], arch + assert mapping.plugin_dependencies == [ + "app", "videoconvertscale"], arch + # JP6: the PNG-staged host-service capture path. + jp6 = descriptor.mapping_for("arm64_jp6") + assert [entry["factory"] for entry in jp6.element_chain] == [ + "filesrc", "pngdec", "videoconvert"] + assert jp6.element_chain[0]["args_template"]["location"] == ( + "/aws_dda/nvidia-csi-capture/latest.jpg.dda_decoded.png") + assert jp6.plugin_dependencies == [ + "coreelements", "png", "videoconvertscale", "python:pillow"] + + +class TestCatalogMirrorEquality: + def test_portal_and_vendor_catalog_nodes_are_byte_identical(self): + # Requirement 1.6: the portal layer catalog and the edge vendor + # mirror carry the node identically — the two nodes.py sources + # stay byte-identical. + import workflow_core.catalog.nodes as portal_nodes + + portal_path = os.path.abspath(portal_nodes.__file__) + if portal_path.endswith(".pyc"): + portal_path = portal_path[:-1] + # tests/ -> workflow_core -> layers -> backend -> edge-cv-portal + # -> repository root + repo_root = os.path.abspath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "..", "..", "..", "..")) + vendor_path = os.path.join( + repo_root, "src", "backend", "workflow_engine", "vendor", + "workflow_core", "catalog", "nodes.py") + assert os.path.isfile(vendor_path), vendor_path + with open(portal_path, "rb") as handle: + portal_bytes = handle.read() + with open(vendor_path, "rb") as handle: + vendor_bytes = handle.read() + assert portal_bytes == vendor_bytes, ( + "portal layer and edge vendor catalog nodes.py have diverged") + + +# -------------------------------------------------------------------------- +# Requirement 2.2: preprocessing node types +# -------------------------------------------------------------------------- + +class TestPreprocessingNodeTypes: + def test_required_preprocessing_types_present(self): + for type_id in ("dewarp", "rotate", "crop", "format_convert"): + descriptor = get_node_type(type_id) + assert descriptor is not None, type_id + assert descriptor.category == CATEGORY_PREPROCESSING + # Video-in, video-out filters. + assert _port_types(descriptor.inputs) == [PORT_TYPE_VIDEO_FRAMES] + assert _port_types(descriptor.outputs) == [PORT_TYPE_VIDEO_FRAMES] + assert descriptor.hardware_dependent is False + + def test_dewarp_parameterization(self): + params = _params_by_name(get_node_type("dewarp")) + assert params["mode"].param_type == "enum" + assert set(params["mode"].constraints["values"]) == { + "fisheye", "barrel", "perspective"} + assert params["strength"].param_type == "float" + assert params["strength"].constraints == {"min": 0.0, "max": 1.0} + assert params["strength"].default == 0.5 + + def test_rotate_parameterization(self): + params = _params_by_name(get_node_type("rotate")) + assert params["method"].required is True + assert params["method"].param_type == "enum" + assert "clockwise" in params["method"].constraints["values"] + assert "rotate-180" in params["method"].constraints["values"] + + def test_crop_parameterization(self): + params = _params_by_name(get_node_type("crop")) + for side in ("top", "bottom", "left", "right"): + assert side in params + assert params[side].param_type == "int" + assert params[side].default == 0 + assert params[side].constraints.get("min") == 0 + + def test_format_convert_parameterization(self): + params = _params_by_name(get_node_type("format_convert")) + assert params["format"].required is True + assert params["format"].param_type == "enum" + assert {"RGB", "BGR", "GRAY8"} <= set(params["format"].constraints["values"]) + assert params["format"].default == "RGB" + + +# -------------------------------------------------------------------------- +# Custom Python preprocessing node type +# (custom-python-frames Requirements 1.1-1.5) +# -------------------------------------------------------------------------- + +class TestCustomPythonPreprocessNodeType: + def test_descriptor_present_in_preprocessing_category(self): + descriptor = get_node_type("custom_python_preprocess") + assert descriptor is not None + assert descriptor.category == CATEGORY_PREPROCESSING + assert descriptor.display_name == "Custom Python (Frames)" + assert descriptor.hardware_dependent is False + + def test_fixed_video_frames_ports_without_type_overrides(self): + # Exactly one VideoFrames input and one VideoFrames output; the + # per-instance port type override parameters of custom_python + # must not exist here (Requirement 1.2). + descriptor = get_node_type("custom_python_preprocess") + assert _port_types(descriptor.inputs) == [PORT_TYPE_VIDEO_FRAMES] + assert _port_types(descriptor.outputs) == [PORT_TYPE_VIDEO_FRAMES] + params = _params_by_name(descriptor) + assert "input_port_type" not in params + assert "output_port_type" not in params + + def test_parameterization(self): + params = _params_by_name(get_node_type("custom_python_preprocess")) + assert params["code"].required is True + assert params["code"].param_type == "code" + assert params["requirements"].required is False + assert params["requirements"].param_type == "string" + + def test_mappings_identical_to_custom_python(self): + # Same emlpython element chain and plugin dependencies as the + # custom_python post-processing node on every architecture + # (Requirement 1.4). + preprocess = get_node_type("custom_python_preprocess") + post = get_node_type("custom_python") + assert {m.arch for m in preprocess.mappings} == set(ARCHITECTURES) + for arch in ARCHITECTURES: + pre_mapping = preprocess.mapping_for(arch) + post_mapping = post.mapping_for(arch) + assert post_mapping is not None, arch + assert pre_mapping.element_chain == post_mapping.element_chain, arch + assert pre_mapping.plugin_dependencies == \ + post_mapping.plugin_dependencies, arch + + +# -------------------------------------------------------------------------- +# Custom Python contract documentation +# (custom-python-frames Requirements 1.5, 8.1, 8.2) +# -------------------------------------------------------------------------- + +class TestCustomPythonContractDocumentation: + def test_preprocess_code_description_documents_process_frame(self): + code = _params_by_name( + get_node_type("custom_python_preprocess"))["code"] + assert "process_frame" in code.description + assert "process(data, metadata)" not in code.description + + def test_custom_python_code_description_documents_actual_entry_points(self): + # The actual runtime entry points (process_frame and handle), + # never the non-existent process(data, metadata) contract + # (Requirement 8.1). + code = _params_by_name(get_node_type("custom_python"))["code"] + assert "process_frame" in code.description + assert "handle" in code.description + assert "process(data, metadata)" not in code.description + + def test_every_code_example_defines_a_runtime_entry_point(self): + # Each code example exec's to a module defining a callable + # process_frame or handle, so it is a valid handler under the + # runtime contract (Requirements 1.5, 8.2). + for type_id in ("custom_python", "custom_python_preprocess"): + code = _params_by_name(get_node_type(type_id))["code"] + assert isinstance(code.examples, list) and code.examples, type_id + for example in code.examples: + namespace = {} + exec(compile(example, "", "exec"), namespace) + entry = namespace.get("process_frame") or namespace.get("handle") + assert callable(entry), (type_id, example) + + +# -------------------------------------------------------------------------- +# Requirement 2.3: model inference node type +# -------------------------------------------------------------------------- + +class TestModelInferenceNodeType: + def test_model_inference_present(self): + descriptor = get_node_type("model_inference") + assert descriptor is not None + assert descriptor.category == CATEGORY_INFERENCE + assert _port_types(descriptor.inputs) == [PORT_TYPE_VIDEO_FRAMES] + assert _port_types(descriptor.outputs) == [PORT_TYPE_INFERENCE_META] + + def test_model_inference_parameterization(self): + descriptor = get_node_type("model_inference") + params = _params_by_name(descriptor) + # Model selected from the portal model registry (Requirement 2.6 shape). + assert params["modelName"].required is True + assert params["modelName"].param_type == "model_ref" + + def test_model_inference_uses_triton_path_on_device_architectures(self): + # Runs via the LocalServer Triton path on every physical device + # architecture: emltriton element configured with the model name + # and Triton repo/server paths. The exact chain is asserted so the + # device output can never drift (the sim stub below must not + # change device behavior). + descriptor = get_node_type("model_inference") + device_mappings = [m for m in descriptor.mappings if m.arch != "sim"] + assert sorted(m.arch for m in device_mappings) == sorted( + ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6"]) + for mapping in device_mappings: + factories = [entry["factory"] for entry in mapping.element_chain] + assert factories == ["capsfilter", "emltriton"] + capsfilter = mapping.element_chain[0] + assert capsfilter["args_template"] == { + "caps": "video/x-raw,format=RGB"} + args = mapping.element_chain[1]["args_template"] + assert args["model"] == "{modelName}" + assert args["model-repo"] == "{triton_model_repo}" + assert args["server-path"] == "{triton_server_path}" + assert mapping.plugin_dependencies == ["coreelements", "emltriton"] + + def test_model_inference_sim_mapping_is_a_passthrough_stub(self): + # The cloud sandbox has no emltriton plugin and registered models + # are device-compiled, so simulation stubs the node: a pass-through + # chain (RGB capsfilter + identity named sim_inference_) + # keeps the stream flowing while the harness injects the configured + # simulated inference outcome (Requirement 12.6). + descriptor = get_node_type("model_inference") + assert descriptor.hardware_dependent is True + sim = descriptor.mapping_for("sim") + assert sim is not None + factories = [entry["factory"] for entry in sim.element_chain] + assert factories == ["capsfilter", "identity"] + assert "emltriton" not in factories + identity = sim.element_chain[1] + assert identity["args_template"]["name"] == "{sim_inference_name}" + assert "emltriton" not in sim.plugin_dependencies + + +# -------------------------------------------------------------------------- +# Bedrock inference node type (reference-comparison via Bedrock runtime) +# -------------------------------------------------------------------------- + +class TestBedrockInferenceNodeType: + def test_bedrock_inference_present_with_two_video_inputs(self): + descriptor = get_node_type("bedrock_inference") + assert descriptor is not None + assert descriptor.category == CATEGORY_INFERENCE + assert descriptor.display_name == "Bedrock Inference" + # Two VideoFrames inputs: the frame under inspection and the + # reference image; one InferenceMeta output. + assert [(port.name, port.port_type) for port in descriptor.inputs] == [ + ("in", PORT_TYPE_VIDEO_FRAMES), + ("reference", PORT_TYPE_VIDEO_FRAMES), + ] + assert _port_types(descriptor.outputs) == [PORT_TYPE_INFERENCE_META] + + def test_bedrock_inference_parameterization(self): + params = _params_by_name(get_node_type("bedrock_inference")) + assert params["model"].param_type == "enum" + assert set(params["model"].constraints["values"]) == { + "us.amazon.nova-pro-v1:0", + "us.amazon.nova-lite-v1:0", + "qwen.qwen3-vl-235b-a22b", + "moonshotai.kimi-k2.5", + } + assert params["model"].default == "us.amazon.nova-lite-v1:0" + assert params["prompt"].required is True + assert params["prompt"].param_type == "string" + assert '"is_anomalous"' in params["prompt"].default + assert '"confidence"' in params["prompt"].default + assert params["region"].required is False + assert params["region"].default == "us-east-1" + assert params["max_tokens"].param_type == "int" + assert params["max_tokens"].default == 256 + + def test_bedrock_inference_is_an_executor_binding_on_device_archs(self): + # Executor-level realization on every physical device + # architecture: no GStreamer elements of its own; the compiler + # terminates the input branches in synthetic capture sinks and + # the binding carries the parameters + capture paths. + descriptor = get_node_type("bedrock_inference") + assert descriptor.hardware_dependent is True + device_mappings = [m for m in descriptor.mappings if m.arch != "sim"] + assert sorted(m.arch for m in device_mappings) == sorted( + ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6"]) + for mapping in device_mappings: + assert mapping.element_chain == [] + assert mapping.executor_binding == "bedrock_inference" + # The capture sink chain (videoconvert ! jpegenc ! + # multifilesink) and the boto3 runtime client. + assert set(mapping.plugin_dependencies) == { + "videoconvertscale", "jpeg", "multifile", "python:boto3"} + + def test_bedrock_inference_sim_mapping_matches_the_model_inference_stub(self): + # Simulation stubs the node exactly like model_inference: the + # sandbox VPC has no internet, so the model is never invoked and + # the harness injects the configured simulated outcome via the + # sim_inference_ identity (Requirement 12.6). + sim = get_node_type("bedrock_inference").mapping_for("sim") + assert sim == get_node_type("model_inference").mapping_for("sim") + + +# -------------------------------------------------------------------------- +# Requirement 2.4: post-processing node types +# -------------------------------------------------------------------------- + +class TestPostProcessingNodeTypes: + def test_required_post_processing_types_present(self): + for type_id in ("custom_python", "inference_filter", "conditional"): + descriptor = get_node_type(type_id) + assert descriptor is not None, type_id + assert descriptor.category == CATEGORY_POST_PROCESSING + + def test_custom_python_parameterization(self): + descriptor = get_node_type("custom_python") + params = _params_by_name(descriptor) + # User-supplied code plus declared input/output port types + # (Requirement 2.7 shape). + assert params["code"].required is True + assert params["code"].param_type == "code" + all_port_types = {PORT_TYPE_VIDEO_FRAMES, PORT_TYPE_INFERENCE_META, + PORT_TYPE_EVENT_SIGNAL} + for name in ("input_port_type", "output_port_type"): + assert params[name].required is True + assert params[name].param_type == "enum" + assert set(params[name].constraints["values"]) == all_port_types + + def test_inference_filter_parameterization(self): + descriptor = get_node_type("inference_filter") + # Filters inference results: InferenceMeta in and out. + assert _port_types(descriptor.inputs) == [PORT_TYPE_INFERENCE_META] + assert _port_types(descriptor.outputs) == [PORT_TYPE_INFERENCE_META] + params = _params_by_name(descriptor) + # Configurable condition over inference metadata. + assert params["condition"].required is True + assert params["condition"].param_type == "string" + + def test_conditional_parameterization(self): + descriptor = get_node_type("conditional") + assert descriptor.display_name == "Conditional" + # Two-path routing: InferenceMeta in, two InferenceMeta outputs. + # The "true" output receives the metadata when the condition + # holds, the "false" output when it does not. + assert _port_types(descriptor.inputs) == [PORT_TYPE_INFERENCE_META] + assert [(port.name, port.port_type) for port in descriptor.outputs] == [ + ("true", PORT_TYPE_INFERENCE_META), + ("false", PORT_TYPE_INFERENCE_META), + ] + params = _params_by_name(descriptor) + # One required condition, same grammar as inference_filter. + assert list(params) == ["condition"] + assert params["condition"].required is True + assert params["condition"].param_type == "string" + assert params["condition"].constraints == {"min_length": 1} + assert descriptor.hardware_dependent is False + + def test_conditional_maps_to_the_conditional_executor_binding_on_all_archs(self): + # Mechanically like inference_filter: an executor-level binding + # (no GStreamer elements), identical on every architecture. + descriptor = get_node_type("conditional") + assert len(descriptor.mappings) == len( + get_node_type("inference_filter").mappings) + for mapping in descriptor.mappings: + assert mapping.element_chain == [] + assert mapping.executor_binding == "conditional" + assert mapping.plugin_dependencies == [] + + +# -------------------------------------------------------------------------- +# Requirement 2.5: output node types +# -------------------------------------------------------------------------- + +class TestOutputNodeTypes: + def test_required_output_types_present(self): + for type_id in ("digital_output", "mqtt_publish", "opcua_write", "capture"): + descriptor = get_node_type(type_id) + assert descriptor is not None, type_id + assert descriptor.category == CATEGORY_OUTPUT + # Output nodes are sinks: at least one input, no output ports. + assert len(descriptor.inputs) >= 1 + assert descriptor.outputs == [] + + def test_digital_output_parameterization(self): + descriptor = get_node_type("digital_output") + params = _params_by_name(descriptor) + assert params["pin"].required is True + assert params["pin"].constraints == {"min": 0, "max": 255} + assert params["signal_type"].required is True + assert set(params["signal_type"].constraints["values"]) == { + "high", "low", "pulse"} + assert params["pulse_width_ms"].param_type == "int" + assert params["pulse_width_ms"].default == 100 + assert params["condition"].required is True + assert descriptor.hardware_dependent is True + + def test_mqtt_publish_parameterization(self): + descriptor = get_node_type("mqtt_publish") + params = _params_by_name(descriptor) + assert params["broker_host"].required is True + assert params["topic"].required is True + assert params["broker_port"].param_type == "int" + assert params["broker_port"].default == 1883 + assert params["broker_port"].constraints == {"min": 1, "max": 65535} + assert set(params["qos"].constraints["values"]) == {0, 1, 2} + assert descriptor.hardware_dependent is True + + def test_mqtt_publish_aws_iot_parameterization(self): + # AWS IoT Core support: an opt-in checkbox plus the thing name + # and device-local certificate file paths, all optional and + # shown only while aws_iot is enabled (depends_on). + descriptor = get_node_type("mqtt_publish") + params = _params_by_name(descriptor) + assert params["aws_iot"].param_type == "bool" + assert params["aws_iot"].required is False + assert params["aws_iot"].default is False + assert params["aws_iot"].depends_on is None + for name in ("iot_thing_name", "iot_ca_cert_path", + "iot_client_cert_path", "iot_private_key_path"): + assert params[name].param_type == "string", name + assert params[name].required is False, name + assert params[name].default is None, name + assert params[name].constraints == {"min_length": 1}, name + assert params[name].depends_on == "aws_iot", name + + def test_opcua_write_parameterization(self): + descriptor = get_node_type("opcua_write") + params = _params_by_name(descriptor) + assert params["endpoint"].required is True + assert params["endpoint"].constraints.get("regex") == r"^opc\.tcp://.+" + assert params["node_id"].required is True + assert descriptor.hardware_dependent is True + + def test_capture_parameterization(self): + descriptor = get_node_type("capture") + # Captures inference results to the device file system. + params = _params_by_name(descriptor) + assert params["output_path"].required is True + assert params["output_path"].param_type == "string" + assert params["quality"].param_type == "int" + assert params["quality"].constraints == {"min": 1, "max": 100} + assert params["interval"].param_type == "int" + assert descriptor.hardware_dependent is False + + +# -------------------------------------------------------------------------- +# Cross-cutting catalog content checks +# -------------------------------------------------------------------------- + +class TestCatalogCoverage: + EXPECTED_TYPE_IDS = { + "camera_source", "aravis_camera_source", "folder_source", + "digital_input", + "dewarp", "rotate", "crop", "format_convert", + "custom_python_preprocess", + "model_inference", "bedrock_inference", + "custom_python", "inference_filter", "conditional", + "digital_output", "mqtt_publish", "opcua_write", "capture", + } + + def test_catalog_contains_exactly_the_expected_types(self): + assert {d.type_id for d in NODE_CATALOG} == self.EXPECTED_TYPE_IDS + + def test_every_palette_section_is_populated(self): + grouped = nodes_by_category() + assert set(grouped) == { + CATEGORY_INPUT, CATEGORY_PREPROCESSING, CATEGORY_INFERENCE, + CATEGORY_POST_PROCESSING, CATEGORY_OUTPUT, + } + for category, descriptors in grouped.items(): + assert descriptors, category + + def test_every_node_type_has_a_display_name(self): + for descriptor in NODE_CATALOG: + assert descriptor.display_name.strip() + + def test_every_parameter_has_a_description(self): + # Field-level help: every parameter of every node type documents + # what it is (rendered by the configuration panel under the label). + for descriptor in NODE_CATALOG: + for parameter in descriptor.parameters: + assert isinstance(parameter.description, str) and \ + parameter.description.strip(), ( + "{0}.{1} has no description".format( + descriptor.type_id, parameter.name)) + + def test_every_parameter_has_at_least_one_working_example(self): + # Field-level help: every parameter carries at least one example + # value that satisfies the parameter's own type and constraints + # (usable verbatim). + from workflow_core.validator import check_parameter_value + + for descriptor in NODE_CATALOG: + for parameter in descriptor.parameters: + context = "{0}.{1}".format(descriptor.type_id, parameter.name) + assert isinstance(parameter.examples, list) and \ + parameter.examples, ( + "{0} has no examples".format(context)) + for example in parameter.examples: + violation = check_parameter_value(parameter, example) + assert violation is None, ( + "{0} example {1!r} is not a valid value: {2}".format( + context, example, violation)) + + def test_condition_descriptions_document_the_expression_language(self): + # The condition rule dialect (fields, operators, worked example) + # is documented on every executor-evaluated condition parameter, + # exactly as the shared evaluator supports it. + for type_id in ("inference_filter", "conditional", "digital_output"): + descriptor = get_node_type(type_id) + condition = next(p for p in descriptor.parameters + if p.name == "condition") + for token in ("is_anomalous", "confidence", "==", "!=", ">=", + "<=", "&&", "||", + "is_anomalous == true && confidence >= 0.8"): + assert token in condition.description, (type_id, token) + + def test_condition_parameters_carry_working_examples(self): + # Every executor-evaluated condition parameter ships 3+ example + # expressions in the documented grammar, over exactly the + # metadata fields the shared evaluator resolves (is_anomalous, + # confidence) — never fields the evaluator does not support. + import re + + for type_id in ("inference_filter", "conditional", "digital_output"): + descriptor = get_node_type(type_id) + condition = next(p for p in descriptor.parameters + if p.name == "condition") + examples = condition.examples + assert isinstance(examples, list) and len(examples) >= 3, type_id + assert "is_anomalous == true" in examples, type_id + for example in examples: + identifiers = set(re.findall(r"[A-Za-z_][A-Za-z0-9_]*", + example)) - {"true", "false"} + assert identifiers <= {"is_anomalous", "confidence"}, ( + type_id, example) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_custom.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_custom.py new file mode 100644 index 00000000..51d10f0e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_custom.py @@ -0,0 +1,310 @@ +"""Unit tests for workflow_core.catalog.custom (custom-node-designer task 1.2). + +Covers declaration -> descriptor conversion (valid round trip, each +invalid-field rejection with the offending field named, the DeepStream +architecture restriction) and merged catalog resolution (duplicate +type_id rejection with built-ins winning, result ordering). + +The exhaustive valid/invalid declaration property is task 1.3; these +tests pin the concrete behavior with examples. + +_Requirements: 5.3, 8.2, 8.5, 8.6_ +""" + +import copy + +import pytest + +from workflow_core.catalog import ( + ARCH_ARM64_JP5, + ARCH_X86_64, + DEEPSTREAM_ARCHITECTURES, + NODE_CATALOG, + DeclarationError, + GstMapping, + NodeTypeDescriptor, + ParameterDescriptor, + PortDescriptor, + descriptor_from_declaration, + resolve_catalog, +) + + +def valid_declaration(**overrides): + """The design document's Custom_Node_Type declaration example + (wire JSON), with the field-level help Requirement 8.1 collects.""" + decl = { + "typeId": "custom.blur_regions", + "category": "preprocessing", + "displayName": "Blur Regions", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [ + { + "name": "radius", + "paramType": "int", + "required": True, + "default": 5, + "constraints": {"min": 1, "max": 64}, + "description": "Blur radius in pixels.", + "examples": [5, 9], + } + ], + "mappings": [ + { + "arch": "x86_64", + "elementChain": [ + {"factory": "blurregions", "argsTemplate": {"radius": "{radius}"}} + ], + "pluginDependencies": ["custom:uc-123/blurregions"], + } + ], + "hardwareDependent": False, + # Extra keys the stored declaration may carry are ignored. + "typeVersion": 1, + "lifecycleState": "test", + } + decl.update(overrides) + return decl + + +class TestValidDeclarationConversion: + def test_round_trip_of_the_design_example(self): + descriptor = descriptor_from_declaration(valid_declaration()) + + assert isinstance(descriptor, NodeTypeDescriptor) + assert descriptor.type_id == "custom.blur_regions" + assert descriptor.category == "preprocessing" + assert descriptor.display_name == "Blur Regions" + assert descriptor.hardware_dependent is False + + assert descriptor.inputs == [PortDescriptor("in", "VideoFrames")] + assert descriptor.outputs == [PortDescriptor("out", "VideoFrames")] + + assert descriptor.parameters == [ + ParameterDescriptor( + name="radius", + param_type="int", + required=True, + default=5, + constraints={"min": 1, "max": 64}, + depends_on=None, + description="Blur radius in pixels.", + examples=[5, 9], + ) + ] + + assert descriptor.mappings == [ + GstMapping( + arch="x86_64", + element_chain=[ + {"factory": "blurregions", "args_template": {"radius": "{radius}"}} + ], + executor_binding=None, + plugin_dependencies=["custom:uc-123/blurregions"], + ) + ] + # Plugin dependency recorded so the compiler includes the plugin + # per Target_Architecture (Requirement 8.6). + assert descriptor.mapping_for("x86_64").plugin_dependencies == [ + "custom:uc-123/blurregions" + ] + + def test_descriptor_is_frozen(self): + descriptor = descriptor_from_declaration(valid_declaration()) + with pytest.raises(Exception): + descriptor.type_id = "other" + + def test_wire_constraint_keys_convert_to_python_keys(self): + decl = valid_declaration( + parameters=[ + { + "name": "label", + "paramType": "string", + "required": False, + "default": "ok", + "constraints": {"minLength": 1, "maxLength": 8}, + "description": "Overlay label text.", + "examples": ["ok", "defect"], + } + ] + ) + descriptor = descriptor_from_declaration(decl) + assert descriptor.parameters[0].constraints == {"min_length": 1, "max_length": 8} + + def test_deepstream_declaration_with_jetpack_mappings_is_accepted(self): + decl = valid_declaration( + deepstream=True, + mappings=[ + { + "arch": arch, + "elementChain": [{"factory": "nvblur", "argsTemplate": {}}], + "pluginDependencies": ["custom:uc-123/nvblur"], + } + for arch in DEEPSTREAM_ARCHITECTURES + ], + ) + descriptor = descriptor_from_declaration(decl) + assert [m.arch for m in descriptor.mappings] == list(DEEPSTREAM_ARCHITECTURES) + + +class TestInvalidFieldRejection: + """Every rejection raises DeclarationError naming the offending field + (Requirement 8.5).""" + + def assert_rejected(self, decl, field): + with pytest.raises(DeclarationError) as excinfo: + descriptor_from_declaration(decl) + assert excinfo.value.field == field + assert field in str(excinfo.value) + + def test_missing_type_id(self): + self.assert_rejected(valid_declaration(typeId=None), "typeId") + + def test_missing_display_name(self): + self.assert_rejected(valid_declaration(displayName=""), "displayName") + + def test_unknown_category(self): + self.assert_rejected(valid_declaration(category="filters"), "category") + + def test_unknown_input_port_type(self): + decl = valid_declaration( + inputs=[{"name": "in", "portType": "AudioFrames"}] + ) + self.assert_rejected(decl, "inputs[0].portType") + + def test_unknown_output_port_type(self): + decl = valid_declaration( + outputs=[{"name": "out", "portType": "Bogus"}] + ) + self.assert_rejected(decl, "outputs[0].portType") + + def test_missing_port_name(self): + decl = valid_declaration(inputs=[{"name": "", "portType": "VideoFrames"}]) + self.assert_rejected(decl, "inputs[0].name") + + def test_unknown_parameter_type(self): + decl = valid_declaration() + decl["parameters"][0]["paramType"] = "decimal" + self.assert_rejected(decl, "parameters[0].paramType") + + def test_default_violating_its_own_constraints(self): + decl = valid_declaration() + decl["parameters"][0]["default"] = 200 # constraints max is 64 + self.assert_rejected(decl, "parameters[0].default") + + def test_default_violating_its_own_type(self): + decl = valid_declaration() + decl["parameters"][0]["default"] = "five" + self.assert_rejected(decl, "parameters[0].default") + + def test_example_violating_constraints(self): + decl = valid_declaration() + decl["parameters"][0]["examples"] = [5, 500] + self.assert_rejected(decl, "parameters[0].examples[1]") + + def test_missing_parameter_description(self): + decl = valid_declaration() + del decl["parameters"][0]["description"] + self.assert_rejected(decl, "parameters[0].description") + + def test_empty_examples(self): + decl = valid_declaration() + decl["parameters"][0]["examples"] = [] + self.assert_rejected(decl, "parameters[0].examples") + + def test_enum_without_values_constraint(self): + decl = valid_declaration() + decl["parameters"][0].update( + paramType="enum", default=None, constraints={}, examples=["a"] + ) + self.assert_rejected(decl, "parameters[0].constraints.values") + + def test_duplicate_parameter_names(self): + decl = valid_declaration() + decl["parameters"].append(copy.deepcopy(decl["parameters"][0])) + self.assert_rejected(decl, "parameters[1].name") + + def test_depends_on_must_name_a_bool_parameter(self): + decl = valid_declaration() + decl["parameters"][0]["dependsOn"] = "no_such_toggle" + self.assert_rejected(decl, "parameters[0].dependsOn") + + def test_unknown_mapping_architecture(self): + decl = valid_declaration() + decl["mappings"][0]["arch"] = "riscv" + self.assert_rejected(decl, "mappings[0].arch") + + def test_duplicate_mapping_architecture(self): + decl = valid_declaration() + decl["mappings"].append(copy.deepcopy(decl["mappings"][0])) + self.assert_rejected(decl, "mappings[1].arch") + + def test_element_chain_entry_without_factory(self): + decl = valid_declaration() + decl["mappings"][0]["elementChain"][0]["factory"] = "" + self.assert_rejected(decl, "mappings[0].elementChain[0].factory") + + def test_non_dict_declaration(self): + self.assert_rejected(["not", "a", "declaration"], "declaration") + + +class TestDeepStreamRestriction: + """DeepStream-flagged declarations are restricted to arm64_jp4/jp5/jp6 + mappings (Requirement 5.3).""" + + def test_deepstream_architectures_are_the_jetpack_builds(self): + assert DEEPSTREAM_ARCHITECTURES == ("arm64_jp4", "arm64_jp5", "arm64_jp6") + + def test_deepstream_x86_64_mapping_rejected(self): + decl = valid_declaration(deepstream=True) # mapping is x86_64 + with pytest.raises(DeclarationError) as excinfo: + descriptor_from_declaration(decl) + assert excinfo.value.field == "mappings[0].arch" + + def test_non_deepstream_x86_64_mapping_accepted(self): + descriptor = descriptor_from_declaration(valid_declaration(deepstream=False)) + assert descriptor.mapping_for(ARCH_X86_64) is not None + + +class TestResolveCatalog: + def _custom(self, type_id): + return descriptor_from_declaration(valid_declaration(typeId=type_id)) + + def test_empty_custom_set_returns_the_builtin_catalog(self): + assert resolve_catalog(()) == NODE_CATALOG + + def test_ordering_builtins_first_then_customs_in_given_order(self): + first = self._custom("custom.first") + second = self._custom("custom.second") + resolved = resolve_catalog([first, second]) + assert isinstance(resolved, tuple) + assert resolved[: len(NODE_CATALOG)] == NODE_CATALOG + assert resolved[len(NODE_CATALOG):] == (first, second) + + def test_custom_duplicate_of_builtin_is_rejected_builtin_wins(self): + builtin_id = NODE_CATALOG[0].type_id + impostor = self._custom(builtin_id) + resolved = resolve_catalog([impostor]) + assert resolved == NODE_CATALOG + # The built-in descriptor itself survives, not the custom one. + assert resolved[0] is NODE_CATALOG[0] + assert impostor not in resolved + + def test_duplicate_among_customs_keeps_the_first(self): + first = self._custom("custom.dup") + second = self._custom("custom.dup") + other = self._custom("custom.other") + resolved = resolve_catalog([first, second, other]) + customs = resolved[len(NODE_CATALOG):] + assert customs == (first, other) + + def test_resolved_catalog_serves_custom_types_alongside_builtins(self): + # Requirement 8.2: the merged catalog includes the Custom_Node_Type + # with the same declaration structure as built-in node types. + custom = self._custom("custom.jp5_only") + resolved = resolve_catalog([custom]) + by_id = {d.type_id: d for d in resolved} + assert by_id["custom.jp5_only"] is custom + assert isinstance(custom.mapping_for(ARCH_X86_64), GstMapping) + assert custom.mapping_for(ARCH_ARM64_JP5) is None diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_models.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_models.py new file mode 100644 index 00000000..a0e88477 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_models.py @@ -0,0 +1,112 @@ +"""Unit tests for the catalog data models, port-type compatibility rules, +and the per-arch LocalServer-bundled plugin manifest (task 1.2). + +Catalog content coverage (presence and parameterization of every node +type) lands in tasks 1.3/1.4; these tests exercise the mechanisms this +task introduces. +""" + +import dataclasses + +import pytest + +from workflow_core.catalog import ( + ARCHITECTURES, + CATEGORIES, + LOCALSERVER_BUNDLED_PLUGINS, + NODE_CATALOG, + PORT_TYPE_EVENT_SIGNAL, + PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES, + GstMapping, + NodeTypeDescriptor, + ParameterDescriptor, + PortDescriptor, + are_port_types_compatible, + bundled_plugins_for, + get_node_type, + incompatibility_reason, + nodes_by_category, +) + + +class TestDataModels: + def test_descriptors_are_frozen_dataclasses(self): + port = PortDescriptor("in", PORT_TYPE_VIDEO_FRAMES) + with pytest.raises(dataclasses.FrozenInstanceError): + port.name = "other" + + param = ParameterDescriptor("gain", "int", required=False, default=4) + with pytest.raises(dataclasses.FrozenInstanceError): + param.default = 5 + + def test_gst_mapping_defaults(self): + mapping = GstMapping(arch="x86_64") + assert mapping.element_chain == [] + assert mapping.executor_binding is None + assert mapping.plugin_dependencies == [] + + def test_mapping_for_returns_arch_specific_mapping(self): + descriptor = get_node_type("folder_source") + assert isinstance(descriptor, NodeTypeDescriptor) + for arch in ARCHITECTURES: + mapping = descriptor.mapping_for(arch) + assert mapping is not None + assert mapping.arch == arch + assert descriptor.mapping_for("no_such_arch") is None + + +class TestPortCompatibility: + def test_exact_match_is_compatible(self): + for port_type in (PORT_TYPE_VIDEO_FRAMES, PORT_TYPE_INFERENCE_META, + PORT_TYPE_EVENT_SIGNAL): + assert are_port_types_compatible(port_type, port_type) + assert incompatibility_reason(port_type, port_type) is None + + def test_inference_meta_coerces_to_video_frames(self): + # InferenceMeta rides the same buffer stream as VideoFrames. + assert are_port_types_compatible(PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES) + assert incompatibility_reason(PORT_TYPE_INFERENCE_META, + PORT_TYPE_VIDEO_FRAMES) is None + + def test_coercion_is_directional(self): + assert not are_port_types_compatible(PORT_TYPE_VIDEO_FRAMES, + PORT_TYPE_INFERENCE_META) + + def test_incompatible_pairs_get_descriptive_reason(self): + reason = incompatibility_reason(PORT_TYPE_VIDEO_FRAMES, + PORT_TYPE_EVENT_SIGNAL) + assert reason is not None + assert PORT_TYPE_VIDEO_FRAMES in reason + assert PORT_TYPE_EVENT_SIGNAL in reason + + def test_unknown_port_types_are_rejected_with_reason(self): + assert incompatibility_reason("Bogus", PORT_TYPE_VIDEO_FRAMES) is not None + assert incompatibility_reason(PORT_TYPE_VIDEO_FRAMES, "Bogus") is not None + + +class TestBundledPluginManifest: + def test_manifest_covers_every_architecture(self): + assert set(LOCALSERVER_BUNDLED_PLUGINS) == set(ARCHITECTURES) + + def test_bundled_plugins_for_unknown_arch_raises(self): + with pytest.raises(KeyError): + bundled_plugins_for("riscv") + + def test_existing_dda_elements_are_bundled_everywhere(self): + for arch in ARCHITECTURES: + bundled = bundled_plugins_for(arch) + assert {"emltriton", "emlcapture", "emoutputevent"} <= bundled + + +class TestCatalogAccess: + def test_get_node_type_roundtrip(self): + for descriptor in NODE_CATALOG: + assert get_node_type(descriptor.type_id) is descriptor + assert get_node_type("no_such_type") is None + + def test_nodes_by_category_uses_known_categories(self): + grouped = nodes_by_category() + assert set(grouped) <= set(CATEGORIES) + assert sum(len(v) for v in grouped.values()) == len(NODE_CATALOG) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_wellformedness_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_wellformedness_properties.py new file mode 100644 index 00000000..056a86ea --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_catalog_wellformedness_properties.py @@ -0,0 +1,221 @@ +"""Property test for catalog well-formedness (task 1.3). + +**Feature: workflow-manager, Property 13: Catalog well-formedness** + +For all node type descriptors in the catalog, the descriptor declares its +input ports, output ports, and parameters completely: every port has a type +from the known port-type set, every parameter has a valid type and +satisfiable constraints, every declared default value satisfies its own +parameter's constraints, and the category is one of the five palette +sections. Every parameter additionally carries field-level help: a +non-empty description and at least one example value satisfying the +parameter's own type and constraints. + +**Validates: Requirements 2.8** +""" + +from __future__ import annotations + +import re + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + CATEGORIES, + NODE_CATALOG, + PARAMETER_TYPES, + PORT_TYPES, + ParameterDescriptor, + PortDescriptor, +) +from workflow_core.validator import check_parameter_value + +# The catalog is a fixed, finite data set; the property quantifies over +# every descriptor in it, so the generator samples descriptors directly +# from the catalog itself. +node_type_descriptors = st.sampled_from(NODE_CATALOG) + + +# --------------------------------------------------------------------------- +# Constraint satisfiability and default-value satisfaction checks +# --------------------------------------------------------------------------- + +def _assert_constraints_satisfiable(param: ParameterDescriptor, context: str): + """Constraints must admit at least one value (Property 13).""" + constraints = param.constraints + + assert isinstance(constraints, dict), ( + "%s: constraints must be a dict, got %r" % (context, type(constraints)) + ) + + # Numeric ranges must be non-empty. + if "min" in constraints and "max" in constraints: + assert constraints["min"] <= constraints["max"], ( + "%s: empty numeric range min=%r > max=%r" + % (context, constraints["min"], constraints["max"]) + ) + + # Length ranges must be non-empty and non-negative. + if "min_length" in constraints: + assert constraints["min_length"] >= 0, ( + "%s: negative min_length %r" % (context, constraints["min_length"]) + ) + if "min_length" in constraints and "max_length" in constraints: + assert constraints["min_length"] <= constraints["max_length"], ( + "%s: empty length range" % context + ) + + # Enumerated value sets must be non-empty. + if "values" in constraints: + assert len(constraints["values"]) > 0, ( + "%s: empty allowed-values list" % context + ) + + # Enum parameters are only satisfiable with a declared value set. + if param.param_type == "enum": + assert constraints.get("values"), ( + "%s: enum parameter without a non-empty 'values' constraint" % context + ) + + # Regex constraints must at least be valid patterns. + if "regex" in constraints: + try: + re.compile(constraints["regex"]) + except re.error as exc: + raise AssertionError( + "%s: invalid regex constraint %r (%s)" + % (context, constraints["regex"], exc) + ) + + +_STRING_LIKE_TYPES = ("string", "code", "model_ref") + + +def _assert_default_satisfies_constraints(param: ParameterDescriptor, context: str): + """A declared default must satisfy its own parameter's constraints.""" + default = param.default + if default is None: + # No declared default (e.g. required parameters the user must set). + return + + constraints = param.constraints + + # Type agreement between default and declared parameter type. + if param.param_type == "int": + assert isinstance(default, int) and not isinstance(default, bool), ( + "%s: int default %r is not an int" % (context, default) + ) + elif param.param_type == "float": + assert isinstance(default, (int, float)) and not isinstance(default, bool), ( + "%s: float default %r is not numeric" % (context, default) + ) + elif param.param_type == "bool": + assert isinstance(default, bool), ( + "%s: bool default %r is not a bool" % (context, default) + ) + elif param.param_type in _STRING_LIKE_TYPES: + assert isinstance(default, str), ( + "%s: %s default %r is not a string" % (context, param.param_type, default) + ) + # enum defaults are checked against the values list below. + + # Range constraints. + if "min" in constraints: + assert default >= constraints["min"], ( + "%s: default %r below min %r" % (context, default, constraints["min"]) + ) + if "max" in constraints: + assert default <= constraints["max"], ( + "%s: default %r above max %r" % (context, default, constraints["max"]) + ) + + # Length constraints (string-like values). + if "min_length" in constraints: + assert len(default) >= constraints["min_length"], ( + "%s: default %r shorter than min_length %r" + % (context, default, constraints["min_length"]) + ) + if "max_length" in constraints: + assert len(default) <= constraints["max_length"], ( + "%s: default %r longer than max_length %r" + % (context, default, constraints["max_length"]) + ) + + # Enumerated value sets. + if "values" in constraints: + assert default in constraints["values"], ( + "%s: default %r not in allowed values %r" + % (context, default, constraints["values"]) + ) + + # Regex constraints. + if "regex" in constraints: + assert re.search(constraints["regex"], default) is not None, ( + "%s: default %r does not match regex %r" + % (context, default, constraints["regex"]) + ) + + +# --------------------------------------------------------------------------- +# Property 13 +# --------------------------------------------------------------------------- + +@given(descriptor=node_type_descriptors) +def test_catalog_well_formedness(descriptor): + """**Feature: workflow-manager, Property 13: Catalog well-formedness** + + **Validates: Requirements 2.8** + """ + ctx = "node type %r" % descriptor.type_id + + # The category is one of the five palette sections. + assert descriptor.category in CATEGORIES, ( + "%s: unknown category %r" % (ctx, descriptor.category) + ) + + # Every port has a type from the known port-type set. + for direction, ports in (("input", descriptor.inputs), + ("output", descriptor.outputs)): + for port in ports: + port_ctx = "%s %s port %r" % (ctx, direction, getattr(port, "name", port)) + assert isinstance(port, PortDescriptor), ( + "%s: not a PortDescriptor" % port_ctx + ) + assert isinstance(port.name, str) and port.name, ( + "%s: missing port name" % port_ctx + ) + assert port.port_type in PORT_TYPES, ( + "%s: unknown port type %r" % (port_ctx, port.port_type) + ) + + # Every parameter has a valid type, satisfiable constraints, and a + # declared default that satisfies its own constraints. + for param in descriptor.parameters: + param_ctx = "%s parameter %r" % (ctx, getattr(param, "name", param)) + assert isinstance(param, ParameterDescriptor), ( + "%s: not a ParameterDescriptor" % param_ctx + ) + assert isinstance(param.name, str) and param.name, ( + "%s: missing parameter name" % param_ctx + ) + assert param.param_type in PARAMETER_TYPES, ( + "%s: unknown parameter type %r" % (param_ctx, param.param_type) + ) + _assert_constraints_satisfiable(param, param_ctx) + _assert_default_satisfies_constraints(param, param_ctx) + + # Field-level help: a non-empty description and at least one + # example value satisfying the parameter's own constraints. + assert isinstance(param.description, str) and param.description.strip(), ( + "%s: missing description" % param_ctx + ) + assert isinstance(param.examples, list) and param.examples, ( + "%s: missing examples" % param_ctx + ) + for example in param.examples: + violation = check_parameter_value(param, example) + assert violation is None, ( + "%s: example %r is not a valid value: %s" + % (param_ctx, example, violation) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_bedrock.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_bedrock.py new file mode 100644 index 00000000..0e4bf3e9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_bedrock.py @@ -0,0 +1,265 @@ +"""Unit tests for compiling the two-input bedrock_inference node. + +On device architectures the node is executor-level: the compiler +terminates every GStreamer branch feeding one of its VideoFrames input +ports in a synthetic frame-capture sink chain (videoconvert ! jpegenc ! +multifilesink location={work_dir}/...), and emits a "bedrock_inference" +executor binding carrying the node's parameters plus the per-port +capture file paths. Frames do not flow through the node. In simulation +the node resolves to the model_inference-style identity stub instead +(no binding, no captures) so the harness injects the configured +simulated inference outcome (Requirement 12.6). +""" + +from __future__ import annotations + +from workflow_core.compiler import CompiledPipelineDocument, compile +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +ARCH = "arm64_jp5" + + +def _node(node_id, type_id, **parameters): + return Node(id=node_id, type=type_id, position=Position(0.0, 0.0), + parameters=parameters) + + +def _connect(cid, source, target): + return Connection( + id=cid, + source=PortEndpoint(node=source[0], port=source[1]), + target=PortEndpoint(node=target[0], port=target[1]), + ) + + +def _graph(nodes, connections): + return WorkflowGraph(nodes=nodes, connections=connections) + + +def _compile_ok(graph, arch=ARCH, simulation=False): + document = compile(graph, arch, simulation=simulation) + assert isinstance(document, CompiledPipelineDocument), document + return document + + +def _all_elements(document): + return [e for s in document.segments for e in s["elements"]] + + +def _bedrock_binding(document, node_id="bedrock"): + bindings = [b for b in document.executor_bindings + if b["binding"] == "bedrock_inference" + and b["nodeId"] == node_id] + assert len(bindings) == 1, document.executor_bindings + return bindings[0] + + +def _capture_sinks(document): + return [e for e in _all_elements(document) + if e["factory"] == "multifilesink"] + + +def two_source_graph(): + """cam -> bedrock.in, ref folder -> bedrock.reference, bedrock -> mqtt.""" + return _graph( + nodes=[ + _node("cam", "camera_source"), + _node("ref", "folder_source", location="/aws_dda/ref/golden.jpg"), + _node("bedrock", "bedrock_inference"), + _node("mqtt", "mqtt_publish", broker_host="10.0.0.12", + topic="factory/line1"), + ], + connections=[ + _connect("c1", ("cam", "out"), ("bedrock", "in")), + _connect("c2", ("ref", "out"), ("bedrock", "reference")), + _connect("c3", ("bedrock", "out"), ("mqtt", "in")), + ], + ) + + +class TestDeviceCompilation: + def test_each_input_branch_terminates_in_a_capture_sink(self): + document = _compile_ok(two_source_graph()) + + sinks = _capture_sinks(document) + assert len(sinks) == 2 + locations = sorted(sink["args"]["location"] for sink in sinks) + assert locations == [ + "{work_dir}/bedrock_frame_cam.jpg", + "{work_dir}/bedrock_frame_ref.jpg", + ] + # Synthetic (nodeId null) capture chains: videoconvert ! jpegenc + # ! multifilesink at the end of each feeding branch. + for segment in document.segments: + factories = [e["factory"] for e in segment["elements"]] + assert factories[-3:] == ["videoconvert", "jpegenc", + "multifilesink"] + for element in segment["elements"][-3:]: + assert element["nodeId"] is None + + def test_binding_carries_parameters_and_capture_paths(self): + document = _compile_ok(two_source_graph()) + binding = _bedrock_binding(document) + + assert binding["capturePaths"] == { + "in": "{work_dir}/bedrock_frame_cam.jpg", + "reference": "{work_dir}/bedrock_frame_ref.jpg", + } + parameters = binding["parameters"] + assert parameters["model"] == "us.amazon.nova-lite-v1:0" + assert '"is_anomalous"' in parameters["prompt"] + assert parameters["region"] == "us-east-1" + assert parameters["max_tokens"] == 256 + assert binding["upstreamNodeIds"] == ["cam", "ref"] + assert binding["downstreamNodeIds"] == ["mqtt"] + + def test_every_node_is_referenced_exactly_once(self): + document = _compile_ok(two_source_graph()) + assert sorted(document.referenced_node_ids()) == [ + "bedrock", "cam", "mqtt", "ref"] + + def test_shared_feeder_serves_both_ports_with_one_capture(self): + graph = _graph( + nodes=[ + _node("cam", "camera_source"), + _node("bedrock", "bedrock_inference"), + _node("mqtt", "mqtt_publish", broker_host="10.0.0.12", + topic="factory/line1"), + ], + connections=[ + _connect("c1", ("cam", "out"), ("bedrock", "in")), + _connect("c2", ("cam", "out"), ("bedrock", "reference")), + _connect("c3", ("bedrock", "out"), ("mqtt", "in")), + ], + ) + document = _compile_ok(graph) + + # One branch, one capture file: both ports read the same frames. + sinks = _capture_sinks(document) + assert len(sinks) == 1 + path = sinks[0]["args"]["location"] + assert _bedrock_binding(document)["capturePaths"] == { + "in": path, "reference": path} + + def test_feeder_also_continuing_downstream_gets_a_capture_tee_branch(self): + graph = _graph( + nodes=[ + _node("cam", "camera_source"), + _node("ref", "folder_source", + location="/aws_dda/ref/golden.jpg"), + _node("bedrock", "bedrock_inference"), + _node("cap", "capture", output_path="/aws_dda/captures"), + _node("mqtt", "mqtt_publish", broker_host="10.0.0.12", + topic="factory/line1"), + ], + connections=[ + _connect("c1", ("cam", "out"), ("bedrock", "in")), + _connect("c2", ("ref", "out"), ("bedrock", "reference")), + # cam also feeds a capture node: its frames must both + # continue downstream and sink to the bedrock file. + _connect("c3", ("cam", "out"), ("cap", "in")), + _connect("c4", ("bedrock", "out"), ("mqtt", "in")), + ], + ) + document = _compile_ok(graph) + + assert len(_capture_sinks(document)) == 2 # cam file + ref file + # cam's segment ends in a tee with two queue-headed branches: + # the capture node's chain and the bedrock frame sink. + tees = [e for e in _all_elements(document) if e["factory"] == "tee"] + assert len(tees) == 1 + tee_name = tees[0]["args"]["name"] + branches = [s for s in document.segments if s["from"] == tee_name] + assert len(branches) == 2 + for branch in branches: + assert branch["elements"][0]["factory"] == "queue" + branch_factory_lists = sorted( + tuple(e["factory"] for e in branch["elements"]) + for branch in branches + ) + assert ("queue", "videoconvert", "jpegenc", "multifilesink") in \ + branch_factory_lists + # Every node still referenced exactly once. + assert sorted(document.referenced_node_ids()) == [ + "bedrock", "cam", "cap", "mqtt", "ref"] + + def test_unconnected_reference_port_has_no_capture_path(self): + graph = _graph( + nodes=[ + _node("cam", "camera_source"), + _node("bedrock", "bedrock_inference"), + _node("mqtt", "mqtt_publish", broker_host="10.0.0.12", + topic="factory/line1"), + ], + connections=[ + _connect("c1", ("cam", "out"), ("bedrock", "in")), + _connect("c2", ("bedrock", "out"), ("mqtt", "in")), + ], + ) + document = _compile_ok(graph) + binding = _bedrock_binding(document) + assert binding["capturePaths"]["in"] == \ + "{work_dir}/bedrock_frame_cam.jpg" + assert binding["capturePaths"]["reference"] is None + + def test_unsafe_node_ids_are_sanitized_in_capture_paths(self): + graph = _graph( + nodes=[ + _node("cam 1", "camera_source"), + _node("ref/2", "folder_source", + location="/aws_dda/ref/golden.jpg"), + _node("bedrock", "bedrock_inference"), + _node("mqtt", "mqtt_publish", broker_host="10.0.0.12", + topic="factory/line1"), + ], + connections=[ + _connect("c1", ("cam 1", "out"), ("bedrock", "in")), + _connect("c2", ("ref/2", "out"), ("bedrock", "reference")), + _connect("c3", ("bedrock", "out"), ("mqtt", "in")), + ], + ) + binding = _bedrock_binding(_compile_ok(graph)) + assert binding["capturePaths"] == { + "in": "{work_dir}/bedrock_frame_cam_1.jpg", + "reference": "{work_dir}/bedrock_frame_ref_2.jpg", + } + + def test_plugin_dependencies_include_boto3_runtime(self): + document = _compile_ok(two_source_graph()) + assert "python:boto3" in document.plugin_dependencies + # The capture chain's GStreamer plugins are LocalServer-bundled + # (jpeg, multifile, videoconvertscale) and subtract out. + assert "jpeg" not in document.plugin_dependencies + assert "multifile" not in document.plugin_dependencies + + +class TestSimulationCompilation: + def test_simulation_stubs_the_node_like_model_inference(self): + document = _compile_ok(two_source_graph(), simulation=True) + + # The identity stub chain replaces the executor binding; the + # harness recognizes sim_inference_ and injects the + # configured simulated outcome (Requirement 12.6). + bedrock_elements = [e for e in _all_elements(document) + if e["nodeId"] == "bedrock"] + assert [e["factory"] for e in bedrock_elements] == [ + "capsfilter", "identity"] + assert bedrock_elements[1]["args"]["name"] == "sim_inference_bedrock" + assert not [b for b in document.executor_bindings + if b["binding"] == "bedrock_inference"] + # No capture sinks in simulation output. + assert _capture_sinks(document) == [] + + def test_sim_architecture_compile_uses_the_stub_chain_too(self): + document = _compile_ok(two_source_graph(), arch="sim") + bedrock_elements = [e for e in _all_elements(document) + if e["nodeId"] == "bedrock"] + assert [e["factory"] for e in bedrock_elements] == [ + "capsfilter", "identity"] + assert _capture_sinks(document) == [] diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_compile.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_compile.py new file mode 100644 index 00000000..4346f9c9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_compile.py @@ -0,0 +1,553 @@ +"""Unit tests for compile() producing the Compiled Pipeline Document (task 4.1). + +Covers: validation refusal, topological element ordering, one element +chain per node tagged with nodeId (emltriton configuration for model +inference, executor bindings for executor-level nodes), tee/queue +fan-out linearization, unmapped-architecture errors, plugin dependency +computation, and the CompiledPipelineDocument JSON shape. + +_Requirements: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6_ +""" + +import json + +from workflow_core.compiler import ( + CODE_UNMAPPED_ARCHITECTURE, + CODE_VALIDATION_ERROR, + COMPILED_DOCUMENT_SCHEMA_VERSION, + CompileContext, + CompiledPipelineDocument, + CompileError, + compile, +) +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +# -------------------------------------------------------------------------- +# Graph-building helpers (self-contained; shared generators are task 2.3) +# -------------------------------------------------------------------------- + +_POS = Position(0.0, 0.0) + + +def _node(node_id, node_type, **parameters): + return Node(id=node_id, type=node_type, position=_POS, parameters=parameters) + + +def _conn(conn_id, source_node, target_node, source_port="out", target_port="in"): + return Connection( + id=conn_id, + source=PortEndpoint(source_node, source_port), + target=PortEndpoint(target_node, target_port), + ) + + +def _folder(node_id="src"): + return _node(node_id, "folder_source", location="/data/images") + + +def _capture(node_id="cap"): + return _node(node_id, "capture", output_path="/out") + + +def _rotate(node_id="rot"): + return _node(node_id, "rotate", method="clockwise") + + +def _inference(node_id="inf", model="widget-anomaly-v3"): + return _node(node_id, "model_inference", modelName=model) + + +def _valid_graph(): + """folder_source -> capture.""" + return WorkflowGraph( + nodes=[_folder(), _capture()], + connections=[_conn("c1", "src", "cap")], + ) + + +def _compile_ok(graph, arch="x86_64", context=None): + result = compile(graph, arch, context) + assert isinstance(result, CompiledPipelineDocument), ( + "expected a document, got errors: {0}".format(result) + ) + return result + + +def _all_elements(document): + return [element for segment in document.segments for element in segment["elements"]] + + +def _elements_of(document, node_id): + return [e for e in _all_elements(document) if e["nodeId"] == node_id] + + +def _factories(document): + return [element["factory"] for element in _all_elements(document)] + + +# -------------------------------------------------------------------------- +# Validation refusal (Requirement 6.1) +# -------------------------------------------------------------------------- + +class TestValidationRefusal: + def test_graph_with_errors_is_refused(self): + # No input node: V1 error. + graph = WorkflowGraph(nodes=[_capture()]) + result = compile(graph, "x86_64") + assert isinstance(result, list) and result + assert all(isinstance(error, CompileError) for error in result) + assert all(error.code == CODE_VALIDATION_ERROR for error in result) + + def test_validation_errors_carry_offending_identifiers(self): + # Missing required parameter on a specific node: V4 error. + graph = WorkflowGraph( + nodes=[_node("src2", "folder_source"), _capture()], + connections=[_conn("c1", "src2", "cap")], + ) + result = compile(graph, "x86_64") + assert isinstance(result, list) + assert any(error.node_id == "src2" for error in result) + + def test_warnings_do_not_block_compilation(self): + # A detached extra input node yields only a W1 warning (unused + # output port) - inputs are reachability roots, so no error. + graph = WorkflowGraph( + nodes=[_folder(), _capture(), _folder("src2")], + connections=[_conn("c1", "src", "cap")], + ) + _compile_ok(graph) + + +# -------------------------------------------------------------------------- +# Element chains, tagging, and topological order (Requirements 6.1, 6.6) +# -------------------------------------------------------------------------- + +class TestElementChains: + def test_every_node_referenced_exactly_once(self): + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _inference(), _capture()], + connections=[ + _conn("c1", "src", "rot"), + _conn("c2", "rot", "inf"), + _conn("c3", "inf", "cap"), + ], + ) + document = _compile_ok(graph) + assert sorted(document.referenced_node_ids()) == ["cap", "inf", "rot", "src"] + + def test_connection_source_elements_precede_target_elements(self): + graph = WorkflowGraph( + nodes=[_capture(), _rotate(), _folder()], # deliberately unsorted + connections=[ + _conn("c1", "src", "rot"), + _conn("c2", "rot", "cap"), + ], + ) + document = _compile_ok(graph) + assert len(document.segments) == 1 + order = [e["nodeId"] for e in document.segments[0]["elements"]] + assert order.index("src") < order.index("rot") < order.index("cap") + + def test_chain_elements_tagged_with_node_id(self): + document = _compile_ok(_valid_graph()) + # folder_source's x86_64 chain: filesrc ! emexifextract ! jpegparse + # ! jpegdec ! videoconvert ! videoflip. + source_elements = _elements_of(document, "src") + assert [e["factory"] for e in source_elements] == [ + "filesrc", "emexifextract", "jpegparse", "jpegdec", + "videoconvert", "videoflip", + ] + + def test_node_parameters_resolved_into_args(self): + document = _compile_ok(_valid_graph()) + filesrc = _elements_of(document, "src")[0] + assert filesrc["args"]["location"] == "/data/images" + # Native types are preserved for single-placeholder templates. + jpegenc = [e for e in _elements_of(document, "cap") if e["factory"] == "jpegenc"][0] + assert jpegenc["args"]["quality"] == 100 # declared default, int + + +# -------------------------------------------------------------------------- +# emltriton configuration (Requirement 6.2) +# -------------------------------------------------------------------------- + +class TestModelInference: + def _graph(self): + return WorkflowGraph( + nodes=[_folder(), _inference(), _capture()], + connections=[_conn("c1", "src", "inf"), _conn("c2", "inf", "cap")], + ) + + def test_exactly_one_emltriton_element(self): + document = _compile_ok(self._graph()) + emltritons = [e for e in _all_elements(document) if e["factory"] == "emltriton"] + assert len(emltritons) == 1 + assert emltritons[0]["nodeId"] == "inf" + + def test_emltriton_args_carry_model_and_localserver_triton_paths(self): + document = _compile_ok(self._graph()) + args = [e for e in _all_elements(document) if e["factory"] == "emltriton"][0]["args"] + assert args["model"] == "widget-anomaly-v3" + assert args["model-repo"] == "/aws_dda/dda_triton/triton_model_repo" + assert args["server-path"] == "/opt/tritonserver" + + def test_context_can_override_triton_paths(self): + context = CompileContext(values={"triton_model_repo": "/custom/repo"}) + document = _compile_ok(self._graph(), context=context) + args = [e for e in _all_elements(document) if e["factory"] == "emltriton"][0]["args"] + assert args["model-repo"] == "/custom/repo" + assert args["server-path"] == "/opt/tritonserver" + + +# -------------------------------------------------------------------------- +# Executor bindings (Requirements 6.1, 6.6) +# -------------------------------------------------------------------------- + +class TestExecutorBindings: + def test_executor_nodes_become_bindings_not_elements(self): + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("filt", "inference_filter", condition="confidence >= 0.8"), + _node("mq", "mqtt_publish", broker_host="broker.local", topic="dda/out"), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "filt"), + _conn("c3", "filt", "mq"), + ], + ) + document = _compile_ok(graph) + + bindings = {b["nodeId"]: b for b in document.executor_bindings} + assert set(bindings) == {"filt", "mq"} + assert bindings["filt"]["binding"] == "inference_filter" + assert bindings["filt"]["parameters"]["condition"] == "confidence >= 0.8" + assert bindings["mq"]["binding"] == "mqtt_publish" + assert bindings["mq"]["parameters"]["topic"] == "dda/out" + assert bindings["mq"]["parameters"]["broker_port"] == 1883 # default + + # Executor nodes contribute no pipeline elements. + assert _elements_of(document, "filt") == [] + assert _elements_of(document, "mq") == [] + # Every node still referenced exactly once across the document. + assert sorted(document.referenced_node_ids()) == ["filt", "inf", "mq", "src"] + + def test_stream_continues_through_executor_nodes(self): + # inf -> (executor) filter -> capture: capture's elements must + # follow inference's in the same stream. + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("filt", "inference_filter", condition="is_anomalous == true"), + _capture(), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "filt"), + _conn("c3", "filt", "cap"), + ], + ) + document = _compile_ok(graph) + assert len(document.segments) == 1 + order = [e["nodeId"] for e in document.segments[0]["elements"]] + assert order.index("inf") < order.index("cap") + + def test_binding_records_upstream_and_downstream_nodes(self): + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("filt", "inference_filter", condition="c"), + _capture(), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "filt"), + _conn("c3", "filt", "cap"), + ], + ) + document = _compile_ok(graph) + binding = document.executor_bindings[0] + assert binding["upstreamNodeIds"] == ["inf"] + assert binding["downstreamNodeIds"] == ["cap"] + # Single-output executor nodes carry no per-port fields. + assert "downstreamNodeIdsByPort" not in binding + assert "portConditions" not in binding + + +# -------------------------------------------------------------------------- +# Conditional node compilation: two-path gating conditions +# -------------------------------------------------------------------------- + +class TestConditionalCompilation: + CONDITION = "is_anomalous == true && confidence >= 0.8" + + def _graph(self): + """src -> inf -> conditional; mq on the "true" path, opc on "false".""" + return WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("br", "conditional", condition=self.CONDITION), + _node("mq", "mqtt_publish", broker_host="b", topic="t"), + Node(id="opc", type="opcua_write", position=_POS, + parameters={"endpoint": "opc.tcp://plc:4840", + "node_id": "ns=2;s=Out"}), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "br"), + _conn("c3", "br", "mq", source_port="true"), + _conn("c4", "br", "opc", source_port="false"), + ], + ) + + def test_conditional_becomes_an_executor_binding_not_elements(self): + document = _compile_ok(self._graph()) + bindings = {b["nodeId"]: b for b in document.executor_bindings} + assert bindings["br"]["binding"] == "conditional" + assert bindings["br"]["parameters"]["condition"] == self.CONDITION + assert _elements_of(document, "br") == [] + # Every node still referenced exactly once across the document. + assert sorted(document.referenced_node_ids()) == [ + "br", "inf", "mq", "opc", "src"] + + def test_both_paths_gating_conditions(self): + # The "true" path is gated by the condition itself; the "false" + # path by its negation, composed with the evaluator's unary '!'. + document = _compile_ok(self._graph()) + binding = {b["nodeId"]: b for b in document.executor_bindings}["br"] + assert binding["portConditions"] == { + "true": self.CONDITION, + "false": "!({0})".format(self.CONDITION), + } + + def test_downstream_nodes_partitioned_by_output_port(self): + document = _compile_ok(self._graph()) + binding = {b["nodeId"]: b for b in document.executor_bindings}["br"] + assert binding["downstreamNodeIdsByPort"] == { + "true": ["mq"], + "false": ["opc"], + } + # The port-agnostic downstream list is unchanged (the union). + assert sorted(binding["downstreamNodeIds"]) == ["mq", "opc"] + assert binding["upstreamNodeIds"] == ["inf"] + + def test_unconnected_false_port_yields_an_empty_port_entry(self): + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("br", "conditional", condition="is_anomalous == true"), + _node("mq", "mqtt_publish", broker_host="b", topic="t"), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "br"), + _conn("c3", "br", "mq", source_port="true"), + ], + ) + document = _compile_ok(graph) + binding = {b["nodeId"]: b for b in document.executor_bindings}["br"] + assert binding["downstreamNodeIdsByPort"] == {"true": ["mq"], "false": []} + + def test_simulation_compilation_keeps_the_conditional_binding(self): + # In simulation mode the hardware outputs become recording stubs + # while the conditional binding compiles identically (12.6). + result = compile(self._graph(), "x86_64", simulation=True) + assert isinstance(result, CompiledPipelineDocument) + bindings = {b["nodeId"]: b for b in result.executor_bindings} + assert bindings["br"]["binding"] == "conditional" + assert bindings["br"]["portConditions"]["true"] == self.CONDITION + assert bindings["mq"]["binding"] == "recording_mqtt_publish" + assert bindings["opc"]["binding"] == "recording_opcua_write" + + def test_gst_stream_continues_through_the_conditional(self): + # A GStreamer node downstream of the conditional still linearizes + # into the stream (the conditional gates executor bindings, not + # buffers). + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("br", "conditional", condition="is_anomalous == true"), + _capture(), + ], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "br"), + _conn("c3", "br", "cap", source_port="true"), + ], + ) + document = _compile_ok(graph) + assert len(document.segments) == 1 + order = [e["nodeId"] for e in document.segments[0]["elements"]] + assert order.index("inf") < order.index("cap") + + +# -------------------------------------------------------------------------- +# Fan-out linearization with tee/queue (Requirement 6.3) +# -------------------------------------------------------------------------- + +class TestFanOut: + def test_fan_out_emits_named_tee_and_queue_per_branch(self): + graph = WorkflowGraph( + nodes=[_folder(), _capture("capA"), _capture("capB")], + connections=[ + _conn("c1", "src", "capA"), + _conn("c2", "src", "capB"), + ], + ) + document = _compile_ok(graph) + + tees = [e for e in _all_elements(document) if e["factory"] == "tee"] + assert len(tees) == 1 + assert tees[0]["args"]["name"] == "t0" + assert tees[0]["nodeId"] is None # synthetic, not a workflow node + + branches = [s for s in document.segments if s["from"] == "t0"] + assert len(branches) == 2 + for branch in branches: + assert branch["elements"][0]["factory"] == "queue" + assert branch["elements"][0]["nodeId"] is None + branch_nodes = {b["elements"][1]["nodeId"] for b in branches} + assert branch_nodes == {"capA", "capB"} + + def test_linear_pipeline_has_no_tee(self): + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _capture()], + connections=[_conn("c1", "src", "rot"), _conn("c2", "rot", "cap")], + ) + document = _compile_ok(graph) + assert "tee" not in _factories(document) + assert "queue" not in _factories(document) + + def test_fan_in_converges_through_a_named_funnel(self): + # src tees to rotA/rotB, both converge on one capture node. + graph = WorkflowGraph( + nodes=[_folder(), _rotate("rotA"), _rotate("rotB"), _capture()], + connections=[ + _conn("c1", "src", "rotA"), + _conn("c2", "src", "rotB"), + _conn("c3", "rotA", "cap"), + _conn("c4", "rotB", "cap"), + ], + ) + document = _compile_ok(graph) + + funnels = [e for e in _all_elements(document) if e["factory"] == "funnel"] + assert len(funnels) == 1 + funnel_name = funnels[0]["args"]["name"] + converging = [s for s in document.segments if s["linkTo"] == funnel_name] + assert len(converging) == 2 + # The capture chain still appears exactly once. + assert document.referenced_node_ids().count("cap") == 1 + + +# -------------------------------------------------------------------------- +# Unmapped architecture (Requirement 6.5) +# -------------------------------------------------------------------------- + +class TestUnmappedArchitecture: + def test_unknown_arch_reports_every_node_with_arch(self): + graph = _valid_graph() + result = compile(graph, "riscv64") + assert isinstance(result, list) + assert {error.code for error in result} == {CODE_UNMAPPED_ARCHITECTURE} + assert sorted(error.node_id for error in result) == ["cap", "src"] + assert all(error.arch == "riscv64" for error in result) + + def test_error_dict_shape(self): + result = compile(_valid_graph(), "riscv64") + entry = result[0].to_dict() + assert set(entry) == {"code", "message", "nodeId", "connectionId", "arch"} + + +# -------------------------------------------------------------------------- +# Plugin dependencies (Requirement 6.4) +# -------------------------------------------------------------------------- + +class TestPluginDependencies: + def test_bundled_plugins_are_excluded(self): + # folder_source + capture only use LocalServer-bundled plugins. + document = _compile_ok(_valid_graph()) + assert document.plugin_dependencies == [] + + def test_non_bundled_dependencies_are_reported(self): + graph = WorkflowGraph( + nodes=[ + _folder(), _node("dw", "dewarp"), _inference(), + # opcua's own parameter is named "node_id", clashing with + # the helper's positional argument - build the Node directly. + Node(id="opc", type="opcua_write", position=_POS, + parameters={"endpoint": "opc.tcp://plc:4840", + "node_id": "ns=2;s=Out"}), + ], + connections=[ + _conn("c1", "src", "dw"), + _conn("c2", "dw", "inf"), + _conn("c3", "inf", "opc"), + ], + ) + document = _compile_ok(graph) + assert document.plugin_dependencies == ["dda-dewarp", "python:opcua"] + + def test_dependencies_are_arch_specific(self): + # camera_source on x86_64 uses bundled v4l2; still nothing extra. + graph = WorkflowGraph( + nodes=[_node("cam", "camera_source"), _capture()], + connections=[_conn("c1", "cam", "cap")], + ) + document = _compile_ok(graph, arch="arm64_jp5") + assert document.plugin_dependencies == [] + assert document.target_arch == "arm64_jp5" + + +# -------------------------------------------------------------------------- +# Compiled Pipeline Document output (Requirements 6.1-6.6) +# -------------------------------------------------------------------------- + +class TestDocumentOutput: + def test_document_dict_shape(self): + context = CompileContext(workflow_id="wf-1", workflow_version="3") + document = _compile_ok(_valid_graph(), context=context) + data = document.to_dict() + assert set(data) == { + "schemaVersion", "workflowId", "workflowVersion", "targetArch", + "segments", "executorBindings", "pluginDependencies", + } + assert data["schemaVersion"] == COMPILED_DOCUMENT_SCHEMA_VERSION + assert data["workflowId"] == "wf-1" + assert data["workflowVersion"] == "3" + assert data["targetArch"] == "x86_64" + + def test_document_serializes_to_json(self): + document = _compile_ok(_valid_graph()) + parsed = json.loads(document.to_json()) + assert parsed["segments"][0]["elements"][0]["factory"] == "filesrc" + for segment in parsed["segments"]: + assert set(segment) == {"name", "from", "linkTo", "elements"} + for element in segment["elements"]: + assert set(element) == {"nodeId", "factory", "args"} + + def test_digital_output_config_json_derived_from_parameters(self): + graph = WorkflowGraph( + nodes=[ + _folder(), _inference(), + _node("dout", "digital_output", pin=7, signal_type="pulse", + condition="is_anomalous == true"), + ], + connections=[_conn("c1", "src", "inf"), _conn("c2", "inf", "dout")], + ) + document = _compile_ok(graph) + emoutput = [e for e in _all_elements(document) if e["factory"] == "emoutputevent"][0] + config = json.loads(emoutput["args"]["config"]) + assert config["pin"] == 7 + assert config["signal_type"] == "pulse" + assert config["pulse_width_ms"] == 100 # declared default + assert config["condition"] == "is_anomalous == true" + # Device-local script path is left for the edge renderer/context. + assert emoutput["args"]["script-path"] == "{dio_script_path}" diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_emltriton_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_emltriton_properties.py new file mode 100644 index 00000000..af757ae1 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_emltriton_properties.py @@ -0,0 +1,170 @@ +"""Property test for emltriton configuration (task 4.5). + +**Feature: workflow-manager, Property 6: Inference nodes compile to correctly configured emltriton elements** + +For all valid Workflow_Definitions containing model inference nodes, each +such node compiles to exactly one ``emltriton`` element whose args include +the node's configured model name and the Triton model-repository and +server paths used by LocalServer. + +**Validates: Requirements 6.2** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + DEVICE_ARCHITECTURES, + PORT_TYPE_VIDEO_FRAMES, + are_port_types_compatible, + get_node_type, +) +from workflow_core.compiler import ( + CompileContext, + CompiledPipelineDocument, + DEFAULT_CONTEXT_VALUES, + compile, +) +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +from .generators import graph_strategy, valid_parameter_value_strategy + +_MODEL_INFERENCE = "model_inference" +_MODEL_NAME_DESCRIPTOR = next( + parameter + for parameter in get_node_type(_MODEL_INFERENCE).parameters + if parameter.name == "modelName" +) + +#: Realistic overrides for the LocalServer Triton paths, exercised through +#: CompileContext (the mechanism the portal/edge use to supply them). +_REPO_OVERRIDES = (None, "/custom/repo", "/aws_dda/alt/triton_model_repo") +_SERVER_OVERRIDES = (None, "/opt/tritonserver-2", "/usr/local/tritonserver") + + +def _video_frame_sources(graph): + """Every (node_id, port_name) output port in ``graph`` whose effective + port type can feed a model_inference node's VideoFrames input.""" + sources = [] + for node in graph.nodes: + descriptor = get_node_type(node.type) + parameter_names = {p.name for p in descriptor.parameters} + override = None + if "output_port_type" in parameter_names: + override = node.parameters.get("output_port_type") + for port in descriptor.outputs: + effective_type = override or port.port_type + if are_port_types_compatible(effective_type, PORT_TYPE_VIDEO_FRAMES): + sources.append((node.id, port.name)) + return sources + + +@st.composite +def graph_with_inference_nodes_strategy(draw): + """Valid Workflow_Definitions guaranteed to contain model inference nodes. + + Draws a validator-valid base graph from the shared ``graph_strategy`` + (which may already include model_inference nodes) and wires 1..3 + additional model_inference nodes onto type-compatible VideoFrames + output ports; ``graph_strategy`` always places at least one + VideoFrames-producing input node, so a compatible source always + exists. Added nodes stay reachable from an input node and their + dangling InferenceMeta outputs are at most warnings, so the augmented + graph remains validator-valid (compilable). + """ + base = draw(graph_strategy()) + sources = _video_frame_sources(base) + used_node_ids = {node.id for node in base.nodes} + used_connection_ids = {connection.id for connection in base.connections} + + nodes = list(base.nodes) + connections = list(base.connections) + for index in range(draw(st.integers(min_value=1, max_value=3))): + node_id = "p6-inf-{0}".format(index) + while node_id in used_node_ids: + node_id += "_" + used_node_ids.add(node_id) + connection_id = "p6-conn-{0}".format(index) + while connection_id in used_connection_ids: + connection_id += "_" + used_connection_ids.add(connection_id) + + model_name = draw(valid_parameter_value_strategy(_MODEL_NAME_DESCRIPTOR)) + nodes.append(Node( + id=node_id, + type=_MODEL_INFERENCE, + position=Position(x=0.0, y=float(index)), + parameters={"modelName": model_name}, + )) + source_node, source_port = draw(st.sampled_from(sources)) + connections.append(Connection( + id=connection_id, + source=PortEndpoint(node=source_node, port=source_port), + target=PortEndpoint(node=node_id, port="in"), + )) + + return WorkflowGraph(nodes=nodes, connections=connections) + + +@given( + graph=graph_with_inference_nodes_strategy(), + target_arch=st.sampled_from(DEVICE_ARCHITECTURES), + repo_override=st.sampled_from(_REPO_OVERRIDES), + server_override=st.sampled_from(_SERVER_OVERRIDES), +) +def test_inference_nodes_compile_to_configured_emltriton_elements( + graph, target_arch, repo_override, server_override +): + """**Feature: workflow-manager, Property 6: Inference nodes compile to correctly configured emltriton elements** + + **Validates: Requirements 6.2** + """ + context_values = {} + if repo_override is not None: + context_values["triton_model_repo"] = repo_override + if server_override is not None: + context_values["triton_server_path"] = server_override + context = CompileContext(values=context_values) + + expected_repo = repo_override or DEFAULT_CONTEXT_VALUES["triton_model_repo"] + expected_server = server_override or DEFAULT_CONTEXT_VALUES["triton_server_path"] + + result = compile(graph, target_arch, context=context) + assert isinstance(result, CompiledPipelineDocument), ( + "compilation of a valid graph failed: {0}".format(result) + ) + + emltritons_by_node = {} + for segment in result.segments: + for element in segment["elements"]: + if element["factory"] == "emltriton": + emltritons_by_node.setdefault(element["nodeId"], []).append(element) + + inference_nodes = [node for node in graph.nodes if node.type == _MODEL_INFERENCE] + assert inference_nodes, "generator must produce at least one model_inference node" + + # emltriton elements come from model inference nodes and nothing else. + assert set(emltritons_by_node) == {node.id for node in inference_nodes}, ( + "emltriton elements do not match the model_inference node set" + ) + + for node in inference_nodes: + elements = emltritons_by_node[node.id] + # Exactly one emltriton element per model inference node. + assert len(elements) == 1, ( + "node '{0}' compiled to {1} emltriton elements".format(node.id, len(elements)) + ) + args = elements[0]["args"] + # Args carry the node's configured model name and the Triton + # model-repository and server paths used by LocalServer. + assert args["model"] == node.parameters["modelName"] + assert args["model-repo"] == expected_repo + assert args["server-path"] == expected_server diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_node_reference_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_node_reference_properties.py new file mode 100644 index 00000000..785e92bd --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_node_reference_properties.py @@ -0,0 +1,54 @@ +"""Property test for compiler node reference exactness (task 4.3). + +**Feature: workflow-manager, Property 4: Compiler references every node exactly once** + +For all valid Workflow_Definitions, the compiled pipeline document +references every node in the definition exactly once — the multiset of +``nodeId`` tags across all segment elements and executor bindings equals +the definition's node set with multiplicity one. + +**Validates: Requirements 6.6, 6.1** +""" + +from __future__ import annotations + +from collections import Counter + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ARCHITECTURES +from workflow_core.compiler import CompiledPipelineDocument, compile + +from .generators import graph_strategy + + +@given(graph=graph_strategy(), target_arch=st.sampled_from(ARCHITECTURES)) +def test_compiler_references_every_node_exactly_once(graph, target_arch): + """**Feature: workflow-manager, Property 4: Compiler references every node exactly once** + + **Validates: Requirements 6.6, 6.1** + """ + document = compile(graph, target_arch) + + # Valid graphs compile: every catalog node type is mapped on every + # target architecture (Requirement 6.1). + assert isinstance(document, CompiledPipelineDocument), ( + "compile() failed for a valid graph on arch {!r}: {}".format( + target_arch, document + ) + ) + + # The multiset of nodeId references across all segment element chains + # and executor bindings equals the definition's node set with + # multiplicity one (Requirement 6.6). referenced_node_ids() yields one + # entry per contiguous element-chain occurrence or executor binding, + # skipping synthetic tee/queue/funnel elements (nodeId null). + expected = Counter(node.id for node in graph.nodes) + actual = Counter(document.referenced_node_ids()) + assert actual == expected, ( + "compiled document node references differ from the graph's node set: " + "missing={}, extra_or_duplicate={}".format( + sorted(expected - actual), sorted(actual - expected) + ) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_order_branching_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_order_branching_properties.py new file mode 100644 index 00000000..aff7aa9c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_order_branching_properties.py @@ -0,0 +1,248 @@ +"""Property test for compiled order and branching (task 4.4). + +**Feature: workflow-manager, Property 5: Compiled order respects the graph, +branches get tee/queue** + +For all valid Workflow_Definitions, for every connection the elements of +the source node precede the elements of the target node in the rendered +pipeline order, and every node whose output feeds more than one downstream +node is followed by a ``tee`` element with a ``queue`` element at the head +of each branch — and nodes without fan-out get no ``tee``. + +Notes on interpretation: + +- "Rendered pipeline order" is the order LocalServer renders the document + in: segments in document order, elements in segment order (the flattened + element sequence of the launch string). +- Executor-level nodes (per the catalog mapping for the target + architecture) contribute no pipeline elements; the stream flows through + them. Ordering and fan-out are therefore checked over the pipeline + stream: a node "feeds" the element-bearing nodes reachable through any + run of element-less executor nodes. + +**Validates: Requirements 6.1, 6.3** +""" + +from __future__ import annotations + +from typing import Dict, List, Set + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import DEVICE_ARCHITECTURES, get_node_type +from workflow_core.compiler import CompiledPipelineDocument, compile + +from .generators import graph_strategy + +# -------------------------------------------------------------------------- +# Expected stream structure, derived independently from graph + catalog +# -------------------------------------------------------------------------- + + +def _element_bearing_node_ids(graph, arch: str) -> Set[str]: + """Nodes whose catalog mapping for ``arch`` has GStreamer elements + (executor-level nodes have none and are collapsed out of the stream).""" + bearing = set() + for node in graph.nodes: + descriptor = get_node_type(node.type) + assert descriptor is not None, node.type + mapping = descriptor.mapping_for(arch) + assert mapping is not None, (node.type, arch) + if mapping.element_chain: + bearing.add(node.id) + return bearing + + +def _successors(graph) -> Dict[str, List[str]]: + """Node id -> deduplicated downstream node ids per the connections.""" + successors: Dict[str, List[str]] = {node.id: [] for node in graph.nodes} + for connection in graph.connections: + source, target = connection.source.node, connection.target.node + if target not in successors[source]: + successors[source].append(target) + return successors + + +def _stream_targets( + node_id: str, + successors: Dict[str, List[str]], + bearing: Set[str], +) -> List[str]: + """The element-bearing nodes ``node_id`` feeds in the pipeline stream, + looking through element-less executor nodes.""" + targets: List[str] = [] + seen = {node_id} + frontier = list(successors[node_id]) + while frontier: + current = frontier.pop(0) + if current in seen: + continue + seen.add(current) + if current in bearing: + targets.append(current) + else: + frontier.extend(successors[current]) + return targets + + +# -------------------------------------------------------------------------- +# Document inspection helpers +# -------------------------------------------------------------------------- + + +def _flat_indexes(document: CompiledPipelineDocument) -> Dict[str, List[int]]: + """Node id -> positions of its elements in the flattened rendered order + (segments in document order, elements in segment order).""" + indexes: Dict[str, List[int]] = {} + position = 0 + for segment in document.segments: + for element in segment["elements"]: + if element["nodeId"] is not None: + indexes.setdefault(element["nodeId"], []).append(position) + position += 1 + return indexes + + +def _chain_span(document: CompiledPipelineDocument, node_id: str): + """(segment, index-of-last-chain-element) for ``node_id``'s elements. + + The chain must live in exactly one segment for the rendered order to + be analyzable (each node maps to one contiguous element chain). + """ + holding = [ + segment for segment in document.segments + if any(element["nodeId"] == node_id for element in segment["elements"]) + ] + assert len(holding) == 1, ( + "elements of node {0!r} appear in {1} segments; expected exactly " + "one".format(node_id, len(holding)) + ) + segment = holding[0] + last = max( + position for position, element in enumerate(segment["elements"]) + if element["nodeId"] == node_id + ) + return segment, last + + +# -------------------------------------------------------------------------- +# The property +# -------------------------------------------------------------------------- + + +@given(graph=graph_strategy(), arch=st.sampled_from(DEVICE_ARCHITECTURES)) +def test_compiled_order_respects_graph_and_branches_get_tee_queue(graph, arch): + """**Feature: workflow-manager, Property 5: Compiled order respects the + graph, branches get tee/queue** + + **Validates: Requirements 6.1, 6.3** + """ + document = compile(graph, arch) + assert isinstance(document, CompiledPipelineDocument), ( + "compile() failed on a valid graph: {0}".format(document) + ) + + bearing = _element_bearing_node_ids(graph, arch) + successors = _successors(graph) + stream_targets = { + node_id: _stream_targets(node_id, successors, bearing) + for node_id in bearing + } + indexes = _flat_indexes(document) + + # ---- Order: source elements precede target elements (Requirement 6.1) + + # Directly connected element-bearing nodes, per the property statement. + for connection in graph.connections: + source, target = connection.source.node, connection.target.node + if source in indexes and target in indexes: + assert max(indexes[source]) < min(indexes[target]), ( + "connection {0!r}: elements of source {1!r} do not all " + "precede elements of target {2!r} in rendered order".format( + connection.id, source, target + ) + ) + + # And through element-less executor nodes: the stream still flows + # source -> target, so ordering must hold across the collapsed run. + for source, targets in stream_targets.items(): + for target in targets: + assert max(indexes[source]) < min(indexes[target]), ( + "stream: elements of {0!r} do not precede elements of " + "downstream {1!r}".format(source, target) + ) + + # ---- Branching: fan-out gets tee + queue-headed branches; no fan-out, + # ---- no tee (Requirement 6.3) + + for node_id in bearing: + fan_out = len(stream_targets[node_id]) + segment, last = _chain_span(document, node_id) + elements = segment["elements"] + following = elements[last + 1] if last + 1 < len(elements) else None + + if fan_out > 1: + # The node's chain is immediately followed by a synthetic tee. + assert following is not None, ( + "fan-out node {0!r} ({1} downstream) has no element after " + "its chain".format(node_id, fan_out) + ) + assert following["factory"] == "tee" and following["nodeId"] is None, ( + "fan-out node {0!r} is followed by {1!r}, not a synthetic " + "tee".format(node_id, following) + ) + tee_name = following["args"]["name"] + # One branch segment per downstream stream target... + branches = [s for s in document.segments if s["from"] == tee_name] + assert len(branches) == fan_out, ( + "tee {0!r} of node {1!r} has {2} branches; expected " + "{3}".format(tee_name, node_id, len(branches), fan_out) + ) + # ...each headed by a synthetic queue. + for branch in branches: + assert branch["elements"], ( + "branch segment {0!r} of tee {1!r} is empty".format( + branch["name"], tee_name + ) + ) + head = branch["elements"][0] + assert head["factory"] == "queue" and head["nodeId"] is None, ( + "branch {0!r} of tee {1!r} starts with {2!r}, not a " + "synthetic queue".format(branch["name"], tee_name, head) + ) + else: + # No fan-out: the chain must not be followed by a tee. + assert following is None or following["factory"] != "tee", ( + "node {0!r} has no fan-out but is followed by a tee".format( + node_id + ) + ) + + # Global consistency: every tee belongs to exactly one fan-out node + # (no stray tees anywhere), and every queue is the head of a branch + # segment hanging off a tee. + all_elements = [ + element for segment in document.segments + for element in segment["elements"] + ] + tee_count = sum(1 for element in all_elements if element["factory"] == "tee") + fan_out_nodes = [n for n in bearing if len(stream_targets[n]) > 1] + assert tee_count == len(fan_out_nodes), ( + "{0} tee elements for {1} fan-out nodes".format( + tee_count, len(fan_out_nodes) + ) + ) + for segment in document.segments: + for position, element in enumerate(segment["elements"]): + if element["factory"] == "queue": + assert position == 0 and segment["from"] is not None, ( + "queue at position {0} of segment {1!r} is not the head " + "of a tee branch".format(position, segment["name"]) + ) + if segment["from"] is not None: + assert segment["elements"][0]["factory"] == "queue", ( + "branch segment {0!r} does not start with a queue".format( + segment["name"] + ) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_plugin_dependencies_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_plugin_dependencies_properties.py new file mode 100644 index 00000000..c27be6c2 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_plugin_dependencies_properties.py @@ -0,0 +1,69 @@ +"""Property test for the compiler's plugin dependency set (task 4.6). + +**Feature: workflow-manager, Property 7: Plugin dependency set correctness** + +For all valid Workflow_Definitions and target architectures, the +compiler's ``pluginDependencies`` output equals the union of the +catalog-declared plugin dependencies of the definition's nodes for that +architecture minus the LocalServer-bundled plugin set for that +architecture. + +**Validates: Requirements 6.4** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + DEVICE_ARCHITECTURES, + bundled_plugins_for, + get_node_type, +) +from workflow_core.compiler import CompiledPipelineDocument, compile + +from .generators import graph_strategy + + +def _expected_plugin_dependencies(graph, target_arch): + """Independent recomputation of the Requirement 6.4 rule: union of the + used mappings' declared dependencies minus the per-arch bundled set.""" + declared = set() + for node in graph.nodes: + mapping = get_node_type(node.type).mapping_for(target_arch) + assert mapping is not None, ( + "catalog has no '{0}' mapping for node type '{1}'".format( + target_arch, node.type + ) + ) + declared.update(mapping.plugin_dependencies) + return declared - bundled_plugins_for(target_arch) + + +@given(graph=graph_strategy(), target_arch=st.sampled_from(DEVICE_ARCHITECTURES)) +def test_plugin_dependency_set_correctness(graph, target_arch): + """**Feature: workflow-manager, Property 7: Plugin dependency set correctness** + + **Validates: Requirements 6.4** + """ + result = compile(graph, target_arch) + assert isinstance(result, CompiledPipelineDocument), ( + "compile failed for a valid graph on '{0}': {1}".format(target_arch, result) + ) + + expected = _expected_plugin_dependencies(graph, target_arch) + + # The output names each dependency exactly once (it is a set). + assert len(result.plugin_dependencies) == len(set(result.plugin_dependencies)), ( + "pluginDependencies contains duplicates: {0}".format(result.plugin_dependencies) + ) + + # pluginDependencies == union of used mappings' declared dependencies + # minus the LocalServer-bundled set for the target architecture + # (Requirement 6.4). + assert set(result.plugin_dependencies) == expected, ( + "pluginDependencies {0} != expected {1} for arch '{2}'".format( + sorted(result.plugin_dependencies), sorted(expected), target_arch + ) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation.py new file mode 100644 index 00000000..971b44c6 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation.py @@ -0,0 +1,439 @@ +"""Unit tests for simulation-mode compilation (task 4.2). + +With ``simulation=True``, hardware-dependent nodes (per the catalog flag) +map to recording stubs: dataset-fed sources via multifilesrc/appsrc for +hardware inputs, ``recording_*`` executor bindings for hardware outputs. +Non-hardware nodes compile identically to non-simulation output. + +_Requirements: 12.6_ +""" + +from workflow_core.catalog import SIM_RECORDING_BINDING_PREFIX +from workflow_core.compiler import ( + CODE_UNMAPPED_ARCHITECTURE, + CompileContext, + CompiledPipelineDocument, + compile, +) +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +# -------------------------------------------------------------------------- +# Graph-building helpers (self-contained, mirroring test_compiler_compile) +# -------------------------------------------------------------------------- + +_POS = Position(0.0, 0.0) + +#: Hardware element factories / executor bindings that must never appear +#: in simulation output (Requirement 12.6). emltriton is proprietary and +#: absent from the sandbox image, so simulation must never emit it. +_HARDWARE_FACTORIES = {"v4l2src", "emoutputevent", "emltriton"} +_HARDWARE_BINDINGS = {"digital_input", "digital_output", "mqtt_publish", + "opcua_write"} + + +def _node(node_id, node_type, **parameters): + return Node(id=node_id, type=node_type, position=_POS, parameters=parameters) + + +def _conn(conn_id, source_node, target_node, source_port="out", target_port="in"): + return Connection( + id=conn_id, + source=PortEndpoint(source_node, source_port), + target=PortEndpoint(target_node, target_port), + ) + + +def _camera(node_id="cam"): + return _node(node_id, "camera_source") + + +def _folder(node_id="src"): + return _node(node_id, "folder_source", location="/data/images") + + +def _inference(node_id="inf"): + return _node(node_id, "model_inference", modelName="widget-anomaly-v3") + + +def _capture(node_id="cap"): + return _node(node_id, "capture", output_path="/out") + + +def _digital_output(node_id="dout"): + return _node(node_id, "digital_output", pin=7, signal_type="pulse", + condition="is_anomalous == true") + + +def _mqtt(node_id="mq"): + return _node(node_id, "mqtt_publish", broker_host="broker.local", + topic="dda/out") + + +def _opcua(node_id="opc"): + return Node(id=node_id, type="opcua_write", position=_POS, + parameters={"endpoint": "opc.tcp://plc:4840", + "node_id": "ns=2;s=Out"}) + + +def _camera_graph(): + """camera_source -> model_inference -> digital_output.""" + return WorkflowGraph( + nodes=[_camera(), _inference(), _digital_output()], + connections=[_conn("c1", "cam", "inf"), _conn("c2", "inf", "dout")], + ) + + +def _mixed_graph(): + """Hardware and non-hardware nodes side by side: + + camera -> rotate -> inference -> filter -> mqtt + \\-> capture + """ + return WorkflowGraph( + nodes=[ + _camera(), + _node("rot", "rotate", method="clockwise"), + _inference(), + _node("filt", "inference_filter", condition="confidence >= 0.8"), + _mqtt(), + _capture(), + ], + connections=[ + _conn("c1", "cam", "rot"), + _conn("c2", "rot", "inf"), + _conn("c3", "inf", "filt"), + _conn("c4", "filt", "mq"), + _conn("c5", "inf", "cap"), + ], + ) + + +def _compile_ok(graph, arch="x86_64", context=None, simulation=False): + result = compile(graph, arch, context, simulation=simulation) + assert isinstance(result, CompiledPipelineDocument), ( + "expected a document, got errors: {0}".format(result) + ) + return result + + +def _all_elements(document): + return [element for segment in document.segments for element in segment["elements"]] + + +def _elements_of(document, node_id): + return [e for e in _all_elements(document) if e["nodeId"] == node_id] + + +def _bindings_by_node(document): + return {b["nodeId"]: b for b in document.executor_bindings} + + +# -------------------------------------------------------------------------- +# Hardware inputs become dataset-fed sources (Requirement 12.6) +# -------------------------------------------------------------------------- + +class TestHardwareInputStubs: + def test_camera_source_stubbed_with_multifilesrc(self): + document = _compile_ok(_camera_graph(), simulation=True) + factories = [e["factory"] for e in _elements_of(document, "cam")] + assert factories == ["multifilesrc", "jpegparse", "jpegdec", "videoconvert"] + # The device element from the x86_64 mapping is gone. + assert "v4l2src" not in [e["factory"] for e in _all_elements(document)] + + def test_dataset_location_resolved_from_context(self): + context = CompileContext(values={"dataset_location": "/data/ds/%05d.jpg"}) + document = _compile_ok(_camera_graph(), context=context, simulation=True) + multifilesrc = _elements_of(document, "cam")[0] + assert multifilesrc["args"]["location"] == "/data/ds/%05d.jpg" + + def test_dataset_location_left_as_placeholder_without_context(self): + document = _compile_ok(_camera_graph(), simulation=True) + multifilesrc = _elements_of(document, "cam")[0] + assert multifilesrc["args"]["location"] == "{dataset_location}" + + def test_digital_input_stubbed_with_appsrc(self): + # digital_input -> custom_python (EventSignal in) -> capture. + graph = WorkflowGraph( + nodes=[ + _node("din", "digital_input", pin=3), + _node("py", "custom_python", code="def handle(x): return x", + input_port_type="EventSignal", + output_port_type="InferenceMeta"), + _capture(), + ], + connections=[_conn("c1", "din", "py"), _conn("c2", "py", "cap")], + ) + non_sim = _compile_ok(graph) + sim = _compile_ok(graph, simulation=True) + + # Non-simulation: executor binding, no elements. + assert _bindings_by_node(non_sim)["din"]["binding"] == "digital_input" + assert _elements_of(non_sim, "din") == [] + + # Simulation: a dataset-fed appsrc element, no hardware binding. + assert "din" not in _bindings_by_node(sim) + elements = _elements_of(sim, "din") + assert [e["factory"] for e in elements] == ["appsrc"] + assert elements[0]["args"]["name"] == "sim_source_din" + + def test_folder_source_stubbed_with_multifilesrc(self): + # Regression: folder_source must never compile to its device + # mapping in simulation — the device path (its location + # parameter) and the DDA emexifextract element do not exist in + # the sandbox. It stubs to the same dataset-fed chain as + # camera_source, leaving {dataset_location} for the harness. + graph = WorkflowGraph( + nodes=[_folder(), _inference(), _capture()], + connections=[_conn("c1", "src", "inf"), _conn("c2", "inf", "cap")], + ) + document = _compile_ok(graph, simulation=True) + elements = _elements_of(document, "src") + assert [e["factory"] for e in elements] == [ + "multifilesrc", "jpegparse", "jpegdec", "videoconvert"] + assert elements[0]["args"]["location"] == "{dataset_location}" + # No device path or DDA element remains anywhere in the output. + factories = {e["factory"] for e in _all_elements(document)} + assert "emexifextract" not in factories + assert not any( + e["factory"] == "filesrc" and e["args"].get("location") == "/data/images" + for e in _all_elements(document) + ) + + def test_folder_source_dataset_location_resolved_from_context(self): + graph = WorkflowGraph( + nodes=[_folder(), _capture()], + connections=[_conn("c1", "src", "cap")], + ) + context = CompileContext(values={"dataset_location": "/data/ds/%05d.jpg"}) + document = _compile_ok(graph, context=context, simulation=True) + multifilesrc = _elements_of(document, "src")[0] + assert multifilesrc["args"]["location"] == "/data/ds/%05d.jpg" + + def test_multiple_digital_inputs_get_distinct_source_names(self): + graph = WorkflowGraph( + nodes=[ + _node("dinA", "digital_input", pin=1), + _node("dinB", "digital_input", pin=2), + _node("py", "custom_python", code="def handle(x): return x", + input_port_type="EventSignal", + output_port_type="InferenceMeta"), + _capture(), + ], + connections=[ + _conn("c1", "dinA", "py"), + _conn("c2", "dinB", "py"), + _conn("c3", "py", "cap"), + ], + ) + document = _compile_ok(graph, simulation=True) + names = { + _elements_of(document, node_id)[0]["args"]["name"] + for node_id in ("dinA", "dinB") + } + assert names == {"sim_source_dinA", "sim_source_dinB"} + + +# -------------------------------------------------------------------------- +# Hardware outputs become recording bindings (Requirement 12.6) +# -------------------------------------------------------------------------- + +class TestHardwareOutputStubs: + def _graph(self): + return WorkflowGraph( + nodes=[_folder(), _inference(), _digital_output(), _mqtt(), _opcua()], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "dout"), + _conn("c3", "inf", "mq"), + _conn("c4", "inf", "opc"), + ], + ) + + def test_hardware_outputs_bind_to_recording_stubs(self): + document = _compile_ok(self._graph(), simulation=True) + bindings = _bindings_by_node(document) + assert bindings["dout"]["binding"] == "recording_digital_output" + assert bindings["mq"]["binding"] == "recording_mqtt_publish" + assert bindings["opc"]["binding"] == "recording_opcua_write" + for node_id in ("dout", "mq", "opc"): + assert bindings[node_id]["binding"].startswith( + SIM_RECORDING_BINDING_PREFIX) + # Recording stubs contribute no pipeline elements. + assert _elements_of(document, node_id) == [] + + def test_recording_bindings_keep_parameters_and_topology(self): + document = _compile_ok(self._graph(), simulation=True) + bindings = _bindings_by_node(document) + # The recorder still sees what the node would have actuated with. + assert bindings["dout"]["parameters"]["pin"] == 7 + assert bindings["dout"]["parameters"]["condition"] == "is_anomalous == true" + assert bindings["mq"]["parameters"]["topic"] == "dda/out" + assert bindings["opc"]["parameters"]["node_id"] == "ns=2;s=Out" + assert bindings["mq"]["upstreamNodeIds"] == ["inf"] + + def test_no_hardware_element_or_binding_remains(self): + document = _compile_ok(_mixed_graph(), simulation=True) + factories = {e["factory"] for e in _all_elements(document)} + assert not factories & _HARDWARE_FACTORIES + bindings = {b["binding"] for b in document.executor_bindings} + assert not bindings & _HARDWARE_BINDINGS + + def test_hardware_only_plugin_dependencies_dropped(self): + # python:opcua is required on devices but not by the recording stub. + graph = self._graph() + non_sim = _compile_ok(graph) + sim = _compile_ok(graph, simulation=True) + assert "python:opcua" in non_sim.plugin_dependencies + assert "python:opcua" not in sim.plugin_dependencies + + +# -------------------------------------------------------------------------- +# Model inference stubs to a pass-through in simulation (Requirement 12.6) +# -------------------------------------------------------------------------- + +class TestModelInferenceStub: + def _graph(self): + return WorkflowGraph( + nodes=[_folder(), _inference(), _capture()], + connections=[_conn("c1", "src", "inf"), _conn("c2", "inf", "cap")], + ) + + def test_simulation_stubs_model_inference_without_emltriton(self): + # The proprietary emltriton plugin does not exist in the sandbox + # and registered models are device-compiled: simulation replaces + # the chain with the RGB capsfilter plus an identity element the + # harness recognizes by its sim_inference_ name. + document = _compile_ok(self._graph(), simulation=True) + elements = _elements_of(document, "inf") + assert [e["factory"] for e in elements] == ["capsfilter", "identity"] + assert elements[0]["args"]["caps"] == "video/x-raw,format=RGB" + assert elements[1]["args"]["name"] == "sim_inference_inf" + assert "emltriton" not in {e["factory"] for e in _all_elements(document)} + # The stub is a pipeline element chain, not an executor binding. + assert "inf" not in _bindings_by_node(document) + + def test_multiple_inference_nodes_get_distinct_stub_names(self): + graph = WorkflowGraph( + nodes=[_folder(), _inference("infA"), _inference("infB"), + _capture("capA"), _capture("capB")], + connections=[ + _conn("c1", "src", "infA"), + _conn("c2", "src", "infB"), + _conn("c3", "infA", "capA"), + _conn("c4", "infB", "capB"), + ], + ) + document = _compile_ok(graph, simulation=True) + names = { + _elements_of(document, node_id)[1]["args"]["name"] + for node_id in ("infA", "infB") + } + assert names == {"sim_inference_infA", "sim_inference_infB"} + + def test_emltriton_plugin_dependency_dropped_in_simulation(self): + non_sim = _compile_ok(self._graph()) + sim = _compile_ok(self._graph(), simulation=True) + # emltriton is LocalServer-bundled so neither lists it; the sim + # stub also declares only bundled coreelements. + assert "emltriton" not in sim.plugin_dependencies + assert set(sim.plugin_dependencies) <= set(non_sim.plugin_dependencies) + + def test_device_output_for_model_inference_is_unchanged(self): + # Non-simulation compilation must stay byte-identical on every + # device architecture: RGB capsfilter + emltriton with the model + # name and LocalServer Triton paths (Requirement 6.2). + for arch in ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", + "arm64_jp6"): + document = _compile_ok(self._graph(), arch=arch) + elements = _elements_of(document, "inf") + assert elements == [ + {"nodeId": "inf", "factory": "capsfilter", + "args": {"caps": "video/x-raw,format=RGB"}}, + {"nodeId": "inf", "factory": "emltriton", + "args": {"model-repo": "/aws_dda/dda_triton/triton_model_repo", + "server-path": "/opt/tritonserver", + "model": "widget-anomaly-v3"}}, + ], arch + + +# -------------------------------------------------------------------------- +# Non-hardware nodes compile identically (Requirement 12.6) +# -------------------------------------------------------------------------- + +class TestNonHardwareNodesUnchanged: + NON_HARDWARE = ("rot", "filt", "cap") + + def _documents(self, arch="x86_64"): + graph = _mixed_graph() + return _compile_ok(graph, arch=arch), _compile_ok( + graph, arch=arch, simulation=True) + + def test_element_chains_identical(self): + non_sim, sim = self._documents() + for node_id in self.NON_HARDWARE: + assert _elements_of(sim, node_id) == _elements_of(non_sim, node_id) + + def test_executor_bindings_identical(self): + non_sim, sim = self._documents() + assert _bindings_by_node(sim)["filt"] == _bindings_by_node(non_sim)["filt"] + + def test_non_hardware_nodes_follow_target_arch(self): + # On arm64_jp6, non-hardware nodes keep their jp6 mappings while + # both frame sources (camera and folder, hardware-dependent) stub + # to the dataset-fed chain even though the target is a device arch. + graph = WorkflowGraph( + nodes=[_folder(), _camera(), _inference(), _capture()], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "cam", "inf"), + _conn("c3", "inf", "cap"), + ], + ) + non_sim = _compile_ok(graph, arch="arm64_jp6") + sim = _compile_ok(graph, arch="arm64_jp6", simulation=True) + # Non-simulation keeps folder_source's jp6 PNG-staged device chain. + assert [e["factory"] for e in _elements_of(non_sim, "src")][:2] == [ + "filesrc", "pngdec"] + # Simulation stubs both sources from the Test_Dataset. + for node_id in ("src", "cam"): + assert [e["factory"] for e in _elements_of(sim, node_id)][0] == \ + "multifilesrc" + # Model inference stubs to the pass-through (hardware-dependent), + # keeping its jp6 emltriton chain out of the simulation output. + assert [e["factory"] for e in _elements_of(non_sim, "inf")] == [ + "capsfilter", "emltriton"] + assert [e["factory"] for e in _elements_of(sim, "inf")] == [ + "capsfilter", "identity"] + # Non-hardware nodes compile identically to the jp6 output. + assert _elements_of(sim, "cap") == _elements_of(non_sim, "cap") + assert sim.target_arch == "arm64_jp6" + + def test_simulation_defaults_off(self): + document = _compile_ok(_camera_graph()) + factories = {e["factory"] for e in _all_elements(document)} + assert "v4l2src" in factories + assert "emoutputevent" in factories + + def test_every_node_still_referenced_exactly_once(self): + document = _compile_ok(_mixed_graph(), simulation=True) + assert sorted(document.referenced_node_ids()) == [ + "cam", "cap", "filt", "inf", "mq", "rot"] + + +# -------------------------------------------------------------------------- +# Unknown architectures stay errors in simulation mode +# -------------------------------------------------------------------------- + +class TestUnknownArchitecture: + def test_unknown_arch_errors_for_every_node_even_in_simulation(self): + result = compile(_camera_graph(), "riscv64", simulation=True) + assert isinstance(result, list) + assert {error.code for error in result} == {CODE_UNMAPPED_ARCHITECTURE} + assert sorted(error.node_id for error in result) == ["cam", "dout", "inf"] + assert all(error.arch == "riscv64" for error in result) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation_properties.py new file mode 100644 index 00000000..8edb537a --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_simulation_properties.py @@ -0,0 +1,334 @@ +"""Property test for simulation stubbing (task 4.8). + +**Feature: workflow-manager, Property 14: Simulation stubs exactly the hardware-dependent nodes** + +For all valid Workflow_Definitions compiled with ``simulation=true``, +every hardware-dependent node (per the catalog flag) is mapped to a +recording stub, no hardware executor binding or hardware element remains +in the output, and every non-hardware-dependent node compiles identically +to the non-simulation output. + +**Validates: Requirements 12.6** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + ARCH_SIM, + DEVICE_ARCHITECTURES, + SIM_RECORDING_BINDING_PREFIX, + are_port_types_compatible, + get_node_type, +) +from workflow_core.compiler import CompiledPipelineDocument, compile +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +from .generators import graph_strategy, node_parameters_strategy + +#: Hardware-dependent input node types (input nodes are reachability +#: roots, so they can be added to a valid graph without wiring; their +#: unused output port is at most a warning). folder_source reads the +#: device file system, so it is hardware-dependent too: every input +#: node type stubs to a dataset-fed source in simulation. +_HARDWARE_INPUT_TYPES = ("camera_source", "folder_source", "digital_input") + +#: Hardware-dependent output node types (must be wired onto a +#: type-compatible source to stay reachable). +_HARDWARE_OUTPUT_TYPES = ("digital_output", "mqtt_publish", "opcua_write") + +#: Non-hardware node types for the minimal-hardware graphs (everything +#: except the required frame source). +_NO_HARDWARE_INTERMEDIATE_TYPES = ("dewarp", "rotate", "crop", "format_convert") + +#: Linking elements the compiler synthesizes (not tied to any node). +_SYNTHETIC_FACTORIES = {"tee", "queue", "funnel"} + + +# -------------------------------------------------------------------------- +# Graph strategies: the shared graph_strategy may or may not include +# hardware-dependent nodes, so it is complemented with a variant that +# guarantees hardware nodes and one that guarantees their absence. +# -------------------------------------------------------------------------- + +def _compatible_sources(nodes, input_port_type): + """Every (node_id, port_name) output port among ``nodes`` whose + effective port type can feed ``input_port_type``.""" + sources = [] + for node in nodes: + descriptor = get_node_type(node.type) + parameter_names = {p.name for p in descriptor.parameters} + override = None + if "output_port_type" in parameter_names: + override = node.parameters.get("output_port_type") + for port in descriptor.outputs: + effective_type = override or port.port_type + if are_port_types_compatible(effective_type, input_port_type): + sources.append((node.id, port.name)) + return sources + + +def _fresh_id(candidate, used): + while candidate in used: + candidate += "_" + used.add(candidate) + return candidate + + +@st.composite +def graph_with_hardware_strategy(draw): + """Valid Workflow_Definitions guaranteed to contain at least one + hardware-dependent node. + + Draws a validator-valid base graph from the shared ``graph_strategy`` + and adds one hardware input node (always reachable, no wiring needed) + plus 0..2 hardware output nodes wired onto type-compatible sources + where such a source exists, so the graph stays validator-valid. + """ + base = draw(graph_strategy()) + nodes = list(base.nodes) + connections = list(base.connections) + used_node_ids = {node.id for node in nodes} + used_connection_ids = {connection.id for connection in connections} + + input_type = draw(st.sampled_from(_HARDWARE_INPUT_TYPES)) + descriptor = get_node_type(input_type) + nodes.append(Node( + id=_fresh_id("p14-hw-in", used_node_ids), + type=input_type, + position=Position(x=0.0, y=-1.0), + parameters=draw(node_parameters_strategy(descriptor)), + )) + + for index in range(draw(st.integers(min_value=0, max_value=2))): + output_type = draw(st.sampled_from(_HARDWARE_OUTPUT_TYPES)) + descriptor = get_node_type(output_type) + input_port = descriptor.inputs[0] + sources = _compatible_sources(nodes, input_port.port_type) + if not sources: + continue + node_id = _fresh_id("p14-hw-out-{0}".format(index), used_node_ids) + nodes.append(Node( + id=node_id, + type=output_type, + position=Position(x=1.0, y=float(index)), + parameters=draw(node_parameters_strategy(descriptor)), + )) + source_node, source_port = draw(st.sampled_from(sources)) + connections.append(Connection( + id=_fresh_id("p14-conn-{0}".format(index), used_connection_ids), + source=PortEndpoint(node=source_node, port=source_port), + target=PortEndpoint(node=node_id, port=input_port.name), + )) + + return WorkflowGraph(nodes=nodes, connections=connections) + + +@st.composite +def graph_with_minimal_hardware_strategy(draw): + """Valid Workflow_Definitions where the *only* hardware-dependent + node is the required frame source: folder_source -> 0..3 + preprocessing intermediates -> 1..2 capture sinks (two captures off + the same source exercise fan-out). + + Hardware-free valid graphs no longer exist: every input node type is + hardware-dependent (all frame/event sources are dataset-fed in + simulation) and validator check V1 requires an input node. This + regime exercises the boundary where exactly one node stubs while + everything downstream must compile identically.""" + nodes = [] + connections = [] + + descriptor = get_node_type("folder_source") + source_node = Node( + id="p14-src", + type="folder_source", + position=Position(x=0.0, y=0.0), + parameters=draw(node_parameters_strategy(descriptor)), + ) + nodes.append(source_node) + last_source = (source_node.id, descriptor.outputs[0].name) + + for index in range(draw(st.integers(min_value=0, max_value=3))): + type_id = draw(st.sampled_from(_NO_HARDWARE_INTERMEDIATE_TYPES)) + descriptor = get_node_type(type_id) + node = Node( + id="p14-mid-{0}".format(index), + type=type_id, + position=Position(x=float(index + 1), y=0.0), + parameters=draw(node_parameters_strategy(descriptor)), + ) + nodes.append(node) + connections.append(Connection( + id="p14-c-{0}".format(len(connections)), + source=PortEndpoint(node=last_source[0], port=last_source[1]), + target=PortEndpoint(node=node.id, port=descriptor.inputs[0].name), + )) + last_source = (node.id, descriptor.outputs[0].name) + + descriptor = get_node_type("capture") + for index in range(1 + draw(st.integers(min_value=0, max_value=1))): + node = Node( + id="p14-cap-{0}".format(index), + type="capture", + position=Position(x=9.0, y=float(index)), + parameters=draw(node_parameters_strategy(descriptor)), + ) + nodes.append(node) + connections.append(Connection( + id="p14-c-{0}".format(len(connections)), + source=PortEndpoint(node=last_source[0], port=last_source[1]), + target=PortEndpoint(node=node.id, port=descriptor.inputs[0].name), + )) + + return WorkflowGraph(nodes=nodes, connections=connections) + + +def simulation_graph_strategy(): + """Valid Workflow_Definitions covering all three regimes: as-drawn + (arbitrary hardware/non-hardware mix), guaranteed extra hardware + nodes beyond the source, and minimal hardware (only the required + dataset-fed frame source).""" + return st.one_of( + graph_strategy(), + graph_with_hardware_strategy(), + graph_with_minimal_hardware_strategy(), + ) + + +# -------------------------------------------------------------------------- +# Helpers over compiled documents +# -------------------------------------------------------------------------- + +def _all_elements(document): + return [element for segment in document.segments for element in segment["elements"]] + + +def _elements_of(document, node_id): + return [e for e in _all_elements(document) if e["nodeId"] == node_id] + + +def _bindings_by_node(document): + return {binding["nodeId"]: binding for binding in document.executor_bindings} + + +def _compile_ok(graph, target_arch, simulation): + result = compile(graph, target_arch, simulation=simulation) + assert isinstance(result, CompiledPipelineDocument), ( + "compilation of a valid graph failed (simulation={0}): {1}".format( + simulation, result) + ) + return result + + +# -------------------------------------------------------------------------- +# Property 14 +# -------------------------------------------------------------------------- + +@given( + graph=simulation_graph_strategy(), + target_arch=st.sampled_from(DEVICE_ARCHITECTURES), +) +def test_simulation_stubs_exactly_the_hardware_dependent_nodes(graph, target_arch): + """**Feature: workflow-manager, Property 14: Simulation stubs exactly the hardware-dependent nodes** + + **Validates: Requirements 12.6** + """ + hardware_nodes = [ + node for node in graph.nodes if get_node_type(node.type).hardware_dependent + ] + non_hardware_nodes = [ + node for node in graph.nodes if not get_node_type(node.type).hardware_dependent + ] + + non_sim = _compile_ok(graph, target_arch, simulation=False) + sim = _compile_ok(graph, target_arch, simulation=True) + + sim_bindings = _bindings_by_node(sim) + non_sim_bindings = _bindings_by_node(non_sim) + + # 1. Every hardware-dependent node is mapped to a recording stub: its + # compiled output is exactly the catalog's sim-architecture mapping + # (recording_* executor binding for outputs, dataset-fed source + # elements for inputs), never the target-arch hardware mapping. + for node in hardware_nodes: + sim_mapping = get_node_type(node.type).mapping_for(ARCH_SIM) + assert sim_mapping is not None, ( + "hardware-dependent type '{0}' lacks a sim mapping".format(node.type) + ) + if sim_mapping.executor_binding: + assert node.id in sim_bindings, ( + "hardware node '{0}' has no recording binding".format(node.id) + ) + binding = sim_bindings[node.id]["binding"] + assert binding == sim_mapping.executor_binding + assert binding.startswith(SIM_RECORDING_BINDING_PREFIX), ( + "hardware node '{0}' bound to non-recording '{1}'".format( + node.id, binding) + ) + assert _elements_of(sim, node.id) == [], ( + "recording-stubbed node '{0}' still contributes elements".format( + node.id) + ) + else: + factories = [e["factory"] for e in _elements_of(sim, node.id)] + expected = [t["factory"] for t in sim_mapping.element_chain] + assert factories == expected, ( + "hardware node '{0}' compiled to {1}, expected sim stub {2}".format( + node.id, factories, expected) + ) + assert node.id not in sim_bindings + + # 2. No hardware executor binding or hardware element remains: the + # hardware nodes' target-arch bindings are absent, and every + # element in the simulation output is accounted for by a graph + # node's (stubbed or unchanged) chain or a synthetic link element. + device_hardware_bindings = set() + for node in hardware_nodes: + device_mapping = get_node_type(node.type).mapping_for(target_arch) + if device_mapping is not None and device_mapping.executor_binding: + device_hardware_bindings.add(device_mapping.executor_binding) + remaining_bindings = {b["binding"] for b in sim.executor_bindings} + assert not device_hardware_bindings & remaining_bindings, ( + "hardware executor bindings remain in simulation output: {0}".format( + sorted(device_hardware_bindings & remaining_bindings)) + ) + + graph_node_ids = {node.id for node in graph.nodes} + for element in _all_elements(sim): + if element["nodeId"] is None: + assert element["factory"] in _SYNTHETIC_FACTORIES, ( + "unexpected untagged element '{0}'".format(element["factory"]) + ) + else: + assert element["nodeId"] in graph_node_ids + + expected_binding_nodes = ( + {node.id for node in hardware_nodes + if get_node_type(node.type).mapping_for(ARCH_SIM).executor_binding} + | {node.id for node in non_hardware_nodes if node.id in non_sim_bindings} + ) + assert set(sim_bindings) == expected_binding_nodes, ( + "simulation executor bindings cover the wrong node set" + ) + + # 3. Every non-hardware-dependent node compiles identically to the + # non-simulation output: same tagged element chain (factories and + # args) and same executor binding. + for node in non_hardware_nodes: + assert _elements_of(sim, node.id) == _elements_of(non_sim, node.id), ( + "non-hardware node '{0}' compiled differently in simulation".format( + node.id) + ) + assert sim_bindings.get(node.id) == non_sim_bindings.get(node.id), ( + "non-hardware node '{0}' has a different executor binding " + "in simulation".format(node.id) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_unmapped_arch_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_unmapped_arch_properties.py new file mode 100644 index 00000000..a2712e0c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_compiler_unmapped_arch_properties.py @@ -0,0 +1,122 @@ +"""Property test for unmapped-architecture compile errors (task 4.7). + +**Feature: workflow-manager, Property 8: Unmapped architecture yields +identifying compile errors** + +For all Workflow_Definitions containing at least one node type with no +GStreamer mapping for the chosen target architecture, compilation fails +with errors that identify exactly those nodes and the unsupported +architecture, and compilation succeeds when all node types have mappings. + +Every catalog node type currently declares mappings for all known +architectures, so the failing branch is exercised with unknown target +architecture strings (every node in the graph is then unmapped) and the +succeeding branch with the known architectures. The expected unmapped +node set is derived from the catalog per drawn graph and architecture, +so the property keeps holding if the catalog ever gains partially +mapped node types. + +**Validates: Requirements 6.5** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ARCHITECTURES, get_node_type +from workflow_core.compiler import ( + CODE_UNMAPPED_ARCHITECTURE, + CompiledPipelineDocument, + CompileError, + compile, +) + +from .generators import graph_strategy + +# -------------------------------------------------------------------------- +# Target architecture strategy +# -------------------------------------------------------------------------- + +#: Realistic-looking architecture identifiers no catalog mapping declares. +_UNKNOWN_ARCH_EXAMPLES = ( + "riscv64", + "arm64_jp7", + "x86", + "X86_64", # case matters: not the known "x86_64" + " x86_64", # surrounding whitespace is a different identifier + "", + "アーキテクチャ", +) + +_unknown_architectures = st.one_of( + st.sampled_from(_UNKNOWN_ARCH_EXAMPLES), + st.text(max_size=16).filter(lambda arch: arch not in ARCHITECTURES), +) + +#: Known architectures (every catalog node type maps all of them) mixed +#: with unknown ones, so both branches of the property are exercised. +_architectures = st.one_of( + st.sampled_from(ARCHITECTURES), + _unknown_architectures, +) + + +def _expected_unmapped_node_ids(graph, target_arch): + """Node ids whose type has no GStreamer mapping for ``target_arch``, + per the catalog declarations (Requirement 6.5).""" + return { + node.id + for node in graph.nodes + if get_node_type(node.type).mapping_for(target_arch) is None + } + + +@given(graph=graph_strategy(), target_arch=_architectures) +def test_unmapped_architecture_yields_identifying_compile_errors(graph, target_arch): + """**Feature: workflow-manager, Property 8: Unmapped architecture + yields identifying compile errors** + + **Validates: Requirements 6.5** + """ + expected_unmapped = _expected_unmapped_node_ids(graph, target_arch) + + result = compile(graph, target_arch) + + if not expected_unmapped: + # Every node type has a mapping: compilation succeeds. + assert isinstance(result, CompiledPipelineDocument), ( + "compilation failed although every node type has a mapping " + "for {!r}: {}".format(target_arch, result) + ) + assert result.target_arch == target_arch + return + + # At least one node type lacks a mapping: compilation fails with + # errors identifying exactly those nodes and the unsupported + # architecture (Requirement 6.5). + assert isinstance(result, list), ( + "expected compile errors for unmapped architecture {!r}, got a " + "document".format(target_arch) + ) + assert result, "compile returned an empty error list" + assert all(isinstance(error, CompileError) for error in result) + assert {error.code for error in result} == {CODE_UNMAPPED_ARCHITECTURE}, ( + "every error must be an unmapped-architecture error" + ) + + # Exactly the unmapped nodes are identified, each exactly once. + reported_node_ids = [error.node_id for error in result] + assert len(reported_node_ids) == len(set(reported_node_ids)), ( + "a node was reported more than once: {}".format(reported_node_ids) + ) + assert set(reported_node_ids) == expected_unmapped, ( + "reported nodes {} != expected unmapped nodes {}".format( + sorted(set(reported_node_ids)), sorted(expected_unmapped) + ) + ) + + # Every error names the unsupported architecture. + assert all(error.arch == target_arch for error in result), ( + "every error must carry the unsupported architecture" + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_generators.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_generators.py new file mode 100644 index 00000000..360dd89d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_generators.py @@ -0,0 +1,141 @@ +"""Smoke tests for the shared hypothesis generators (task 2.3). + +Assert the invariants the generators promise to downstream property tests +(tasks 2.4, 2.5, 3.4, 4.3-4.8): + +- ``graph_strategy`` graphs pass ``validate()`` with no error-severity + findings and serialize/parse cleanly, +- each defect-seeding combinator produces graphs whose error findings are + exactly the seeded (and implied) defect set, +- schema-corrupting document mutators always produce documents that + ``parse()`` rejects with a descriptive error. + +**Validates: Requirements 3.4, 4.6, 6.6** +""" + +import json + +import pytest +from hypothesis import given, strategies as st + +from workflow_core.serializer import ( + Node, + Position, + WorkflowGraph, + graph_to_document, + parse, + serialize, +) +from workflow_core.validator import SEVERITY_ERROR, validate + +from tests.generators import ( + ALL_DEFECT_CLASSES, + corrupted_document_strategy, + graph_strategy, + seeded_graph_strategy, + single_node_graph_strategy, +) + + +def _error_triples(findings): + return { + (finding.code, finding.node_id, finding.connection_id) + for finding in findings + if finding.severity == SEVERITY_ERROR + } + + +def _expected_triples(seeded): + return { + (expected.code, expected.node_id, expected.connection_id) + for expected in seeded.expected + } + + +# --------------------------------------------------------------------------- +# graph_strategy: valid graphs +# --------------------------------------------------------------------------- + +@given(graph=graph_strategy()) +def test_graph_strategy_yields_validator_valid_graphs(graph): + """Valid graphs contain no error-severity findings and round-trip + through the canonical serializer.""" + findings = validate(graph) + errors = _error_triples(findings) + assert errors == set(), "graph_strategy produced error findings: {}".format(errors) + + document = serialize(graph) # must not raise (unique, non-empty ids) + result = parse(document) + assert result.ok, "serialized valid graph failed to parse: {}".format(result.error) + + +@given(graph=single_node_graph_strategy()) +def test_single_node_graph_strategy_is_well_formed(graph): + """Single-node graphs (serializer edge case) serialize and parse.""" + assert len(graph.nodes) == 1 + assert graph.connections == [] + result = parse(serialize(graph)) + assert result.ok, result.error + + +# --------------------------------------------------------------------------- +# Defect-seeding combinators: exactness per defect class and in combination +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("defect", ALL_DEFECT_CLASSES) +@given(data=st.data()) +def test_each_defect_class_seeds_exactly_its_findings(defect, data): + """Each combinator alone yields exactly the intended defect class + (plus its declared implied findings), and nothing else.""" + seeded = data.draw(seeded_graph_strategy(defect_classes=[defect])) + assert seeded.defects == frozenset({defect}) + assert seeded.expected, "a seeded graph must expect at least one finding" + assert _error_triples(validate(seeded.graph)) == _expected_triples(seeded) + + +@given(seeded=seeded_graph_strategy()) +def test_combined_defect_seeding_matches_expected_findings_exactly(seeded): + """Random defect-class combinations still produce exactly the declared + expected error findings.""" + assert seeded.defects # nonempty by construction + assert _error_triples(validate(seeded.graph)) == _expected_triples(seeded) + + +# --------------------------------------------------------------------------- +# Schema-corrupting document mutators +# --------------------------------------------------------------------------- + +@given(document=corrupted_document_strategy()) +def test_corrupted_documents_are_rejected_descriptively(document): + """Every corrupted document is rejected: no graph, and a descriptive + error with a code and message.""" + result = parse(document) + assert not result.ok + assert result.graph is None + assert result.error.code + assert result.error.message + + +# --------------------------------------------------------------------------- +# Deterministic examples +# --------------------------------------------------------------------------- + +def test_defect_class_constants_are_distinct(): + assert len(ALL_DEFECT_CLASSES) == 6 + assert len(set(ALL_DEFECT_CLASSES)) == 6 + + +def test_dropping_nodes_key_is_a_schema_violation(): + """A hand-built corruption representative: dropping a required + top-level key must be rejected as a schema violation.""" + graph = WorkflowGraph( + nodes=[ + Node(id="n1", type="camera_source", position=Position(0, 0), parameters={}), + ], + connections=[], + ) + document = graph_to_document(graph) + del document["nodes"] + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == "SCHEMA_VIOLATION" diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_package_skeleton.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_package_skeleton.py new file mode 100644 index 00000000..2acbe379 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_package_skeleton.py @@ -0,0 +1,35 @@ +"""Smoke tests for the workflow_core package skeleton and test setup. + +Verifies the package layout imports cleanly and that the hypothesis +profile registered in conftest.py (25+ examples per property for fast +local runs) is active for this test session. +""" + +from hypothesis import given, settings +from hypothesis import strategies as st + + +def test_package_imports(): + import workflow_core + import workflow_core.catalog + import workflow_core.compiler + import workflow_core.serializer + import workflow_core.validator + + assert workflow_core.__version__ + + +def test_hypothesis_profile_runs_at_least_25_examples(): + assert settings().max_examples >= 25 + + +def test_hypothesis_executes_properties(): + """Sanity-check that hypothesis is wired up and generates examples.""" + executed = [] + + @given(st.integers()) + def prop(n): + executed.append(n) + + prop() + assert len(executed) >= 25 diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_parameter_predicate_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_parameter_predicate_properties.py new file mode 100644 index 00000000..6c485ce4 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_parameter_predicate_properties.py @@ -0,0 +1,282 @@ +"""Property test for the parameter constraint predicate (task 3.3). + +**Feature: workflow-manager, Property 9: Parameter constraint predicate correctness** + +For all parameter descriptors in the catalog and all generated values (valid +and invalid against the descriptor's type and constraints), the shared +parameter-validation predicate accepts the value if and only if the value +satisfies the descriptor's declared type and constraints. + +**Validates: Requirements 1.8** +""" + +from __future__ import annotations + +import re +from typing import Any + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + NODE_CATALOG, + PARAMETER_TYPES, + ParameterDescriptor, +) +from workflow_core.validator import ( + check_parameter_value, + is_parameter_value_valid, +) + +# --------------------------------------------------------------------------- +# Independent oracle +# +# States the property's right-hand side ("the value satisfies the +# descriptor's declared type and constraints") directly from the descriptor +# semantics documented in workflow_core.catalog.models, written +# independently of the predicate implementation under test. +# --------------------------------------------------------------------------- + +_STRING_LIKE = ("string", "code", "model_ref") + + +def _value_has_declared_type(param_type: str, value: Any) -> bool: + if param_type in _STRING_LIKE: + return isinstance(value, str) + if param_type == "int": + return isinstance(value, int) and not isinstance(value, bool) + if param_type == "float": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if param_type == "bool": + return isinstance(value, bool) + if param_type == "enum": + # An enum's "type" is membership in its declared value set, which is + # checked as the "values" constraint below. + return True + # Unknown declared type: nothing satisfies it. + return False + + +def _is_member(value: Any, members: list) -> bool: + """Membership that never conflates bools with numerically equal ints.""" + for member in members: + if isinstance(value, bool) or isinstance(member, bool): + if value is member: + return True + elif value == member: + return True + return False + + +def _value_satisfies_constraints(constraints: dict, value: Any) -> bool: + if "values" in constraints and not _is_member(value, constraints["values"]): + return False + + if isinstance(value, (int, float)) and not isinstance(value, bool): + minimum = constraints.get("min") + if minimum is not None and not value >= minimum: # NaN fails bounds + return False + maximum = constraints.get("max") + if maximum is not None and not value <= maximum: + return False + + if isinstance(value, str): + min_length = constraints.get("min_length") + if min_length is not None and len(value) < min_length: + return False + max_length = constraints.get("max_length") + if max_length is not None and len(value) > max_length: + return False + pattern = constraints.get("regex") + if pattern is not None and re.search(pattern, value) is None: + return False + + return True + + +def _expected_valid(descriptor: ParameterDescriptor, value: Any) -> bool: + """The property's specification of validity.""" + if value is None: + return not descriptor.required + return _value_has_declared_type(descriptor.param_type, value) and ( + _value_satisfies_constraints(descriptor.constraints or {}, value) + ) + + +# --------------------------------------------------------------------------- +# Descriptor generation: random ParameterDescriptors with satisfiable +# constraints, plus every descriptor actually declared in the catalog. +# --------------------------------------------------------------------------- + +# Simple, valid regex constraints paired with hypothesis' from_regex for +# generating conforming strings (search semantics, like the predicate). +_REGEX_POOL = ( + r"^opc\.tcp://.+", + r"^[A-Za-z][A-Za-z0-9_-]*$", + r"^\d+$", + r"^/[^\0]*$", +) + +_ENUM_MEMBERS = st.one_of( + st.text(max_size=10), + st.integers(min_value=-100, max_value=100), + st.booleans(), +) + + +@st.composite +def _generated_descriptors(draw) -> ParameterDescriptor: + param_type = draw(st.sampled_from(PARAMETER_TYPES)) + required = draw(st.booleans()) + constraints: dict = {} + + if param_type == "enum": + constraints["values"] = draw( + st.lists(_ENUM_MEMBERS, min_size=1, max_size=6) + ) + elif param_type == "int": + kind = draw(st.sampled_from(["none", "range", "values"])) + if kind == "range": + low = draw(st.integers(min_value=-1000, max_value=1000)) + high = draw(st.integers(min_value=low, max_value=low + 2000)) + if draw(st.booleans()): + constraints["min"] = low + if draw(st.booleans()): + constraints["max"] = high + elif kind == "values": + constraints["values"] = draw( + st.lists(st.integers(min_value=-100, max_value=100), + min_size=1, max_size=6) + ) + elif param_type == "float": + if draw(st.booleans()): + low = draw(st.floats(min_value=-1e6, max_value=1e6, + allow_nan=False, allow_infinity=False)) + high = draw(st.floats(min_value=low, max_value=1e6 + 1, + allow_nan=False, allow_infinity=False)) + if draw(st.booleans()): + constraints["min"] = low + if draw(st.booleans()): + constraints["max"] = high + elif param_type in _STRING_LIKE: + kind = draw(st.sampled_from(["none", "length", "regex"])) + if kind == "length": + min_length = draw(st.integers(min_value=0, max_value=20)) + max_length = draw(st.integers(min_value=min_length, + max_value=min_length + 30)) + if draw(st.booleans()): + constraints["min_length"] = min_length + if draw(st.booleans()): + constraints["max_length"] = max_length + elif kind == "regex": + constraints["regex"] = draw(st.sampled_from(_REGEX_POOL)) + # bool: no constraints. + + return ParameterDescriptor( + name=draw(st.sampled_from(["p", "gain", "endpoint", "名前"])), + param_type=param_type, + required=required, + default=None, + constraints=constraints, + ) + + +_CATALOG_DESCRIPTORS = tuple( + parameter + for node_type in NODE_CATALOG + for parameter in node_type.parameters +) + +_descriptors = st.one_of( + _generated_descriptors(), + st.sampled_from(_CATALOG_DESCRIPTORS), +) + + +# --------------------------------------------------------------------------- +# Value generation: a conforming generator targeted at the descriptor's +# constraints plus an arbitrary pool covering wrong types, out-of-range +# numbers, NaN/inf, empty/whitespace/unicode strings, and None. +# --------------------------------------------------------------------------- + +_ARBITRARY_VALUES = st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=True, allow_infinity=True), + st.text(max_size=40), + st.sampled_from(["", " ", "\t\n", "日本語テスト", "opc.tcp://plc:4840"]), + st.lists(st.integers(), max_size=3), + st.dictionaries(st.text(max_size=3), st.integers(), max_size=2), +) + + +def _conforming_values(descriptor: ParameterDescriptor) -> st.SearchStrategy: + constraints = descriptor.constraints or {} + param_type = descriptor.param_type + + if "values" in constraints: + return st.sampled_from(constraints["values"]) + if param_type == "int": + return st.integers( + min_value=constraints.get("min", -(10 ** 6)), + max_value=constraints.get("max", 10 ** 6), + ) + if param_type == "float": + return st.floats( + min_value=constraints.get("min", -1e6), + max_value=constraints.get("max", 1e6), + allow_nan=False, + allow_infinity=False, + ) + if param_type == "bool": + return st.booleans() + if param_type in _STRING_LIKE: + if "regex" in constraints: + return st.from_regex(constraints["regex"]) + return st.text( + min_size=constraints.get("min_length", 0), + max_size=constraints.get("max_length", 40), + ) + return _ARBITRARY_VALUES + + +@st.composite +def _descriptor_value_pairs(draw): + descriptor = draw(_descriptors) + value = draw(st.one_of(_conforming_values(descriptor), _ARBITRARY_VALUES)) + return descriptor, value + + +# --------------------------------------------------------------------------- +# Property 9 +# --------------------------------------------------------------------------- + +@given(case=_descriptor_value_pairs()) +def test_parameter_constraint_predicate_correctness(case): + """**Feature: workflow-manager, Property 9: Parameter constraint predicate correctness** + + **Validates: Requirements 1.8** + """ + descriptor, value = case + expected = _expected_valid(descriptor, value) + + violation = check_parameter_value(descriptor, value) + + # The predicate accepts the value iff it satisfies the declared type + # and constraints. + assert (violation is None) == expected, ( + "check_parameter_value(%r, %r) returned %r but the value %s the " + "descriptor's declared type and constraints" + % (descriptor, value, violation, + "satisfies" if expected else "violates") + ) + + # The boolean form agrees with the violation form. + assert is_parameter_value_valid(descriptor, value) == expected + + # Rejections are descriptive (non-empty code and message) so the + # configuration panel can display a validation error (Requirement 1.8). + if violation is not None: + assert violation.code + assert violation.message diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_parser_rejection_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_parser_rejection_properties.py new file mode 100644 index 00000000..60c7c000 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_parser_rejection_properties.py @@ -0,0 +1,102 @@ +"""Property test for parser rejection of invalid documents (task 2.5). + +**Feature: workflow-manager, Property 2: Parser rejects invalid documents descriptively** + +For all invalid JSON documents (random junk, or valid documents corrupted +by random schema-violating mutations), ``parse`` returns a descriptive +error identifying a violation location, never a graph and never an +unhandled exception. + +**Validates: Requirements 3.3** +""" + +from __future__ import annotations + +import json + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.serializer import ParseError, ParseResult, parse + +from .generators import corrupted_document_strategy + + +# --------------------------------------------------------------------------- +# Invalid-document generators +# +# Three families, each invalid *by construction* so the property never +# quantifies over an accidentally valid document: +# +# 1. Random junk that is not valid JSON at all. +# 2. Valid JSON whose top-level value is not an object (the schema +# requires an object), covering scalars, arrays, and nested values. +# 3. Well-formed documents corrupted by a random schema-violating +# mutation (shared ``corrupted_document_strategy``: dropped required +# keys, wrong types, bad schema versions, extra properties, duplicate +# ids, dangling node references). +# --------------------------------------------------------------------------- + +def _is_not_json(text: str) -> bool: + try: + json.loads(text) + except ValueError: + return True + return False + + +#: Random junk strings that fail JSON decoding outright. +non_json_junk = st.text(max_size=50).filter(_is_not_json) + +#: Valid JSON documents whose top-level value is not an object. +_non_object_json_values = st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=20), + st.lists( + st.one_of(st.none(), st.booleans(), st.integers(), st.text(max_size=10)), + max_size=5, + ), +) +non_object_documents = _non_object_json_values.map(json.dumps) + +invalid_documents = st.one_of( + non_json_junk, + non_object_documents, + corrupted_document_strategy(), +) + + +# --------------------------------------------------------------------------- +# Property 2 +# --------------------------------------------------------------------------- + +@given(document=invalid_documents) +def test_parser_rejects_invalid_documents_descriptively(document): + """**Feature: workflow-manager, Property 2: Parser rejects invalid documents descriptively** + + **Validates: Requirements 3.3** + """ + # Never an unhandled exception: any raise here fails the property. + result = parse(document) + + # Never a graph. + assert isinstance(result, ParseResult) + assert not result.ok, "invalid document was parsed successfully" + assert result.graph is None, "invalid document produced a graph" + + # A descriptive error identifying a violation location. + error = result.error + assert isinstance(error, ParseError) + assert isinstance(error.code, str) and error.code, "error missing a code" + assert isinstance(error.message, str) and error.message.strip(), ( + "error missing a descriptive message" + ) + # The location is a JSON pointer (RFC 6901): the empty string denotes + # the document root; any other pointer must start with "/". + assert isinstance(error.path, str), "error missing a violation path" + assert error.path == "" or error.path.startswith("/"), ( + "violation path %r is not a JSON pointer" % error.path + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_aravis_roundtrip_compilation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_aravis_roundtrip_compilation.py new file mode 100644 index 00000000..bfbc7a7c --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_aravis_roundtrip_compilation.py @@ -0,0 +1,163 @@ +"""Property test for the Aravis node's generic catalog paths (task 1.4). + +**Feature: aravis-camera-input, Property 1: Aravis node definitions round-trip and compile through generic catalog paths** + +For any valid workflow definition containing ``aravis_camera_source`` +nodes, serializing then parsing the definition produces an equivalent +graph, and validating then compiling it for a device architecture +succeeds and renders the node's appsrc-headed element chain — with the +appsrc ``name`` resolving the ``{nodeId}`` token uniquely per node. + +**Validates: Requirements 1.5** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import DEVICE_ARCHITECTURES, get_node_type +from workflow_core.compiler import CompiledPipelineDocument, compile +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, + parse, + serialize, +) +from workflow_core.validator import SEVERITY_ERROR, validate + +from .generators import graph_strategy, node_parameters_strategy + +ARAVIS_TYPE_ID = "aravis_camera_source" + +_ARAVIS_DESCRIPTOR = get_node_type(ARAVIS_TYPE_ID) +_CAPTURE_DESCRIPTOR = get_node_type("capture") + + +@st.composite +def aravis_graph_strategy(draw): + """Valid Workflow_Definitions guaranteed to contain 1..3 + ``aravis_camera_source`` nodes. + + A drawn boolean starts from a random valid catalog graph (which may + itself contain Aravis nodes now that the shared generators include + the type among the video inputs), producing mixed-camera documents; + otherwise the graph is Aravis-only. Each appended Aravis node feeds + its own ``capture`` sink so the graph stays validator-valid (input + + output present, everything reachable, all connections type- + compatible). + """ + if draw(st.booleans()): + base = draw(graph_strategy()) + nodes = list(base.nodes) + connections = list(base.connections) + else: + nodes = [] + connections = [] + + for index in range(draw(st.integers(min_value=1, max_value=3))): + source_id = "aravis-src-{0}".format(index) + sink_id = "aravis-cap-{0}".format(index) + nodes.append(Node( + id=source_id, + type=ARAVIS_TYPE_ID, + position=Position(x=float(index), y=0.0), + parameters=draw(node_parameters_strategy(_ARAVIS_DESCRIPTOR)), + )) + nodes.append(Node( + id=sink_id, + type="capture", + position=Position(x=float(index), y=100.0), + parameters=draw(node_parameters_strategy(_CAPTURE_DESCRIPTOR)), + )) + connections.append(Connection( + id="aravis-conn-{0}".format(index), + source=PortEndpoint(node=source_id, port="out"), + target=PortEndpoint(node=sink_id, port="in"), + )) + + return WorkflowGraph(nodes=nodes, connections=connections) + + +def _aravis_elements_by_node(document: CompiledPipelineDocument): + """nodeId -> ordered element list, for elements tagged with that id.""" + elements_by_node = {} + for segment in document.segments: + for element in segment["elements"]: + node_id = element["nodeId"] + if node_id is not None: + elements_by_node.setdefault(node_id, []).append(element) + return elements_by_node + + +@given(graph=aravis_graph_strategy()) +def test_aravis_definitions_round_trip_and_compile(graph): + """**Feature: aravis-camera-input, Property 1: Aravis node definitions round-trip and compile through generic catalog paths** + + **Validates: Requirements 1.5** + """ + aravis_nodes = [node for node in graph.nodes if node.type == ARAVIS_TYPE_ID] + assert aravis_nodes, "strategy must produce at least one Aravis node" + + # --- serialize -> parse equivalence (generic serializer path) -------- + document = serialize(graph) + result = parse(document) + assert result.ok, ( + "parse rejected a serialized Aravis definition: {0}".format(result.error) + ) + assert result.graph is not None + assert result.graph.is_equivalent_to(graph), ( + "parsed graph is not equivalent to the original" + ) + assert graph.is_equivalent_to(result.graph), ( + "original graph is not equivalent to the parsed graph" + ) + assert serialize(result.graph) == document, ( + "re-serialized document is not byte-identical to the original" + ) + + # --- validation succeeds (generic validator path) -------------------- + errors = [ + finding for finding in validate(graph) + if finding.severity == SEVERITY_ERROR + ] + assert not errors, ( + "validate reported errors for a valid Aravis definition: " + "{0}".format(errors) + ) + + # --- compilation succeeds per device architecture with the appsrc + # chain rendered and {nodeId} resolved uniquely per node -------------- + for arch in DEVICE_ARCHITECTURES: + compiled = compile(graph, arch) + assert isinstance(compiled, CompiledPipelineDocument), ( + "compile failed on '{0}': {1}".format(arch, compiled) + ) + + elements_by_node = _aravis_elements_by_node(compiled) + appsrc_names = [] + for node in aravis_nodes: + elements = elements_by_node.get(node.id) + assert elements, ( + "no elements rendered for Aravis node '{0}' on " + "'{1}'".format(node.id, arch) + ) + factories = [element["factory"] for element in elements] + assert factories == ["appsrc", "videoconvert"], ( + "Aravis node '{0}' chain on '{1}' is {2}, expected " + "appsrc ! videoconvert".format(node.id, arch, factories) + ) + name = elements[0]["args"].get("name") + assert name == "appsrc_{0}".format(node.id), ( + "appsrc name {0!r} did not resolve the {{nodeId}} token " + "for node '{1}' on '{2}'".format(name, node.id, arch) + ) + appsrc_names.append(name) + + assert len(set(appsrc_names)) == len(appsrc_names), ( + "appsrc names are not unique per node on '{0}': " + "{1}".format(arch, appsrc_names) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_classification.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_classification.py new file mode 100644 index 00000000..2e3a15ec --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_classification.py @@ -0,0 +1,237 @@ +"""Property test for plugin-set classification (task 1.5). + +**Feature: custom-node-designer, Property 6: Plugin-set classification is exact** + +For all plugin sources, ``classify_plugin_set`` returns good, bad, or +ugly exactly for modules belonging to the corresponding official +GStreamer plugin set (by module name or known repository location), and +unclassified for every other source — including arbitrary public +repository URLs; and every classification value has a non-empty +plain-language explanation. + +**Validates: Requirements 15.1, 15.3, 15.4** +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog.classification import ( + CLASSIFICATION_BAD, + CLASSIFICATION_GOOD, + CLASSIFICATION_UGLY, + CLASSIFICATION_UNCLASSIFIED, + CLASSIFICATIONS, + EXPLANATIONS, + classify_plugin_set, +) + +# --------------------------------------------------------------------------- +# Ground truth: the official plugin-set module names and their expected +# classifications, restated here from the requirements (15.1) rather +# than imported, so the test cannot silently agree with a wrong table. +# --------------------------------------------------------------------------- + +OFFICIAL_MODULES = { + "gst-plugins-good": CLASSIFICATION_GOOD, + "gst-plugins-bad": CLASSIFICATION_BAD, + "gst-plugins-ugly": CLASSIFICATION_UGLY, +} + +official_module_names = st.sampled_from(sorted(OFFICIAL_MODULES)) + +_padding = st.text(alphabet=" \t", max_size=2) + +_segment_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-_." + +def _strip_git(segment): + return segment[: -len(".git")] if segment.endswith(".git") else segment + +#: URL path segments guaranteed not to be an official plugin-set name, +#: even after the classifier strips a ``.git`` suffix. +_safe_segment = st.text( + alphabet=_segment_alphabet, min_size=1, max_size=25 +).filter(lambda s: _strip_git(s) not in OFFICIAL_MODULES and s != ".git") + + +# --------------------------------------------------------------------------- +# Official sources: known freedesktop.org repository locations of the +# official plugin sets, in every supported URL shape. +# --------------------------------------------------------------------------- + +@st.composite +def official_urls(draw): + """(repo_url, expected_classification) for a known official location.""" + module = draw(official_module_names) + expected = OFFICIAL_MODULES[module] + shape = draw(st.sampled_from(["gitlab", "monorepo", "legacy", "release"])) + if shape == "gitlab": + suffix = draw(st.sampled_from(["", ".git", "/"])) + url = "https://gitlab.freedesktop.org/gstreamer/%s%s" % (module, suffix) + elif shape == "monorepo": + branch = draw(st.sampled_from(["main", "1.22", "discontinued-for-monorepo"])) + url = ( + "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/" + "%s/subprojects/%s" % (branch, module) + ) + elif shape == "legacy": + scheme = draw(st.sampled_from(["https", "http", "git"])) + host = draw(st.sampled_from(["cgit.freedesktop.org", "anongit.freedesktop.org"])) + suffix = draw(st.sampled_from(["", "/"])) + url = "%s://%s/gstreamer/%s%s" % (scheme, host, module, suffix) + else: # release + url = "https://gstreamer.freedesktop.org/src/%s/" % module + return url, expected + + +@st.composite +def official_sources(draw): + """(module_name, repo_url, expected) where the source is official + either by module name or by known repository location.""" + if draw(st.booleans()): + # Official by module name (surrounding whitespace tolerated); + # any accompanying URL — even a non-official one — must not + # change the answer, since the name already identifies the set. + module = draw(official_module_names) + name = draw(_padding) + module + draw(_padding) + url = draw(st.one_of(st.none(), st.just(""), arbitrary_urls())) + return name, url, OFFICIAL_MODULES[module] + # Official by known repository location; the module name, if any, + # is a non-official one so classification must come from the URL. + url, expected = draw(official_urls()) + name = draw(st.one_of(st.none(), st.just(""), arbitrary_module_names())) + return name, url, expected + + +# --------------------------------------------------------------------------- +# Arbitrary (non-official) sources: never guessed into an official set. +# --------------------------------------------------------------------------- + +def arbitrary_module_names(): + """Module names that are not an official plugin-set name.""" + return st.text(max_size=30).filter( + lambda s: s.strip() not in OFFICIAL_MODULES + ) + + +@st.composite +def _other_host_urls(draw): + """Public repositories on non-freedesktop hosts, including ones + deliberately named after official plugin sets (Requirement 15.4).""" + host = draw(st.sampled_from( + ["github.com", "gitlab.com", "bitbucket.org", "example.com", "git.sr.ht"] + )) + segments = draw(st.lists( + st.one_of(_safe_segment, official_module_names), min_size=1, max_size=4 + )) + return "https://%s/%s" % (host, "/".join(segments)) + + +@st.composite +def _freedesktop_non_official_urls(draw): + """freedesktop.org hosts with paths that are not an official set.""" + shape = draw(st.sampled_from(["gitlab-other-ns", "gitlab-safe", "legacy", "release"])) + if shape == "gitlab-other-ns": + # Not the gstreamer namespace, even with an official-looking tail + # (nor a segment that strips to it, e.g. "gstreamer.git"). + first = draw(_safe_segment.filter(lambda s: _strip_git(s) != "gstreamer")) + rest = draw(st.lists( + st.one_of(_safe_segment, official_module_names), max_size=3 + )) + path = "/".join([first] + rest) + return "https://gitlab.freedesktop.org/%s" % path + if shape == "gitlab-safe": + # gstreamer namespace but no official plugin-set segment. + rest = draw(st.lists(_safe_segment, max_size=3)) + return "https://gitlab.freedesktop.org/%s" % "/".join(["gstreamer"] + rest) + if shape == "legacy": + host = draw(st.sampled_from(["cgit.freedesktop.org", "anongit.freedesktop.org"])) + segments = draw(st.lists(_safe_segment, min_size=1, max_size=3)) + return "https://%s/%s" % (host, "/".join(segments)) + # release tree without an official plugin-set segment + segments = draw(st.lists(_safe_segment, min_size=1, max_size=3)) + return "https://gstreamer.freedesktop.org/%s" % "/".join(segments) + + +@st.composite +def _wrong_scheme_urls(draw): + """Official-looking paths behind unsupported URL schemes.""" + scheme = draw(st.sampled_from(["ftp", "ssh", "file", "svn"])) + module = draw(official_module_names) + return "%s://gitlab.freedesktop.org/gstreamer/%s" % (scheme, module) + + +#: Junk strings that are not URLs at all; excluding "freedesktop.org" +#: keeps them non-official by construction, not by circular checking. +_junk_urls = st.text(max_size=40).filter(lambda s: "freedesktop.org" not in s) + +def arbitrary_urls(): + """Repository URLs that are not a known official location.""" + return st.one_of( + _other_host_urls(), + _freedesktop_non_official_urls(), + _wrong_scheme_urls(), + _junk_urls, + ) + + +@st.composite +def arbitrary_sources(draw): + """(module_name, repo_url) with no official identity at all.""" + name = draw(st.one_of(st.none(), arbitrary_module_names())) + url = draw(st.one_of(st.none(), arbitrary_urls())) + return name, url + + +# --------------------------------------------------------------------------- +# Property 6 +# --------------------------------------------------------------------------- + +@settings(max_examples=25) +@given(source=official_sources()) +def test_official_sources_classify_exactly(source): + """**Feature: custom-node-designer, Property 6: Plugin-set classification is exact** + + Modules belonging to an official GStreamer plugin set — by module + name or known repository location — classify to exactly that set. + + **Validates: Requirements 15.1, 15.4** + """ + module_name, repo_url, expected = source + assert classify_plugin_set(module_name, repo_url) == expected + + +@settings(max_examples=25) +@given(source=arbitrary_sources()) +def test_arbitrary_sources_classify_unclassified(source): + """**Feature: custom-node-designer, Property 6: Plugin-set classification is exact** + + Every other source — including arbitrary public repository URLs and + look-alike names — is never guessed into an official set. + + **Validates: Requirements 15.4** + """ + module_name, repo_url = source + assert classify_plugin_set(module_name, repo_url) == CLASSIFICATION_UNCLASSIFIED + + +@settings(max_examples=25) +@given(source=st.one_of( + official_sources().map(lambda s: (s[0], s[1])), + arbitrary_sources(), +)) +def test_every_classification_has_explanation(source): + """**Feature: custom-node-designer, Property 6: Plugin-set classification is exact** + + For all sources, the classification is one of the four taxonomy + values and carries a non-empty plain-language explanation. + + **Validates: Requirements 15.3** + """ + module_name, repo_url = source + result = classify_plugin_set(module_name, repo_url) + assert result in CLASSIFICATIONS + explanation = EXPLANATIONS[result] + assert isinstance(explanation, str) + assert explanation.strip() diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_custom_python_preprocess_compilation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_custom_python_preprocess_compilation.py new file mode 100644 index 00000000..ffac12b6 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_custom_python_preprocess_compilation.py @@ -0,0 +1,156 @@ +"""Property test for the compiled emlpython element of Custom Python +preprocessing nodes (custom-python-frames task 1.4). + +**Feature: custom-python-frames, Property 12: Compiled emlpython element +per Custom Python preprocessing node** + +For any valid workflow graph embedding ``custom_python_preprocess`` nodes +with arbitrary node ids, compiling for any architecture yields exactly one +``emlpython`` element per such node, tagged with that node's id and +carrying ``handler-path`` equal to ``python/{nodeId}/handler.py``. + +**Validates: Requirements 2.3** +""" + +from __future__ import annotations + +from collections import Counter + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ARCHITECTURES +from workflow_core.compiler import CompiledPipelineDocument, compile +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +# Arbitrary node ids: non-empty, exercising unicode, whitespace, and +# punctuation (the compiled handler path embeds the id verbatim). +_NODE_ID = st.text(min_size=1, max_size=12) + +# Required `code` parameter values: any non-empty string satisfies the +# descriptor's min_length constraint (content is never interpreted by +# the compiler). +_CODE = st.text(min_size=1, max_size=40) + +# format_convert output formats (its required parameter's enum values), +# used for interleaved non-Custom-Python preprocessing nodes. +_FORMATS = ("RGB", "RGBA", "BGR", "GRAY8", "NV12", "I420") + + +@st.composite +def _preprocess_workflow(draw): + """A valid workflow embedding 1..3 ``custom_python_preprocess`` nodes. + + Structure: camera_source -> [custom_python_preprocess (optionally + followed by an interleaved format_convert)]+ -> capture. The linear + VideoFrames chain is always validator-valid (input and output nodes + present, compatible ports, acyclic, all reachable, required + parameters set). + + Returns ``(graph, preprocess_node_ids)``. + """ + preprocess_count = draw(st.integers(min_value=1, max_value=3)) + # One drawn boolean per preprocess node: follow it with an + # interleaved format_convert node (varying the surrounding graph). + interleaved = [draw(st.booleans()) for _ in range(preprocess_count)] + + total_nodes = 2 + preprocess_count + sum(interleaved) + node_ids = draw( + st.lists(_NODE_ID, min_size=total_nodes, max_size=total_nodes, unique=True) + ) + id_iter = iter(node_ids) + + def next_id(): + return next(id_iter) + + def make_node(type_id, parameters): + return Node( + id=next_id(), + type=type_id, + position=Position(x=0.0, y=0.0), + parameters=parameters, + ) + + nodes = [make_node("camera_source", {})] + preprocess_ids = [] + for follow_with_convert in interleaved: + parameters = {"code": draw(_CODE)} + if draw(st.booleans()): + parameters["requirements"] = draw(st.text(max_size=20)) + node = make_node("custom_python_preprocess", parameters) + preprocess_ids.append(node.id) + nodes.append(node) + if follow_with_convert: + nodes.append( + make_node("format_convert", {"format": draw(st.sampled_from(_FORMATS))}) + ) + nodes.append(make_node("capture", {"output_path": "/aws_dda/captures"})) + + # Linear wiring: every node's single output feeds the next node's + # single input (camera_source's port is "out", every consumer's is + # "in"). + connections = [ + Connection( + id="c{0}".format(index), + source=PortEndpoint(node=nodes[index].id, port="out"), + target=PortEndpoint(node=nodes[index + 1].id, port="in"), + ) + for index in range(len(nodes) - 1) + ] + + return WorkflowGraph(nodes=nodes, connections=connections), preprocess_ids + + +@given( + workflow=_preprocess_workflow(), + target_arch=st.sampled_from(ARCHITECTURES), +) +def test_compiled_emlpython_element_per_preprocess_node(workflow, target_arch): + """**Feature: custom-python-frames, Property 12: Compiled emlpython + element per Custom Python preprocessing node** + + **Validates: Requirements 2.3** + """ + graph, preprocess_ids = workflow + + document = compile(graph, target_arch) + assert isinstance(document, CompiledPipelineDocument), ( + "compile() failed for a valid graph on arch {!r}: {}".format( + target_arch, document + ) + ) + + emlpython_elements = [ + element + for segment in document.segments + for element in segment["elements"] + if element["factory"] == "emlpython" + ] + + # Exactly one emlpython element per custom_python_preprocess node, + # tagged with that node's id — no extras, none missing. + expected = Counter(preprocess_ids) + actual = Counter(element["nodeId"] for element in emlpython_elements) + assert actual == expected, ( + "emlpython elements do not match the preprocess node set on arch " + "{!r}: missing={}, extra_or_duplicate={}".format( + target_arch, sorted(expected - actual), sorted(actual - expected) + ) + ) + + # Each element carries the packaging-layout handler path derived from + # its node id (Requirement 2.3). + for element in emlpython_elements: + expected_path = "python/{0}/handler.py".format(element["nodeId"]) + assert element["args"]["handler-path"] == expected_path, ( + "emlpython element for node {!r} carries handler-path {!r}, " + "expected {!r}".format( + element["nodeId"], element["args"].get("handler-path"), expected_path + ) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_declaration_conversion.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_declaration_conversion.py new file mode 100644 index 00000000..99d42e00 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_declaration_conversion.py @@ -0,0 +1,486 @@ +"""Property test for Custom_Node_Type declaration conversion (task 1.3). + +**Feature: custom-node-designer, Property 1: Declaration conversion accepts exactly the valid declarations** + +For all Custom_Node_Type declarations (valid ones, and ones corrupted with +a random known defect — port type outside PORT_TYPES, category outside +CATEGORIES, parameter type outside PARAMETER_TYPES, architecture outside +ARCHITECTURES, default violating its own constraints, DeepStream mapping +outside arm64_jp4/jp5/jp6, duplicate parameter names or mapping +architectures, ...), ``descriptor_from_declaration`` succeeds if and only +if the declaration is valid; on success the resulting descriptor +faithfully reflects the declaration and satisfies the same catalog +well-formedness predicate as built-in node types, and DeepStream-flagged +declarations yield mappings only for arm64_jp4/jp5/jp6; on failure the +error identifies the offending field. + +**Validates: Requirements 1.7, 5.3, 8.4, 8.5** +""" + +from __future__ import annotations + +import copy + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog import ( + ARCHITECTURES, + CATEGORIES, + DEEPSTREAM_ARCHITECTURES, + PARAMETER_TYPES, + PORT_TYPES, + DeclarationError, + descriptor_from_declaration, +) +from workflow_core.validator import check_parameter_value + +# --------------------------------------------------------------------------- +# Building blocks +# --------------------------------------------------------------------------- + +# Identifier-ish names. Lowercase a-j only, so sentinel values used by the +# corruption strategies (uppercase / punctuation) can never collide with a +# generated name. +_NAME = st.text(alphabet="abcdefghij", min_size=1, max_size=8) + +_DESCRIPTION = st.text( + alphabet="abcdefghij ", min_size=1, max_size=30 +).filter(lambda s: s.strip()) + +#: Wire constraint keys -> ParameterDescriptor.constraints keys (mirrors +#: the module under test's _WIRE_CONSTRAINT_KEY_MAP). +_WIRE_CONSTRAINT_KEY_MAP = {"minLength": "min_length", "maxLength": "max_length"} + +_NON_JETSON_ARCHITECTURES = tuple( + arch for arch in ARCHITECTURES if arch not in DEEPSTREAM_ARCHITECTURES +) + + +def _invalid_member(known): + """A value outside the ``known`` constant tuple (or not a string at all).""" + return st.one_of( + st.none(), + st.text(alphabet="XYZ0123456789", max_size=8).filter(lambda s: s not in known), + ) + + +# --------------------------------------------------------------------------- +# Valid parameter declarations (wire shape), one strategy per parameter type +# --------------------------------------------------------------------------- + +@st.composite +def _int_param(draw, name): + lo = draw(st.integers(-1000, 1000)) + hi = lo + draw(st.integers(0, 1000)) + value = st.integers(lo, hi) + return { + "name": name, + "paramType": "int", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "constraints": {"min": lo, "max": hi}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=3)), + } + + +@st.composite +def _float_param(draw, name): + lo = draw(st.floats(-1e6, 1e6, allow_nan=False, allow_infinity=False)) + hi = lo + draw(st.floats(0, 1e6, allow_nan=False, allow_infinity=False)) + value = st.floats(min_value=lo, max_value=hi, allow_nan=False, allow_infinity=False) + return { + "name": name, + "paramType": "float", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "constraints": {"min": lo, "max": hi}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=3)), + } + + +@st.composite +def _string_like_param(draw, name, param_type): + min_len = draw(st.integers(0, 3)) + max_len = min_len + draw(st.integers(0, 5)) + value = st.text(alphabet="abcdefghij", min_size=min_len, max_size=max_len) + return { + "name": name, + "paramType": param_type, + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "constraints": {"minLength": min_len, "maxLength": max_len}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=3)), + } + + +@st.composite +def _bool_param(draw, name): + return { + "name": name, + "paramType": "bool", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), st.booleans())), + "constraints": {}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(st.booleans(), min_size=1, max_size=2)), + } + + +@st.composite +def _enum_param(draw, name): + values = draw(st.lists(_NAME, min_size=1, max_size=4, unique=True)) + member = st.sampled_from(values) + return { + "name": name, + "paramType": "enum", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), member)), + "constraints": {"values": values}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(member, min_size=1, max_size=3)), + } + + +def _param_of_type(param_type, name): + if param_type == "int": + return _int_param(name) + if param_type == "float": + return _float_param(name) + if param_type == "bool": + return _bool_param(name) + if param_type == "enum": + return _enum_param(name) + return _string_like_param(name, param_type) # string / code / model_ref + + +@st.composite +def _parameter_lists(draw): + """1..4 uniquely named parameters. Element 0 is always an int with a + min/max range so the default/example corruption strategies always have + a bounded parameter to violate; validly, sometimes a parameter + dependsOn a bool parameter of the same declaration.""" + names = draw(st.lists(_NAME, min_size=1, max_size=4, unique=True)) + params = [draw(_int_param(names[0]))] + for name in names[1:]: + param_type = draw(st.sampled_from(PARAMETER_TYPES)) + params.append(draw(_param_of_type(param_type, name))) + + bool_names = [p["name"] for p in params if p["paramType"] == "bool"] + if bool_names and draw(st.booleans()): + toggle = draw(st.sampled_from(bool_names)) + candidates = [p for p in params if p["name"] != toggle] + if candidates: + draw(st.sampled_from(candidates))["dependsOn"] = toggle + return params + + +# --------------------------------------------------------------------------- +# Valid ports and mappings (wire shape) +# --------------------------------------------------------------------------- + +_PORTS = st.lists( + st.fixed_dictionaries({"name": _NAME, "portType": st.sampled_from(PORT_TYPES)}), + min_size=1, + max_size=2, +) + +_ELEMENT_ARGS = st.dictionaries(_NAME, _NAME, max_size=2) + + +@st.composite +def _elements(draw): + element = {"factory": draw(_NAME)} + args = draw(st.one_of(st.none(), _ELEMENT_ARGS)) + if args is not None: + element["argsTemplate"] = args + return element + + +@st.composite +def _mapping_for(draw, arch): + mapping = { + "arch": arch, + "elementChain": draw(st.lists(_elements(), max_size=2)), + "pluginDependencies": draw(st.lists(_NAME, max_size=2)), + } + executor_binding = draw(st.one_of(st.none(), _NAME)) + if executor_binding is not None: + mapping["executorBinding"] = executor_binding + return mapping + + +@st.composite +def _mapping_lists(draw, deepstream): + pool = DEEPSTREAM_ARCHITECTURES if deepstream else ARCHITECTURES + archs = draw( + st.lists(st.sampled_from(pool), min_size=1, max_size=len(pool), unique=True) + ) + return [draw(_mapping_for(arch)) for arch in archs] + + +# --------------------------------------------------------------------------- +# Valid declarations +# --------------------------------------------------------------------------- + +@st.composite +def valid_declarations(draw): + deepstream = draw(st.booleans()) + return { + "typeId": "custom." + draw(_NAME), + "displayName": draw(_DESCRIPTION), + "category": draw(st.sampled_from(CATEGORIES)), + "deepstream": deepstream, + "hardwareDependent": draw(st.booleans()), + "inputs": draw(_PORTS), + "outputs": draw(_PORTS), + "parameters": draw(_parameter_lists()), + "mappings": draw(_mapping_lists(deepstream)), + } + + +# --------------------------------------------------------------------------- +# Corruptions: each takes (draw, valid declaration), plants exactly one +# known defect in place, and returns the field the error must identify. +# --------------------------------------------------------------------------- + +def _corrupt_category(draw, decl): + decl["category"] = draw(_invalid_member(CATEGORIES)) + return "category" + + +def _corrupt_port_type(draw, decl): + side = draw(st.sampled_from(["inputs", "outputs"])) + index = draw(st.integers(0, len(decl[side]) - 1)) + decl[side][index]["portType"] = draw(_invalid_member(PORT_TYPES)) + return "{0}[{1}].portType".format(side, index) + + +def _corrupt_param_type(draw, decl): + index = draw(st.integers(0, len(decl["parameters"]) - 1)) + decl["parameters"][index]["paramType"] = draw(_invalid_member(PARAMETER_TYPES)) + return "parameters[{0}].paramType".format(index) + + +def _corrupt_mapping_arch(draw, decl): + index = draw(st.integers(0, len(decl["mappings"]) - 1)) + decl["mappings"][index]["arch"] = draw(_invalid_member(ARCHITECTURES)) + return "mappings[{0}].arch".format(index) + + +def _corrupt_deepstream_non_jetson(draw, decl): + # A DeepStream-flagged declaration mapping a non-Jetson architecture + # (Requirement 5.3). + decl["deepstream"] = True + decl["mappings"] = [decl["mappings"][0]] + decl["mappings"][0]["arch"] = draw(st.sampled_from(_NON_JETSON_ARCHITECTURES)) + return "mappings[0].arch" + + +def _corrupt_duplicate_mapping_arch(draw, decl): + decl["mappings"].append(copy.deepcopy(decl["mappings"][0])) + return "mappings[{0}].arch".format(len(decl["mappings"]) - 1) + + +def _corrupt_duplicate_parameter_name(draw, decl): + decl["parameters"].append(copy.deepcopy(decl["parameters"][0])) + return "parameters[{0}].name".format(len(decl["parameters"]) - 1) + + +def _corrupt_default(draw, decl): + # parameters[0] is always the bounded int parameter. + param = decl["parameters"][0] + param["default"] = param["constraints"]["max"] + 1 + draw(st.integers(0, 100)) + return "parameters[0].default" + + +def _corrupt_example(draw, decl): + param = decl["parameters"][0] + param["examples"] = list(param["examples"]) + [param["constraints"]["max"] + 1] + return "parameters[0].examples[{0}]".format(len(param["examples"]) - 1) + + +def _corrupt_empty_examples(draw, decl): + decl["parameters"][0]["examples"] = [] + return "parameters[0].examples" + + +def _corrupt_depends_on(draw, decl): + # Uppercase sentinel: generated parameter names are lowercase a-j only. + decl["parameters"][0]["dependsOn"] = "NO_SUCH_TOGGLE" + return "parameters[0].dependsOn" + + +def _corrupt_enum_without_values(draw, decl): + decl["parameters"][0] = { + "name": decl["parameters"][0]["name"], + "paramType": "enum", + "required": False, + "default": None, + "constraints": {}, + "description": "an enum with no declared values", + "examples": ["a"], + } + return "parameters[0].constraints.values" + + +def _corrupt_type_id(draw, decl): + decl["typeId"] = draw(st.sampled_from([None, "", " "])) + return "typeId" + + +def _corrupt_display_name(draw, decl): + decl["displayName"] = draw(st.sampled_from([None, "", " "])) + return "displayName" + + +_CORRUPTIONS = [ + _corrupt_category, + _corrupt_port_type, + _corrupt_param_type, + _corrupt_mapping_arch, + _corrupt_deepstream_non_jetson, + _corrupt_duplicate_mapping_arch, + _corrupt_duplicate_parameter_name, + _corrupt_default, + _corrupt_example, + _corrupt_empty_examples, + _corrupt_depends_on, + _corrupt_enum_without_values, + _corrupt_type_id, + _corrupt_display_name, +] + + +@st.composite +def declaration_cases(draw): + """(declaration, expected_error_field). expected_error_field is None + for valid declarations.""" + decl = draw(valid_declarations()) + if draw(st.booleans()): + return decl, None + corruption = draw(st.sampled_from(_CORRUPTIONS)) + return decl, corruption(draw, decl) + + +# --------------------------------------------------------------------------- +# Success-side assertions +# --------------------------------------------------------------------------- + +def _expected_constraints(wire_constraints): + return { + _WIRE_CONSTRAINT_KEY_MAP.get(key, key): value + for key, value in (wire_constraints or {}).items() + } + + +def _assert_faithful(decl, descriptor): + """The converted descriptor reflects the declaration exactly.""" + assert descriptor.type_id == decl["typeId"] + assert descriptor.display_name == decl["displayName"] + assert descriptor.category == decl["category"] + assert descriptor.hardware_dependent == decl["hardwareDependent"] + + for wire_ports, ports in ((decl["inputs"], descriptor.inputs), + (decl["outputs"], descriptor.outputs)): + assert [(p.name, p.port_type) for p in ports] == [ + (w["name"], w["portType"]) for w in wire_ports + ] + + assert len(descriptor.parameters) == len(decl["parameters"]) + for wire, param in zip(decl["parameters"], descriptor.parameters): + assert param.name == wire["name"] + assert param.param_type == wire["paramType"] + assert param.required == wire["required"] + assert param.default == wire["default"] + assert param.constraints == _expected_constraints(wire["constraints"]) + assert param.depends_on == wire.get("dependsOn") + assert param.description == wire["description"] + assert param.examples == wire["examples"] + + assert len(descriptor.mappings) == len(decl["mappings"]) + for wire, mapping in zip(decl["mappings"], descriptor.mappings): + assert mapping.arch == wire["arch"] + assert mapping.element_chain == [ + {"factory": e["factory"], "args_template": e.get("argsTemplate") or {}} + for e in wire["elementChain"] + ] + assert mapping.executor_binding == wire.get("executorBinding") + assert mapping.plugin_dependencies == wire["pluginDependencies"] + + +def _assert_well_formed(descriptor): + """The descriptor satisfies the same catalog well-formedness predicate + as built-in node types (Requirement 8.4).""" + assert descriptor.type_id and isinstance(descriptor.type_id, str) + assert descriptor.category in CATEGORIES + + for port in list(descriptor.inputs) + list(descriptor.outputs): + assert isinstance(port.name, str) and port.name + assert port.port_type in PORT_TYPES + + for param in descriptor.parameters: + assert isinstance(param.name, str) and param.name + assert param.param_type in PARAMETER_TYPES + + constraints = param.constraints + if "min" in constraints and "max" in constraints: + assert constraints["min"] <= constraints["max"] + if "min_length" in constraints: + assert constraints["min_length"] >= 0 + if "min_length" in constraints and "max_length" in constraints: + assert constraints["min_length"] <= constraints["max_length"] + if param.param_type == "enum": + assert constraints.get("values") + + # Field-level help, defaults and examples all satisfy the + # parameter's own type and constraints. + assert isinstance(param.description, str) and param.description.strip() + assert isinstance(param.examples, list) and param.examples + if param.default is not None: + assert check_parameter_value(param, param.default) is None + for example in param.examples: + assert example is not None + assert check_parameter_value(param, example) is None + + seen_archs = set() + for mapping in descriptor.mappings: + assert mapping.arch in ARCHITECTURES + assert mapping.arch not in seen_archs + seen_archs.add(mapping.arch) + + +# --------------------------------------------------------------------------- +# Property 1 +# --------------------------------------------------------------------------- + +@settings(max_examples=25) +@given(case=declaration_cases()) +def test_declaration_conversion_accepts_exactly_the_valid_declarations(case): + """**Feature: custom-node-designer, Property 1: Declaration conversion accepts exactly the valid declarations** + + **Validates: Requirements 1.7, 5.3, 8.4, 8.5** + """ + decl, expected_field = case + + if expected_field is None: + descriptor = descriptor_from_declaration(copy.deepcopy(decl)) + _assert_faithful(decl, descriptor) + _assert_well_formed(descriptor) + # DeepStream-flagged declarations yield mappings only for the + # JetPack architectures (Requirement 5.3). + if decl["deepstream"]: + assert all( + mapping.arch in DEEPSTREAM_ARCHITECTURES + for mapping in descriptor.mappings + ) + else: + with pytest.raises(DeclarationError) as excinfo: + descriptor_from_declaration(decl) + # The error identifies the offending field (Requirements 1.7, 8.5). + assert excinfo.value.field == expected_field + assert expected_field in str(excinfo.value) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_merged_catalog_compilation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_merged_catalog_compilation.py new file mode 100644 index 00000000..002e3c05 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_merged_catalog_compilation.py @@ -0,0 +1,250 @@ +"""Property test for merged-catalog compilation (task 1.9). + +**Feature: custom-node-designer, Property 12: Merged-catalog compilation includes custom plugin dependencies** + +For all valid Workflow_Definitions over a merged catalog containing +custom node types, compilation for an architecture the custom types map +to includes each custom node's declared plugin dependency in the +compiled document's pluginDependencies, and compilation for an +architecture a custom type has no mapping for fails with an error +identifying that node and the unsupported architecture. + +**Validates: Requirements 5.4, 8.6** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + DEVICE_ARCHITECTURES, + bundled_plugins_for, + descriptor_from_declaration, + resolve_catalog, +) +from workflow_core.catalog.nodes import NODE_CATALOG +from workflow_core.compiler import ( + CODE_UNMAPPED_ARCHITECTURE, + CompiledPipelineDocument, + compile, +) +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) + +from .generators import node_parameters_strategy + +# --------------------------------------------------------------------------- +# Building blocks +# --------------------------------------------------------------------------- + +_NAME = st.text(alphabet="abcdefghij", min_size=1, max_size=8) + +#: Custom plugin dependency names. The "customdep" prefix keeps them +#: disjoint from every LOCALSERVER_BUNDLED_PLUGINS entry, so a custom +#: node's declared dependency is never subtracted as "already bundled" +#: and must therefore appear verbatim in pluginDependencies. +_PLUGIN_DEPENDENCIES = st.lists(_NAME, min_size=1, max_size=2, unique=True).map( + lambda names: ["customdep" + name for name in names] +) + +#: Mid-pipeline palette categories for the custom types (V1 input/output +#: presence is provided by the built-in source and capture nodes). +_MID_CATEGORIES = ("preprocessing", "inference", "post_processing") + +#: Built-in VideoFrames sources feeding the custom chain. +_VIDEO_INPUT_TYPES = ("camera_source", "folder_source") + +_BUILTINS_BY_ID = {descriptor.type_id: descriptor for descriptor in NODE_CATALOG} + + +@st.composite +def _custom_declarations(draw): + """1..3 uniquely named custom VideoFrames->VideoFrames declarations. + + Each declaration maps a random nonempty subset of the device + architectures that always includes a shared ``common_arch`` (so a + target every custom type maps to is guaranteed to exist), and every + mapping declares at least one plugin dependency. + """ + common_arch = draw(st.sampled_from(DEVICE_ARCHITECTURES)) + count = draw(st.integers(min_value=1, max_value=3)) + names = draw(st.lists(_NAME, min_size=count, max_size=count, unique=True)) + + declarations = [] + for name in names: + extra_archs = draw( + st.sets(st.sampled_from(DEVICE_ARCHITECTURES), max_size=2) + ) + archs = sorted({common_arch} | extra_archs) + declarations.append({ + "typeId": "custom." + name, + "displayName": "Custom " + name, + "category": draw(st.sampled_from(_MID_CATEGORIES)), + "hardwareDependent": False, + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [], + "mappings": [ + { + "arch": arch, + "elementChain": [{"factory": "identity"}], + "pluginDependencies": draw(_PLUGIN_DEPENDENCIES), + } + for arch in archs + ], + }) + return declarations, common_arch + + +@st.composite +def merged_catalog_cases(draw): + """(graph, custom descriptors, merged catalog, common_arch). + + The graph is a valid Workflow_Definition over the merged catalog: + a built-in VideoFrames source, a chain of every generated custom + node, and a built-in capture output node. + """ + declarations, common_arch = draw(_custom_declarations()) + descriptors = [descriptor_from_declaration(decl) for decl in declarations] + merged = resolve_catalog(descriptors) + + source_type = draw(st.sampled_from(_VIDEO_INPUT_TYPES)) + nodes = [ + Node( + id="src", + type=source_type, + position=Position(x=0.0, y=0.0), + parameters=draw(node_parameters_strategy(_BUILTINS_BY_ID[source_type])), + ) + ] + connections = [] + previous = "src" + for index, descriptor in enumerate(descriptors): + node_id = "custom-{0}".format(index) + nodes.append(Node( + id=node_id, + type=descriptor.type_id, + position=Position(x=float(index + 1), y=0.0), + parameters={}, + )) + connections.append(Connection( + id="c{0}".format(len(connections)), + source=PortEndpoint(node=previous, port="out"), + target=PortEndpoint(node=node_id, port="in"), + )) + previous = node_id + nodes.append(Node( + id="sink", + type="capture", + position=Position(x=float(len(descriptors) + 1), y=0.0), + parameters=draw(node_parameters_strategy(_BUILTINS_BY_ID["capture"])), + )) + connections.append(Connection( + id="c{0}".format(len(connections)), + source=PortEndpoint(node=previous, port="out"), + target=PortEndpoint(node="sink", port="in"), + )) + + graph = WorkflowGraph(nodes=nodes, connections=connections) + return graph, descriptors, merged, common_arch + + +def _expected_plugin_dependencies(graph, merged, target_arch): + """Independent recomputation of the compiled dependency set: union of + the used mappings' declared dependencies minus the per-arch bundled + set, over the *merged* catalog.""" + descriptors_by_id = {descriptor.type_id: descriptor for descriptor in merged} + declared = set() + for node in graph.nodes: + mapping = descriptors_by_id[node.type].mapping_for(target_arch) + declared.update(mapping.plugin_dependencies) + return declared - bundled_plugins_for(target_arch) + + +# --------------------------------------------------------------------------- +# Property 12 +# --------------------------------------------------------------------------- + +@given(case=merged_catalog_cases()) +def test_merged_catalog_compilation_includes_custom_plugin_dependencies(case): + """**Feature: custom-node-designer, Property 12: Merged-catalog compilation includes custom plugin dependencies** + + **Validates: Requirements 5.4, 8.6** + """ + graph, descriptors, merged, common_arch = case + descriptors_by_id = {descriptor.type_id: descriptor for descriptor in merged} + custom_node_ids = { + node.id: descriptors_by_id[node.type] + for node in graph.nodes + if node.type.startswith("custom.") + } + + # --- mapped architecture: compilation succeeds and carries every + # custom node's declared plugin dependency (Requirement 8.6) -------- + result = compile(graph, common_arch, catalog=merged) + assert isinstance(result, CompiledPipelineDocument), ( + "compile failed for a valid merged-catalog graph on '{0}': {1}".format( + common_arch, result + ) + ) + compiled_dependencies = set(result.plugin_dependencies) + for node_id, descriptor in custom_node_ids.items(): + mapping = descriptor.mapping_for(common_arch) + for dependency in mapping.plugin_dependencies: + assert dependency in compiled_dependencies, ( + "custom node '{0}' dependency '{1}' missing from " + "pluginDependencies {2}".format( + node_id, dependency, sorted(compiled_dependencies) + ) + ) + + # The full set still follows the compiler's dependency rule over the + # merged catalog (built-in and custom descriptors treated alike). + expected = _expected_plugin_dependencies(graph, merged, common_arch) + assert compiled_dependencies == expected, ( + "pluginDependencies {0} != expected {1} for arch '{2}'".format( + sorted(compiled_dependencies), sorted(expected), common_arch + ) + ) + + # --- unmapped architectures: compilation fails identifying the node + # and the unsupported architecture (Requirement 5.4) ---------------- + for arch in DEVICE_ARCHITECTURES: + missing_ids = { + node_id + for node_id, descriptor in custom_node_ids.items() + if descriptor.mapping_for(arch) is None + } + if not missing_ids: + continue + + errors = compile(graph, arch, catalog=merged) + assert isinstance(errors, list) and errors, ( + "compile unexpectedly succeeded on '{0}' despite unmapped " + "custom nodes {1}".format(arch, sorted(missing_ids)) + ) + + unmapped_errors = [ + error for error in errors if error.code == CODE_UNMAPPED_ARCHITECTURE + ] + assert len(unmapped_errors) == len(errors), ( + "unexpected non-unmapped compile errors: {0}".format(errors) + ) + + # Exactly the nodes without a mapping for this architecture are + # reported, each error naming the node and the architecture. + reported = {(error.node_id, error.arch) for error in unmapped_errors} + assert reported == {(node_id, arch) for node_id in missing_ids}, ( + "unmapped errors {0} != expected nodes {1} on '{2}'".format( + sorted(reported), sorted(missing_ids), arch + ) + ) + for error in unmapped_errors: + assert error.node_id in error.message + assert arch in error.message diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_preprocess_connection_validation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_preprocess_connection_validation.py new file mode 100644 index 00000000..30edb7e8 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_preprocess_connection_validation.py @@ -0,0 +1,133 @@ +"""Property test for validator acceptance of preprocessing node connections. + +**Feature: custom-python-frames, Property 1: Validator acceptance of +preprocessing node connections** + +For any workflow graph wiring a source node's output port into a +`custom_python_preprocess` node's input port, the Workflow_Validator +accepts the connection exactly when +`are_port_types_compatible(source_type, VideoFrames)` holds under the +catalog's declared coercion rules (VideoFrames exactly, and +InferenceMeta via the declared coercion), and otherwise reports a port +type compatibility error identifying that connection. + +**Validates: Requirements 2.1, 2.2** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.catalog import ( + NODE_CATALOG, + PORT_TYPES, + are_port_types_compatible, + get_node_type, +) +from workflow_core.catalog.models import PORT_TYPE_VIDEO_FRAMES +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) +from workflow_core.validator import CODE_V2_INCOMPATIBLE_TYPES, validate + +from .generators import node_parameters_strategy + +#: Every catalog node type that declares at least one output port is a +#: candidate source for wiring into the preprocessing node's input. +_SOURCE_DESCRIPTORS = [d for d in NODE_CATALOG if d.outputs] + + +@st.composite +def preprocess_wiring_strategy(draw): + """A two-node graph: a random source node type's drawn output port + wired into a `custom_python_preprocess` node's `in` port. + + Returns ``(graph, connection_id, effective_source_port_type)`` where + the effective type accounts for per-instance `output_port_type` + overrides (custom_python), mirroring the validator's port resolution. + """ + descriptor = draw(st.sampled_from(_SOURCE_DESCRIPTORS)) + + # Per-instance port typing: when the source type declares an + # output_port_type parameter, draw the override explicitly so every + # port type is exercised as a source. + forced = None + parameter_names = {p.name for p in descriptor.parameters} + if "output_port_type" in parameter_names: + forced = {"output_port_type": draw(st.sampled_from(PORT_TYPES))} + + source = Node( + id="src", + type=descriptor.type_id, + position=Position(x=0.0, y=0.0), + parameters=draw(node_parameters_strategy(descriptor, forced)), + ) + + output_port = draw(st.sampled_from(descriptor.outputs)) + if forced is not None: + effective_type = forced["output_port_type"] + else: + effective_type = output_port.port_type + + preprocess_descriptor = get_node_type("custom_python_preprocess") + preprocess = Node( + id="pre", + type="custom_python_preprocess", + position=Position(x=100.0, y=0.0), + parameters=draw(node_parameters_strategy(preprocess_descriptor)), + ) + + connection = Connection( + id="c1", + source=PortEndpoint(node=source.id, port=output_port.name), + target=PortEndpoint(node=preprocess.id, port="in"), + ) + + graph = WorkflowGraph(nodes=[source, preprocess], connections=[connection]) + return graph, connection.id, effective_type + + +@given(wiring=preprocess_wiring_strategy()) +def test_validator_accepts_preprocess_input_exactly_for_video_frames(wiring): + """**Feature: custom-python-frames, Property 1: Validator acceptance + of preprocessing node connections** + + validate() reports no port type compatibility finding for the + connection exactly when are_port_types_compatible(source_port_type, + VideoFrames) holds under the catalog's declared coercion rules + (VideoFrames exactly, and InferenceMeta via the declared coercion), + and reports a port type compatibility finding identifying the + connection for every other source port type. + + **Validates: Requirements 2.1, 2.2** + """ + graph, connection_id, source_port_type = wiring + + findings = validate(graph) + compatibility_findings = [ + finding for finding in findings + if finding.code == CODE_V2_INCOMPATIBLE_TYPES + and finding.connection_id == connection_id + ] + + if are_port_types_compatible(source_port_type, PORT_TYPE_VIDEO_FRAMES): + assert compatibility_findings == [], ( + "{0} -> custom_python_preprocess input is compatible with " + "VideoFrames under the declared coercion rules and must be " + "accepted, but validate() reported: {1}".format( + source_port_type, + [f.to_dict() for f in compatibility_findings], + ) + ) + else: + assert compatibility_findings, ( + "{0} output wired into the custom_python_preprocess input " + "(connection '{1}') must yield a port type compatibility " + "finding identifying the connection, but validate() reported " + "none".format(source_port_type, connection_id) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_completeness.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_completeness.py new file mode 100644 index 00000000..32e0d0b9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_completeness.py @@ -0,0 +1,110 @@ +"""Property test for Plugin_Scaffold completeness (task 1.7). + +**Feature: custom-node-designer, Property 2: Scaffold generation is complete for the declaration** + +For all valid Custom_Node_Type declarations, the generated Plugin_Scaffold +contains the Frame_Processing_Hook source file, exactly one build +configuration per selected Target_Architecture, and parameter plumbing +that exposes every declared parameter name to the hook. + +**Validates: Requirements 1.2, 1.4** +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog import DEEPSTREAM_ARCHITECTURES, DEVICE_ARCHITECTURES +from workflow_core.scaffold import ( + HOOK_FILE, + build_config_path, + c_source_path, + render_scaffold, +) + +from .test_property_declaration_conversion import valid_declarations + +# --------------------------------------------------------------------------- +# Strategy: valid declarations with a Target_Architecture selection +# --------------------------------------------------------------------------- + + +@st.composite +def declarations_with_architectures(draw): + """(declaration, expected selected Target_Architectures). + + Reuses the Property 1 valid-declaration strategy (node-catalog wire + shape) and either adds an explicit ``architectures`` selection or + omits the key to exercise the fallback to the declaration's mapping + architectures. DeepStream-flagged declarations select only JetPack + architectures, matching the declaration-level restriction. + """ + declaration = draw(valid_declarations()) + + # The selection the omitted-key fallback would produce: the + # declaration's mapping architectures restricted to device + # architectures (mappings may also name non-device architectures + # such as the simulation architecture, which carry no build). + fallback = [m["arch"] for m in declaration["mappings"] + if m["arch"] in DEVICE_ARCHITECTURES] + + if not fallback or draw(st.booleans()): + pool = (DEEPSTREAM_ARCHITECTURES if declaration["deepstream"] + else DEVICE_ARCHITECTURES) + selected = draw(st.lists( + st.sampled_from(pool), min_size=1, max_size=len(pool), + unique=True)) + declaration["architectures"] = list(selected) + else: + selected = fallback + + return declaration, selected + + +# --------------------------------------------------------------------------- +# Property 2 +# --------------------------------------------------------------------------- + + +@settings(max_examples=25) +@given(case=declarations_with_architectures()) +def test_scaffold_generation_is_complete_for_the_declaration(case): + """**Feature: custom-node-designer, Property 2: Scaffold generation is complete for the declaration** + + **Validates: Requirements 1.2, 1.4** + """ + declaration, selected = case + files = render_scaffold(declaration) + + # The scaffold is a file map of non-empty sources (Requirement 1.2). + assert isinstance(files, dict) + for path, content in files.items(): + assert isinstance(path, str) and path + assert isinstance(content, str) and content.strip(), path + + # The Frame_Processing_Hook source file is present and carries the + # hook contract the user fills in (Requirement 1.2). + assert HOOK_FILE in files + hook = files[HOOK_FILE] + assert "def process_frame(frame, params):" in hook + + # Exactly one build configuration per selected Target_Architecture: + # every selected architecture has its configuration, and no build + # configuration exists for an unselected architecture (Requirement 1.2). + expected_build_configs = {build_config_path(arch) for arch in selected} + actual_build_configs = { + path for path in files + if path in {build_config_path(a) for a in DEVICE_ARCHITECTURES} + } + assert actual_build_configs == expected_build_configs + assert len(expected_build_configs) == len(selected) + + # Every declared parameter name is exposed to the hook: listed in the + # hook file's declared-parameters map and plumbed into the params + # dict built by the C skeleton element (Requirement 1.4). + c_source = files[c_source_path(declaration)] + for parameter in declaration["parameters"]: + name = parameter["name"] + assert repr(name) in hook + assert 'PyDict_SetItemString (params, "{0}"'.format(name) in c_source diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_validation.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_validation.py new file mode 100644 index 00000000..161bb39b --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_property_scaffold_validation.py @@ -0,0 +1,238 @@ +"""Property test for Plugin_Scaffold validation (task 1.8). + +**Feature: custom-node-designer, Property 3: Scaffold validation rejects non-buildable source** + +For all scaffold file maps produced by corrupting a valid scaffold with a +random defect (removing the Frame_Processing_Hook file, removing all build +configurations, emptying required files), scaffold validation rejects the +source with a description of the failure, and accepts every uncorrupted +scaffold. + +**Validates: Requirements 2.6** +""" + +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog import CATEGORIES, PORT_TYPES +from workflow_core.catalog.models import DEVICE_ARCHITECTURES +from workflow_core.scaffold import ( + HOOK_FILE, + ScaffoldError, + build_config_path, + c_source_path, + render_scaffold, + scaffold_defects, + validate_scaffold, +) + +# --------------------------------------------------------------------------- +# Valid declaration generation (wire shape + selected architectures) +# --------------------------------------------------------------------------- + +# Identifier-ish names: lowercase a-j so every typeId yields a usable +# GStreamer element name and parameter names are valid throughout. +_NAME = st.text(alphabet="abcdefghij", min_size=1, max_size=8) + +_DESCRIPTION = st.text( + alphabet="abcdefghij ", min_size=1, max_size=30 +).filter(lambda s: s.strip()) + + +@st.composite +def _int_param(draw, name): + lo = draw(st.integers(-100, 100)) + hi = lo + draw(st.integers(0, 100)) + value = st.integers(lo, hi) + return { + "name": name, + "paramType": "int", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "constraints": {"min": lo, "max": hi}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=2)), + } + + +@st.composite +def _float_param(draw, name): + lo = draw(st.floats(-1e3, 1e3, allow_nan=False, allow_infinity=False)) + hi = lo + draw(st.floats(0, 1e3, allow_nan=False, allow_infinity=False)) + value = st.floats(min_value=lo, max_value=hi, + allow_nan=False, allow_infinity=False) + return { + "name": name, + "paramType": "float", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "constraints": {"min": lo, "max": hi}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=2)), + } + + +@st.composite +def _bool_param(draw, name): + return { + "name": name, + "paramType": "bool", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), st.booleans())), + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(st.booleans(), min_size=1, max_size=2)), + } + + +@st.composite +def _string_param(draw, name): + value = st.text(alphabet="abcdefghij", min_size=0, max_size=8) + return { + "name": name, + "paramType": "string", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), value)), + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(value, min_size=1, max_size=2)), + } + + +@st.composite +def _enum_param(draw, name): + values = draw(st.lists(_NAME, min_size=1, max_size=3, unique=True)) + member = st.sampled_from(values) + return { + "name": name, + "paramType": "enum", + "required": draw(st.booleans()), + "default": draw(st.one_of(st.none(), member)), + "constraints": {"values": values}, + "description": draw(_DESCRIPTION), + "examples": draw(st.lists(member, min_size=1, max_size=2)), + } + + +def _param_for(draw, name): + kind = draw(st.sampled_from(["int", "float", "bool", "string", "enum"])) + strategy = { + "int": _int_param, + "float": _float_param, + "bool": _bool_param, + "string": _string_param, + "enum": _enum_param, + }[kind] + return draw(strategy(name)) + + +_PORTS = st.lists( + st.fixed_dictionaries( + {"name": _NAME, "portType": st.sampled_from(PORT_TYPES)}), + min_size=1, + max_size=2, +) + + +@st.composite +def valid_declarations(draw): + """A valid Custom_Node_Type declaration with selected architectures, + accepted by render_scaffold.""" + names = draw(st.lists(_NAME, min_size=0, max_size=3, unique=True)) + architectures = draw(st.lists( + st.sampled_from(DEVICE_ARCHITECTURES), + min_size=1, max_size=len(DEVICE_ARCHITECTURES), unique=True)) + return { + "typeId": "custom." + draw(_NAME), + "displayName": draw(_DESCRIPTION), + "description": draw(st.one_of(st.none(), _DESCRIPTION)), + "category": draw(st.sampled_from(CATEGORIES)), + "inputs": draw(_PORTS), + "outputs": draw(_PORTS), + "parameters": [_param_for(draw, name) for name in names], + "architectures": architectures, + } + + +# --------------------------------------------------------------------------- +# Corruptions: each plants one named defect (Property 3) into a rendered +# scaffold file map in place, and returns the fragments the failure +# description must contain. +# --------------------------------------------------------------------------- + +def _remove_hook_file(draw, files, declaration): + """Removing the Frame_Processing_Hook file.""" + del files[HOOK_FILE] + return [HOOK_FILE] + + +def _remove_all_build_configurations(draw, files, declaration): + """Removing all build configurations.""" + for arch in declaration["architectures"]: + del files[build_config_path(arch)] + # every missing architecture must be described + return [build_config_path(arch) for arch in declaration["architectures"]] + + +def _empty_required_file(draw, files, declaration): + """Emptying a required file (hook, C skeleton, or a build config).""" + required = [HOOK_FILE, c_source_path(declaration)] + [ + build_config_path(arch) for arch in declaration["architectures"]] + path = draw(st.sampled_from(required)) + files[path] = draw(st.sampled_from(["", " ", "\n\t \n"])) + return [path, "empty"] + + +_CORRUPTIONS = [ + _remove_hook_file, + _remove_all_build_configurations, + _empty_required_file, +] + + +@st.composite +def scaffold_cases(draw): + """(declaration, files, expected_fragments). expected_fragments is + None for an uncorrupted scaffold, otherwise the substrings the + rejection description must contain.""" + declaration = draw(valid_declarations()) + files = render_scaffold(declaration) + if draw(st.booleans()): + return declaration, files, None + corruption = draw(st.sampled_from(_CORRUPTIONS)) + return declaration, files, corruption(draw, files, declaration) + + +# --------------------------------------------------------------------------- +# Property 3 +# --------------------------------------------------------------------------- + +@settings(max_examples=25) +@given(case=scaffold_cases()) +def test_scaffold_validation_rejects_non_buildable_source(case): + """**Feature: custom-node-designer, Property 3: Scaffold validation rejects non-buildable source** + + **Validates: Requirements 2.6** + """ + declaration, files, expected_fragments = case + + if expected_fragments is None: + # Every uncorrupted scaffold is accepted. + assert scaffold_defects(files, declaration) == [] + assert validate_scaffold(files, declaration) is None + else: + # Every corrupted scaffold is rejected with a description of the + # failure (Requirement 2.6). + defects = scaffold_defects(files, declaration) + assert defects, "corrupted scaffold reported no defects" + assert all(isinstance(defect, str) and defect.strip() + for defect in defects) + + with pytest.raises(ScaffoldError) as excinfo: + validate_scaffold(files, declaration) + assert excinfo.value.defects == defects + + message = str(excinfo.value) + for fragment in expected_fragments: + assert fragment in message diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_scaffold.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_scaffold.py new file mode 100644 index 00000000..6d147a96 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_scaffold.py @@ -0,0 +1,283 @@ +"""Unit tests for workflow_core.scaffold (Plugin_Scaffold rendering and +validation, Requirements 1.2, 1.3, 1.4, 1.5). + +Covers: rendered file map completeness (hook file, C skeleton, one +meson.build per selected Target_Architecture, README), the +process_frame(frame, params) hook contract, declared parameters surfacing +as GObject properties plumbed into the params dict, generation failures +identifying the failing input, and scaffold validation rejecting +non-buildable source (missing hook file, missing build configurations, +empty required files) with a description of the failure. +""" + +import pytest + +from workflow_core.catalog import ARCH_ARM64_JP5, ARCH_X86_64 +from workflow_core.scaffold import ( + HOOK_FILE, + README_FILE, + ScaffoldError, + build_config_path, + c_source_path, + element_name_for, + render_scaffold, + scaffold_defects, + validate_scaffold, +) + + +def make_declaration(**overrides): + """A valid Custom_Node_Type declaration in the wire shape accepted by + descriptor_from_declaration, plus the selected architectures.""" + declaration = { + "typeId": "custom_blur", + "displayName": "Custom Blur", + "description": "Blurs each frame with a configurable radius.", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [ + { + "name": "radius", + "paramType": "int", + "required": True, + "default": 3, + "constraints": {"min": 1, "max": 99}, + "description": "Blur radius in pixels.", + "examples": [3], + }, + { + "name": "mode", + "paramType": "enum", + "required": False, + "default": "gaussian", + "constraints": {"values": ["gaussian", "box"]}, + "description": "Blur kernel to apply.", + "examples": ["gaussian"], + }, + { + "name": "normalize", + "paramType": "bool", + "required": False, + "default": False, + "description": "Normalize output intensity.", + "examples": [True], + }, + { + "name": "strength", + "paramType": "float", + "required": False, + "default": 0.5, + "constraints": {"min": 0.0, "max": 1.0}, + "description": "Blend strength between input and output.", + "examples": [0.5], + }, + ], + "architectures": [ARCH_X86_64, ARCH_ARM64_JP5], + } + declaration.update(overrides) + return declaration + + +class TestRenderScaffold: + def test_renders_expected_file_map(self): + declaration = make_declaration() + files = render_scaffold(declaration) + assert set(files) == { + HOOK_FILE, + c_source_path(declaration), + README_FILE, + build_config_path(ARCH_X86_64), + build_config_path(ARCH_ARM64_JP5), + } + for path, content in files.items(): + assert isinstance(content, str) and content.strip(), path + + def test_hook_exposes_process_frame_contract(self): + hook = render_scaffold(make_declaration())[HOOK_FILE] + assert "def process_frame(frame, params):" in hook + assert "return frame" in hook + + def test_hook_lists_every_declared_parameter(self): + hook = render_scaffold(make_declaration())[HOOK_FILE] + for name in ("radius", "mode", "normalize", "strength"): + assert name in hook + + def test_parameters_surface_as_gobject_properties(self): + declaration = make_declaration() + c_source = render_scaffold(declaration)[c_source_path(declaration)] + # one property installed per declared parameter, typed by paramType + assert c_source.count("g_object_class_install_property") == 4 + assert 'g_param_spec_int ("radius"' in c_source + assert 'g_param_spec_string ("mode"' in c_source + assert 'g_param_spec_boolean ("normalize"' in c_source + assert 'g_param_spec_double ("strength"' in c_source + # every declared name is plumbed into the hook's params dict + for name in ("radius", "mode", "normalize", "strength"): + assert 'PyDict_SetItemString (params, "{0}"'.format(name) in c_source + + def test_c_skeleton_bridges_appsink_appsrc_to_hook(self): + declaration = make_declaration() + c_source = render_scaffold(declaration)[c_source_path(declaration)] + assert "appsink" in c_source and "appsrc" in c_source + assert '"process_frame"' in c_source + assert 'gst_element_register (plugin, "customblur"' in c_source + + def test_c_skeleton_avoids_version_gated_apis(self): + # The C skeleton must link against every supported device stack: + # JetPack 4 ships GStreamer 1.14 / glib 2.56, JetPack 5 ships + # GStreamer 1.16. Symbols introduced after those versions must not + # appear anywhere in the rendered scaffold. + version_gated_symbols = ( + "gst_buffer_new_memdup", # GStreamer >= 1.20 + "g_memdup2", # glib >= 2.68 + "gst_element_request_pad_simple", # GStreamer >= 1.20 + ) + files = render_scaffold(make_declaration()) + for path, content in files.items(): + for symbol in version_gated_symbols: + assert symbol not in content, ( + "{0} references version-gated symbol {1}".format( + path, symbol)) + + def test_one_build_configuration_per_selected_architecture(self): + declaration = make_declaration( + architectures=[ARCH_X86_64, ARCH_ARM64_JP5]) + files = render_scaffold(declaration) + for arch in (ARCH_X86_64, ARCH_ARM64_JP5): + meson = files[build_config_path(arch)] + assert "project('gst-customblur'" in meson + assert arch in meson + + def test_readme_documents_parameters_and_architectures(self): + files = render_scaffold(make_declaration()) + readme = files[README_FILE] + assert "process_frame" in readme + for name in ("radius", "mode", "normalize", "strength"): + assert name in readme + assert ARCH_X86_64 in readme and ARCH_ARM64_JP5 in readme + + def test_no_parameters_declared(self): + declaration = make_declaration(parameters=[]) + files = render_scaffold(declaration) + c_source = files[c_source_path(declaration)] + assert "g_object_class_install_property" not in c_source + assert "def process_frame(frame, params):" in files[HOOK_FILE] + + +class TestRenderScaffoldFailures: + def test_invalid_category_identifies_field(self): + with pytest.raises(ScaffoldError) as exc_info: + render_scaffold(make_declaration(category="nonsense")) + assert exc_info.value.field == "category" + + def test_invalid_port_type_identifies_field(self): + with pytest.raises(ScaffoldError) as exc_info: + render_scaffold(make_declaration( + inputs=[{"name": "in", "portType": "Bogus"}])) + assert exc_info.value.field == "inputs[0].portType" + + def test_empty_architectures_rejected(self): + with pytest.raises(ScaffoldError) as exc_info: + render_scaffold(make_declaration(architectures=[])) + assert exc_info.value.field == "architectures" + + def test_unknown_architecture_rejected(self): + with pytest.raises(ScaffoldError) as exc_info: + render_scaffold(make_declaration(architectures=["riscv"])) + assert exc_info.value.field == "architectures[0]" + + def test_duplicate_architecture_rejected(self): + with pytest.raises(ScaffoldError) as exc_info: + render_scaffold(make_declaration( + architectures=[ARCH_X86_64, ARCH_X86_64])) + assert exc_info.value.field == "architectures[1]" + + def test_architectures_fall_back_to_mappings(self): + declaration = make_declaration(architectures=None) + del declaration["architectures"] + declaration["mappings"] = [ + {"arch": ARCH_X86_64, "elementChain": [], "pluginDependencies": []} + ] + files = render_scaffold(declaration) + assert build_config_path(ARCH_X86_64) in files + + +class TestElementName: + def test_derived_from_type_id(self): + assert element_name_for({"typeId": "custom_blur"}) == "customblur" + assert element_name_for({"typeId": "My-Node.2"}) == "mynode2" + + def test_leading_digit_gets_prefixed(self): + assert element_name_for({"typeId": "3dfilter"})[0].isalpha() + + def test_unusable_type_id_rejected(self): + with pytest.raises(ScaffoldError) as exc_info: + element_name_for({"typeId": "---"}) + assert exc_info.value.field == "typeId" + + +class TestValidateScaffold: + def test_accepts_rendered_scaffold(self): + declaration = make_declaration() + files = render_scaffold(declaration) + assert validate_scaffold(files, declaration) is None + assert scaffold_defects(files, declaration) == [] + + def test_rejects_missing_hook_file(self): + declaration = make_declaration() + files = render_scaffold(declaration) + del files[HOOK_FILE] + with pytest.raises(ScaffoldError) as exc_info: + validate_scaffold(files, declaration) + assert "Frame_Processing_Hook" in str(exc_info.value) + assert HOOK_FILE in str(exc_info.value) + + def test_rejects_missing_build_configuration(self): + declaration = make_declaration() + files = render_scaffold(declaration) + del files[build_config_path(ARCH_ARM64_JP5)] + with pytest.raises(ScaffoldError) as exc_info: + validate_scaffold(files, declaration) + assert ARCH_ARM64_JP5 in str(exc_info.value) + + def test_rejects_all_build_configurations_missing(self): + declaration = make_declaration() + files = render_scaffold(declaration) + del files[build_config_path(ARCH_X86_64)] + del files[build_config_path(ARCH_ARM64_JP5)] + with pytest.raises(ScaffoldError) as exc_info: + validate_scaffold(files, declaration) + message = str(exc_info.value) + assert ARCH_X86_64 in message and ARCH_ARM64_JP5 in message + + def test_rejects_empty_required_file(self): + declaration = make_declaration() + for path in (HOOK_FILE, c_source_path(declaration), + build_config_path(ARCH_X86_64)): + files = render_scaffold(declaration) + files[path] = " \n" + with pytest.raises(ScaffoldError) as exc_info: + validate_scaffold(files, declaration) + assert "empty" in str(exc_info.value) + assert path in str(exc_info.value) + + def test_collects_every_defect_with_descriptions(self): + declaration = make_declaration() + files = render_scaffold(declaration) + del files[HOOK_FILE] + files[c_source_path(declaration)] = "" + defects = scaffold_defects(files, declaration) + assert len(defects) == 2 + assert all(isinstance(d, str) and d for d in defects) + + def test_missing_readme_does_not_fail_buildability(self): + declaration = make_declaration() + files = render_scaffold(declaration) + del files[README_FILE] + assert validate_scaffold(files, declaration) is None + + def test_rejects_non_file_map(self): + declaration = make_declaration() + defects = scaffold_defects(["not", "a", "map"], declaration) + assert defects and "file map" in defects[0] diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_schema_migrations.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_schema_migrations.py new file mode 100644 index 00000000..7211766d --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_schema_migrations.py @@ -0,0 +1,311 @@ +"""Unit tests for schema migration fixtures. + +Covers task 2.6 of the workflow-manager spec: fixture documents per +registered migration, asserting the migrated output and the reported +migration path (``ParseResult.migrations == [from, to]``). + +Two layers of coverage: + +1. A fixtures-driven test parameterized over ``registered_migrations()`` + captured at import time. The current schema version is 1 and no + production migrations exist yet, so the parameterized test is empty + today; when a production migration is registered, the guard test + fails until a fixture document is added to + ``PRODUCTION_MIGRATION_FIXTURES``, keeping future migrations + automatically covered. +2. Explicit synthetic fixtures exercising the migration machinery now: + a single-step v0 -> v1 upgrade and a multi-step v-1 -> v0 -> v1 path, + each asserting the migrated document content and the reported path. + +Synthetic migrations registered here are always cleaned up with +``unregister_migration`` so concurrent test files stay isolated. + +_Requirements: 3.5_ +""" + +import json + +import pytest + +from workflow_core.serializer import ( + ERROR_UNSUPPORTED_SCHEMA_VERSION, + SCHEMA_VERSION, + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, + parse, + register_migration, + registered_migrations, + serialize, + unregister_migration, +) + +# --------------------------------------------------------------------------- +# Fixtures-driven coverage of registered (production) migrations +# --------------------------------------------------------------------------- + +#: Fixture documents per registered production migration, keyed by +#: from-version. Each entry maps a from-version to a document at that +#: version and the canonical document expected after migration to the +#: current schema version. Add an entry here for every migration +#: registered in workflow_core itself. +PRODUCTION_MIGRATION_FIXTURES = { + # from_version: {"document": {...}, "expected_document": {...}}, +} + +#: Production migrations visible at import time, before any test +#: registers synthetic migrations. +_PRODUCTION_MIGRATIONS = sorted(registered_migrations()) + + +def test_every_production_migration_has_a_fixture(): + """Every migration registered by workflow_core has a fixture document. + + Fails when a new production migration lands without a corresponding + fixture, so future migrations are automatically covered here. + """ + missing = set(_PRODUCTION_MIGRATIONS) - set(PRODUCTION_MIGRATION_FIXTURES) + assert not missing, ( + "registered migrations without fixture documents: {}; add entries " + "to PRODUCTION_MIGRATION_FIXTURES in {}".format(sorted(missing), __file__) + ) + + +def test_no_stale_production_fixtures(): + stale = set(PRODUCTION_MIGRATION_FIXTURES) - set(_PRODUCTION_MIGRATIONS) + assert not stale, "fixtures for unregistered migrations: {}".format(sorted(stale)) + + +@pytest.mark.parametrize("from_version", _PRODUCTION_MIGRATIONS) +def test_production_migration_fixture(from_version): + """Requirement 3.5: each registered migration upgrades its fixture + document to the current version and reports the migration path.""" + fixture = PRODUCTION_MIGRATION_FIXTURES[from_version] + result = parse(json.dumps(fixture["document"])) + assert result.ok, result.error + assert result.migrations == [from_version, SCHEMA_VERSION] + assert json.loads(serialize(result.graph)) == fixture["expected_document"] + + +# --------------------------------------------------------------------------- +# Synthetic single-step migration: v0 -> v1 +# --------------------------------------------------------------------------- + +# Version 0 documents store node positions as [x, y] arrays and allow +# omitting "parameters"; the migration converts positions to objects and +# fills missing parameters with {}. +_V0_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["schemaVersion", "nodes", "connections"], + "additionalProperties": False, + "properties": { + "schemaVersion": {"const": 0}, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "position"], + "additionalProperties": False, + "properties": { + "id": {"type": "string", "minLength": 1}, + "type": {"type": "string", "minLength": 1}, + "position": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": {"type": "number"}, + }, + "parameters": {"type": "object"}, + }, + }, + }, + "connections": {"type": "array"}, + }, +} + + +def _upgrade_v0_to_v1(document): + return { + "schemaVersion": 1, + "nodes": [ + { + "id": node["id"], + "type": node["type"], + "position": {"x": node["position"][0], "y": node["position"][1]}, + "parameters": node.get("parameters", {}), + } + for node in document["nodes"] + ], + "connections": document["connections"], + } + + +#: A v0 fixture document exercising both migration behaviors: array +#: positions and an omitted "parameters" key (node n2). +V0_FIXTURE_DOCUMENT = { + "schemaVersion": 0, + "nodes": [ + { + "id": "n1", + "type": "camera_source", + "position": [100, 200], + "parameters": {"device": "/dev/video0", "gain": 4}, + }, + { + "id": "n2", + "type": "capture", + "position": [400, 200], + }, + ], + "connections": [ + {"id": "c1", "from": {"node": "n1", "port": "out"}, "to": {"node": "n2", "port": "in"}}, + ], +} + + +def _v0_expected_graph(): + return WorkflowGraph( + nodes=[ + Node( + id="n1", + type="camera_source", + position=Position(x=100, y=200), + parameters={"device": "/dev/video0", "gain": 4}, + ), + Node( + id="n2", + type="capture", + position=Position(x=400, y=200), + parameters={}, + ), + ], + connections=[ + Connection( + id="c1", + source=PortEndpoint(node="n1", port="out"), + target=PortEndpoint(node="n2", port="in"), + ), + ], + ) + + +@pytest.fixture +def v0_migration(): + """Register the synthetic v0 -> v1 migration; always unregister.""" + register_migration(0, _V0_SCHEMA, _upgrade_v0_to_v1) + try: + yield + finally: + unregister_migration(0) + + +class TestSingleStepMigrationFixture: + def test_migrated_output_matches_expected_document(self, v0_migration): + """Requirement 3.5: the v0 fixture migrates to the documented + v1 content (positions converted, missing parameters filled).""" + result = parse(json.dumps(V0_FIXTURE_DOCUMENT)) + assert result.ok, result.error + assert result.graph.is_equivalent_to(_v0_expected_graph()) + assert serialize(result.graph) == serialize(_v0_expected_graph()) + + def test_migration_path_is_reported(self, v0_migration): + """Requirement 3.5: the parse result reports [from, to].""" + result = parse(json.dumps(V0_FIXTURE_DOCUMENT)) + assert result.ok, result.error + assert result.migrations == [0, SCHEMA_VERSION] + + def test_migrated_document_round_trips_without_further_migration(self, v0_migration): + """The migrated graph serializes to a current-version document + that parses cleanly with no migration reported.""" + migrated = parse(json.dumps(V0_FIXTURE_DOCUMENT)).graph + result = parse(serialize(migrated)) + assert result.ok, result.error + assert result.migrations is None + assert result.graph.is_equivalent_to(migrated) + + +# --------------------------------------------------------------------------- +# Synthetic multi-step migration: v-1 -> v0 -> v1 +# --------------------------------------------------------------------------- + +# Version -1 documents use "vertices" instead of "nodes"; the -1 -> 0 +# migration renames the key, and the registered v0 -> v1 migration +# completes the path. +_V_MINUS_1_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["schemaVersion", "vertices", "connections"], + "additionalProperties": False, + "properties": { + "schemaVersion": {"const": -1}, + "vertices": {"type": "array"}, + "connections": {"type": "array"}, + }, +} + + +V_MINUS_1_FIXTURE_DOCUMENT = { + "schemaVersion": -1, + "vertices": V0_FIXTURE_DOCUMENT["nodes"], + "connections": V0_FIXTURE_DOCUMENT["connections"], +} + + +@pytest.fixture +def multi_step_migrations(): + """Register v-1 -> v0 and v0 -> v1; record the steps applied.""" + applied = [] + + def upgrade_v_minus_1_to_v0(document): + applied.append(-1) + return { + "schemaVersion": 0, + "nodes": document["vertices"], + "connections": document["connections"], + } + + def upgrade_v0_to_v1_recording(document): + applied.append(0) + return _upgrade_v0_to_v1(document) + + register_migration(-1, _V_MINUS_1_SCHEMA, upgrade_v_minus_1_to_v0) + try: + register_migration(0, _V0_SCHEMA, upgrade_v0_to_v1_recording) + try: + yield applied + finally: + unregister_migration(0) + finally: + unregister_migration(-1) + + +class TestMultiStepMigrationFixture: + def test_multi_step_path_migrates_and_reports_endpoints(self, multi_step_migrations): + """Requirement 3.5: a v-1 document upgrades stepwise to the + current version and the result reports [-1, current].""" + result = parse(json.dumps(V_MINUS_1_FIXTURE_DOCUMENT)) + assert result.ok, result.error + assert result.migrations == [-1, SCHEMA_VERSION] + assert result.graph.is_equivalent_to(_v0_expected_graph()) + + def test_steps_apply_in_ascending_version_order(self, multi_step_migrations): + applied = multi_step_migrations + result = parse(json.dumps(V_MINUS_1_FIXTURE_DOCUMENT)) + assert result.ok, result.error + assert applied == [-1, 0] + + def test_incomplete_migration_chain_is_unsupported(self): + """A version with a registered step but a gap in the chain to + the current version is reported as unsupported, not migrated.""" + register_migration(-1, _V_MINUS_1_SCHEMA, lambda document: document) + try: + # v0 -> v1 is NOT registered, so -1 has no complete path. + result = parse(json.dumps(V_MINUS_1_FIXTURE_DOCUMENT)) + assert not result.ok + assert result.error.code == ERROR_UNSUPPORTED_SCHEMA_VERSION + assert result.migrations is None + finally: + unregister_migration(-1) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_canonical.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_canonical.py new file mode 100644 index 00000000..bf4ed451 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_canonical.py @@ -0,0 +1,303 @@ +"""Unit tests for the WorkflowGraph model and canonical serialization. + +Covers task 2.1 of the workflow-manager spec: +- WorkflowGraph model (nodes with id/type/position/parameters, + connections with typed port endpoints) +- Workflow_Definition JSON Schema (schemaVersion 1) +- serialize(graph) canonical output: sorted keys, nodes and connections + ordered by id + +_Requirements: 3.1_ +""" + +import json + +import pytest + +from workflow_core.serializer import ( + SCHEMA_VERSION, + SCHEMAS_BY_VERSION, + WORKFLOW_DEFINITION_SCHEMA, + WORKFLOW_DEFINITION_SCHEMA_V1, + Connection, + Node, + PortEndpoint, + Position, + SerializationError, + WorkflowGraph, + graph_to_document, + serialize, +) + + +def _sample_graph(): + """The design.md example graph: camera -> model inference.""" + return WorkflowGraph( + nodes=[ + Node( + id="n1", + type="camera_source", + position=Position(x=100, y=200), + parameters={"device": "/dev/video0", "gain": 4}, + ), + Node( + id="n2", + type="model_inference", + position=Position(x=400, y=200), + parameters={"modelName": "widget-anomaly-v3"}, + ), + ], + connections=[ + Connection( + id="c1", + source=PortEndpoint(node="n1", port="out"), + target=PortEndpoint(node="n2", port="in"), + ), + ], + ) + + +class TestGraphModel: + def test_node_holds_id_type_position_parameters(self): + node = Node( + id="n1", + type="crop", + position=Position(x=1.5, y=-2.0), + parameters={"top": 10}, + ) + assert node.id == "n1" + assert node.type == "crop" + assert node.position == Position(x=1.5, y=-2.0) + assert node.parameters == {"top": 10} + + def test_node_parameters_default_to_empty_dict(self): + node = Node(id="n1", type="rotate", position=Position(x=0, y=0)) + assert node.parameters == {} + + def test_connection_holds_typed_port_endpoints(self): + conn = Connection( + id="c1", + source=PortEndpoint(node="a", port="out"), + target=PortEndpoint(node="b", port="in"), + ) + assert conn.source.node == "a" + assert conn.source.port == "out" + assert conn.target.node == "b" + assert conn.target.port == "in" + + def test_node_by_id_and_connection_by_id(self): + graph = _sample_graph() + assert graph.node_by_id("n2").type == "model_inference" + assert graph.node_by_id("missing") is None + assert graph.connection_by_id("c1").source.node == "n1" + assert graph.connection_by_id("missing") is None + + def test_empty_graph_defaults(self): + graph = WorkflowGraph() + assert graph.nodes == [] + assert graph.connections == [] + + def test_equivalence_is_order_insensitive(self): + graph = _sample_graph() + reversed_graph = WorkflowGraph( + nodes=list(reversed(graph.nodes)), + connections=list(graph.connections), + ) + assert graph.is_equivalent_to(reversed_graph) + assert reversed_graph.is_equivalent_to(graph) + + def test_equivalence_detects_differences(self): + graph = _sample_graph() + changed = _sample_graph() + changed.nodes[0].parameters["gain"] = 99 + assert not graph.is_equivalent_to(changed) + assert not graph.is_equivalent_to(WorkflowGraph()) + assert not graph.is_equivalent_to("not a graph") + + +class TestJsonSchema: + def test_current_schema_version_is_1(self): + assert SCHEMA_VERSION == 1 + assert SCHEMAS_BY_VERSION[1] is WORKFLOW_DEFINITION_SCHEMA_V1 + assert WORKFLOW_DEFINITION_SCHEMA is WORKFLOW_DEFINITION_SCHEMA_V1 + + def test_schema_declares_required_top_level_fields(self): + schema = WORKFLOW_DEFINITION_SCHEMA_V1 + assert schema["type"] == "object" + assert set(schema["required"]) == {"schemaVersion", "nodes", "connections"} + assert schema["properties"]["schemaVersion"] == {"const": 1} + assert schema["additionalProperties"] is False + + def test_schema_node_shape(self): + node_schema = WORKFLOW_DEFINITION_SCHEMA_V1["properties"]["nodes"]["items"] + assert set(node_schema["required"]) == {"id", "type", "position", "parameters"} + position_schema = node_schema["properties"]["position"] + assert set(position_schema["required"]) == {"x", "y"} + + def test_schema_connection_shape(self): + conn_schema = WORKFLOW_DEFINITION_SCHEMA_V1["properties"]["connections"]["items"] + assert set(conn_schema["required"]) == {"id", "from", "to"} + endpoint_schema = conn_schema["properties"]["from"] + assert set(endpoint_schema["required"]) == {"node", "port"} + + +class TestCanonicalSerialization: + def test_serialized_document_contains_everything(self): + """Requirement 3.1: all nodes, configurations, positions, + connections, and a schema version identifier.""" + document = json.loads(serialize(_sample_graph())) + assert document["schemaVersion"] == 1 + assert document["nodes"] == [ + { + "id": "n1", + "type": "camera_source", + "position": {"x": 100, "y": 200}, + "parameters": {"device": "/dev/video0", "gain": 4}, + }, + { + "id": "n2", + "type": "model_inference", + "position": {"x": 400, "y": 200}, + "parameters": {"modelName": "widget-anomaly-v3"}, + }, + ] + assert document["connections"] == [ + { + "id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}, + }, + ] + + def test_output_is_independent_of_input_ordering(self): + graph = _sample_graph() + shuffled = WorkflowGraph( + nodes=list(reversed(graph.nodes)), + connections=list(reversed(graph.connections)), + ) + assert serialize(graph) == serialize(shuffled) + + def test_nodes_and_connections_ordered_by_id(self): + graph = WorkflowGraph( + nodes=[ + Node(id=node_id, type="rotate", position=Position(x=0, y=0)) + for node_id in ["n9", "n10", "a", "z"] + ], + connections=[ + Connection( + id=conn_id, + source=PortEndpoint(node="a", port="out"), + target=PortEndpoint(node="z", port="in"), + ) + for conn_id in ["c2", "c10", "c1"] + ], + ) + document = json.loads(serialize(graph)) + # Lexicographic ordering by id (canonical, deterministic). + assert [n["id"] for n in document["nodes"]] == ["a", "n10", "n9", "z"] + assert [c["id"] for c in document["connections"]] == ["c1", "c10", "c2"] + + def test_object_keys_sorted_at_every_level(self): + output = serialize(_sample_graph()) + document = json.loads(output) + + def assert_sorted(obj): + if isinstance(obj, dict): + keys = list(obj.keys()) + # json.loads preserves document key order in dicts. + assert keys == sorted(keys), "unsorted keys: {}".format(keys) + for value in obj.values(): + assert_sorted(value) + elif isinstance(obj, list): + for item in obj: + assert_sorted(item) + + assert_sorted(document) + # Serialization of the parsed document with sorted keys is a fixpoint. + assert output == json.dumps(document, sort_keys=True, indent=2, ensure_ascii=True) + + def test_repeated_serialization_is_byte_identical(self): + graph = _sample_graph() + assert serialize(graph) == serialize(graph) + + def test_empty_graph_serializes(self): + document = json.loads(serialize(WorkflowGraph())) + assert document == {"schemaVersion": 1, "nodes": [], "connections": []} + + def test_nested_parameter_keys_sorted(self): + graph = WorkflowGraph( + nodes=[ + Node( + id="n1", + type="custom_python", + position=Position(x=0, y=0), + parameters={"zeta": 1, "alpha": {"b": 2, "a": 1}}, + ) + ] + ) + output = serialize(graph) + assert output.index('"alpha"') < output.index('"zeta"') + assert output.index('"a"') < output.index('"b"') + + def test_unicode_parameters_are_ascii_escaped(self): + graph = WorkflowGraph( + nodes=[ + Node( + id="n1", + type="mqtt_publish", + position=Position(x=0, y=0), + parameters={"topic": "caméra/λ"}, + ) + ] + ) + output = serialize(graph) + assert output == output.encode("ascii").decode("ascii") + assert json.loads(output)["nodes"][0]["parameters"]["topic"] == "caméra/λ" + + def test_graph_to_document_matches_serialize(self): + graph = _sample_graph() + assert json.loads(serialize(graph)) == graph_to_document(graph) + + +class TestSerializationErrors: + def test_duplicate_node_ids_rejected(self): + graph = WorkflowGraph( + nodes=[ + Node(id="n1", type="rotate", position=Position(x=0, y=0)), + Node(id="n1", type="crop", position=Position(x=1, y=1)), + ] + ) + with pytest.raises(SerializationError, match="duplicate node id"): + serialize(graph) + + def test_duplicate_connection_ids_rejected(self): + endpoint = PortEndpoint(node="n1", port="out") + graph = WorkflowGraph( + connections=[ + Connection(id="c1", source=endpoint, target=endpoint), + Connection(id="c1", source=endpoint, target=endpoint), + ] + ) + with pytest.raises(SerializationError, match="duplicate connection id"): + serialize(graph) + + def test_empty_node_id_rejected(self): + graph = WorkflowGraph( + nodes=[Node(id="", type="rotate", position=Position(x=0, y=0))] + ) + with pytest.raises(SerializationError, match="non-empty string"): + serialize(graph) + + def test_non_json_parameter_value_rejected(self): + graph = WorkflowGraph( + nodes=[ + Node( + id="n1", + type="custom_python", + position=Position(x=0, y=0), + parameters={"callback": object()}, + ) + ] + ) + with pytest.raises(SerializationError, match="not JSON-representable"): + serialize(graph) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_parse.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_parse.py new file mode 100644 index 00000000..2a083e07 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_parse.py @@ -0,0 +1,317 @@ +"""Unit tests for parse(): descriptive errors and schema migration. + +Covers task 2.2 of the workflow-manager spec: +- parse(doc) runs JSON-Schema validation first, reporting the first + violation with a JSON-pointer path, then constructs the graph +- stepwise migration registry: older supported schemaVersion documents + upgrade to current with ParseResult.migrations == [from, to]; + unsupported versions return UNSUPPORTED_SCHEMA_VERSION + +The dedicated round-trip and rejection property tests are tasks 2.4/2.5; +per-migration fixture tests are task 2.6. + +_Requirements: 3.2, 3.3, 3.5_ +""" + +import json + +import pytest + +from workflow_core.serializer import ( + ERROR_DUPLICATE_ID, + ERROR_INVALID_JSON, + ERROR_MIGRATION_FAILED, + ERROR_SCHEMA_VIOLATION, + ERROR_UNKNOWN_NODE_REFERENCE, + ERROR_UNSUPPORTED_SCHEMA_VERSION, + SCHEMA_VERSION, + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, + parse, + register_migration, + registered_migrations, + serialize, + unregister_migration, +) + + +def _sample_graph(): + """The design.md example graph: camera -> model inference.""" + return WorkflowGraph( + nodes=[ + Node( + id="n1", + type="camera_source", + position=Position(x=100, y=200), + parameters={"device": "/dev/video0", "gain": 4}, + ), + Node( + id="n2", + type="model_inference", + position=Position(x=400, y=200), + parameters={"modelName": "widget-anomaly-v3"}, + ), + ], + connections=[ + Connection( + id="c1", + source=PortEndpoint(node="n1", port="out"), + target=PortEndpoint(node="n2", port="in"), + ), + ], + ) + + +def _sample_document(): + return json.loads(serialize(_sample_graph())) + + +class TestParseValidDocuments: + def test_parse_of_serialized_graph_is_equivalent(self): + """Requirement 3.2: parsing a valid document produces an + equivalent graph.""" + graph = _sample_graph() + result = parse(serialize(graph)) + assert result.ok + assert result.error is None + assert result.graph.is_equivalent_to(graph) + + def test_current_version_document_reports_no_migrations(self): + result = parse(serialize(_sample_graph())) + assert result.ok + assert result.migrations is None + + def test_empty_graph_document_parses(self): + result = parse(json.dumps({"schemaVersion": 1, "nodes": [], "connections": []})) + assert result.ok + assert result.graph.is_equivalent_to(WorkflowGraph()) + + def test_parsed_graph_preserves_positions_and_parameters(self): + result = parse(serialize(_sample_graph())) + node = result.graph.node_by_id("n1") + assert node.position == Position(x=100, y=200) + assert node.parameters == {"device": "/dev/video0", "gain": 4} + connection = result.graph.connection_by_id("c1") + assert connection.source == PortEndpoint(node="n1", port="out") + assert connection.target == PortEndpoint(node="n2", port="in") + + +class TestParseDescriptiveErrors: + def test_invalid_json_reports_invalid_json(self): + result = parse("{not json") + assert not result.ok + assert result.graph is None + assert result.error.code == ERROR_INVALID_JSON + assert "line 1" in result.error.message + + def test_non_object_document_is_a_schema_violation(self): + result = parse(json.dumps([1, 2, 3])) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + assert result.error.path == "" + + def test_missing_required_field_reports_pointer(self): + document = _sample_document() + del document["nodes"] + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + assert result.error.path == "/nodes" + assert "nodes" in result.error.message + + def test_wrong_type_reports_pointer_to_violating_value(self): + document = _sample_document() + document["nodes"][1]["position"]["x"] = "not a number" + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + assert result.error.path == "/nodes/1/position/x" + + def test_missing_node_field_reports_pointer(self): + document = _sample_document() + del document["nodes"][0]["type"] + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + assert result.error.path == "/nodes/0/type" + + def test_empty_id_reports_pointer(self): + document = _sample_document() + document["connections"][0]["id"] = "" + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + assert result.error.path == "/connections/0/id" + + def test_additional_property_is_a_violation(self): + document = _sample_document() + document["extra"] = True + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + + def test_first_violation_only_is_reported(self): + """Requirement 3.3: the first violation encountered is reported.""" + document = _sample_document() + document["nodes"][0]["id"] = 42 # first violation in document order + document["connections"][0]["to"]["port"] = None # later violation + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.path == "/nodes/0/id" + + def test_duplicate_node_id_reported_with_pointer(self): + document = _sample_document() + document["nodes"][1]["id"] = "n1" + document["connections"] = [] + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_DUPLICATE_ID + assert result.error.path == "/nodes/1/id" + + def test_connection_to_unknown_node_reported_with_pointer(self): + document = _sample_document() + document["connections"][0]["to"]["node"] = "ghost" + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_UNKNOWN_NODE_REFERENCE + assert result.error.path == "/connections/0/to/node" + assert "ghost" in result.error.message + + def test_parse_never_returns_graph_and_error_together(self): + for doc in ["", "null", "[]", '{"schemaVersion": 1}']: + result = parse(doc) + assert result.graph is None + assert result.error is not None + + +class TestSchemaVersionHandling: + def test_future_version_is_unsupported(self): + document = _sample_document() + document["schemaVersion"] = SCHEMA_VERSION + 1 + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_UNSUPPORTED_SCHEMA_VERSION + assert result.error.path == "/schemaVersion" + + def test_unknown_older_version_is_unsupported(self): + document = _sample_document() + document["schemaVersion"] = 0 + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_UNSUPPORTED_SCHEMA_VERSION + + def test_non_integer_version_is_rejected(self): + document = _sample_document() + document["schemaVersion"] = "1" + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code in ( + ERROR_SCHEMA_VIOLATION, + ERROR_UNSUPPORTED_SCHEMA_VERSION, + ) + + def test_boolean_version_is_rejected(self): + document = _sample_document() + document["schemaVersion"] = True + result = parse(json.dumps(document)) + assert not result.ok + + +@pytest.fixture +def v0_migration(): + """Register a synthetic v0 -> v1 migration for the duration of a test. + + Version 0 documents use 'edges' instead of 'connections'; the + migration renames the key. + """ + v0_schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["schemaVersion", "nodes", "edges"], + "additionalProperties": False, + "properties": { + "schemaVersion": {"const": 0}, + "nodes": {"type": "array"}, + "edges": {"type": "array"}, + }, + } + + def upgrade(document): + return { + "schemaVersion": 1, + "nodes": document["nodes"], + "connections": document["edges"], + } + + register_migration(0, v0_schema, upgrade) + yield + unregister_migration(0) + + +class TestMigration: + def test_older_supported_version_migrates_and_reports(self, v0_migration): + """Requirement 3.5: older supported documents migrate to current + and the parse result reports the migration.""" + current = _sample_document() + v0_document = { + "schemaVersion": 0, + "nodes": current["nodes"], + "edges": current["connections"], + } + result = parse(json.dumps(v0_document)) + assert result.ok + assert result.migrations == [0, SCHEMA_VERSION] + assert result.graph.is_equivalent_to(_sample_graph()) + + def test_old_document_validated_against_its_own_schema(self, v0_migration): + # 'connections' is not a valid key at v0 (schema has 'edges'). + result = parse(json.dumps({"schemaVersion": 0, "nodes": [], "connections": []})) + assert not result.ok + assert result.error.code == ERROR_SCHEMA_VIOLATION + + def test_failing_migration_reports_migration_failed(self): + def broken(document): + raise RuntimeError("boom") + + register_migration( + 0, + {"type": "object", "properties": {"schemaVersion": {"const": 0}}}, + broken, + ) + try: + result = parse(json.dumps({"schemaVersion": 0})) + assert not result.ok + assert result.error.code == ERROR_MIGRATION_FAILED + assert "boom" in result.error.message + finally: + unregister_migration(0) + + def test_migration_producing_invalid_document_is_rejected(self): + register_migration( + 0, + {"type": "object", "properties": {"schemaVersion": {"const": 0}}}, + lambda document: {"schemaVersion": 1}, # missing nodes/connections + ) + try: + result = parse(json.dumps({"schemaVersion": 0})) + assert not result.ok + assert result.error.code == ERROR_MIGRATION_FAILED + finally: + unregister_migration(0) + + def test_unregistered_version_stays_unsupported(self, v0_migration): + document = _sample_document() + document["schemaVersion"] = -1 + result = parse(json.dumps(document)) + assert not result.ok + assert result.error.code == ERROR_UNSUPPORTED_SCHEMA_VERSION + + def test_registry_rejects_current_and_duplicate_versions(self, v0_migration): + with pytest.raises(ValueError, match="older than the current"): + register_migration(SCHEMA_VERSION, {}, lambda d: d) + with pytest.raises(ValueError, match="already registered"): + register_migration(0, {}, lambda d: d) + assert 0 in registered_migrations() diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_roundtrip_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_roundtrip_properties.py new file mode 100644 index 00000000..4e65b1b0 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_serializer_roundtrip_properties.py @@ -0,0 +1,56 @@ +"""Property test for the serialization round trip (task 2.4). + +**Feature: workflow-manager, Property 1: Serialization round trip** + +For all valid Workflow_Definition graphs, ``parse(serialize(g))`` produces +a graph equivalent to ``g``, and ``serialize(parse(serialize(g)))`` +produces JSON byte-identical to ``serialize(g)``. + +**Validates: Requirements 3.1, 3.2, 3.4** +""" + +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from workflow_core.serializer import parse, serialize + +from .generators import graph_strategy, single_node_graph_strategy + +# Round-trip quantifies over all well-formed graphs: validator-valid random +# graphs from the catalog plus the single-node serializer edge case. +workflow_graphs = st.one_of(graph_strategy(), single_node_graph_strategy()) + + +@given(graph=workflow_graphs) +def test_serialization_round_trip(graph): + """**Feature: workflow-manager, Property 1: Serialization round trip** + + **Validates: Requirements 3.1, 3.2, 3.4** + """ + document = serialize(graph) + + # parse(serialize(g)) succeeds and produces a graph equivalent to g + # (Requirements 3.1, 3.2, 3.4). + result = parse(document) + assert result.ok, "parse rejected a serialized valid graph: {}".format(result.error) + assert result.graph is not None + # A current-version document must not report any migration. + assert result.migrations is None, ( + "unexpected migrations for a current-version document: {!r}".format(result.migrations) + ) + assert result.graph.is_equivalent_to(graph), ( + "parsed graph is not equivalent to the original" + ) + # Equivalence is symmetric; check the other direction too. + assert graph.is_equivalent_to(result.graph), ( + "original graph is not equivalent to the parsed graph" + ) + + # serialize(parse(serialize(g))) is byte-identical to serialize(g) + # (Requirement 3.4: canonical serialization). + reserialized = serialize(result.graph) + assert reserialized == document, ( + "re-serialized document is not byte-identical to the original serialization" + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_checks.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_checks.py new file mode 100644 index 00000000..11f76051 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_checks.py @@ -0,0 +1,416 @@ +"""Unit tests for validate() with all checks (task 3.2). + +Covers V1 (input/output presence), V2 (connection port direction and +type compatibility), V3 (cycle detection with cycle membership), V4 +(required parameters satisfy constraints), V5 (reachability from input +nodes), and W1 warnings — plus the completeness contract: all checks +always run and the full findings list is returned. + +_Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_ +""" + +from workflow_core.serializer import ( + Connection, + Node, + PortEndpoint, + Position, + WorkflowGraph, +) +from workflow_core.validator import ( + CODE_UNKNOWN_NODE_TYPE, + CODE_V1_NO_INPUT_NODE, + CODE_V1_NO_OUTPUT_NODE, + CODE_V2_INCOMPATIBLE_TYPES, + CODE_V2_SOURCE_NOT_OUTPUT, + CODE_V2_TARGET_NOT_INPUT, + CODE_V2_UNKNOWN_NODE, + CODE_V2_UNKNOWN_PORT, + CODE_V3_CYCLE, + CODE_V4_INVALID_PARAMETER_VALUE, + CODE_V4_MISSING_REQUIRED_PARAMETER, + CODE_V5_UNREACHABLE_NODE, + CODE_W1_OUTPUT_NODE_NO_INPUT, + CODE_W1_UNUSED_OUTPUT_PORT, + SEVERITY_ERROR, + SEVERITY_WARNING, + validate, +) + +# -------------------------------------------------------------------------- +# Graph-building helpers +# -------------------------------------------------------------------------- + +_POS = Position(0.0, 0.0) + + +def _node(node_id, node_type, **parameters): + return Node(id=node_id, type=node_type, position=_POS, parameters=parameters) + + +def _conn(conn_id, source_node, target_node, source_port="out", target_port="in"): + return Connection( + id=conn_id, + source=PortEndpoint(source_node, source_port), + target=PortEndpoint(target_node, target_port), + ) + + +def _folder(node_id="src"): + return _node(node_id, "folder_source", location="/data/images") + + +def _capture(node_id="cap"): + return _node(node_id, "capture", output_path="/out") + + +def _rotate(node_id="rot"): + return _node(node_id, "rotate", method="clockwise") + + +def _inference(node_id="inf"): + return _node(node_id, "model_inference", modelName="widget-anomaly-v3") + + +def _valid_graph(): + """folder_source -> capture: passes every error check.""" + return WorkflowGraph( + nodes=[_folder(), _capture()], + connections=[_conn("c1", "src", "cap")], + ) + + +def _codes(findings): + return [finding.code for finding in findings] + + +def _errors(findings): + return [f for f in findings if f.severity == SEVERITY_ERROR] + + +def _by_code(findings, code): + return [f for f in findings if f.code == code] + + +# -------------------------------------------------------------------------- +# Baseline: a valid graph has no error findings +# -------------------------------------------------------------------------- + +class TestValidGraph: + def test_valid_graph_has_no_errors(self): + assert _errors(validate(_valid_graph())) == [] + + def test_finding_dict_shape(self): + findings = validate(WorkflowGraph()) + assert findings, "empty graph must produce findings" + entry = findings[0].to_dict() + assert set(entry) == {"severity", "code", "message", "nodeId", "connectionId"} + + +# -------------------------------------------------------------------------- +# V1: at least one input and one output node (Requirement 4.1) +# -------------------------------------------------------------------------- + +class TestV1: + def test_missing_input_node_reported(self): + graph = WorkflowGraph(nodes=[_capture()]) + assert CODE_V1_NO_INPUT_NODE in _codes(validate(graph)) + + def test_missing_output_node_reported(self): + graph = WorkflowGraph(nodes=[_folder()]) + assert CODE_V1_NO_OUTPUT_NODE in _codes(validate(graph)) + + def test_empty_graph_reports_both(self): + codes = _codes(validate(WorkflowGraph())) + assert CODE_V1_NO_INPUT_NODE in codes + assert CODE_V1_NO_OUTPUT_NODE in codes + + def test_satisfied_graph_reports_neither(self): + codes = _codes(validate(_valid_graph())) + assert CODE_V1_NO_INPUT_NODE not in codes + assert CODE_V1_NO_OUTPUT_NODE not in codes + + +# -------------------------------------------------------------------------- +# V2: connection direction and type compatibility (Requirement 4.2) +# -------------------------------------------------------------------------- + +class TestV2: + def test_incompatible_types_reported_with_connection_id(self): + # EventSignal (digital_input out) -> VideoFrames (rotate in) + graph = WorkflowGraph( + nodes=[_node("din", "digital_input", pin=1), _rotate(), _capture()], + connections=[_conn("bad", "din", "rot")], + ) + found = _by_code(validate(graph), CODE_V2_INCOMPATIBLE_TYPES) + assert len(found) == 1 + assert found[0].connection_id == "bad" + assert found[0].severity == SEVERITY_ERROR + + def test_declared_coercion_inference_meta_to_video_frames_ok(self): + # model_inference out (InferenceMeta) -> capture in (VideoFrames) + graph = WorkflowGraph( + nodes=[_folder(), _inference(), _capture()], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "cap"), + ], + ) + assert _by_code(validate(graph), CODE_V2_INCOMPATIBLE_TYPES) == [] + + def test_source_must_be_output_port(self): + # Using rotate's input port as the connection source. + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _capture()], + connections=[_conn("c1", "rot", "cap", source_port="in")], + ) + found = _by_code(validate(graph), CODE_V2_SOURCE_NOT_OUTPUT) + assert len(found) == 1 + assert found[0].connection_id == "c1" + + def test_target_must_be_input_port(self): + # Using rotate's output port as the connection target. + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _capture()], + connections=[_conn("c1", "src", "rot", target_port="out")], + ) + found = _by_code(validate(graph), CODE_V2_TARGET_NOT_INPUT) + assert len(found) == 1 + assert found[0].connection_id == "c1" + + def test_unknown_node_reference_reported(self): + graph = WorkflowGraph( + nodes=[_folder(), _capture()], + connections=[_conn("c1", "ghost", "cap")], + ) + found = _by_code(validate(graph), CODE_V2_UNKNOWN_NODE) + assert len(found) == 1 + assert found[0].connection_id == "c1" + + def test_unknown_port_reference_reported(self): + graph = WorkflowGraph( + nodes=[_folder(), _capture()], + connections=[_conn("c1", "src", "cap", source_port="nope")], + ) + found = _by_code(validate(graph), CODE_V2_UNKNOWN_PORT) + assert len(found) == 1 + assert found[0].connection_id == "c1" + + def test_custom_python_declared_port_types_respected(self): + # custom_python with declared EventSignal output feeding capture's + # VideoFrames input must be incompatible. + custom = _node( + "py", "custom_python", + code="def handler(x):\n return x", + input_port_type="InferenceMeta", + output_port_type="EventSignal", + ) + graph = WorkflowGraph( + nodes=[_folder(), _inference(), custom, _capture()], + connections=[ + _conn("c1", "src", "inf"), + _conn("c2", "inf", "py"), + _conn("c3", "py", "cap"), + ], + ) + found = _by_code(validate(graph), CODE_V2_INCOMPATIBLE_TYPES) + assert [f.connection_id for f in found] == ["c3"] + + +# -------------------------------------------------------------------------- +# V3: cycle detection reporting cycle membership (Requirement 4.3) +# -------------------------------------------------------------------------- + +class TestV3: + def test_two_node_cycle_reports_both_members(self): + rot_a = _rotate("rotA") + rot_b = _rotate("rotB") + graph = WorkflowGraph( + nodes=[_folder(), rot_a, rot_b, _capture()], + connections=[ + _conn("c1", "src", "rotA"), + _conn("c2", "rotA", "rotB"), + _conn("c3", "rotB", "rotA"), + _conn("c4", "rotA", "cap"), + ], + ) + found = _by_code(validate(graph), CODE_V3_CYCLE) + assert sorted(f.node_id for f in found) == ["rotA", "rotB"] + for finding in found: + assert "rotA" in finding.message and "rotB" in finding.message + + def test_self_loop_is_a_cycle(self): + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _capture()], + connections=[ + _conn("c1", "src", "rot"), + _conn("c2", "rot", "rot"), + _conn("c3", "rot", "cap"), + ], + ) + found = _by_code(validate(graph), CODE_V3_CYCLE) + assert [f.node_id for f in found] == ["rot"] + + def test_acyclic_graph_reports_no_cycles(self): + assert _by_code(validate(_valid_graph()), CODE_V3_CYCLE) == [] + + def test_diamond_fanout_is_not_a_cycle(self): + # src -> rotA -> cap, src -> rotB -> cap (diamond, still a DAG). + graph = WorkflowGraph( + nodes=[_folder(), _rotate("rotA"), _rotate("rotB"), _capture()], + connections=[ + _conn("c1", "src", "rotA"), + _conn("c2", "src", "rotB"), + _conn("c3", "rotA", "cap"), + _conn("c4", "rotB", "cap"), + ], + ) + assert _by_code(validate(graph), CODE_V3_CYCLE) == [] + + +# -------------------------------------------------------------------------- +# V4: required parameters satisfy constraints (Requirement 4.4) +# -------------------------------------------------------------------------- + +class TestV4: + def test_missing_required_parameter_identifies_node_and_parameter(self): + graph = WorkflowGraph( + nodes=[_node("src2", "folder_source"), _capture()], + connections=[_conn("c1", "src2", "cap")], + ) + found = _by_code(validate(graph), CODE_V4_MISSING_REQUIRED_PARAMETER) + assert len(found) == 1 + assert found[0].node_id == "src2" + assert "location" in found[0].message + + def test_constraint_violation_reported(self): + # capture quality must be 1..100 + bad_capture = _node("cap", "capture", output_path="/out", quality=200) + graph = WorkflowGraph( + nodes=[_folder(), bad_capture], + connections=[_conn("c1", "src", "cap")], + ) + found = _by_code(validate(graph), CODE_V4_INVALID_PARAMETER_VALUE) + assert len(found) == 1 + assert found[0].node_id == "cap" + assert "quality" in found[0].message + + def test_required_parameter_with_default_is_satisfied_when_omitted(self): + # rotate.method is required but has default "clockwise". + graph = WorkflowGraph( + nodes=[_folder(), _node("rot", "rotate"), _capture()], + connections=[_conn("c1", "src", "rot"), _conn("c2", "rot", "cap")], + ) + assert _by_code(validate(graph), CODE_V4_MISSING_REQUIRED_PARAMETER) == [] + + def test_explicit_null_clears_a_required_parameter(self): + graph = WorkflowGraph( + nodes=[_node("src2", "folder_source", location=None), _capture()], + connections=[_conn("c1", "src2", "cap")], + ) + found = _by_code(validate(graph), CODE_V4_MISSING_REQUIRED_PARAMETER) + assert [f.node_id for f in found] == ["src2"] + + +# -------------------------------------------------------------------------- +# V5: reachability from input nodes (Requirement 4.5) +# -------------------------------------------------------------------------- + +class TestV5: + def test_detached_node_reported_unreachable(self): + graph = WorkflowGraph( + nodes=[_folder(), _capture(), _rotate("stray")], + connections=[_conn("c1", "src", "cap")], + ) + found = _by_code(validate(graph), CODE_V5_UNREACHABLE_NODE) + assert [f.node_id for f in found] == ["stray"] + + def test_transitively_connected_nodes_are_reachable(self): + graph = WorkflowGraph( + nodes=[_folder(), _rotate(), _capture()], + connections=[_conn("c1", "src", "rot"), _conn("c2", "rot", "cap")], + ) + assert _by_code(validate(graph), CODE_V5_UNREACHABLE_NODE) == [] + + def test_input_nodes_are_reachable_roots(self): + graph = WorkflowGraph(nodes=[_folder(), _capture()]) + found = _by_code(validate(graph), CODE_V5_UNREACHABLE_NODE) + # The capture node has no path from src; src itself is a root. + assert [f.node_id for f in found] == ["cap"] + + def test_no_input_nodes_means_all_nodes_unreachable(self): + graph = WorkflowGraph( + nodes=[_rotate(), _capture()], + connections=[_conn("c1", "rot", "cap")], + ) + found = _by_code(validate(graph), CODE_V5_UNREACHABLE_NODE) + assert sorted(f.node_id for f in found) == ["cap", "rot"] + + +# -------------------------------------------------------------------------- +# W1: warnings (Requirement 4.6) +# -------------------------------------------------------------------------- + +class TestW1: + def test_output_node_without_incoming_connection_warns(self): + graph = WorkflowGraph(nodes=[_folder(), _capture()]) + found = _by_code(validate(graph), CODE_W1_OUTPUT_NODE_NO_INPUT) + assert [f.node_id for f in found] == ["cap"] + assert all(f.severity == SEVERITY_WARNING for f in found) + + def test_unused_output_port_warns(self): + graph = WorkflowGraph(nodes=[_folder(), _capture()]) + found = _by_code(validate(graph), CODE_W1_UNUSED_OUTPUT_PORT) + assert [f.node_id for f in found] == ["src"] + assert all(f.severity == SEVERITY_WARNING for f in found) + + def test_fully_wired_graph_has_no_warnings(self): + findings = validate(_valid_graph()) + assert [f for f in findings if f.severity == SEVERITY_WARNING] == [] + + +# -------------------------------------------------------------------------- +# Completeness: all checks run, complete list returned (Requirement 4.6) +# -------------------------------------------------------------------------- + +class TestCompleteness: + def test_multiple_defect_classes_reported_together(self): + """A graph seeded with V1/V2/V3/V4/V5 defects plus a W1 condition + yields findings for every class in a single validate() call.""" + rot_a = _rotate("rotA") + rot_b = _rotate("rotB") + graph = WorkflowGraph( + nodes=[ + # No input node at all (V1). + rot_a, rot_b, + _node("filt", "inference_filter"), # missing condition (V4) + _node("stray", "rotate", method="clockwise"), # detached (V5) + _capture(), # no incoming connection (W1) + ], + connections=[ + _conn("c1", "rotA", "rotB"), + _conn("c2", "rotB", "rotA"), # cycle (V3) + _conn("c3", "rotA", "filt"), # VideoFrames -> InferenceMeta (V2) + ], + ) + codes = set(_codes(validate(graph))) + assert CODE_V1_NO_INPUT_NODE in codes + assert CODE_V2_INCOMPATIBLE_TYPES in codes + assert CODE_V3_CYCLE in codes + assert CODE_V4_MISSING_REQUIRED_PARAMETER in codes + assert CODE_V5_UNREACHABLE_NODE in codes + assert CODE_W1_OUTPUT_NODE_NO_INPUT in codes + + def test_unknown_node_type_reported_and_does_not_crash_other_checks(self): + graph = WorkflowGraph( + nodes=[_node("mystery", "not_a_real_type"), _folder(), _capture()], + connections=[ + _conn("c1", "src", "cap"), + _conn("c2", "mystery", "cap"), + ], + ) + findings = validate(graph) + unknown = _by_code(findings, CODE_UNKNOWN_NODE_TYPE) + assert [f.node_id for f in unknown] == ["mystery"] + # V1 is still satisfied by the known nodes; no crash occurred and + # the unknown-typed node is excluded from reachability roots. + assert CODE_V1_NO_INPUT_NODE not in _codes(findings) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_finding_exactness_properties.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_finding_exactness_properties.py new file mode 100644 index 00000000..289ae67e --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_finding_exactness_properties.py @@ -0,0 +1,101 @@ +"""Property test for validator finding-set exactness (task 3.4). + +**Feature: workflow-manager, Property 3: Validator finding-set exactness** + +For all graphs constructed by seeding a random valid graph with a random +set of known defects (missing input/output nodes, incompatible-port +connections, injected cycles, cleared required parameters, detached +unreachable nodes), the validator returns findings that exactly match the +seeded defect set — every seeded defect is reported with the correct +node/connection identifier (and cycle findings name nodes actually in the +cycle), and no findings are reported for defect classes that were not +seeded. + +**Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6** +""" + +from __future__ import annotations + +from typing import FrozenSet + +from hypothesis import given + +from workflow_core.validator import ( + SEVERITY_ERROR, + SEVERITY_WARNING, + validate, +) + +from .generators import ( + ExpectedFinding, + graph_strategy, + seeded_graph_strategy, +) + + +def _error_findings(findings) -> FrozenSet[ExpectedFinding]: + """Project validate()'s error-severity findings onto the + (code, node_id, connection_id) shape used by SeededGraph.expected.""" + return frozenset( + ExpectedFinding( + code=finding.code, + node_id=finding.node_id, + connection_id=finding.connection_id, + ) + for finding in findings + if finding.severity == SEVERITY_ERROR + ) + + +@given(seeded=seeded_graph_strategy()) +def test_validator_reports_exactly_the_seeded_defects(seeded): + """**Feature: workflow-manager, Property 3: Validator finding-set exactness** + + The error-severity findings returned by validate() are exactly the + expected findings carried by the seeded graph: every seeded defect is + reported with the correct node/connection identifier and code, and no + error findings appear for defect classes that were not seeded. + + **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6** + """ + findings = validate(seeded.graph) + + actual = _error_findings(findings) + + missing = seeded.expected - actual + unexpected = actual - seeded.expected + assert actual == seeded.expected, ( + "validator findings do not exactly match the seeded defect set " + "(defect classes seeded: {0})\n" + " missing (seeded but not reported): {1}\n" + " unexpected (reported but not seeded): {2}".format( + sorted(seeded.defects), sorted(missing, key=repr), + sorted(unexpected, key=repr), + ) + ) + + # Every finding carries the fields Requirement 4.6 mandates. + for finding in findings: + assert finding.severity in (SEVERITY_ERROR, SEVERITY_WARNING) + assert finding.code + assert finding.message + + +@given(graph=graph_strategy()) +def test_validator_reports_no_errors_for_valid_graphs(graph): + """**Feature: workflow-manager, Property 3: Validator finding-set exactness** + + The zero-defect case of finding-set exactness: for graphs with no + seeded defects, validate() returns no error-severity findings + (warnings such as unused output ports are permitted). + + **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 4.5, 4.6** + """ + findings = validate(graph) + + errors = [f for f in findings if f.severity == SEVERITY_ERROR] + assert errors == [], ( + "valid graph produced error findings: {0}".format( + [f.to_dict() for f in errors] + ) + ) diff --git a/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_parameters.py b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_parameters.py new file mode 100644 index 00000000..9e21f5e9 --- /dev/null +++ b/edge-cv-portal/backend/layers/workflow_core/tests/test_validator_parameters.py @@ -0,0 +1,228 @@ +"""Unit tests for the shared parameter constraint predicate (task 3.1). + +Covers the declared type check plus min/max, enum, length, and regex +constraints over ParameterDescriptor. + +_Requirements: 1.8, 4.4_ +""" + +import pytest + +from workflow_core.catalog import ParameterDescriptor +from workflow_core.validator import ( + VIOLATION_MAX, + VIOLATION_MAX_LENGTH, + VIOLATION_MIN, + VIOLATION_MIN_LENGTH, + VIOLATION_REGEX, + VIOLATION_REQUIRED, + VIOLATION_TYPE, + VIOLATION_UNKNOWN_TYPE, + VIOLATION_VALUES, + check_parameter_value, + is_parameter_value_valid, +) + + +def _desc(name="p", param_type="string", required=True, default=None, constraints=None): + return ParameterDescriptor( + name=name, + param_type=param_type, + required=required, + default=default, + constraints=constraints or {}, + ) + + +# -------------------------------------------------------------------------- +# Required / missing values +# -------------------------------------------------------------------------- + +class TestRequired: + def test_missing_value_on_required_parameter_is_violation(self): + violation = check_parameter_value(_desc(required=True), None) + assert violation is not None + assert violation.code == VIOLATION_REQUIRED + assert "p" in violation.message + + def test_missing_value_on_optional_parameter_is_valid(self): + assert check_parameter_value(_desc(required=False), None) is None + + def test_missing_value_on_optional_parameter_skips_constraints(self): + desc = _desc(required=False, constraints={"min_length": 5}) + assert is_parameter_value_valid(desc, None) + + +# -------------------------------------------------------------------------- +# Type checks +# -------------------------------------------------------------------------- + +class TestTypeCheck: + @pytest.mark.parametrize("param_type,good,bad", [ + ("string", "hello", 42), + ("code", "print('x')", 3.14), + ("model_ref", "widget-anomaly-v3", ["not", "a", "string"]), + ("int", 7, "7"), + ("float", 0.5, "0.5"), + ("bool", True, "true"), + ]) + def test_declared_type_enforced(self, param_type, good, bad): + desc = _desc(param_type=param_type) + assert check_parameter_value(desc, good) is None + violation = check_parameter_value(desc, bad) + assert violation is not None + assert violation.code == VIOLATION_TYPE + + def test_bool_is_not_an_int(self): + violation = check_parameter_value(_desc(param_type="int"), True) + assert violation is not None + assert violation.code == VIOLATION_TYPE + + def test_int_is_accepted_for_float(self): + assert check_parameter_value(_desc(param_type="float"), 1) is None + + def test_float_is_rejected_for_int(self): + violation = check_parameter_value(_desc(param_type="int"), 1.5) + assert violation is not None + assert violation.code == VIOLATION_TYPE + + def test_unknown_declared_type_is_violation(self): + violation = check_parameter_value(_desc(param_type="mystery"), "x") + assert violation is not None + assert violation.code == VIOLATION_UNKNOWN_TYPE + + +# -------------------------------------------------------------------------- +# Numeric min/max +# -------------------------------------------------------------------------- + +class TestNumericRange: + def test_within_range_is_valid(self): + desc = _desc(param_type="int", constraints={"min": 0, "max": 100}) + assert check_parameter_value(desc, 50) is None + + def test_boundaries_inclusive(self): + desc = _desc(param_type="int", constraints={"min": 0, "max": 100}) + assert check_parameter_value(desc, 0) is None + assert check_parameter_value(desc, 100) is None + + def test_below_min(self): + desc = _desc(param_type="int", constraints={"min": 0, "max": 100}) + violation = check_parameter_value(desc, -1) + assert violation is not None + assert violation.code == VIOLATION_MIN + + def test_above_max(self): + desc = _desc(param_type="int", constraints={"min": 0, "max": 100}) + violation = check_parameter_value(desc, 101) + assert violation is not None + assert violation.code == VIOLATION_MAX + + def test_min_only(self): + desc = _desc(param_type="int", constraints={"min": 0}) + assert check_parameter_value(desc, 10 ** 12) is None + assert check_parameter_value(desc, -1).code == VIOLATION_MIN + + def test_float_range(self): + desc = _desc(param_type="float", constraints={"min": 0.0, "max": 1.0}) + assert check_parameter_value(desc, 0.5) is None + assert check_parameter_value(desc, 1.5).code == VIOLATION_MAX + + def test_nan_fails_bounded_range(self): + desc = _desc(param_type="float", constraints={"min": 0.0, "max": 1.0}) + violation = check_parameter_value(desc, float("nan")) + assert violation is not None + + +# -------------------------------------------------------------------------- +# Enum / discrete value sets +# -------------------------------------------------------------------------- + +class TestEnumValues: + def test_member_is_valid(self): + desc = _desc(param_type="enum", + constraints={"values": ["rising", "falling", "both"]}) + assert check_parameter_value(desc, "falling") is None + + def test_non_member_is_violation(self): + desc = _desc(param_type="enum", + constraints={"values": ["rising", "falling", "both"]}) + violation = check_parameter_value(desc, "sideways") + assert violation is not None + assert violation.code == VIOLATION_VALUES + + def test_int_valued_enum(self): + # Mirrors the mqtt_publish qos parameter in the catalog. + desc = _desc(param_type="enum", constraints={"values": [0, 1, 2]}) + assert check_parameter_value(desc, 1) is None + assert check_parameter_value(desc, 3).code == VIOLATION_VALUES + + def test_bool_does_not_match_int_member(self): + desc = _desc(param_type="enum", constraints={"values": [0, 1, 2]}) + violation = check_parameter_value(desc, True) + assert violation is not None + assert violation.code == VIOLATION_VALUES + + def test_values_constraint_on_int_type(self): + desc = _desc(param_type="int", constraints={"values": [10, 20, 30]}) + assert check_parameter_value(desc, 20) is None + assert check_parameter_value(desc, 25).code == VIOLATION_VALUES + + +# -------------------------------------------------------------------------- +# String length and regex +# -------------------------------------------------------------------------- + +class TestStringConstraints: + def test_min_length(self): + desc = _desc(constraints={"min_length": 1}) + assert check_parameter_value(desc, "x") is None + assert check_parameter_value(desc, "").code == VIOLATION_MIN_LENGTH + + def test_max_length(self): + desc = _desc(constraints={"max_length": 3}) + assert check_parameter_value(desc, "abc") is None + assert check_parameter_value(desc, "abcd").code == VIOLATION_MAX_LENGTH + + def test_regex_match(self): + # Mirrors the opcua_write endpoint parameter in the catalog. + desc = _desc(constraints={"min_length": 1, "regex": r"^opc\.tcp://.+"}) + assert check_parameter_value(desc, "opc.tcp://plc-01:4840") is None + violation = check_parameter_value(desc, "http://plc-01:4840") + assert violation is not None + assert violation.code == VIOLATION_REGEX + + def test_length_applies_to_code_and_model_ref(self): + for param_type in ("code", "model_ref"): + desc = _desc(param_type=param_type, constraints={"min_length": 1}) + assert check_parameter_value(desc, "").code == VIOLATION_MIN_LENGTH + assert check_parameter_value(desc, "value") is None + + def test_unicode_length(self): + desc = _desc(constraints={"min_length": 2, "max_length": 4}) + assert check_parameter_value(desc, "日本語") is None + assert check_parameter_value(desc, "日").code == VIOLATION_MIN_LENGTH + + def test_empty_constraints_accept_any_string(self): + assert check_parameter_value(_desc(constraints={}), "") is None + + +# -------------------------------------------------------------------------- +# Catalog cross-check: every catalog default satisfies its own descriptor +# -------------------------------------------------------------------------- + +class TestCatalogDefaults: + def test_all_catalog_defaults_are_valid(self): + from workflow_core.catalog import NODE_CATALOG + + for node_type in NODE_CATALOG: + for parameter in node_type.parameters: + if parameter.default is None: + continue + violation = check_parameter_value(parameter, parameter.default) + assert violation is None, ( + "{0}.{1} default {2!r} violates its own constraints: {3}".format( + node_type.type_id, parameter.name, + parameter.default, violation, + ) + ) diff --git a/edge-cv-portal/backend/requirements-dev.txt b/edge-cv-portal/backend/requirements-dev.txt new file mode 100644 index 00000000..f2119ee2 --- /dev/null +++ b/edge-cv-portal/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +# Development / test-only dependencies for the portal backend. +# Runtime Lambda dependencies live in requirements.txt and the layer +# requirements files; nothing here is packaged into a deployment artifact. +pytest==8.4.2 +moto[s3,dynamodb]==5.1.22 diff --git a/edge-cv-portal/backend/tests/conftest.py b/edge-cv-portal/backend/tests/conftest.py new file mode 100644 index 00000000..3c831560 --- /dev/null +++ b/edge-cv-portal/backend/tests/conftest.py @@ -0,0 +1,474 @@ +""" +Shared pytest fixtures for portal backend integration tests. + +Provides a moto-backed AWS environment (DynamoDB tables, GSIs, and the +portal artifacts S3 bucket) plus the real `shared_utils` and +`workflow_core` Lambda layers on sys.path, so handler modules such as +functions/workflows.py can be imported and invoked with synthetic API +Gateway events, exercising the real RBAC / audit / persistence code +paths against local (in-memory) AWS. + +Notes on module isolation: some standalone tests in this directory +(e.g. test_captures.py) install a *fake* `shared_utils` into +sys.modules at collection time. The session fixture below therefore +pops any previously imported `shared_utils` / `workflows` modules and +re-imports the real ones from the layer paths while the moto mock is +active, without disturbing modules (like `captures`) that already +bound their imports. +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_BACKEND = os.path.abspath(os.path.join(_HERE, "..")) +_FUNCTIONS_DIR = os.path.join(_BACKEND, "functions") +_SHARED_LAYER = os.path.join(_BACKEND, "layers", "shared", "python") +_WORKFLOW_CORE_LAYER = os.path.join(_BACKEND, "layers", "workflow_core", "python") + +REGION = "us-east-1" + +# Table / bucket names used by the moto-backed test stack. +TEST_ENV = { + # Fake credentials so boto3 can never reach real AWS. + "AWS_ACCESS_KEY_ID": "testing", + "AWS_SECRET_ACCESS_KEY": "testing", + "AWS_SECURITY_TOKEN": "testing", + "AWS_SESSION_TOKEN": "testing", + "AWS_DEFAULT_REGION": REGION, + "AWS_REGION": REGION, + # shared_utils tables + "USECASES_TABLE": "test-usecases", + "USER_ROLES_TABLE": "test-user-roles", + "AUDIT_LOG_TABLE": "test-audit-log", + "DEPLOYMENTS_TABLE": "test-deployments", + # devices.py / deployments.py (custom-node-designer task 10.5): + # portal-recorded device attributes (test_device, target_architecture) + "DEVICES_TABLE": "test-devices", + # workflows.py tables / bucket + "WORKFLOWS_TABLE": "test-workflows", + "WORKFLOW_VERSIONS_TABLE": "test-workflow-versions", + "PORTAL_ARTIFACTS_BUCKET": "test-portal-artifacts", + "WORKFLOWS_S3_PREFIX": "workflows", + # plugin_records.py (custom-node-designer) + "PLUGIN_RECORDS_TABLE": "test-plugin-records", + "PLUGIN_SOURCES_S3_PREFIX": "plugin-sources", + # plugin_importer.py (custom-node-designer) + "FETCH_PROJECT_NAME": "dda-plugin-fetch", + "MODULE_INDEX_CACHE_TABLE": "test-module-index-cache", + # custom_node_types.py (custom-node-designer) + "CUSTOM_NODE_TYPES_TABLE": "test-custom-node-types", + # plugin_simulator.py (custom-node-designer) + "SIMULATION_RUNS_TABLE": "test-simulation-runs", + # Distinct from the "test-test-datasets" table test_workflow_testing_ + # errors.py creates for itself (that module re-points the env var and + # re-imports workflow_testing inside its own fixture). + "TEST_DATASETS_TABLE": "test-simulator-datasets", + "PLUGIN_SIMULATIONS_PREFIX": "plugin-simulations", + # SIMULATOR_STATE_MACHINE_ARN is set inside the aws_stack fixture (the + # moto state machine only exists once the mock is active). + # plugin_builds.py (custom-node-designer) + "PLUGIN_STAGING_PREFIX": "plugin-staging", + "PLUGIN_LIBRARY_CUSTOM_PREFIX": "workflow-plugins/custom", + "PLUGIN_COMPONENTS_FUNCTION_NAME": "test-plugin-components", + "BUILD_PROJECTS_JSON": json.dumps({ + arch: f"dda-plugin-build-{arch}" + for arch in ("x86_64", "x86_64_nvidia", + "arm64_jp4", "arm64_jp5", "arm64_jp6") + }), + # PLUGIN_SIGNING_KEY_ARN is set inside the aws_stack fixture (the + # moto KMS key only exists once the mock is active). +} + +# Environment must be in place before shared_utils / workflows are +# imported (both read table names at module import time). +os.environ.update(TEST_ENV) + +for _path in (_SHARED_LAYER, _FUNCTIONS_DIR): + if _path not in sys.path: + sys.path.insert(0, _path) + +# The workflow_core layer directory also carries the layer's vendored +# Lambda-runtime dependencies (CPython 3.11 manylinux wheels, e.g. +# jsonschema's rpds). Appended rather than prepended so they never +# shadow the host interpreter's own packages during local test runs. +if _WORKFLOW_CORE_LAYER not in sys.path: + sys.path.append(_WORKFLOW_CORE_LAYER) + + +def _create_tables(dynamodb): + """Create the DynamoDB tables (and GSIs) the workflow stack uses.""" + dynamodb.create_table( + TableName=TEST_ENV["WORKFLOWS_TABLE"], + KeySchema=[{"AttributeName": "workflow_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "workflow_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-workflows-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "created_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["WORKFLOW_VERSIONS_TABLE"], + KeySchema=[ + {"AttributeName": "workflow_id", "KeyType": "HASH"}, + {"AttributeName": "version", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "workflow_id", "AttributeType": "S"}, + {"AttributeName": "version", "AttributeType": "N"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["DEPLOYMENTS_TABLE"], + KeySchema=[{"AttributeName": "deployment_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "deployment_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-deployments-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "created_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["USECASES_TABLE"], + KeySchema=[{"AttributeName": "usecase_id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "usecase_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["DEVICES_TABLE"], + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["USER_ROLES_TABLE"], + KeySchema=[ + {"AttributeName": "user_id", "KeyType": "HASH"}, + {"AttributeName": "usecase_id", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "user_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["PLUGIN_RECORDS_TABLE"], + KeySchema=[ + {"AttributeName": "plugin_id", "KeyType": "HASH"}, + {"AttributeName": "version", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "plugin_id", "AttributeType": "S"}, + {"AttributeName": "version", "AttributeType": "N"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-plugins-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "created_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["CUSTOM_NODE_TYPES_TABLE"], + KeySchema=[ + {"AttributeName": "node_type_id", "KeyType": "HASH"}, + {"AttributeName": "version", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "node_type_id", "AttributeType": "S"}, + {"AttributeName": "version", "AttributeType": "N"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-node-types-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "node_type_id", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["MODULE_INDEX_CACHE_TABLE"], + KeySchema=[{"AttributeName": "cache_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "cache_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["SIMULATION_RUNS_TABLE"], + KeySchema=[{"AttributeName": "run_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "run_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "started_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-runs-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "started_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["TEST_DATASETS_TABLE"], + KeySchema=[{"AttributeName": "dataset_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "dataset_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-datasets-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "created_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + dynamodb.create_table( + TableName=TEST_ENV["AUDIT_LOG_TABLE"], + KeySchema=[ + {"AttributeName": "event_id", "KeyType": "HASH"}, + {"AttributeName": "timestamp", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "event_id", "AttributeType": "S"}, + {"AttributeName": "timestamp", "AttributeType": "N"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + + +@pytest.fixture(scope="session") +def aws_stack(): + """moto-backed AWS with the workflow tables, bucket, and real modules.""" + from moto import mock_aws + + with mock_aws(): + import boto3 + + dynamodb = boto3.client("dynamodb", region_name=REGION) + s3 = boto3.client("s3", region_name=REGION) + _create_tables(dynamodb) + s3.create_bucket(Bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"]) + + # plugin_builds.py: the asymmetric portal signing key (ECDSA + # P-256) and the five per-arch CodeBuild projects. + kms = boto3.client("kms", region_name=REGION) + signing_key = kms.create_key( + KeySpec="ECC_NIST_P256", KeyUsage="SIGN_VERIFY", + Description="test plugin signing key", + ) + os.environ["PLUGIN_SIGNING_KEY_ARN"] = signing_key["KeyMetadata"]["Arn"] + + codebuild = boto3.client("codebuild", region_name=REGION) + # The per-arch build projects plus plugin_importer.py's + # lightweight repository-fetch project (async import StartBuild). + project_names = list(json.loads(TEST_ENV["BUILD_PROJECTS_JSON"]).values()) + project_names.append(TEST_ENV["FETCH_PROJECT_NAME"]) + for project_name in project_names: + codebuild.create_project( + name=project_name, + source={"type": "S3", + "location": f"{TEST_ENV['PORTAL_ARTIFACTS_BUCKET']}/plugin-sources/"}, + artifacts={"type": "NO_ARTIFACTS"}, + environment={"type": "LINUX_CONTAINER", "image": "test-image", + "computeType": "BUILD_GENERAL1_LARGE"}, + serviceRole="arn:aws:iam::123456789012:role/service-role/test-build-role", + ) + + # plugin_simulator.py: the Plugin_Simulator state machine (task 8.2). + # A trivial pass-state definition suffices for StartExecution / + # DescribeExecution against moto. + stepfunctions = boto3.client("stepfunctions", region_name=REGION) + simulator_machine = stepfunctions.create_state_machine( + name="dda-plugin-simulator", + definition=json.dumps({ + "StartAt": "Noop", + "States": {"Noop": {"Type": "Pass", "End": True}}, + }), + roleArn="arn:aws:iam::123456789012:role/service-role/test-sfn-role", + ) + os.environ["SIMULATOR_STATE_MACHINE_ARN"] = simulator_machine["stateMachineArn"] + + # Import the real layer + handler modules inside the mock so + # their module-level boto3 clients are intercepted by moto. + # Pop any fake shared_utils installed by standalone tests. + # node_catalog_resolution is imported at module scope (i.e. at + # collection time, before this mock is active) by + # test_property_catalog_membership.py; its module-level boto3 + # client would otherwise stay cached in sys.modules and poison + # every later consumer (workflow_validation, workflow_generator, + # workflow_packaging) imported inside the mock. + for module_name in ("workflows", "plugin_records", "plugin_importer", + "plugin_builds", "plugin_simulator", + "custom_node_types", "plugin_components", + "workflow_packaging", "node_catalog_resolution", + "shared_utils"): + sys.modules.pop(module_name, None) + import workflows # noqa: F401 + import plugin_records # noqa: F401 + import plugin_importer # noqa: F401 + import plugin_builds # noqa: F401 + import plugin_simulator # noqa: F401 + import custom_node_types # noqa: F401 + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + workflows=workflows, + plugin_records=plugin_records, + plugin_importer=plugin_importer, + plugin_builds=plugin_builds, + plugin_simulator=plugin_simulator, + custom_node_types=custom_node_types, + s3=s3, + kms=kms, + codebuild=codebuild, + stepfunctions=stepfunctions, + tables=SimpleNamespace( + workflows=resource.Table(TEST_ENV["WORKFLOWS_TABLE"]), + versions=resource.Table(TEST_ENV["WORKFLOW_VERSIONS_TABLE"]), + deployments=resource.Table(TEST_ENV["DEPLOYMENTS_TABLE"]), + devices=resource.Table(TEST_ENV["DEVICES_TABLE"]), + usecases=resource.Table(TEST_ENV["USECASES_TABLE"]), + user_roles=resource.Table(TEST_ENV["USER_ROLES_TABLE"]), + plugin_records=resource.Table(TEST_ENV["PLUGIN_RECORDS_TABLE"]), + custom_node_types=resource.Table(TEST_ENV["CUSTOM_NODE_TYPES_TABLE"]), + module_index_cache=resource.Table(TEST_ENV["MODULE_INDEX_CACHE_TABLE"]), + simulation_runs=resource.Table(TEST_ENV["SIMULATION_RUNS_TABLE"]), + test_datasets=resource.Table(TEST_ENV["TEST_DATASETS_TABLE"]), + audit_log=resource.Table(TEST_ENV["AUDIT_LOG_TABLE"]), + ), + ) + + +class WorkflowStoreEnv: + """Helper facade for invoking the Workflow_Store API in tests. + + Each test gets fresh uuid-based use case and user ids, so tests are + isolated from one another without table truncation. + """ + + def __init__(self, stack): + self.stack = stack + self.workflows = stack.workflows + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + # ------------------------------------------------------------- setup + def create_usecase(self, name="Test Use Case"): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": name, + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="DataScientist"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + def put_deployment(self, usecase_id, status="IN_PROGRESS", **attrs): + deployment_id = f"dep-{uuid.uuid4()}" + item = { + "deployment_id": deployment_id, + "usecase_id": usecase_id, + "created_at": 1, + "deployment_status": status, + } + item.update(attrs) + self.stack.tables.deployments.put_item(Item=item) + return deployment_id + + # ----------------------------------------------------------- invoke + def event(self, method, resource, user, workflow_id=None, body=None, query=None): + return { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", workflow_id or ""), + "pathParameters": {"id": workflow_id} if workflow_id else None, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + def invoke(self, method, resource, user, workflow_id=None, body=None, query=None): + """Invoke the handler; returns (status_code, parsed_body).""" + response = self.workflows.handler( + self.event(method, resource, user, workflow_id, body, query), None + ) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def env(aws_stack): + return WorkflowStoreEnv(aws_stack) + + +# --------------------------------------------------------------------------- +# Hypothesis profile: cap property tests at 25 examples for fast local runs. +# Per-test @settings(max_examples=...) decorators take precedence; keep them +# at or below this budget. Override with HYPOTHESIS_PROFILE=ci for a larger +# run. +# --------------------------------------------------------------------------- +from hypothesis import settings as _hyp_settings # noqa: E402 + +_hyp_settings.register_profile("portal-fast", max_examples=25) +_hyp_settings.register_profile("ci", max_examples=100) +_hyp_settings.load_profile(os.environ.get("HYPOTHESIS_PROFILE", "portal-fast")) diff --git a/edge-cv-portal/backend/tests/test_account_sync_ack.py b/edge-cv-portal/backend/tests/test_account_sync_ack.py new file mode 100644 index 00000000..b56f14a3 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_account_sync_ack.py @@ -0,0 +1,473 @@ +""" +Unit tests for the account_sync.py ack ingest and timeout sweep +(spec: portal-user-manager, task 3.5). + +Covers: SQS ack ingest (partial-batch-failure pattern) - a reported +ackSyncId matching the row's current syncId marks it success with +lastSyncAt = appliedAt and clears pendingChanges (Reqs 7.4, 7.5); a +reported error marks failed with the device's reason, pending retained; +stale acks are discarded without state change; malformed records are +dead-lettered (or reported for redelivery when no DLQ is configured), +never silently lost. Timeout sweep on the 5-minute schedule - +in_progress rows with attemptAt older than 60 s and no ack are marked +failed / device unreachable with pending changes retained (Reqs 7.6, +7.9). + +DynamoDB and SQS run against real moto (aws_stack conftest fixture); +the iot-data client is a recording fake installed over the module's +iot_data_client factory. + +_Requirements: 7.4, 7.5, 7.6, 7.9_ +""" +import json +import os +import sys +import time + +import pytest + +REGION = "us-east-1" +ACCOUNT_SYNC_TABLE = "test-account-sync" + + +class FakeIotDataClient: + """Records update_thing_shadow writes (schedule-path tests).""" + + def __init__(self): + self.updates = [] + + def update_thing_shadow(self, thingName, shadowName, payload): + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {"payload": b"{}"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def account_sync(aws_stack): + """The real account_sync module imported inside the moto mock, with + the account-sync table created.""" + import boto3 + + os.environ["ACCOUNT_SYNC_TABLE"] = ACCOUNT_SYNC_TABLE + + ddb = boto3.client("dynamodb", region_name=REGION) + if ACCOUNT_SYNC_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=ACCOUNT_SYNC_TABLE, + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("account_sync", None) + import account_sync as module + return module + + +@pytest.fixture +def sync_table(account_sync): + """The moto-backed sync-state table, emptied per test.""" + import boto3 + + table = boto3.resource( + "dynamodb", region_name=REGION).Table(ACCOUNT_SYNC_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"device_id": item["device_id"]}) + return table + + +@pytest.fixture +def install_iot(account_sync, monkeypatch): + def _install(): + fake = FakeIotDataClient() + monkeypatch.setattr(account_sync, "iot_data_client", + lambda device_id: fake) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def now_ms(): + return int(time.time() * 1000) + + +def staged_row(device_id, sync_id="sync-1", accounts=None, + pending=True, status="pending", **extra): + row = { + "device_id": device_id, + "syncId": sync_id, + "accounts": accounts if accounts is not None else { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}, + }, + "status": status, + "pendingChanges": pending, + "stagedAt": 1700000000000, + } + row.update(extra) + return row + + +def ack_record(thing_name, reported, message_id="m-1"): + """One SQS record shaped like the shadow-documents topic rule output.""" + body = { + "thing_name": thing_name, + "current": {"state": {"reported": reported}}, + } + return {"eventSource": "aws:sqs", "messageId": message_id, + "body": json.dumps(body)} + + +def sqs_event(*records): + return {"Records": list(records)} + + +def schedule_event(): + return {"source": "aws.events", "detail-type": "Scheduled Event", + "detail": {}} + + +# --------------------------------------------- matching acks (Reqs 7.4, 7.5) + +class TestAckSuccess: + def test_matching_ack_marks_success_and_clears_pending( + self, account_sync, sync_table): + """Req 7.4: an ackSyncId equal to the row's current syncId marks + the row success with lastSyncAt = appliedAt and clears + pendingChanges.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-42", status="in_progress", + attemptAt=now_ms())) + + result = account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-42", + "appliedAt": 1700000123456, + "accountCount": 1})), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "success" + assert row["lastSyncAt"] == 1700000123456 + assert row["pendingChanges"] is False + assert row["syncId"] == "sync-42" + + def test_matching_ack_clears_a_previous_failure_reason( + self, account_sync, sync_table): + """A retried sync that finally acks leaves no stale failure + reason on the row.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-9", status="in_progress", + attemptAt=now_ms(), failureReason="device unreachable")) + + account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-9", "appliedAt": 1700000000001, + "accountCount": 1})), None) + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "success" + assert "failureReason" not in row + + def test_zero_change_sync_ack_reports_success( + self, account_sync, sync_table): + """Req 7.5: a sync with zero account changes acks the same way + and is reported successful.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-0", accounts={}, + status="in_progress", attemptAt=now_ms())) + + result = account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-0", "appliedAt": 1700000000002, + "accountCount": 0})), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "success" + assert row["pendingChanges"] is False + + def test_duplicate_ack_delivery_is_idempotent( + self, account_sync, sync_table): + """SQS may deliver the same documents event more than once.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-42", status="in_progress", + attemptAt=now_ms())) + reported = {"ackSyncId": "sync-42", "appliedAt": 1700000123456, + "accountCount": 1} + + account_sync.handler(sqs_event(ack_record("edge-1", reported)), None) + result = account_sync.handler( + sqs_event(ack_record("edge-1", reported, "m-2")), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "success" + assert row["lastSyncAt"] == 1700000123456 + + +# ------------------------------------------------- error acks (Req 7.6) + +class TestAckError: + def test_reported_error_marks_failed_with_the_devices_reason( + self, account_sync, sync_table): + """A device validation failure marks the row failed with the + device's reason; the staged set and pendingChanges are retained + for retry (Req 7.6).""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-7", status="in_progress", + attemptAt=now_ms())) + + result = account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-7", + "error": "unsupported document version 99"})), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "failed" + assert row["failureReason"] == "unsupported document version 99" + assert row["pendingChanges"] is True + assert set(row["accounts"]) == {"op1"} + assert "lastSyncAt" not in row + + +# ------------------------------------------------------ stale acks + +class TestStaleAcks: + def test_stale_ack_is_discarded_without_state_change( + self, account_sync, sync_table): + """An ack for a superseded syncId never touches the row: the + newer staged sync stays pending/in flight.""" + before = staged_row( + "edge-1", sync_id="sync-new", status="in_progress", + attemptAt=1700000000000) + sync_table.put_item(Item=before) + + result = account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-old", + "appliedAt": 1700000123456, "accountCount": 1})), + None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert row["pendingChanges"] is True + assert "lastSyncAt" not in row + + def test_stale_error_ack_is_discarded_too( + self, account_sync, sync_table): + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-new", status="in_progress", + attemptAt=1700000000000)) + + account_sync.handler(sqs_event(ack_record( + "edge-1", {"ackSyncId": "sync-old", "error": "bad doc"})), + None) + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert "failureReason" not in row + + def test_ack_for_an_unknown_device_is_discarded( + self, account_sync, sync_table): + result = account_sync.handler(sqs_event(ack_record( + "ghost-device", {"ackSyncId": "sync-1", + "appliedAt": 1, "accountCount": 0})), None) + + assert result["batchItemFailures"] == [] + assert "Item" not in sync_table.get_item( + Key={"device_id": "ghost-device"}) + + +# --------------------------------------- non-ack and malformed records + +class TestRecordHygiene: + def test_desired_only_documents_event_is_ignored( + self, account_sync, sync_table): + """Our own desired writes fire the documents topic too; events + without a reported state carry nothing to ingest.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-1", status="in_progress", + attemptAt=now_ms())) + body = {"thing_name": "edge-1", + "current": {"state": {"desired": {"syncId": "sync-1"}}}} + + result = account_sync.handler(sqs_event( + {"eventSource": "aws:sqs", "messageId": "m-1", + "body": json.dumps(body)}), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + + def test_reported_state_without_ack_sync_id_is_ignored( + self, account_sync, sync_table): + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-1", status="in_progress", + attemptAt=now_ms())) + + result = account_sync.handler(sqs_event(ack_record( + "edge-1", {"somethingElse": True})), None) + + assert result["batchItemFailures"] == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + + def test_malformed_record_is_dead_lettered_not_redelivered( + self, account_sync, sync_table, monkeypatch): + """Malformed records are logged and dead-lettered, never + reported as batch failures (endless redelivery).""" + import boto3 + + sqs = boto3.client("sqs", region_name=REGION) + dlq_url = sqs.create_queue( + QueueName="test-account-sync-ack-dlq")["QueueUrl"] + monkeypatch.setenv("ACCOUNT_SYNC_ACK_DLQ_URL", dlq_url) + + result = account_sync.handler(sqs_event( + {"eventSource": "aws:sqs", "messageId": "m-bad", + "body": "not json at all"}), None) + + assert result["batchItemFailures"] == [] + messages = sqs.receive_message( + QueueUrl=dlq_url, MaxNumberOfMessages=10).get("Messages", []) + assert len(messages) == 1 + assert messages[0]["Body"] == "not json at all" + + def test_malformed_record_without_dlq_is_kept_for_redelivery( + self, account_sync, sync_table, monkeypatch): + """When no DLQ is configured the record must not be lost: it is + reported as a batch item failure instead.""" + monkeypatch.delenv("ACCOUNT_SYNC_ACK_DLQ_URL", raising=False) + + result = account_sync.handler(sqs_event( + {"eventSource": "aws:sqs", "messageId": "m-bad", + "body": json.dumps({"current": {}})}), None) + + assert result["batchItemFailures"] == [ + {"itemIdentifier": "m-bad"}] + + def test_transient_failure_reports_only_the_affected_record( + self, account_sync, sync_table, monkeypatch): + """A persistence error on one record retries that record only; + the rest of the batch still processes (partial batch + response).""" + sync_table.put_item(Item=staged_row( + "edge-ok", sync_id="s-ok", status="in_progress", + attemptAt=now_ms())) + + original = account_sync._ingest_ack + + def flaky(device_id, reported): + if device_id == "edge-down": + raise RuntimeError("dynamodb unavailable") + return original(device_id, reported) + + monkeypatch.setattr(account_sync, "_ingest_ack", flaky) + + result = account_sync.handler(sqs_event( + ack_record("edge-down", + {"ackSyncId": "s-x", "appliedAt": 1, + "accountCount": 0}, "m-down"), + ack_record("edge-ok", + {"ackSyncId": "s-ok", "appliedAt": 2, + "accountCount": 1}, "m-ok"), + ), None) + + assert result["batchItemFailures"] == [ + {"itemIdentifier": "m-down"}] + row = sync_table.get_item(Key={"device_id": "edge-ok"})["Item"] + assert row["status"] == "success" + + +# ----------------------------------------- timeout sweep (Reqs 7.6, 7.9) + +class TestTimeoutSweep: + def test_in_progress_older_than_60s_is_marked_device_unreachable( + self, account_sync, sync_table): + """Req 7.9: no ack within 60 s of the attempt -> failed with + reason 'device unreachable'; Req 7.6: the staged set and + pendingChanges are retained.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-1", status="in_progress", + attemptAt=now_ms() - 120_000)) + + account_sync._sweep_timeouts() + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "failed" + assert row["failureReason"] == "device unreachable" + assert row["pendingChanges"] is True + assert set(row["accounts"]) == {"op1"} + assert row["syncId"] == "sync-1" + + def test_recent_in_progress_rows_are_left_alone( + self, account_sync, sync_table): + """An attempt still inside the 60 s ack window is not a + timeout.""" + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-1", status="in_progress", + attemptAt=now_ms() - 5_000)) + + account_sync._sweep_timeouts() + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert "failureReason" not in row + + def test_non_in_progress_rows_are_never_swept( + self, account_sync, sync_table): + """Acked (success) and already-failed rows are untouched no + matter how old their attemptAt is.""" + sync_table.put_item(Item=staged_row( + "edge-ok", status="success", pending=False, + attemptAt=now_ms() - 900_000, lastSyncAt=1700000000000)) + sync_table.put_item(Item=staged_row( + "edge-failed", status="failed", + failureReason="shadow write failed: down", + attemptAt=now_ms() - 900_000)) + + account_sync._sweep_timeouts() + + ok = sync_table.get_item(Key={"device_id": "edge-ok"})["Item"] + assert ok["status"] == "success" + failed = sync_table.get_item( + Key={"device_id": "edge-failed"})["Item"] + assert failed["failureReason"] == "shadow write failed: down" + + def test_schedule_sweeps_timeouts_before_attempting_delivery( + self, account_sync, sync_table, install_iot): + """The 5-minute schedule runs the sweep, then retries the (still + pending) timed-out device in the same pass (Reqs 7.7, 7.9).""" + iot = install_iot() + old_attempt = now_ms() - 120_000 + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-1", status="in_progress", + attemptAt=old_attempt)) + # A timed-out row that the attempt pass will skip (no pending + # changes) exposes the sweep's marking through the schedule + # entry itself. + sync_table.put_item(Item=staged_row( + "edge-2", sync_id="sync-2", status="in_progress", + pending=False, attemptAt=old_attempt)) + + result = account_sync.handler(schedule_event(), None) + + # edge-1: swept to failed/unreachable, then re-attempted because + # pending changes were retained - it ends in_progress with a + # fresh attemptAt and the shadow written again (Req 7.7). + assert result["attempted"] == 1 + assert len(iot.updates) == 1 + assert iot.updates[0]["thing_name"] == "edge-1" + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert row["attemptAt"] > old_attempt + + # edge-2: swept to failed/unreachable and not re-attempted. + swept = sync_table.get_item(Key={"device_id": "edge-2"})["Item"] + assert swept["status"] == "failed" + assert swept["failureReason"] == "device unreachable" diff --git a/edge-cv-portal/backend/tests/test_account_sync_attempt.py b/edge-cv-portal/backend/tests/test_account_sync_attempt.py new file mode 100644 index 00000000..5389707d --- /dev/null +++ b/edge-cv-portal/backend/tests/test_account_sync_attempt.py @@ -0,0 +1,348 @@ +""" +Unit tests for the account_sync.py sync-attempt entry +(spec: portal-user-manager, task 3.4). + +Covers: direct invoke ({action: 'sync_attempt', device_id, syncId}) +building the desired document from the staged set and writing the +dda-user-accounts named shadow, the row stamped in_progress with +attemptAt; shadow-write failure and size-limit violation marked failed +with the reason and pending changes retained (Req 7.6); the EventBridge +5-minute schedule attempting delivery for every device with pending +changes (Req 7.7); SQS-shaped events routed away from the attempt path +(ack ingest is task 3.5). + +DynamoDB runs against real moto (aws_stack conftest fixture); the +iot-data client is a recording fake installed over the module's +iot_data_client factory. + +_Requirements: 7.6, 7.7_ +""" +import json +import os +import sys + +import pytest + +REGION = "us-east-1" +ACCOUNT_SYNC_TABLE = "test-account-sync" +SHADOW_NAME = "dda-user-accounts" + + +# ------------------------------------------------- fake iot-data client + +class FakeIotDataClient: + """Records update_thing_shadow writes; optionally fails per thing.""" + + def __init__(self, fail_for=None, error=None): + self.updates = [] + self.fail_for = set(fail_for or []) + self.error = error or RuntimeError("shadow service unavailable") + + def update_thing_shadow(self, thingName, shadowName, payload): + if thingName in self.fail_for: + raise self.error + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {"payload": b"{}"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def account_sync(aws_stack): + """The real account_sync module imported inside the moto mock, with + the account-sync table created.""" + import boto3 + + os.environ["ACCOUNT_SYNC_TABLE"] = ACCOUNT_SYNC_TABLE + + ddb = boto3.client("dynamodb", region_name=REGION) + if ACCOUNT_SYNC_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=ACCOUNT_SYNC_TABLE, + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("account_sync", None) + import account_sync as module + return module + + +@pytest.fixture +def sync_table(account_sync): + """The moto-backed sync-state table, emptied per test.""" + import boto3 + + table = boto3.resource( + "dynamodb", region_name=REGION).Table(ACCOUNT_SYNC_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"device_id": item["device_id"]}) + return table + + +@pytest.fixture +def install_iot(account_sync, monkeypatch): + def _install(fail_for=None, error=None): + fake = FakeIotDataClient(fail_for=fail_for, error=error) + monkeypatch.setattr(account_sync, "iot_data_client", + lambda device_id: fake) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def staged_row(device_id, sync_id="sync-1", accounts=None, + pending=True, status="pending", **extra): + row = { + "device_id": device_id, + "syncId": sync_id, + "accounts": accounts if accounts is not None else { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}, + }, + "status": status, + "pendingChanges": pending, + "stagedAt": 1700000000000, + } + row.update(extra) + return row + + +def direct_invoke(account_sync, device_id, sync_id="sync-1"): + return account_sync.handler( + {"action": "sync_attempt", "device_id": device_id, + "syncId": sync_id}, None) + + +def schedule_event(): + return {"source": "aws.events", "detail-type": "Scheduled Event", + "detail": {}} + + +# ----------------------------------------------------- direct sync attempt + +class TestDirectInvoke: + def test_writes_desired_shadow_and_stamps_in_progress( + self, account_sync, sync_table, install_iot): + """The desired document is built from the staged set and written + to the dda-user-accounts named shadow; the row is stamped + in_progress with attemptAt, pending changes retained until the + ack (task 3.5).""" + iot = install_iot() + accounts = { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True, + "verifier": {"algorithm": "pbkdf2-sha256", + "iterations": 10, "salt": "c2FsdA==", + "hash": "aGFzaA=="}}, + "disabled-viewer": {"email": "v@example.com", "role": "Viewer", + "enabled": False}, + } + sync_table.put_item(Item=staged_row( + "edge-1", sync_id="sync-42", accounts=accounts)) + + result = direct_invoke(account_sync, "edge-1", "sync-42") + + assert result["status"] == "in_progress" + assert result["syncId"] == "sync-42" + + assert len(iot.updates) == 1 + update = iot.updates[0] + assert update["thing_name"] == "edge-1" + assert update["shadow_name"] == SHADOW_NAME + desired = update["payload"]["state"]["desired"] + assert desired["syncId"] == "sync-42" + assert desired["version"] == 1 + assert set(desired["accounts"]) == {"op1", "disabled-viewer"} + assert desired["accounts"]["op1"]["verifier"]["iterations"] == 10 + # Disabled account carried marked enabled=false, never dropped + assert desired["accounts"]["disabled-viewer"]["enabled"] is False + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert row["attemptAt"] > 0 + assert row["pendingChanges"] is True + assert row["syncId"] == "sync-42" + + def test_uses_the_rows_current_sync_id_over_a_stale_invoke( + self, account_sync, sync_table, install_iot): + """An attribute-change hook may refresh the staged set between + staging and the async invoke: the row's current syncId and + content are what get delivered.""" + iot = install_iot() + sync_table.put_item(Item=staged_row("edge-1", sync_id="fresh-sync")) + + result = direct_invoke(account_sync, "edge-1", "stale-sync") + + assert result["syncId"] == "fresh-sync" + assert iot.updates[0]["payload"]["state"]["desired"]["syncId"] == \ + "fresh-sync" + + def test_no_staged_row_is_skipped_without_shadow_write( + self, account_sync, sync_table, install_iot): + iot = install_iot() + result = direct_invoke(account_sync, "ghost-device") + assert result["status"] == "skipped" + assert iot.updates == [] + + def test_no_pending_changes_is_skipped_without_shadow_write( + self, account_sync, sync_table, install_iot): + """A row already delivered (ack cleared pendingChanges) is not + re-attempted by a late direct invoke.""" + iot = install_iot() + sync_table.put_item(Item=staged_row( + "edge-1", pending=False, status="success")) + result = direct_invoke(account_sync, "edge-1") + assert result["status"] == "skipped" + assert iot.updates == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "success" + + +# ------------------------------------------------- failure paths (Req 7.6) + +class TestAttemptFailures: + def test_shadow_write_failure_marks_failed_pending_retained( + self, account_sync, sync_table, install_iot): + """Req 7.6: a shadow-write failure marks the row failed with the + reason; the staged set and pendingChanges are retained for the + scheduled retry.""" + install_iot(fail_for={"edge-1"}) + sync_table.put_item(Item=staged_row("edge-1", sync_id="sync-9")) + + result = direct_invoke(account_sync, "edge-1", "sync-9") + + assert result["status"] == "failed" + assert "shadow write failed" in result["reason"] + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "failed" + assert "shadow write failed" in row["failureReason"] + assert row["pendingChanges"] is True + assert row["syncId"] == "sync-9" + assert set(row["accounts"]) == {"op1"} + + def test_size_limit_violation_marks_failed_without_shadow_write( + self, account_sync, sync_table, install_iot): + """Req 7.6: a staged set whose rendered document exceeds the + 8 KB shadow limit fails with the explicit reason; nothing is + written to the shadow and pending changes are retained.""" + iot = install_iot() + oversized = { + f"user-{i:04d}": {"email": "x" * 60 + "@example.com", + "role": "Operator", "enabled": True} + for i in range(100) + } + sync_table.put_item(Item=staged_row( + "edge-1", accounts=oversized)) + + result = direct_invoke(account_sync, "edge-1") + + assert result["status"] == "failed" + assert "8 KB" in result["reason"] + assert iot.updates == [] + + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "failed" + assert "8 KB" in row["failureReason"] + assert row["pendingChanges"] is True + + def test_successful_retry_clears_a_previous_failure_reason( + self, account_sync, sync_table, install_iot): + install_iot() + sync_table.put_item(Item=staged_row( + "edge-1", status="failed", + failureReason="shadow write failed: down")) + + result = direct_invoke(account_sync, "edge-1") + + assert result["status"] == "in_progress" + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "in_progress" + assert "failureReason" not in row + + +# --------------------------------------------- scheduled sweep (Req 7.7) + +class TestScheduledSweep: + def test_schedule_attempts_every_device_with_pending_changes( + self, account_sync, sync_table, install_iot): + """Req 7.7: the 5-minute schedule attempts delivery for every + device with pendingChanges=true - and only those.""" + iot = install_iot() + sync_table.put_item(Item=staged_row("edge-1", sync_id="s1")) + sync_table.put_item(Item=staged_row( + "edge-2", sync_id="s2", status="failed", + failureReason="shadow write failed: down")) + sync_table.put_item(Item=staged_row( + "edge-3", sync_id="s3", pending=False, status="success")) + + result = account_sync.handler(schedule_event(), None) + + assert result["attempted"] == 2 + attempted = {u["thing_name"] for u in iot.updates} + assert attempted == {"edge-1", "edge-2"} + + for device_id in ("edge-1", "edge-2"): + row = sync_table.get_item( + Key={"device_id": device_id})["Item"] + assert row["status"] == "in_progress" + assert row["pendingChanges"] is True + untouched = sync_table.get_item( + Key={"device_id": "edge-3"})["Item"] + assert untouched["status"] == "success" + + def test_one_failing_device_never_blocks_the_others( + self, account_sync, sync_table, install_iot): + install_iot(fail_for={"edge-1"}) + sync_table.put_item(Item=staged_row("edge-1", sync_id="s1")) + sync_table.put_item(Item=staged_row("edge-2", sync_id="s2")) + + result = account_sync.handler(schedule_event(), None) + + assert result["attempted"] == 2 + by_device = {r["device_id"]: r for r in result["results"]} + assert by_device["edge-1"]["status"] == "failed" + assert by_device["edge-2"]["status"] == "in_progress" + + def test_schedule_with_nothing_pending_is_a_noop( + self, account_sync, sync_table, install_iot): + iot = install_iot() + result = account_sync.handler(schedule_event(), None) + assert result["attempted"] == 0 + assert iot.updates == [] + + +# ------------------------------------------------------- event routing + +class TestEventRouting: + def test_sqs_records_route_to_the_ack_path_not_the_attempt_path( + self, account_sync, sync_table, install_iot): + """SQS-shaped events (the ack ingest, task 3.5) never trigger + shadow writes; unprocessed records are reported for redelivery.""" + iot = install_iot() + sync_table.put_item(Item=staged_row("edge-1")) + + result = account_sync.handler({ + "Records": [{"eventSource": "aws:sqs", "messageId": "m-1", + "body": "{}"}], + }, None) + + assert result["batchItemFailures"] == [{"itemIdentifier": "m-1"}] + assert iot.updates == [] + row = sync_table.get_item(Key={"device_id": "edge-1"})["Item"] + assert row["status"] == "pending" + + def test_unrecognized_event_shape_is_skipped( + self, account_sync, sync_table, install_iot): + iot = install_iot() + result = account_sync.handler({"something": "else"}, None) + assert result["status"] == "skipped" + assert iot.updates == [] diff --git a/edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py b/edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py new file mode 100644 index 00000000..82c58d7d --- /dev/null +++ b/edge-cv-portal/backend/tests/test_adjust_revision_stale_read.py @@ -0,0 +1,1142 @@ +""" +Tests for the adjust-revision stale-read fix +(.kiro/specs/adjust-revision-stale-read-fix), covering BOTH wave-1 +task groups: + +- Task 1 — bug condition exploration (Property 1): the adjustment + paths write the record's revision mapping (arch_revisions / fetches) + with an update_item and then auto-start the queued builds in the + SAME invocation via plugin_builds.start_queued_builds. That + auto-start re-reads the record through + plugin_records.get_version_item, which issues a plain + (eventually-consistent) get_item. A stale read returns the pre-write + item, arch_source_prefix silently falls back to the flat + source_s3_prefix, and the CodeBuild sourceLocationOverride names the + WRONG source tree (bugfix.md 1.1/1.2). + +- Task 2 — preservation properties (Property 2): everything the fix + must NOT change — the default get_version_item read call shape + (3.5), start_queued_builds' idempotency/never-raise semantics (3.3), + arch_source_prefix resolution for mapped/unmapped/flat records + (3.1, 3.2), manual retries (3.4), and adjustment fetch-failure + isolation (3.6). These PASS on unfixed code, capturing the baseline. + +The stale read is simulated deterministically (design "Exploratory Bug +Condition Checking"): plugin_records.plugin_table is swapped for a proxy +whose get_item returns a frozen pre-write snapshot UNLESS +ConsistentRead=True is passed. Writes pass through to the real (moto) +table — the write itself is correct; only the read timing is wrong. + +EXPECTED OUTCOME ON UNFIXED CODE: every Task 1 test except the +retry-masking edge case FAILS (the failures are the counterexamples +that confirm the root cause; they become the fix-validation tests once +task 3.1 lands), while every Task 2 preservation test PASSES. + +Feature: adjust-revision-stale-read-fix, Property 1: Auto-Started +Builds Resolve the Post-Write Source Tree +Feature: adjust-revision-stale-read-fix, Property 2: All Other Reads +and Auto-Start Semantics Are Unchanged +""" +import contextlib +import copy +import json +import uuid + +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from conftest import TEST_ENV +from test_plugin_importer import (ImporterEnv, MESON_PLUGIN, + RecordingCodeBuild, REPO_URL) + +BUCKET = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] +FETCH_ARN_PREFIX = ImporterEnv.FETCH_BUILD_ARN_PREFIX +ALL_ARCHS = ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6"] + +# Task 2 (preservation): the configured per-arch CodeBuild projects and +# an architecture no project is configured for (3.3). +CONFIGURED_ARCHS = sorted(json.loads(TEST_ENV["BUILD_PROJECTS_JSON"])) +UNCONFIGURED_ARCH = "mips64" + + +# ===================================================================== +# Deterministic stale-read stub (isBugCondition from the design): +# get_item returns the pre-write item unless ConsistentRead=True. +# ===================================================================== + +class StaleReadTable: + """Proxy over the real (moto) plugin table. + + ``freeze`` captures the CURRENT item as the replica's lagging state. + Plain ``get_item`` calls on a frozen key return that snapshot + forever (the eventually-consistent read landing on a stale replica); + ``ConsistentRead=True`` reads — and every write — pass through to + the real table. All get_item kwargs are recorded for the + ConsistentRead assertion. + """ + + def __init__(self, real_table): + self._real = real_table + self._stale = {} + self.get_item_calls = [] + + def freeze(self, plugin_id, version=1): + raw = self._real.get_item( + Key={"plugin_id": plugin_id, "version": version}, + ConsistentRead=True).get("Item") + self._stale[(plugin_id, version)] = copy.deepcopy(raw) + + def get_item(self, **kwargs): + self.get_item_calls.append(copy.deepcopy(kwargs)) + if kwargs.get("ConsistentRead"): + return self._real.get_item(**kwargs) + key = kwargs.get("Key") or {} + frozen = (key.get("plugin_id"), key.get("version")) + if frozen in self._stale: + item = self._stale[frozen] + return {"Item": copy.deepcopy(item)} if item is not None else {} + return self._real.get_item(**kwargs) + + def __getattr__(self, name): + return getattr(self._real, name) + + +@contextlib.contextmanager +def stale_read_stub(stack, plugin_id, version=1): + """Swap plugin_records.plugin_table for the stale-read proxy. + + plugin_records.get_version_item resolves plugin_table through the + plugin_records module globals at call time, so this intercepts the + auto-start's re-read no matter which module calls it. plugin_importer + and plugin_builds bound the ORIGINAL plugin_table function at import + time, so their direct update_item writes keep hitting the real table + (the write is correct; only the re-read goes stale). + """ + records = stack.plugin_records + stub = StaleReadTable(records.plugin_table()) + stub.freeze(plugin_id, version) + original = records.plugin_table + records.plugin_table = lambda: stub + try: + yield stub + finally: + records.plugin_table = original + + +@contextlib.contextmanager +def recording_codebuild(stack): + """Capture plugin_builds' StartBuild submissions.""" + builds = stack.plugin_builds + recorder = RecordingCodeBuild(builds.codebuild) + original = builds.codebuild + builds.codebuild = recorder + try: + yield recorder + finally: + builds.codebuild = original + + +# ===================================================================== +# Record / event helpers +# ===================================================================== + +def new_record_ids(stack): + """Fresh usecase + plugin ids and the record's flat source prefix.""" + usecase_id = f"uc-{uuid.uuid4()}" + stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Stale Read Exploration Use Case", + "account_id": "123456789012", + }) + plugin_id = f"plugin-{uuid.uuid4()}" + base = f"plugin-sources/{usecase_id}/{plugin_id}/1/" + return usecase_id, plugin_id, base + + +def put_imported_record(stack, usecase_id, plugin_id, *, artifacts, + requested, fetches=None, arch_revisions=None): + """Persist a settled repository-import Plugin_Record directly.""" + base = f"plugin-sources/{usecase_id}/{plugin_id}/1/" + item = { + "plugin_id": plugin_id, + "version": 1, + "usecase_id": usecase_id, + "name": "gst-plugins-good", + "kind": "imported", + "import_status": "imported", + "source_s3_prefix": base, + "requested_architectures": list(requested), + "artifacts": artifacts, + "provenance": {"repoUrl": REPO_URL, "revision": "default", + "importedBy": "user-import", "importedAt": 1, + "classification": "good"}, + "created_by": "user-import", + "created_at": 1, + "updated_at": 1, + } + if fetches is not None: + item["fetches"] = fetches + if arch_revisions is not None: + item["arch_revisions"] = arch_revisions + stack.tables.plugin_records.put_item(Item=item) + return item + + +def fresh_item(stack, plugin_id, version=1): + """Read the REAL table state (bypasses the stale-read stub).""" + return stack.tables.plugin_records.get_item( + Key={"plugin_id": plugin_id, "version": version}, + ConsistentRead=True).get("Item") + + +def make_admin(stack, usecase_id): + user_id = f"user-{uuid.uuid4()}" + stack.tables.user_roles.put_item(Item={ + "user_id": user_id, "usecase_id": usecase_id, + "role": "UseCaseAdmin", + }) + return {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": "Viewer"} + + +def api_event(method, resource, user, plugin_id, version, body): + return { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", plugin_id).replace("{v}", + str(version)), + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": json.dumps(body), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def adjustment_fetch_event(item, build_id, slug, status="SUCCEEDED"): + """EventBridge CodeBuild fetch result with REVISION_SLUG attribution.""" + return { + "source": "aws.codebuild", + "detail-type": "CodeBuild Build State Change", + "detail": { + "build-status": status, + "project-name": TEST_ENV["FETCH_PROJECT_NAME"], + "build-id": FETCH_ARN_PREFIX + build_id, + "additional-information": { + "environment": { + "environment-variables": [ + {"name": "USECASE_ID", "value": item["usecase_id"]}, + {"name": "PLUGIN_ID", "value": item["plugin_id"]}, + {"name": "PLUGIN_VERSION", + "value": str(item["version"])}, + {"name": "REVISION_SLUG", "value": slug}, + ], + }, + }, + }, + } + + +def arch_starts(recorder, arch): + return [call for call in recorder.calls + if call["projectName"] == f"dda-plugin-build-{arch}"] + + +# ===================================================================== +# Bug condition exploration (EXPECTED TO FAIL on unfixed code) +# ===================================================================== + +class TestAdjustRevisionStaleRead: + """Counterexample hunt for bugfix.md 1.1 / 1.2 / 1.3.""" + + def test_fetch_success_stale_read_builds_from_adjusted_tree( + self, aws_stack): + """Bug condition 1.1: an adjustment fetch succeeds, + _handle_adjustment_fetch_result writes arch_revisions[arch] and + auto-starts the queued build — the submission MUST name the + adjusted rev-{slug}/ tree even when the re-read is stale. + + Unfixed code submits the flat source_s3_prefix (the original + revision's tree). + """ + arch, slug, revision = "arm64_jp5", "1.16", "1.16" + build_id = "dda-plugin-fetch:adjust-fetch-1" + usecase_id, plugin_id, base = new_record_ids(aws_stack) + # State after adjust_revision's ADJUST_FETCH write: the arch is + # re-queued and the revision's fetch is in flight with the + # pending_archs marker. arch_revisions is NOT written yet. + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch, "x86_64"], + artifacts={arch: {"buildStatus": "queued"}, + "x86_64": {"buildStatus": "succeeded"}}, + fetches={slug: {"revision": revision, + "source_prefix": f"{base}rev-{slug}/", + "status": "fetching", + "pending_archs": [arch], + "fetch_build_id": build_id}}, + ) + + with stale_read_stub(aws_stack, item["plugin_id"]), \ + recording_codebuild(aws_stack) as recorder: + result = aws_stack.plugin_builds.handler( + adjustment_fetch_event(item, build_id, slug), None) + + assert result == {"recorded": True, "revision_slug": slug, + "fetch_status": "succeeded", + "import_status": "imported"} + # The WRITE landed (root cause is not a write problem): the real + # table maps the arch to the adjusted revision's slug. + record = fresh_item(aws_stack, item["plugin_id"]) + assert record["arch_revisions"] == {arch: slug} + assert record["fetches"][slug]["status"] == "succeeded" + + # ...but what did the auto-started build actually compile? + starts = arch_starts(recorder, arch) + assert len(starts) == 1, ( + f"expected exactly one auto-started {arch} build, " + f"got {len(starts)}") + assert starts[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}rev-{slug}/", ( + "auto-started build must compile the adjusted revision's " + "tree (bugfix 2.1); unfixed code submits the flat " + "source_s3_prefix — the original revision's tree") + # Task 4 (end-to-end): the arch entry advanced queued -> + # building with the started build id persisted. + record = fresh_item(aws_stack, item["plugin_id"]) + assert record["artifacts"][arch]["buildStatus"] == "building" + assert record["artifacts"][arch]["buildId"] + + def test_reuse_path_stale_read_builds_from_adjusted_tree( + self, aws_stack): + """Bug condition 1.2: adjust_revision ADJUST_REUSE maps the arch + to an already-fetched revision, re-queues it, and auto-starts — + the submission MUST name the reused entry's rev-{slug}/ tree. + + Unfixed code hands the stale (pre-write) item to the auto-start: + with the arch already queued pre-write, the build is submitted + from the flat source_s3_prefix (no mapping visible); had the + arch been 'failed' pre-write, the stale item would show nothing + queued and no build would start at all. Both are the same stale + re-read. + """ + arch, slug, revision = "arm64_jp4", "1.16", "1.16" + usecase_id, plugin_id, base = new_record_ids(aws_stack) + # An earlier adjustment already synced revision 1.16's tree + # (fetches entry 'succeeded'); this platform's entry is queued. + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch, "x86_64"], + artifacts={arch: {"buildStatus": "queued"}, + "x86_64": {"buildStatus": "succeeded"}}, + fetches={slug: {"revision": revision, + "source_prefix": f"{base}rev-{slug}/", + "status": "succeeded"}}, + ) + admin = make_admin(aws_stack, item["usecase_id"]) + + with stale_read_stub(aws_stack, item["plugin_id"]), \ + recording_codebuild(aws_stack) as recorder: + response = aws_stack.plugin_importer.handler(api_event( + "POST", "/plugins/{id}/versions/{v}/adjust-revision", + admin, item["plugin_id"], 1, + {"architecture": arch, "revision": revision}), None) + + assert response["statusCode"] == 202, response["body"] + # The WRITE landed: the real table carries the reuse mapping. + record = fresh_item(aws_stack, item["plugin_id"]) + assert record["arch_revisions"] == {arch: slug} + + starts = arch_starts(recorder, arch) + assert len(starts) == 1, ( + f"expected exactly one auto-started {arch} build, " + f"got {len(starts)}") + assert starts[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}rev-{slug}/", ( + "reuse-path auto-start must compile the reused entry's " + "tree (bugfix 2.2); unfixed code submits the flat " + "source_s3_prefix") + # Task 4 (end-to-end): the re-queued arch entry advanced + # queued -> building with the started build id persisted. + record = fresh_item(aws_stack, item["plugin_id"]) + assert record["artifacts"][arch]["buildStatus"] == "building" + assert record["artifacts"][arch]["buildId"] + + def test_start_queued_builds_rereads_with_consistent_read( + self, aws_stack): + """Bug condition 2.3 mechanism: start_queued_builds' re-read of + the record must pass ConsistentRead=True to get_item. + + Unfixed code never sets the key (get_version_item has no way to + request it), which IS the stale-read window. + """ + arch = "arm64_jp6" + usecase_id, plugin_id, _ = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch], + artifacts={arch: {"buildStatus": "queued"}}, + ) + + with stale_read_stub(aws_stack, item["plugin_id"]) as stub, \ + recording_codebuild(aws_stack): + aws_stack.plugin_builds.start_queued_builds( + item["plugin_id"], 1) + + reads = [call for call in stub.get_item_calls + if (call.get("Key") or {}).get("plugin_id") + == item["plugin_id"]] + assert reads, "start_queued_builds never re-read the record" + assert any(call.get("ConsistentRead") is True for call in reads), ( + "start_queued_builds' re-read must request ConsistentRead=True " + "(read-your-own-write after the same-invocation adjustment " + f"update); observed get_item calls: {reads}") + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.too_slow, + HealthCheck.filter_too_much]) + @given(data=st.data()) + def test_property_1_auto_started_builds_resolve_post_write_tree( + self, aws_stack, data): + """Feature: adjust-revision-stale-read-fix, Property 1: + Auto-Started Builds Resolve the Post-Write Source Tree + + For ANY record x adjusted arch x slug where a same-invocation + revision-mapping write is followed by the auto-start whose + eventually-consistent re-read returns the pre-write item + (isBugCondition true), the auto-started submission's + sourceLocationOverride SHALL name the just-written + fetches[arch_revisions[arch]].source_prefix. + + **Validates: Requirements 2.1, 2.2, 2.3** + """ + arch = data.draw(st.sampled_from(ALL_ARCHS), label="adjusted arch") + slug = data.draw(st.from_regex(r"[a-z0-9][a-z0-9.\-]{0,10}", + fullmatch=True), + label="revision slug") + others = [a for a in ALL_ARCHS if a != arch] + extra = data.draw(st.dictionaries( + st.sampled_from(others), + st.sampled_from(["building", "succeeded", "failed"]), + max_size=2), label="other archs") + + artifacts = {arch: {"buildStatus": "queued"}} + artifacts.update({a: {"buildStatus": s} for a, s in extra.items()}) + usecase_id, plugin_id, base = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch, *sorted(extra)], + artifacts=artifacts, + ) + + with stale_read_stub(aws_stack, item["plugin_id"]) as stub, \ + recording_codebuild(aws_stack) as recorder: + # The adjustment write (both race sites share this shape): + # arch_revisions[arch] -> slug with the slug's fetches entry + # settled — hits the REAL table while the frozen snapshot + # keeps serving plain reads (the lagging replica). + aws_stack.tables.plugin_records.update_item( + Key={"plugin_id": item["plugin_id"], "version": 1}, + UpdateExpression="SET arch_revisions = :ar, fetches = :f", + ExpressionAttributeValues={ + ":ar": {arch: slug}, + ":f": {slug: {"revision": slug, + "source_prefix": f"{base}rev-{slug}/", + "status": "succeeded"}}, + }, + ) + # Same-invocation auto-start immediately after the write. + aws_stack.plugin_builds.start_queued_builds( + item["plugin_id"], 1) + + starts = arch_starts(recorder, arch) + assert len(starts) == 1, ( + f"expected exactly one auto-started {arch} build, " + f"got {len(starts)}") + assert starts[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}rev-{slug}/", ( + "the auto-started submission must reflect the post-write " + f"mapping arch_revisions[{arch}]={slug}; unfixed code " + "resolves the stale item's flat source_s3_prefix") + + +# ===================================================================== +# Edge case: manual retry masks the defect (bugfix 1.3) +# EXPECTED TO PASS on unfixed code — documents the masking behavior. +# ===================================================================== + +class TestRetryMasksStaleRead: + def test_manual_retry_after_stale_start_builds_adjusted_tree( + self, aws_stack): + """After the stale-read auto-start, a manual retry (POST + .../build) reads FRESH state and resolves the adjusted + revision's tree — the first failure looks transient (1.3). + """ + arch, slug, revision = "arm64_jp5", "1.16", "1.16" + build_id = "dda-plugin-fetch:adjust-retry-1" + usecase_id, plugin_id, base = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch], + artifacts={arch: {"buildStatus": "queued"}}, + fetches={slug: {"revision": revision, + "source_prefix": f"{base}rev-{slug}/", + "status": "fetching", + "pending_archs": [arch], + "fetch_build_id": build_id}}, + ) + admin = make_admin(aws_stack, item["usecase_id"]) + + # First round: adjustment fetch settles and auto-starts under + # the stale-read window (the field failure). + with stale_read_stub(aws_stack, item["plugin_id"]), \ + recording_codebuild(aws_stack): + aws_stack.plugin_builds.handler( + adjustment_fetch_event(item, build_id, slug), None) + + # Manual retry seconds later: the stub is gone, reads reflect + # the write, and the build compiles the adjusted tree. + with recording_codebuild(aws_stack) as retry_recorder: + response = aws_stack.plugin_builds.handler(api_event( + "POST", "/plugins/{id}/versions/{v}/build", + admin, item["plugin_id"], 1, + {"architectures": [arch]}), None) + + assert response["statusCode"] == 202, response["body"] + starts = arch_starts(retry_recorder, arch) + assert len(starts) == 1 + assert starts[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}rev-{slug}/", ( + "a manual retry with fresh reads must resolve the " + "adjusted revision's tree (this masking is why the " + "defect looks like a flaky build)") + + +# ===================================================================== +# Task 2: preservation properties (EXPECTED TO PASS on unfixed code) +# +# Feature: adjust-revision-stale-read-fix, Property 2: All Other Reads +# and Auto-Start Semantics Are Unchanged +# +# Observation-first methodology: the assertions below transcribe the +# behavior OBSERVED on unfixed code for non-bug-condition inputs (the +# design's Preservation Requirements). They must keep passing, +# unchanged, once task 3.1 lands. +# ===================================================================== + +class PreservationRecordingTable: + """Proxy over the real (moto) plugin table that records every + get_item's kwargs and passes all calls through unchanged — used to + assert the default read's CALL SHAPE (no ConsistentRead key, 3.5). + """ + + def __init__(self, real_table): + self._real = real_table + self.get_item_calls = [] + + def get_item(self, **kwargs): + self.get_item_calls.append(copy.deepcopy(kwargs)) + return self._real.get_item(**kwargs) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class PreservationRecordingCodeBuild: + """CodeBuild stub that records StartBuild calls and fabricates + build ids (no real moto submission — fast enough for 100-iteration + property runs).""" + + def __init__(self): + self.calls = [] + self._counter = 0 + + def start_build(self, **kwargs): + self.calls.append(copy.deepcopy(kwargs)) + self._counter += 1 + return {"build": { + "id": f"{kwargs['projectName']}:preservation-{self._counter}"}} + + +class PreservationFailingCodeBuild: + """CodeBuild stub whose StartBuild always fails — the failure must + be recorded on the arch entry WITHOUT raising to the caller (3.3). + """ + + MESSAGE = "StartBuild throttled" + + def start_build(self, **kwargs): + raise RuntimeError(self.MESSAGE) + + +@contextlib.contextmanager +def _swapped(module, attribute, value): + """Temporarily replace one module attribute.""" + original = getattr(module, attribute) + setattr(module, attribute, value) + try: + yield value + finally: + setattr(module, attribute, original) + + +def preservation_slug_st(): + """Revision slugs shaped like the adjustment paths produce them.""" + return st.from_regex(r"[a-z0-9][a-z0-9.\-]{0,10}", fullmatch=True) + + +def preservation_artifact_entry_st(): + """One per-arch artifact entry across the statuses auto-start can + encounter: only 'queued' entries may be started; the rest must be + left byte-identical (3.3).""" + return st.sampled_from( + ["queued", "building", "succeeded", "failed"] + ).map(lambda status: {"buildStatus": status}) + + +@st.composite +def preservation_record_items(draw): + """Record shapes across the non-bug-condition input domain: arch + subsets, artifact statuses, an optionally unconfigured arch, and + flat / partially mapped / fully mapped revision layouts (including + a dangling arch_revisions mapping whose fetches entry is missing — + which resolves through the flat fallback exactly like an unmapped + arch).""" + usecase_id = f"uc-{draw(st.uuids())}" + plugin_id = f"plugin-{draw(st.uuids())}" + base = f"plugin-sources/{usecase_id}/{plugin_id}/1/" + archs = draw(st.lists(st.sampled_from(CONFIGURED_ARCHS), + min_size=1, max_size=3, unique=True)) + if draw(st.booleans()): + archs = archs + [UNCONFIGURED_ARCH] + artifacts = {arch: draw(preservation_artifact_entry_st()) + for arch in archs} + item = { + "plugin_id": plugin_id, + "version": 1, + "usecase_id": usecase_id, + "name": "gst-plugins-good", + "kind": "imported", + "import_status": "imported", + "source_s3_prefix": base, + "requested_architectures": list(archs), + "artifacts": artifacts, + "provenance": {"repoUrl": REPO_URL, "revision": "default", + "importedBy": "user-import", "importedAt": 1, + "classification": "good"}, + "created_by": "user-import", + "created_at": 1, + "updated_at": 1, + } + if draw(st.booleans()): + mapped = draw(st.lists(st.sampled_from(archs), unique=True)) + arch_revisions = {arch: draw(preservation_slug_st()) + for arch in mapped} + fetches = {slug: {"revision": slug, + "source_prefix": f"{base}rev-{slug}/", + "status": "succeeded"} + for slug in set(arch_revisions.values())} + # Sometimes drop one fetch entry: the dangling mapping falls + # back to the flat prefix, matching the unmapped case (3.1). + if fetches and draw(st.booleans()): + fetches.pop(draw(st.sampled_from(sorted(fetches)))) + item["arch_revisions"] = arch_revisions + item["fetches"] = fetches + return item + + +def preservation_seed_record(stack, item): + """Persist a generated record shape into the real (moto) table.""" + stack.tables.plugin_records.put_item(Item=item) + return item + + +def preservation_expected_prefix(item, arch): + """Baseline source-resolution oracle, transcribed from the behavior + observed on unfixed code (3.1, 3.2): per-arch mapping when + arch_revisions[arch] -> fetches[slug].source_prefix resolves, + otherwise the flat source_s3_prefix.""" + slug = (item.get("arch_revisions") or {}).get(arch) + fetch = (item.get("fetches") or {}).get(slug) if slug else None + prefix = (fetch or {}).get("source_prefix") + return prefix or item.get("source_s3_prefix") or "" + + +class TestPreservationDefaultReadCallShape: + """Requirement 3.5: get_version_item's default read is unchanged — + the get_item call carries NO ConsistentRead key at all.""" + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.too_slow, + HealthCheck.filter_too_much]) + @given(item=preservation_record_items()) + def test_property_2_default_read_has_no_consistent_read_key( + self, aws_stack, item): + """Feature: adjust-revision-stale-read-fix, Property 2: All + Other Reads and Auto-Start Semantics Are Unchanged + + For ANY record, get_version_item(plugin_id, version) without + the parameter issues a get_item whose kwargs carry ONLY the Key + (no ConsistentRead — the call is byte-identical to today's) and + returns the decoded item. + + **Validates: Requirements 3.5** + """ + preservation_seed_record(aws_stack, item) + records = aws_stack.plugin_records + recorder = PreservationRecordingTable(records.plugin_table()) + with _swapped(records, "plugin_table", lambda: recorder): + got = records.get_version_item(item["plugin_id"], 1) + + (call,) = recorder.get_item_calls + assert "ConsistentRead" not in call, ( + "the default get_version_item read must not carry a " + f"ConsistentRead key; observed kwargs: {call}") + assert call["Key"] == {"plugin_id": item["plugin_id"], + "version": 1} + # The decoded item round-trips (Decimals back to native ints). + assert got == item + + def test_missing_item_returns_none(self, aws_stack): + assert aws_stack.plugin_records.get_version_item( + f"plugin-{uuid.uuid4()}", 1) is None + + +class TestPreservationAutoStartSemantics: + """Requirement 3.3: start_queued_builds' idempotency and + never-raise semantics are the observed baseline.""" + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.too_slow, + HealthCheck.filter_too_much]) + @given(item=preservation_record_items()) + def test_property_2_auto_start_semantics_match_baseline( + self, aws_stack, item): + """Feature: adjust-revision-stale-read-fix, Property 2: All + Other Reads and Auto-Start Semantics Are Unchanged + + For ANY record, start_queued_builds starts exactly the + queued+configured architectures (each from the baseline source + resolution), leaves already-started and non-queued entries + byte-identical, and leaves unconfigured architectures queued. + + **Validates: Requirements 3.3** + """ + preservation_seed_record(aws_stack, item) + before = fresh_item(aws_stack, item["plugin_id"]) + recorder = PreservationRecordingCodeBuild() + with _swapped(aws_stack.plugin_builds, "codebuild", recorder): + result = aws_stack.plugin_builds.start_queued_builds( + item["plugin_id"], 1) + + artifacts = item["artifacts"] + expected_started = sorted( + arch for arch in item["requested_architectures"] + if artifacts[arch]["buildStatus"] == "queued" + and arch in CONFIGURED_ARCHS) + started = sorted( + call["projectName"].replace("dda-plugin-build-", "") + for call in recorder.calls) + assert started == expected_started, ( + "auto-start must submit exactly the queued+configured " + f"architectures; expected {expected_started}, got {started}") + # Each submission resolves through the baseline oracle. + for call in recorder.calls: + arch = call["projectName"].replace("dda-plugin-build-", "") + assert call["sourceLocationOverride"] == \ + f"{BUCKET}/{preservation_expected_prefix(item, arch)}" + # Returned entries mirror the started archs; {} when none. + assert sorted(result) == expected_started + # Persisted state: started archs advanced to building; every + # other entry — non-queued and unconfigured-queued alike — is + # byte-identical. + after = fresh_item(aws_stack, item["plugin_id"]) + for arch in item["requested_architectures"]: + if arch in expected_started: + assert after["artifacts"][arch]["buildStatus"] == "building" + assert after["artifacts"][arch]["buildId"] + else: + assert after["artifacts"][arch] == \ + before["artifacts"][arch], ( + f"{arch} ({artifacts[arch]['buildStatus']}) must " + "be left untouched by the auto-start") + + def test_missing_record_returns_empty(self, aws_stack): + """A missing Plugin_Record returns {} without starting anything + and without raising (3.3).""" + recorder = PreservationRecordingCodeBuild() + with _swapped(aws_stack.plugin_builds, "codebuild", recorder): + result = aws_stack.plugin_builds.start_queued_builds( + f"plugin-{uuid.uuid4()}", 1) + assert result == {} + assert recorder.calls == [] + + def test_start_build_failure_recorded_without_raising( + self, aws_stack): + """A StartBuild failure settles the arch entry 'failed' with + the error in logTail — and never raises to the caller (3.3).""" + arch = "x86_64" + usecase_id, plugin_id, _ = new_record_ids(aws_stack) + put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch], + artifacts={arch: {"buildStatus": "queued"}}, + ) + + with _swapped(aws_stack.plugin_builds, "codebuild", + PreservationFailingCodeBuild()): + result = aws_stack.plugin_builds.start_queued_builds( + plugin_id, 1) + + entry = result[arch] + assert entry["buildStatus"] == "failed" + assert PreservationFailingCodeBuild.MESSAGE in entry["logTail"] + after = fresh_item(aws_stack, plugin_id) + assert after["artifacts"][arch]["buildStatus"] == "failed" + assert PreservationFailingCodeBuild.MESSAGE in \ + after["artifacts"][arch]["logTail"] + + +class TestPreservationArchSourcePrefixResolution: + """Requirements 3.1 / 3.2: arch_source_prefix resolution is the + observed baseline for mapped, unmapped, and flat records.""" + + @settings(max_examples=100, deadline=None, + suppress_health_check=[HealthCheck.too_slow, + HealthCheck.filter_too_much]) + @given(item=preservation_record_items()) + def test_property_2_resolution_matches_baseline(self, aws_stack, item): + """Feature: adjust-revision-stale-read-fix, Property 2: All + Other Reads and Auto-Start Semantics Are Unchanged + + For ANY generated item and EVERY arch — mapped through + arch_revisions[arch] -> fetches[slug].source_prefix, unmapped + (flat fallback), dangling-mapped, and unconfigured — + arch_source_prefix is byte-identical to the baseline oracle. + + **Validates: Requirements 3.1, 3.2** + """ + for arch in [*ALL_ARCHS, UNCONFIGURED_ARCH]: + assert aws_stack.plugin_builds.arch_source_prefix( + item, arch) == preservation_expected_prefix(item, arch) + + def test_flat_single_revision_record_resolves_flat_prefix( + self, aws_stack): + """A record with no per-arch mappings builds from the flat + source_s3_prefix layout for every arch (3.2).""" + base = "plugin-sources/uc-flat/plugin-flat/1/" + item = {"plugin_id": "plugin-flat", "version": 1, + "source_s3_prefix": base} + for arch in ALL_ARCHS: + assert aws_stack.plugin_builds.arch_source_prefix( + item, arch) == base + + def test_mapped_and_unmapped_archs_resolve_independently( + self, aws_stack): + """A partially mapped record resolves the mapped arch through + its rev-{slug}/ tree and every other arch through the flat + prefix (3.1).""" + base = "plugin-sources/uc-mixed/plugin-mixed/1/" + item = { + "plugin_id": "plugin-mixed", "version": 1, + "source_s3_prefix": base, + "arch_revisions": {"arm64_jp5": "1-16"}, + "fetches": {"1-16": {"revision": "1.16", + "source_prefix": f"{base}rev-1-16/", + "status": "succeeded"}}, + } + assert aws_stack.plugin_builds.arch_source_prefix( + item, "arm64_jp5") == f"{base}rev-1-16/" + for arch in [a for a in ALL_ARCHS if a != "arm64_jp5"]: + assert aws_stack.plugin_builds.arch_source_prefix( + item, arch) == base + + +class TestPreservationRetryAndFailurePaths: + """Requirements 3.4 / 3.6: manual retry and adjustment + fetch-failure handling are the observed baseline.""" + + def test_manual_retry_resubmits_recorded_tree(self, aws_stack): + """POST .../build re-submits the platform from its CURRENTLY + RECORDED source tree — the arch's mapped rev-{slug}/ prefix + (3.4).""" + arch, slug = "arm64_jp5", "1-16" + usecase_id, plugin_id, base = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch], + artifacts={arch: {"buildStatus": "failed", + "logTail": "meson failed"}}, + arch_revisions={arch: slug}, + fetches={slug: {"revision": "1.16", + "source_prefix": f"{base}rev-{slug}/", + "status": "succeeded"}}, + ) + admin = make_admin(aws_stack, item["usecase_id"]) + + with recording_codebuild(aws_stack) as recorder: + response = aws_stack.plugin_builds.handler(api_event( + "POST", "/plugins/{id}/versions/{v}/build", + admin, item["plugin_id"], 1, + {"architectures": [arch]}), None) + + assert response["statusCode"] == 202, response["body"] + starts = arch_starts(recorder, arch) + assert len(starts) == 1 + assert starts[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}rev-{slug}/", ( + "a manual retry must re-submit from the platform's " + "currently recorded source tree") + + def test_adjustment_fetch_failure_touches_only_affected_arch( + self, aws_stack): + """A FAILED adjustment fetch settles the fetches entry 'failed' + and records the fetch-failure logTail on the affected arch's + entry ONLY — no builds started, other platforms' entries and + arch_revisions byte-identical (3.6).""" + arch, other = "arm64_jp4", "arm64_jp5" + slug, revision = "1.16", "1.16" + other_slug = "1-18" + build_id = "dda-plugin-fetch:adjust-fail-1" + usecase_id, plugin_id, base = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=[arch, other], + artifacts={arch: {"buildStatus": "queued"}, + other: {"buildStatus": "succeeded", + "s3Key": "plugin-staging/lib.so", + "logTail": ""}}, + arch_revisions={other: other_slug}, + fetches={slug: {"revision": revision, + "source_prefix": f"{base}rev-{slug}/", + "status": "fetching", + "pending_archs": [arch], + "fetch_build_id": build_id}, + other_slug: {"revision": "1.18", + "source_prefix": f"{base}rev-{other_slug}/", + "status": "succeeded"}}, + ) + before = fresh_item(aws_stack, item["plugin_id"]) + + with recording_codebuild(aws_stack) as recorder: + result = aws_stack.plugin_builds.handler( + adjustment_fetch_event(item, build_id, slug, + status="FAILED"), None) + + assert result["recorded"] is True + assert result["fetch_status"] == "failed" + # No builds started on the failure path. + assert recorder.calls == [] + after = fresh_item(aws_stack, item["plugin_id"]) + # The failure surfaces on the affected platform's entry only. + assert after["artifacts"][arch]["buildStatus"] == "failed" + assert revision in after["artifacts"][arch]["logTail"] + assert "could not be fetched" in after["artifacts"][arch]["logTail"] + # Everything else is byte-identical: the other platform's entry, + # the prior mapping, and the other revision's fetch entry. + assert after["artifacts"][other] == before["artifacts"][other] + assert after["arch_revisions"] == before["arch_revisions"] + assert after["fetches"][other_slug] == before["fetches"][other_slug] + # The failed slot settled with its pending marker cleared. + assert after["fetches"][slug]["status"] == "failed" + assert "pending_archs" not in after["fetches"][slug] + + +# ===================================================================== +# Task 4: unit tests for the fix specifics +# +# Deterministic unit coverage of the two changed functions +# (get_version_item's consistent_read parameter, start_queued_builds' +# consistent re-read) and the flows around them. Bullets already +# exercised elsewhere in this file are NOT repeated here: +# - default read omits ConsistentRead / returns the decoded item / +# returns None when missing: TestPreservationDefaultReadCallShape +# - start_queued_builds re-reads with ConsistentRead=True and, under +# the stale-read stub, submits the adjusted prefix: +# TestAdjustRevisionStaleRead (including both end-to-end paths with +# the queued -> building advancement asserted there) +# - StartBuild exception recorded as a failed entry without raising, +# and missing record returns {}: TestPreservationAutoStartSemantics +# ===================================================================== + +class TestGetVersionItemConsistentReadMode: + """Fix specifics: the opt-in consistent_read=True mode of + plugin_records.get_version_item (design 'Changes Required' 1). + + **Validates: Requirements 2.3, 3.5** + """ + + def test_consistent_read_true_passes_the_key_and_decodes_the_item( + self, aws_stack): + """consistent_read=True issues a get_item carrying EXACTLY the + Key plus ConsistentRead=True — and returns the same decoded + item as the default mode.""" + usecase_id, plugin_id, _ = new_record_ids(aws_stack) + item = put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=["x86_64"], + artifacts={"x86_64": {"buildStatus": "succeeded"}}, + ) + records = aws_stack.plugin_records + recorder = PreservationRecordingTable(records.plugin_table()) + with _swapped(records, "plugin_table", lambda: recorder): + got = records.get_version_item(plugin_id, 1, + consistent_read=True) + + (call,) = recorder.get_item_calls + assert call.get("ConsistentRead") is True, ( + "consistent_read=True must pass ConsistentRead=True to " + f"get_item; observed kwargs: {call}") + assert call["Key"] == {"plugin_id": plugin_id, "version": 1} + assert set(call) == {"Key", "ConsistentRead"}, ( + "the consistent-mode call must add ONLY the ConsistentRead " + f"key; observed kwargs: {call}") + # The decoded item round-trips in consistent mode too. + assert got == item + + def test_consistent_read_missing_item_returns_none(self, aws_stack): + """A missing record returns None in consistent mode, exactly + like the default mode.""" + assert aws_stack.plugin_records.get_version_item( + f"plugin-{uuid.uuid4()}", 1, consistent_read=True) is None + + def test_consistent_read_bypasses_the_stale_snapshot(self, aws_stack): + """Under the stale-read stub, the default read returns the + frozen pre-write item while consistent_read=True reads through + to the post-write state — the read-your-own-write mechanism the + fix relies on.""" + usecase_id, plugin_id, _ = new_record_ids(aws_stack) + put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=["x86_64"], + artifacts={"x86_64": {"buildStatus": "queued"}}, + ) + records = aws_stack.plugin_records + with stale_read_stub(aws_stack, plugin_id): + # Post-freeze write lands on the real table only. + aws_stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression="SET updated_at = :t", + ExpressionAttributeValues={":t": 2}, + ) + stale = records.get_version_item(plugin_id, 1) + consistent = records.get_version_item(plugin_id, 1, + consistent_read=True) + + assert stale["updated_at"] == 1 + assert consistent["updated_at"] == 2 + + +class TestStartQueuedBuildsIdempotencyUnits: + """Fix specifics: deterministic idempotency coverage of + start_queued_builds on a mixed record (already-building arch + untouched, unconfigured arch left queued, queued+configured arch + started from its resolved prefix). + + **Validates: Requirements 3.3** + """ + + def test_mixed_record_starts_only_the_queued_configured_arch( + self, aws_stack): + building_entry = {"buildStatus": "building", + "buildId": "dda-plugin-build-arm64_jp5:prior", + "logTail": ""} + usecase_id, plugin_id, base = new_record_ids(aws_stack) + put_imported_record( + aws_stack, usecase_id, plugin_id, + requested=["x86_64", "arm64_jp5", UNCONFIGURED_ARCH], + artifacts={"x86_64": {"buildStatus": "queued"}, + "arm64_jp5": dict(building_entry), + UNCONFIGURED_ARCH: {"buildStatus": "queued"}}, + ) + + recorder = PreservationRecordingCodeBuild() + with _swapped(aws_stack.plugin_builds, "codebuild", recorder): + result = aws_stack.plugin_builds.start_queued_builds( + plugin_id, 1) + + # Exactly one submission: the queued+configured arch, from the + # record's flat prefix. + assert [c["projectName"] for c in recorder.calls] == \ + ["dda-plugin-build-x86_64"] + assert recorder.calls[0]["sourceLocationOverride"] == \ + f"{BUCKET}/{base}" + assert sorted(result) == ["x86_64"] + + after = fresh_item(aws_stack, plugin_id) + assert after["artifacts"]["x86_64"]["buildStatus"] == "building" + assert after["artifacts"]["x86_64"]["buildId"] + # Already-building arch is byte-identical; unconfigured arch is + # left queued. + assert after["artifacts"]["arm64_jp5"] == building_entry + assert after["artifacts"][UNCONFIGURED_ARCH] == \ + {"buildStatus": "queued"} + + +class TestImportTimeFlowUnderStaleWindow: + """Fix specifics: the original import fetch-settle -> + start_queued_builds flow still starts ALL queued architectures + from the flat prefix — even inside the stale-read window (the + settle write invisible to plain reads) — with unchanged + idempotency on duplicate deliveries. + + **Validates: Requirements 3.2, 3.3** + """ + + ARCHS = ["x86_64", "arm64_jp5"] + + def test_import_settle_starts_all_queued_archs_from_flat_prefix( + self, aws_stack): + env = ImporterEnv(aws_stack) + usecase_id = env.create_usecase() + admin = env.make_user() + env.assign_role(admin, usecase_id, "UseCaseAdmin") + status, body = env.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": self.ARCHS, + }) + assert status == 202, body + plugin = body["plugin"] + env.sync_source(plugin, {"meson.build": MESON_PLUGIN, + "gstmyfilter.c": None}) + detail = env.fetch_result_detail(plugin, body["import"]["buildId"]) + + # Freeze the pre-settle state ('fetching', no artifacts): the + # settle write and the auto-start happen in one invocation, so + # a stale re-read would show NOTHING queued and silently start + # nothing (design edge case). The consistent re-read must see + # the queued entries. + with stale_read_stub(aws_stack, plugin["plugin_id"]), \ + recording_codebuild(aws_stack) as recorder: + result = env.deliver_fetch_result(detail) + duplicate = env.deliver_fetch_result(detail) + + assert result == {"recorded": True, "import_status": "imported"} + # Unchanged idempotency: the duplicate delivery settles nothing + # and starts nothing. + assert duplicate == {"recorded": False, + "reason": "already recorded"} + + starts = [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + assert sorted(c["projectName"] for c in starts) == \ + sorted(f"dda-plugin-build-{a}" for a in self.ARCHS) + for call in starts: + assert call["sourceLocationOverride"] == \ + f"{BUCKET}/{plugin['source_s3_prefix']}", ( + "import-time auto-start must build from the flat " + "source_s3_prefix (no per-arch mappings yet)") + + record = fresh_item(aws_stack, plugin["plugin_id"]) + for arch in self.ARCHS: + assert record["artifacts"][arch]["buildStatus"] == "building" + assert record["artifacts"][arch]["buildId"] diff --git a/edge-cv-portal/backend/tests/test_audit_strict_helpers.py b/edge-cv-portal/backend/tests/test_audit_strict_helpers.py new file mode 100644 index 00000000..ef41a788 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_audit_strict_helpers.py @@ -0,0 +1,161 @@ +""" +Unit tests for the strict two-phase audit helpers in shared_utils +(spec: portal-user-manager, task 1.1). + +record_audit_event_strict / finalize_audit_event implement the +audit-before-effect protocol: a 'pending' entry is written (raising on +failure) before the guarded operation, then finalized to +success/failure/rejected. Details are sanitized against the +password/verifier/hash/temp* denylist before any write. + +_Requirements: 6.1, 6.3, 6.4, 6.5_ +""" +import pytest +from botocore.exceptions import ClientError + + +@pytest.fixture +def shared(aws_stack): + """The real shared_utils module imported inside the moto mock.""" + import shared_utils + return shared_utils + + +@pytest.fixture +def audit_table(aws_stack): + """The moto-backed audit log table the strict helpers write to.""" + return aws_stack.tables.audit_log + + +def get_entry(audit_table, event_id): + from boto3.dynamodb.conditions import Key + items = audit_table.query( + KeyConditionExpression=Key("event_id").eq(event_id) + )["Items"] + assert len(items) == 1 + return items[0] + + +class TestRecordAuditEventStrict: + def test_writes_pending_entry_with_all_fields(self, shared, audit_table): + event_id = shared.record_audit_event_strict( + user_id="admin-1", + action="role_change", + resource_type="user_account", + resource_id="operator1", + details={"previous_role": "Viewer", "new_role": "Operator"}, + ) + + entry = get_entry(audit_table, event_id) + assert entry["user_id"] == "admin-1" + assert entry["action"] == "role_change" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "operator1" + assert entry["result"] == "pending" + assert entry["details"] == {"previous_role": "Viewer", + "new_role": "Operator"} + assert entry["timestamp"] > 0 + + def test_supported_action_types_and_results(self, shared, audit_table): + assert shared.USER_ACCOUNT_RESOURCE_TYPE == "user_account" + assert set(shared.USER_ACCOUNT_AUDIT_ACTIONS) == { + "password_change", "forgot_password", "role_change", + "account_create", "account_disable", + "account_enable", "account_delete"} + assert set(shared.AUDIT_RESULTS) == { + "pending", "success", "failure", "rejected"} + for action in shared.USER_ACCOUNT_AUDIT_ACTIONS: + event_id = shared.record_audit_event_strict( + "admin-1", action, shared.USER_ACCOUNT_RESOURCE_TYPE, "u") + assert get_entry(audit_table, event_id)["action"] == action + + def test_unique_event_ids_within_same_millisecond(self, shared): + ids = {shared.record_audit_event_strict( + "admin-1", "password_change", "user_account", "u") + for _ in range(5)} + assert len(ids) == 5 + + def test_invalid_result_raises(self, shared): + with pytest.raises(ValueError): + shared.record_audit_event_strict( + "admin-1", "role_change", "user_account", "u", + result="not-a-result") + + def test_raises_when_put_item_fails(self, shared, monkeypatch): + """Req 6.4: an unrecordable audit entry must surface as an + exception so the caller aborts the action.""" + monkeypatch.setattr(shared, "AUDIT_LOG_TABLE", "does-not-exist") + with pytest.raises(ClientError): + shared.record_audit_event_strict( + "admin-1", "password_change", "user_account", "u") + + +class TestDetailsSanitization: + def test_denylisted_keys_dropped(self, shared, audit_table): + """Req 6.3: passwords, hashes, verifiers, and temp* values never + reach the audit log.""" + event_id = shared.record_audit_event_strict( + "admin-1", "password_change", "user_account", "u", + details={ + "password": "s3cret!", + "new_password": "s3cret!", + "password_hash": "abc", + "verifier": {"salt": "x", "hash": "y"}, + "credential_verifier": "abc", + "temp_password": "s3cret!", + "temporaryPassword": "s3cret!", + "permanent": True, + "nested": {"tempPass": "s3cret!", "reason": "ok", + "list": [{"hash": "z", "kept": 1}]}, + }) + + details = get_entry(audit_table, event_id)["details"] + assert details == { + "permanent": True, + "nested": {"reason": "ok", "list": [{"kept": 1}]}, + } + + def test_sanitize_is_prefix_match_for_temp_only(self, shared): + # 'attempt' contains 'temp' but is not temp-prefixed: kept. + assert shared.sanitize_audit_details( + {"attempt_count": 3, "template": "x"} + ) == {"attempt_count": 3} + + +class TestFinalizeAuditEvent: + def test_finalizes_to_success_with_merged_details(self, shared, audit_table): + event_id = shared.record_audit_event_strict( + "admin-1", "role_change", "user_account", "operator1", + details={"previous_role": "Viewer"}) + + shared.finalize_audit_event(event_id, "success", + {"new_role": "Operator", + "temp_password": "leak!"}) + + entry = get_entry(audit_table, event_id) + assert entry["result"] == "success" + assert entry["details"] == {"previous_role": "Viewer", + "new_role": "Operator"} + assert entry["completed_at"] >= entry["timestamp"] + # identity fields preserved (Req 6.1) + assert entry["user_id"] == "admin-1" + assert entry["resource_id"] == "operator1" + + @pytest.mark.parametrize("result", ["failure", "rejected"]) + def test_terminal_results(self, shared, audit_table, result): + event_id = shared.record_audit_event_strict( + "admin-1", "forgot_password", "user_account", "u") + shared.finalize_audit_event(event_id, result, {"reason": "why"}) + entry = get_entry(audit_table, event_id) + assert entry["result"] == result + assert entry["details"]["reason"] == "why" + + def test_invalid_result_raises(self, shared): + event_id = shared.record_audit_event_strict( + "admin-1", "role_change", "user_account", "u") + with pytest.raises(ValueError): + shared.finalize_audit_event(event_id, "pending") + + def test_unknown_event_raises(self, shared): + with pytest.raises(ValueError): + shared.finalize_audit_event("no-such-event", "success") diff --git a/edge-cv-portal/backend/tests/test_bedrock_common_settings_failure.py b/edge-cv-portal/backend/tests/test_bedrock_common_settings_failure.py new file mode 100644 index 00000000..955ec38e --- /dev/null +++ b/edge-cv-portal/backend/tests/test_bedrock_common_settings_failure.py @@ -0,0 +1,85 @@ +""" +Unit test: a raising settings-table read inside +`bedrock_common.get_bedrock_configuration` logs a warning and falls back +to the workflow-generation defaults; no exception escapes. + +Task 1.5 (spec: custom-node-code-assist). + +The DynamoDB resource on the module is replaced with a stub whose +`Table(...).get_item` raises botocore ClientError, so the failure path is +exercised deterministically without moto. + +_Requirements: 4.5_ +""" +import logging + +import pytest +from botocore.exceptions import ClientError + +import bedrock_common + + +class _RaisingTable: + """Stub DynamoDB Table whose get_item always raises ClientError.""" + + def get_item(self, **kwargs): + raise ClientError( + error_response={ + "Error": { + "Code": "ProvisionedThroughputExceededException", + "Message": "Throughput exceeded", + } + }, + operation_name="GetItem", + ) + + +class _RaisingDynamoResource: + def Table(self, name): + return _RaisingTable() + + +@pytest.fixture +def failing_settings_read(monkeypatch): + """Point bedrock_common at a settings table whose read raises.""" + monkeypatch.setattr(bedrock_common, "SETTINGS_TABLE", "test-settings-raising") + monkeypatch.setattr(bedrock_common, "dynamodb", _RaisingDynamoResource()) + + +def test_settings_read_failure_falls_back_to_defaults( + failing_settings_read, caplog +): + """ClientError from the settings read yields the defaults, a warning, + and no escaping exception (Requirement 4.5).""" + with caplog.at_level(logging.WARNING): + config = bedrock_common.get_bedrock_configuration() # must not raise + + # The returned configuration is exactly the workflow-generation + # defaults, with the timeout already an int clamped to [1, 60]. + expected = dict(bedrock_common.DEFAULT_BEDROCK_CONFIG) + expected["timeout_seconds"] = int(expected["timeout_seconds"]) + assert config == expected + + timeout = config["timeout_seconds"] + assert isinstance(timeout, int) and not isinstance(timeout, bool) + assert 1 <= timeout <= bedrock_common.MAX_TIMEOUT_SECONDS + + # A warning about the failed read was logged. + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "Could not read Bedrock configuration" in r.getMessage() + for r in warnings + ) + + +def test_settings_read_failure_result_matches_no_table_configured( + failing_settings_read, monkeypatch +): + """The failure fallback is semantically identical to having no + settings table configured at all (the pure-defaults path).""" + failing_config = bedrock_common.get_bedrock_configuration() + + monkeypatch.setattr(bedrock_common, "SETTINGS_TABLE", None) + default_config = bedrock_common.get_bedrock_configuration() + + assert failing_config == default_config diff --git a/edge-cv-portal/backend/tests/test_bedrock_configuration.py b/edge-cv-portal/backend/tests/test_bedrock_configuration.py new file mode 100644 index 00000000..b22dfe85 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_bedrock_configuration.py @@ -0,0 +1,460 @@ +""" +Bedrock_Configuration settings API tests (workflow-manager Requirement 10.6). + +Task 10.2 (spec: workflow-manager). + +The Bedrock_Configuration read/write API rides the existing +PortalAdmin-only /data-accounts/{id} routes with the reserved id +'bedrock-configuration' (no new API Gateway routes may be added), handled +by data_accounts.py. These tests cover: + +1. Authorization: only PortalAdmin (Permission.BEDROCK_CONFIG_WRITE) may + read or write; every other role gets 403 and a denied audit record. +2. Reads return the effective configuration (defaults before anything is + stored, stored values afterwards). +3. Writes validate inputs (timeout <= 60, temperature/top_p in [0, 1], + non-empty model id, positive max_tokens) and persist the exact item + shape read by workflow_generator.get_bedrock_configuration(): + {setting_key: 'bedrock_configuration', + value: {model_id, region, max_tokens, temperature, top_p, + timeout_seconds}} +4. Successful updates write an audit record. + +Runs against the shared moto stack from conftest.py. + +_Requirements: 10.6_ +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION + +SETTINGS_TABLE_NAME = "test-settings-bedrock" +RESOURCE_ID = "bedrock-configuration" + +VALID_CONFIG = { + "model_id": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "region": "us-west-2", + "max_tokens": 2048, + "temperature": 0.5, + "top_p": 0.8, + "timeout_seconds": 45, +} + + +@pytest.fixture(scope="module") +def bedrock_env(aws_stack): + """Settings table + freshly imported data_accounts module inside moto.""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds SETTINGS_TABLE and a moto-intercepted + # boto3 resource (conftest pattern). + sys.modules.pop("data_accounts", None) + import data_accounts + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + data_accounts=data_accounts, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + ) + + +@pytest.fixture(autouse=True) +def clean_setting(bedrock_env): + """Each test starts with no stored bedrock_configuration item.""" + bedrock_env.settings_table.delete_item(Key={"setting_key": "bedrock_configuration"}) + yield + + +def make_user(role): + user_id = f"user-{uuid.uuid4()}" + return {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": role} + + +def invoke(bedrock_env, method, user, body=None): + event = { + "httpMethod": method, + "resource": "/data-accounts/{id}", + "path": f"/data-accounts/{RESOURCE_ID}", + "pathParameters": {"id": RESOURCE_ID}, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = bedrock_env.data_accounts.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def stored_item(bedrock_env): + return bedrock_env.settings_table.get_item( + Key={"setting_key": "bedrock_configuration"}).get("Item") + + +@pytest.fixture +def audit_table(aws_stack): + """The moto-backed audit log table shared_utils.log_audit_event writes to.""" + import boto3 + from conftest import TEST_ENV + + return boto3.resource("dynamodb", region_name=REGION).Table( + TEST_ENV["AUDIT_LOG_TABLE"]) + + +# =========================================================================== +# 1. Authorization (Requirement 10.6: PortalAdmin only) +# =========================================================================== + +class TestBedrockConfigurationAuthz: + + @pytest.mark.parametrize("role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + @pytest.mark.parametrize("method", ["GET", "PUT"]) + def test_non_portal_admin_is_denied(self, bedrock_env, role, method): + """Every non-PortalAdmin role gets 403 on read and write and no + configuration is persisted (Requirement 10.6).""" + user = make_user(role) + status, payload = invoke(bedrock_env, method, user, body=VALID_CONFIG) + assert status == 403 + assert "bedrock-config:write" in payload["required_permissions"] + assert stored_item(bedrock_env) is None + + def test_portal_admin_is_allowed(self, bedrock_env): + """PortalAdmin holds bedrock-config:write and can read and write + (Requirement 10.6).""" + admin = make_user("PortalAdmin") + status, _ = invoke(bedrock_env, "GET", admin) + assert status == 200 + status, _ = invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) + assert status == 200 + + +# =========================================================================== +# 2. Reads (defaults and stored values) +# =========================================================================== + +class TestBedrockConfigurationRead: + + def test_read_returns_defaults_when_nothing_stored(self, bedrock_env): + admin = make_user("PortalAdmin") + status, payload = invoke(bedrock_env, "GET", admin) + assert status == 200 + config = payload["bedrock_configuration"] + assert config["model_id"] + assert config["timeout_seconds"] == 60 + assert set(config) == {"model_id", "region", "max_tokens", + "temperature", "top_p", "timeout_seconds"} + + def test_read_returns_stored_values(self, bedrock_env): + admin = make_user("PortalAdmin") + status, _ = invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) + assert status == 200 + status, payload = invoke(bedrock_env, "GET", admin) + assert status == 200 + assert payload["bedrock_configuration"] == VALID_CONFIG + + +# =========================================================================== +# 3. Writes: persisted shape and input validation +# =========================================================================== + +class TestBedrockConfigurationWrite: + + def test_write_persists_the_shape_workflow_generator_reads(self, bedrock_env): + """The stored item is {setting_key, value: {...}} with all six keys + - exactly what workflow_generator.get_bedrock_configuration() + expects (Requirement 10.6).""" + admin = make_user("PortalAdmin") + status, _ = invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) + assert status == 200 + + item = stored_item(bedrock_env) + assert item["setting_key"] == "bedrock_configuration" + value = item["value"] + assert set(value) == {"model_id", "region", "max_tokens", + "temperature", "top_p", "timeout_seconds"} + assert value["model_id"] == VALID_CONFIG["model_id"] + assert value["region"] == VALID_CONFIG["region"] + assert int(value["max_tokens"]) == 2048 + assert float(value["temperature"]) == 0.5 + assert float(value["top_p"]) == 0.8 + assert int(value["timeout_seconds"]) == 45 + + def test_partial_update_merges_over_current_values(self, bedrock_env): + admin = make_user("PortalAdmin") + invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) + status, payload = invoke(bedrock_env, "PUT", admin, + body={"temperature": 0.9}) + assert status == 200 + config = payload["bedrock_configuration"] + assert config["temperature"] == 0.9 + assert config["model_id"] == VALID_CONFIG["model_id"] + assert config["timeout_seconds"] == VALID_CONFIG["timeout_seconds"] + + @pytest.mark.parametrize("overrides,expected_error", [ + ({"timeout_seconds": 61}, "timeout_seconds"), + ({"timeout_seconds": 0}, "timeout_seconds"), + ({"temperature": 1.5}, "temperature"), + ({"temperature": -0.1}, "temperature"), + ({"top_p": 2}, "top_p"), + ({"model_id": " "}, "model_id"), + ({"max_tokens": 0}, "max_tokens"), + ({"max_tokens": "many"}, "max_tokens"), + ]) + def test_invalid_values_are_rejected(self, bedrock_env, overrides, + expected_error): + """Timeout <= 60, temperature/top_p in [0, 1], non-empty model id, + positive integer max_tokens (task 10.2 validation rules).""" + admin = make_user("PortalAdmin") + body = dict(VALID_CONFIG, **overrides) + status, payload = invoke(bedrock_env, "PUT", admin, body=body) + assert status == 400 + assert any(expected_error in e for e in payload["validation_errors"]) + assert stored_item(bedrock_env) is None + + +# =========================================================================== +# 4. Audit records +# =========================================================================== + +class TestBedrockConfigurationAudit: + + def test_update_writes_audit_record(self, bedrock_env, audit_table): + admin = make_user("PortalAdmin") + status, _ = invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) + assert status == 200 + + records = [i for i in audit_table.scan()["Items"] + if i["user_id"] == admin["user_id"] + and i["action"] == "update_bedrock_configuration"] + assert len(records) == 1 + assert records[0]["resource_id"] == "bedrock_configuration" + assert records[0]["result"] == "success" + assert int(records[0]["timestamp"]) > 0 + + def test_denied_attempt_writes_unauthorized_access_record( + self, bedrock_env, audit_table): + viewer = make_user("Viewer") + status, _ = invoke(bedrock_env, "PUT", viewer, body=VALID_CONFIG) + assert status == 403 + + records = [i for i in audit_table.scan()["Items"] + if i["user_id"] == viewer["user_id"] + and i["action"] == "unauthorized_access"] + assert len(records) == 1 + assert records[0]["result"] == "denied" + assert "bedrock-config:write" in records[0]["details"]["required_permissions"] + + +# =========================================================================== +# 5. Model options for the settings dropdown +# GET /data-accounts/bedrock-configuration/models +# =========================================================================== + +def invoke_models(bedrock_env, user, query=None): + """GET /data-accounts/bedrock-configuration/models; (status, body).""" + event = { + "httpMethod": "GET", + "resource": "/data-accounts/{id}/models", + "path": f"/data-accounts/{RESOURCE_ID}/models", + "pathParameters": {"id": RESOURCE_ID}, + "queryStringParameters": query, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = bedrock_env.data_accounts.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +class FakeBedrockControlClient: + """Stand-in for the bedrock control-plane client (list APIs).""" + + def __init__(self, profiles=None, models=None, + profiles_error=None, models_error=None): + self.profiles = profiles or [] + self.models = models or [] + self.profiles_error = profiles_error + self.models_error = models_error + + def list_inference_profiles(self, **kwargs): + if self.profiles_error: + raise self.profiles_error + return {"inferenceProfileSummaries": self.profiles} + + def list_foundation_models(self, **kwargs): + if self.models_error: + raise self.models_error + return {"modelSummaries": self.models} + + +def access_denied_error(operation): + from botocore.exceptions import ClientError + return ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + operation, + ) + + +@pytest.fixture +def fake_bedrock(bedrock_env, monkeypatch): + """Injects a FakeBedrockControlClient; records the requested region.""" + state = SimpleNamespace(client=FakeBedrockControlClient(), regions=[]) + + def fake_get_client(region): + state.regions.append(region) + return state.client + + monkeypatch.setattr(bedrock_env.data_accounts, + "_get_bedrock_control_client", fake_get_client) + return state + + +PROFILES = [ + {"inferenceProfileId": "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "inferenceProfileName": "US Anthropic Claude Sonnet 4.5"}, + {"inferenceProfileId": "us.amazon.nova-pro-v1:0", + "inferenceProfileName": "US Amazon Nova Pro"}, +] + +FOUNDATION_MODELS = [ + # Fronted by a listed inference profile: must be deduplicated away. + {"modelId": "anthropic.claude-sonnet-4-5-20250929-v1:0", + "modelName": "Claude Sonnet 4.5", + "modelLifecycle": {"status": "ACTIVE"}, + "inferenceTypesSupported": ["INFERENCE_PROFILE"]}, + # Plain ON_DEMAND foundation model: included. + {"modelId": "amazon.titan-text-express-v1", + "modelName": "Titan Text Express", + "modelLifecycle": {"status": "ACTIVE"}, + "inferenceTypesSupported": ["ON_DEMAND"]}, + # Legacy/EOL model: excluded. + {"modelId": "anthropic.claude-v2", + "modelName": "Claude 2", + "modelLifecycle": {"status": "LEGACY"}, + "inferenceTypesSupported": ["ON_DEMAND"]}, + # Not invokable on demand (profile-only, no profile listed): excluded. + {"modelId": "meta.llama4-maverick-17b-instruct-v1:0", + "modelName": "Llama 4 Maverick", + "modelLifecycle": {"status": "ACTIVE"}, + "inferenceTypesSupported": ["INFERENCE_PROFILE"]}, +] + + +class TestBedrockModelOptions: + + @pytest.mark.parametrize("role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_is_denied(self, bedrock_env, fake_bedrock, role): + """The models listing is gated exactly like the configuration GET: + PortalAdmin only (Requirement 10.6).""" + status, payload = invoke_models(bedrock_env, make_user(role)) + assert status == 403 + assert "bedrock-config:write" in payload["required_permissions"] + assert fake_bedrock.regions == [] + + def test_returns_deduplicated_invokable_options(self, bedrock_env, + fake_bedrock): + """Inference profiles and ACTIVE ON_DEMAND foundation models are + returned as {id, label}; profiles win over the foundation models + they front; non-ON_DEMAND and non-ACTIVE models are excluded; + anthropic sorts first.""" + fake_bedrock.client = FakeBedrockControlClient( + profiles=PROFILES, models=FOUNDATION_MODELS) + + status, payload = invoke_models(bedrock_env, make_user("PortalAdmin")) + assert status == 200 + assert "permissions" not in payload + + ids = [m["id"] for m in payload["models"]] + assert ids == [ + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", # anthropic first + "amazon.titan-text-express-v1", + "us.amazon.nova-pro-v1:0", + ] + assert payload["models"][0]["label"] == "US Anthropic Claude Sonnet 4.5" + # Deduplicated: the profile-fronted foundation model id is absent. + assert "anthropic.claude-sonnet-4-5-20250929-v1:0" not in ids + # Excluded: LEGACY lifecycle and profile-only inference types. + assert "anthropic.claude-v2" not in ids + assert "meta.llama4-maverick-17b-instruct-v1:0" not in ids + + def test_uses_configured_region_with_query_override(self, bedrock_env, + fake_bedrock): + """The listing targets the stored Bedrock_Configuration region; + ?region=... overrides it.""" + admin = make_user("PortalAdmin") + invoke(bedrock_env, "PUT", admin, body=VALID_CONFIG) # us-west-2 + + status, payload = invoke_models(bedrock_env, admin) + assert status == 200 + assert payload["region"] == "us-west-2" + assert fake_bedrock.regions[-1] == "us-west-2" + + status, payload = invoke_models(bedrock_env, admin, + query={"region": "eu-central-1"}) + assert status == 200 + assert payload["region"] == "eu-central-1" + assert fake_bedrock.regions[-1] == "eu-central-1" + + def test_access_denied_returns_empty_list_with_permissions_hint( + self, bedrock_env, fake_bedrock): + """When the Lambda lacks the bedrock list permissions, the endpoint + degrades gracefully: 200 with an empty list and a 'permissions' + hint so the UI falls back to free-text entry.""" + fake_bedrock.client = FakeBedrockControlClient( + profiles_error=access_denied_error("ListInferenceProfiles"), + models_error=access_denied_error("ListFoundationModels"), + ) + + status, payload = invoke_models(bedrock_env, make_user("PortalAdmin")) + assert status == 200 + assert payload["models"] == [] + assert "permissions" in payload + + def test_partial_access_denied_still_returns_available_options( + self, bedrock_env, fake_bedrock): + """If only one list call is denied the other's results are still + returned, together with the permissions hint.""" + fake_bedrock.client = FakeBedrockControlClient( + profiles=PROFILES, + models_error=access_denied_error("ListFoundationModels"), + ) + + status, payload = invoke_models(bedrock_env, make_user("PortalAdmin")) + assert status == 200 + assert [m["id"] for m in payload["models"]] == [ + "us.anthropic.claude-sonnet-4-5-20250929-v1:0", + "us.amazon.nova-pro-v1:0", + ] + assert "permissions" in payload diff --git a/edge-cv-portal/backend/tests/test_camera_binding_completeness_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_completeness_properties.py new file mode 100644 index 00000000..ee7a0e0e --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_completeness_properties.py @@ -0,0 +1,224 @@ +""" +Property-based test for deploy-time Camera_Binding completeness +validation — validate_camera_bindings (functions/deployments.py). + +**Feature: camera-registry-sync, Property 13: Binding completeness validation** + +*For any* workflow version with binding points, any set of target +devices, and any bindings map, deployment validation reports an unbound +error identifying exactly the (Camera_Input_Node, Edge_Device) pairs +missing from the map — including maps that bind the same node to +different sources on different devices without error — and reports +nothing for workflow versions containing no Camera_Input_Nodes. + +**Validates: Requirements 8.3, 8.7, 8.9** + +Task 11.2 (spec: camera-registry-sync). The example-based unit tests over +the same function live in test_camera_binding_validation.py (task 11.1). +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_DEVICES = ["line-a", "line-b", "line-c"] + +#: Per-(device, node) binding shapes. "missing" and "empty" are the two +#: unbound forms (no entry at all / an entry carrying neither a +#: cameraSourceId nor an override); "source" and "override" are the two +#: bound forms of Requirement 8.7. +_BINDING_KINDS = st.sampled_from(["missing", "empty", "source", "override"]) + + +def _clean_registry_entry(): + """A synced, present, non-stale Camera-type entry: bound sources are + kept healthy and compatible so completeness is the only signal.""" + return {"type": "Camera", + "params": {"devicePath": "/dev/video0"}, + "sync_status": "synced", + "absent": False, + "stale": False} + + +@st.composite +def _completeness_cases(draw): + """A version with binding points, targets, per-device registries, and + a bindings map with a known set of unbound (device, node) pairs. + + Bound registered-source bindings use a source id unique to the + device (``cfg-{device}-{node}``), so any case where the same node is + bound on several devices exercises the distinct-bindings-per-device + acceptance of Requirement 8.3 by construction. + """ + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + node_types = { + node_id: draw(st.sampled_from(["camera_source", "acme.cam_input"])) + for node_id in node_ids + } + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + bindings = {} + unbound_pairs = set() + for device in targets: + cameras = {} + device_bindings = {} + for node_id in node_ids: + kind = draw(_BINDING_KINDS) + if kind == "override" and node_types[node_id] != "camera_source": + # Overrides for custom types need caller-supplied + # descriptors; out of scope for completeness. + kind = "source" + if kind == "missing": + unbound_pairs.add((device, node_id)) + elif kind == "empty": + device_bindings[node_id] = {} + unbound_pairs.add((device, node_id)) + elif kind == "source": + camera_source_id = f"cfg-{device}-{node_id}" + cameras[camera_source_id] = _clean_registry_entry() + device_bindings[node_id] = { + "cameraSourceId": camera_source_id} + else: + device_bindings[node_id] = { + "override": {"device": "/dev/video1", + "gain": draw(st.integers(0, 100))}} + # A device with no bound nodes may be missing from the map + # entirely or present with an empty entry — both mean unbound. + if device_bindings or draw(st.booleans()): + bindings[device] = device_bindings + registry_snapshot[device] = {"never_synced": False, + "cameras": cameras} + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": node_id, + "node_type": node_types[node_id], + "compiled_device_paths": {"x86_64": "/dev/video0"}} + for node_id in node_ids + ], + } + return version, targets, registry_snapshot, bindings, unbound_pairs + + +@st.composite +def _no_camera_node_cases(draw): + """A version without Camera_Input_Nodes plus arbitrary targets, + registries (including never-synced and degraded entries), and + bindings maps — none of which may produce output (Req 8.9).""" + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, max_size=3)) + registry_snapshot = {} + bindings = {} + for device in targets: + if draw(st.booleans()): + registry_snapshot[device] = { + "never_synced": draw(st.booleans()), + "cameras": { + "cfg-1": { + "type": draw(st.sampled_from( + ["Camera", "Folder", "RTSP"])), + "params": {"devicePath": "/dev/video0"}, + "sync_status": draw(st.sampled_from( + ["synced", "pending", "failed"])), + "absent": draw(st.booleans()), + "stale": draw(st.booleans()), + }, + }, + } + if draw(st.booleans()): + bindings[device] = { + "n1": draw(st.sampled_from( + [{}, {"cameraSourceId": "cfg-1"}, + {"override": {"gain": 5}}])), + } + version = {"has_binding_points": draw(st.booleans()), + "camera_input_nodes": []} + confirmed = draw(st.lists(st.sampled_from(["w-1", "w-2"]), max_size=2)) + return version, targets, registry_snapshot, bindings, confirmed + + +# --------------------------------------------------------------------------- +# Property 13 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_completeness_cases()) +def test_unbound_errors_identify_exactly_the_missing_pairs(deployments, case): + """**Feature: camera-registry-sync, Property 13: Binding completeness + validation** + + **Validates: Requirements 8.3, 8.7** + + Unbound errors are produced exactly for the (node, device) pairs + missing a binding, each identifying its node and device; distinct + per-device bindings for the same node are accepted without error. + """ + version, targets, registry_snapshot, bindings, unbound_pairs = case + + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + unbound_errors = [e for e in errors + if e["code"] == deployments.CAMERA_ERROR_UNBOUND] + + # Exactly one unbound error per missing (device, node) pair — no + # pair reported twice, no bound pair reported (8.7). + assert {(e["device"], e["nodeId"]) for e in unbound_errors} \ + == unbound_pairs + assert len(unbound_errors) == len(unbound_pairs) + + # Each error message identifies the node and the device (8.7). + for error in unbound_errors: + assert error["nodeId"] in error["message"] + assert error["device"] in error["message"] + + # Every bound pair is a healthy, compatible source or a valid + # override — including the same node bound to different sources on + # different devices — so completeness is the only error and there + # are no warnings (8.3). + assert errors == unbound_errors + assert warnings == [] + + +@settings(deadline=None) +@given(_no_camera_node_cases()) +def test_version_without_camera_nodes_reports_nothing(deployments, case): + """**Feature: camera-registry-sync, Property 13: Binding completeness + validation** + + **Validates: Requirements 8.9** + + A version containing no Camera_Input_Nodes produces no errors and no + warnings for any targets, registry snapshots, or bindings maps. + """ + version, targets, registry_snapshot, bindings, confirmed = case + + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, confirmed) + + assert errors == [] + assert warnings == [] diff --git a/edge-cv-portal/backend/tests/test_camera_binding_context.py b/edge-cv-portal/backend/tests/test_camera_binding_context.py new file mode 100644 index 00000000..9392152f --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_context.py @@ -0,0 +1,384 @@ +""" +Deploy-time binding context endpoint and Camera_Binding delivery — +get_camera_binding_context / create_workflow_deployment +(functions/deployments.py, camera-registry-sync task 11.7). + +Verifies: +- the binding context view returns, per Camera_Input_Node and target + Edge_Device, the device's registered Camera_Sources with hint-matching + pre-selection (Requirements 8.1, 8.5), and an empty matrix for versions + without Camera_Input_Nodes (8.9); +- submission with camera_bindings writes desired.bindings["{wf}/{ver}"] + into each target thing's dda-camera-bindings shadow, prunes keys for + versions no longer deployed, leaves the Greengrass component set + untouched, and stores the bindings on the workflow-deployment record + (Requirements 8.2, 8.6, 12.3); +- a registry read failure rejects with REGISTRY_UNAVAILABLE instead of + skipping validation; a mid-submission shadow write failure aborts + deployment creation and best-effort prunes already-written targets. +""" +import io +import json +import sys +import uuid + +import pytest +from botocore.exceptions import ClientError + +from conftest import REGION, TEST_ENV + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-binding-context" +ACCOUNT_ID = "123456789012" +NOW_MS = 1_730_000_000_000 + + +# -------------------------------------------------------------------------- +# Module import with the camera registry table in place +# -------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def binding_stack(aws_stack): + import boto3 + import os + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + resource = boto3.resource("dynamodb", region_name=REGION) + yield { + "deployments": deployments, + "registry": resource.Table(CAMERA_REGISTRY_TABLE_NAME), + } + + +# -------------------------------------------------------------------------- +# Fake Use_Case-account clients (greengrassv2 / iot-data) +# -------------------------------------------------------------------------- + +class FakeGreengrass: + """Just enough greengrassv2 for the deployment flow: LocalServer + compatibility listing, latest-deployment lookup, and creation.""" + + def __init__(self): + self.installed = {} + self.create_deployment_calls = [] + + def register_device(self, thing_name, local_server_version="99.0.0"): + self.installed[thing_name] = [{ + "componentName": "aws.edgeml.dda.LocalServer.x86_64", + "componentVersion": local_server_version, + }] + + def get_paginator(self, operation): + assert operation == "list_installed_components" + installed = self.installed + + class _Paginator: + def paginate(self, coreDeviceThingName=None, **_): + return iter([{"installedComponents": + list(installed.get(coreDeviceThingName, []))}]) + return _Paginator() + + def list_deployments(self, **_): + return {"deployments": []} + + def create_deployment(self, **params): + self.create_deployment_calls.append(params) + deployment_id = f"dep-{uuid.uuid4()}" + return {"deploymentId": deployment_id, + "iotJobId": f"job-{deployment_id}", + "iotJobArn": f"arn:aws:iot:{REGION}:{ACCOUNT_ID}:job/x"} + + +class FakeIotData: + """Stateful fake of the assumed-role iot-data client: named-shadow + get/update with standard null-key pruning semantics.""" + + def __init__(self): + self.bindings = {} # thing_name -> {key: bindings} + self.fail_for = set() + + def get_thing_shadow(self, thingName, shadowName): + assert shadowName == "dda-camera-bindings" + if thingName not in self.bindings: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no shadow"}}, "GetThingShadow") + payload = json.dumps({"state": {"desired": { + "bindings": self.bindings[thingName]}}}) + return {"payload": io.BytesIO(payload.encode())} + + def update_thing_shadow(self, thingName, shadowName, payload): + assert shadowName == "dda-camera-bindings" + if thingName in self.fail_for: + raise ClientError( + {"Error": {"Code": "InternalFailure", "Message": "boom"}}, + "UpdateThingShadow") + update = json.loads(payload)["state"]["desired"]["bindings"] + current = self.bindings.setdefault(thingName, {}) + for key, value in update.items(): + if value is None: + current.pop(key, None) + else: + current[key] = value + + +# -------------------------------------------------------------------------- +# Harness +# -------------------------------------------------------------------------- + +class BindingEnv: + def __init__(self, env, binding_stack, monkeypatch): + self.env = env + self.deployments = binding_stack["deployments"] + self.registry = binding_stack["registry"] + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_id = env.create_usecase() + self.workflow_id = f"wf-{uuid.uuid4()}" + + self.gg = FakeGreengrass() + self.iot_data = FakeIotData() + + def fake_client(service_name, usecase, session_name=None, region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "greengrassv2": + return self.gg + if service_name == "iot-data": + return self.iot_data + if service_name == "iot": + # Built unconditionally by the create flow; only used to + # resolve thing-group targets, which these tests avoid. + return object() + raise AssertionError(f"unexpected client: {service_name}") + + monkeypatch.setattr(self.deployments, "get_usecase_client", fake_client) + + # ------------------------------------------------------------- setup + def seed_workflow(self, camera_nodes, has_binding_points=True): + self.env.stack.tables.workflows.put_item(Item={ + "workflow_id": self.workflow_id, + "usecase_id": self.usecase_id, + "name": "camera workflow", + "latest_version": 1, + }) + item = { + "workflow_id": self.workflow_id, + "version": 1, + "validation_status": {"status": "passed", "validated_at": 1, + "findings": []}, + "component_arn": f"arn:aws:greengrass:{REGION}:{ACCOUNT_ID}:" + f"components:wf:versions:1", + } + if camera_nodes is not None: + item["has_binding_points"] = has_binding_points + item["camera_input_nodes"] = camera_nodes + self.env.stack.tables.versions.put_item(Item=item) + + def seed_registry(self, thing_name, cameras, never_synced=False): + self.registry.put_item(Item={ + "device_id": thing_name, "sk": "META", + "usecase_id": self.usecase_id, + "never_synced": never_synced, "last_report_at": NOW_MS, + }) + for csid, entry in cameras.items(): + item = { + "device_id": thing_name, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": self.usecase_id, + "name": f"cam {csid}", "type": "Camera", + "params": {"devicePath": "/dev/video0"}, + "capabilities": {}, "origin": "edge-configured", + "version": 1, "sync_status": "synced", "absent": False, + # Fresh (non-stale) unless the entry overrides it. + "last_reported_at": int( + self.deployments.datetime.utcnow().timestamp() * 1000), + } + item.update(entry) + self.registry.put_item(Item={k: v for k, v in item.items() + if v is not None}) + + # ------------------------------------------------------------ invoke + def binding_context(self, targets, **query): + query = {"view": "binding-context", "usecase_id": self.usecase_id, + "workflow_id": self.workflow_id, + "target_devices": ",".join(targets), **query} + event = self.env.event("GET", "/deployments", self.user, query=query) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def deploy(self, targets, **body): + body = {"component_type": "workflow", "usecase_id": self.usecase_id, + "workflow_id": self.workflow_id, "target_devices": targets, + **body} + event = self.env.event("POST", "/deployments", self.user, body=body) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def camera_node(node_id="n1", hint_csid=None): + node = {"node_id": node_id, "node_type": "camera_source", + "compiled_device_paths": {"x86_64": "/dev/video0"}} + if hint_csid: + node["binding_hint"] = {"cameraSourceId": hint_csid} + return node + + +@pytest.fixture +def fleet(env, binding_stack, monkeypatch): + return BindingEnv(env, binding_stack, monkeypatch) + + +# -------------------------------------------------------------------------- +# Binding context endpoint (Requirements 8.1, 8.5, 8.9) +# -------------------------------------------------------------------------- + +class TestBindingContext: + def test_per_target_cameras_with_hint_preselection(self, fleet): + """The hint pre-selects only on devices whose registry contains + the hinted source (8.5); every device's Camera_Sources are the + selectable options (8.1).""" + fleet.seed_workflow([camera_node(hint_csid="cfg-1")]) + fleet.seed_registry("line-a", {"cfg-1": {}, "cfg-2": {}}) + fleet.seed_registry("line-b", {"cfg-9": {}}) + + status, payload = fleet.binding_context(["line-a", "line-b"]) + + assert status == 200, payload + assert payload["binding_required"] is True + assert payload["camera_input_nodes"] == [{ + "node_id": "n1", "node_type": "camera_source", + "binding_hint": {"cameraSourceId": "cfg-1"}, + }] + line_a = payload["targets"]["line-a"] + assert line_a["state"] == "synced" + assert [c["camera_source_id"] for c in line_a["cameras"]] == \ + ["cfg-1", "cfg-2"] + assert line_a["preselected"] == {"n1": "cfg-1"} + # Fields the picker displays (7.4-shaped view) + option = line_a["cameras"][0] + for field in ("name", "type", "params", "sync_status", "stale", + "absent", "origin"): + assert field in option + # line-b does not register the hinted source: no pre-selection + assert payload["targets"]["line-b"]["preselected"] == {} + + def test_never_synced_target_state(self, fleet): + fleet.seed_workflow([camera_node()]) + status, payload = fleet.binding_context(["line-x"]) + assert status == 200, payload + assert payload["targets"]["line-x"]["state"] == "never-synced" + assert payload["targets"]["line-x"]["cameras"] == [] + + def test_version_without_camera_nodes_returns_empty_matrix(self, fleet): + """No Camera_Input_Nodes: the frontend skips the step (8.9); no + targets are even required.""" + fleet.seed_workflow(None) + status, payload = fleet.binding_context([]) + assert status == 200, payload + assert payload["binding_required"] is False + assert payload["camera_input_nodes"] == [] + assert payload["targets"] == {} + + +# -------------------------------------------------------------------------- +# Submission: delivery, pruning, record storage (8.2, 8.6, 12.3) +# -------------------------------------------------------------------------- + +class TestSubmission: + def test_bindings_delivered_pruned_and_recorded(self, fleet): + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.seed_registry("line-b", {"cfg-2": {}}) + fleet.gg.register_device("line-a") + fleet.gg.register_device("line-b") + # Pre-existing shadow keys on line-a: an older version of this + # workflow and a workflow absent from the deployment's component + # set — both no longer deployed, both pruned. + key = f"{fleet.workflow_id}/1" + fleet.iot_data.bindings["line-a"] = { + f"{fleet.workflow_id}/0": {"n1": {"cameraSourceId": "old"}}, + "gone-wf/3": {"n1": {"cameraSourceId": "old"}}, + } + bindings = {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}, + "line-b": {"n1": {"cameraSourceId": "cfg-2"}}} + + status, payload = fleet.deploy(["line-a", "line-b"], + camera_bindings=bindings) + + assert status == 201, payload + assert payload["camera_bindings_delivered"] is True + # Distinct bindings per device, keyed {workflowId}/{version} (8.2) + assert fleet.iot_data.bindings["line-a"] == { + key: {"n1": {"cameraSourceId": "cfg-1"}}} + assert fleet.iot_data.bindings["line-b"] == { + key: {"n1": {"cameraSourceId": "cfg-2"}}} + # Greengrass deployment carries only the component map — the + # artifact and component set are untouched by bindings (8.6) + [call] = fleet.gg.create_deployment_calls + assert call["components"] == { + f"dda.workflow.{fleet.workflow_id}": {"componentVersion": "1.0.0"}} + # Bindings stored on the workflow-deployment record (12.3) + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": payload["deployment_id"]})["Item"] + assert record["camera_bindings"] == bindings + + def test_registry_read_failure_rejects_with_registry_unavailable( + self, fleet, monkeypatch): + fleet.seed_workflow([camera_node()]) + fleet.gg.register_device("line-a") + monkeypatch.setattr(fleet.deployments, "CAMERA_REGISTRY_TABLE", + "missing-table-name") + status, payload = fleet.deploy( + ["line-a"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}) + assert status == 503 + assert payload["error"]["code"] == "REGISTRY_UNAVAILABLE" + assert fleet.gg.create_deployment_calls == [] + + def test_mid_submission_failure_aborts_and_prunes_written_targets( + self, fleet): + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.seed_registry("line-b", {"cfg-2": {}}) + fleet.gg.register_device("line-a") + fleet.gg.register_device("line-b") + fleet.iot_data.fail_for.add("line-b") + + status, payload = fleet.deploy( + ["line-a", "line-b"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}, + "line-b": {"n1": {"cameraSourceId": "cfg-2"}}}) + + assert status == 502 + assert payload["error"]["code"] == "BINDING_DELIVERY_FAILED" + assert payload["error"]["details"]["failed_device"] == "line-b" + # No Greengrass deployment was created + assert fleet.gg.create_deployment_calls == [] + # line-a's already-written key was best-effort pruned + assert f"{fleet.workflow_id}/1" not in \ + fleet.iot_data.bindings.get("line-a", {}) + + def test_unbound_node_rejected_before_any_shadow_write(self, fleet): + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.gg.register_device("line-a") + status, payload = fleet.deploy(["line-a"]) + assert status == 409 + assert payload["error"]["code"] == "CAMERA_BINDINGS_INVALID" + assert fleet.iot_data.bindings == {} + assert fleet.gg.create_deployment_calls == [] diff --git a/edge-cv-portal/backend/tests/test_camera_binding_degraded_warning_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_degraded_warning_properties.py new file mode 100644 index 00000000..29ccd768 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_degraded_warning_properties.py @@ -0,0 +1,263 @@ +""" +Property-based test for deploy-time degraded-source warning gating — +validate_camera_bindings (functions/deployments.py). + +**Feature: camera-registry-sync, Property 15: Degraded-source warnings gate +submission on confirmation** + +*For any* binding whose referenced Camera_Source is stale, marked absent, +or has sync status pending or failed, and for any target device that has +never synced, validation emits a warning identifying the condition, and +the deployment is accepted if and only if every emitted warning id +appears in the submitted confirmations (with never-synced devices +additionally restricted to manual overrides). + +**Validates: Requirements 8.8, 9.3** + +Task 11.4 (spec: camera-registry-sync). The example-based unit tests over +the same function live in test_camera_binding_validation.py (task 11.1); +the completeness property (task 11.2) lives in +test_camera_binding_completeness_properties.py and the existence property +(task 11.3) in test_camera_binding_existence_properties.py. +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_DEVICES = ["line-a", "line-b", "line-c"] + +#: The Requirement 9.3 degraded conditions a bound registry entry may be +#: in, drawn independently so any combination (including none) occurs. +_SYNC_STATUSES = ["synced", "pending", "failed"] + + +@st.composite +def _degraded_cases(draw): + """A version with binding points, targets, per-device registries, and + bindings with a known set of expected warnings. + + Each target is either + + * **never synced with an empty registry** (8.8) — absent from the + snapshot entirely or present with ``never_synced`` and no cameras; + its nodes are bound by manual override (permitted) or by a + registered-source reference (restricted: the empty registry makes + it a missing-source error), or + * **synced** — every node bound to an entry present in the registry + whose degraded flags (absent / stale / sync status) are drawn + independently, so bindings range from fully healthy (no warning) + to degraded in every condition at once. + + Also draws the subset of expected warnings to confirm in the + partial-confirmation pass. + """ + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + node_types = { + node_id: draw(st.sampled_from(["camera_source", "acme.cam_input"])) + for node_id in node_ids + } + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + bindings = {} + # (device, node_id, camera_source_id) -> frozenset of conditions + expected_degraded = {} + expected_never_synced = set() + # Source references on never-synced devices: restricted to manual + # override, so these must surface as missing-source errors (8.8). + expected_missing = set() + confirm_keys = set() + + for device in targets: + device_bindings = {} + if draw(st.booleans()): + # Never synced, no registered Camera_Sources (8.8). + expected_never_synced.add(device) + if draw(st.booleans()): + confirm_keys.add(("never-synced", device)) + if draw(st.booleans()): + # Present in the snapshot as never synced and empty; the + # other branch leaves it out entirely (fail-safe rule). + registry_snapshot[device] = {"never_synced": True, + "cameras": {}} + for node_id in node_ids: + override_allowed = node_types[node_id] == "camera_source" + if override_allowed and draw(st.booleans()): + device_bindings[node_id] = { + "override": {"device": "/dev/video1", + "gain": draw(st.integers(0, 100))}} + else: + camera_source_id = f"cfg-{device}-{node_id}" + device_bindings[node_id] = { + "cameraSourceId": camera_source_id} + expected_missing.add( + (device, node_id, camera_source_id)) + else: + cameras = {} + for node_id in node_ids: + camera_source_id = f"cfg-{device}-{node_id}" + entry = { + # Camera is compatible with both node types, so type + # mismatches never intrude on the warning signal. + "type": "Camera", + "params": {"devicePath": "/dev/video0"}, + "sync_status": draw(st.sampled_from(_SYNC_STATUSES)), + "absent": draw(st.booleans()), + "stale": draw(st.booleans()), + } + cameras[camera_source_id] = entry + device_bindings[node_id] = { + "cameraSourceId": camera_source_id} + conditions = set() + if entry["absent"]: + conditions.add("absent") + if entry["stale"]: + conditions.add("stale") + if entry["sync_status"] in ("pending", "failed"): + conditions.add(entry["sync_status"]) + if conditions: + key = (device, node_id, camera_source_id) + expected_degraded[key] = frozenset(conditions) + if draw(st.booleans()): + confirm_keys.add(("degraded",) + key) + registry_snapshot[device] = {"never_synced": False, + "cameras": cameras} + bindings[device] = device_bindings + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": node_id, + "node_type": node_types[node_id], + "compiled_device_paths": {"x86_64": "/dev/video0"}} + for node_id in node_ids + ], + } + return (version, targets, registry_snapshot, bindings, + expected_degraded, expected_never_synced, expected_missing, + confirm_keys) + + +def _warning_key(deployments, warning): + """A warning's identity independent of its id string.""" + if warning["code"] == deployments.CAMERA_WARNING_NEVER_SYNCED: + return ("never-synced", warning["device"]) + return ("degraded", warning["device"], warning["nodeId"], + warning["cameraSourceId"]) + + +# --------------------------------------------------------------------------- +# Property 15 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_degraded_cases()) +def test_degraded_warnings_gate_submission_on_confirmation( + deployments, case): + """**Feature: camera-registry-sync, Property 15: Degraded-source + warnings gate submission on confirmation** + + **Validates: Requirements 8.8, 9.3** + + Warnings are emitted exactly for degraded bindings (identifying each + condition) and never-synced targets (restricted to manual override: + registered-source references on them are missing-source errors), and + each warning is marked confirmed iff its id was submitted — + submitting all warning ids from a first pass yields a second pass in + which every warning is confirmed. + """ + (version, targets, registry_snapshot, bindings, + expected_degraded, expected_never_synced, expected_missing, + confirm_keys) = case + + # -------------------------------------------------- unconfirmed pass + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + degraded = [w for w in warnings + if w["code"] == deployments.CAMERA_WARNING_SOURCE_DEGRADED] + never_synced = [w for w in warnings + if w["code"] == deployments.CAMERA_WARNING_NEVER_SYNCED] + + # Exactly one degraded warning per degraded binding, identifying the + # Camera_Source and every condition it is in (9.3). + assert {(w["device"], w["nodeId"], w["cameraSourceId"]): + frozenset(w["conditions"]) + for w in degraded} == expected_degraded + assert len(degraded) == len(expected_degraded) + for warning in degraded: + assert warning["cameraSourceId"] in warning["message"] + assert warning["device"] in warning["message"] + for condition in warning["conditions"]: + assert condition in warning["message"] + + # Exactly one never-synced warning per never-synced target (8.8). + assert {w["device"] for w in never_synced} == expected_never_synced + assert len(never_synced) == len(expected_never_synced) + for warning in never_synced: + assert warning["device"] in warning["message"] + + assert len(warnings) == len(degraded) + len(never_synced) + + # Warning ids are distinct, so confirming one never confirms another. + ids = [w["id"] for w in warnings] + assert len(set(ids)) == len(ids) + + # Nothing is confirmed when nothing was submitted. + assert all(not w["confirmed"] for w in warnings) + + # Never-synced targets permit binding only through manual override: + # the only errors are the missing-source rejections of their + # registered-source references (8.8). + assert {(e["device"], e["nodeId"], e["cameraSourceId"]) + for e in errors} == expected_missing + assert len(errors) == len(expected_missing) + assert all(e["code"] == deployments.CAMERA_ERROR_SOURCE_MISSING + for e in errors) + + # ------------------------------------------- partial-confirmation pass + submitted = [w["id"] for w in warnings + if _warning_key(deployments, w) in confirm_keys] + partial_errors, partial_warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, + submitted + ["not-a-real-warning-id"]) + + assert [w["id"] for w in partial_warnings] == ids + assert partial_errors == errors + # Each warning is confirmed iff its id was submitted, so the caller's + # every-warning-confirmed acceptance rule passes exactly when the + # submission covered all warnings. + for warning in partial_warnings: + assert warning["confirmed"] == ( + _warning_key(deployments, warning) in confirm_keys) + + # ---------------------------------------------- full-confirmation pass + full_errors, full_warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, ids) + + assert [w["id"] for w in full_warnings] == ids + assert full_errors == errors + assert all(w["confirmed"] for w in full_warnings) diff --git a/edge-cv-portal/backend/tests/test_camera_binding_delivery_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_delivery_properties.py new file mode 100644 index 00000000..fb10cbaf --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_delivery_properties.py @@ -0,0 +1,261 @@ +""" +Property-based test for deploy-time Camera_Binding delivery — +deliver_camera_bindings (functions/deployments.py). + +**Feature: camera-registry-sync, Property 19: Binding delivery round trip** + +*For any* validated bindings map, the desired document written to each +target device's bindings shadow, decoded back, equals the submitted +bindings for that device and workflow version, and the packaged +Workflow_Component artifact is not modified by the submission. + +**Validates: Requirements 8.2, 8.6** + +Task 11.9 (spec: camera-registry-sync). Exercised at the pure-function +level against a stateful fake iot-data client (mirroring FakeIotData in +test_camera_binding_context.py, task 11.7): delivery touches nothing +but the `dda-camera-bindings` shadow — the call log admits only +shadow get/update on the delivery targets, which is how the artifact +remains untouched by construction (8.6); the route-level example that +the Greengrass component set carries no binding material is covered by +test_camera_binding_context.py. +""" +import io +import json +import sys + +import pytest +from botocore.exceptions import ClientError +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Fake iot-data client (mirrors task 11.7's FakeIotData, plus a call log) +# --------------------------------------------------------------------------- + +class FakeIotData: + """Stateful fake of the assumed-role iot-data client: named-shadow + get/update with standard null-key pruning semantics. Every call is + logged as (operation, thing_name, shadow_name) so the property can + assert the delivery path performs no other writes.""" + + def __init__(self): + self.bindings = {} # thing_name -> {key: bindings} + self.fail_for = set() + self.calls = [] + + def get_thing_shadow(self, thingName, shadowName): + self.calls.append(("get", thingName, shadowName)) + if thingName not in self.bindings: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no shadow"}}, "GetThingShadow") + payload = json.dumps({"state": {"desired": { + "bindings": self.bindings[thingName]}}}) + return {"payload": io.BytesIO(payload.encode())} + + def update_thing_shadow(self, thingName, shadowName, payload): + self.calls.append(("update", thingName, shadowName)) + if thingName in self.fail_for: + raise ClientError( + {"Error": {"Code": "InternalFailure", "Message": "boom"}}, + "UpdateThingShadow") + update = json.loads(payload)["state"]["desired"]["bindings"] + current = self.bindings.setdefault(thingName, {}) + for key, value in update.items(): + if value is None: + current.pop(key, None) + else: + current[key] = value + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_THING_POOL = ["line-a", "line-b", "line-c", "line-d"] +_NODE_IDS = ["n1", "n2", "n3"] +_WORKFLOW_IDS = ["wf-cam", "wf-other"] +#: Every {workflowId}/{version} key a shadow or deployment may carry. +_KEY_POOL = [f"{wf}/{v}" for wf in _WORKFLOW_IDS for v in (1, 2, 3)] + +#: The two bound forms of a per-node Camera_Binding (Req 8.2 / 8.4). +_binding_values = st.one_of( + st.builds(lambda csid: {"cameraSourceId": csid}, + st.sampled_from(["cfg-1", "cfg-2", "cfg-3"])), + st.builds(lambda gain: {"override": {"device": "/dev/video1", + "gain": gain}}, + st.integers(0, 100)), +) + + +@st.composite +def _delivery_cases(draw): + """Target things, the deployment's binding key, a per-device + bindings map (devices may carry distinct bindings for the same node, + an empty entry, or no entry at all), pre-existing shadow keys with + distinguishable old content (possibly including the binding key + itself), and the set of keys still deployed to the fleet.""" + things = draw(st.lists(st.sampled_from(_THING_POOL), + unique=True, min_size=1, max_size=4)) + binding_key = draw(st.sampled_from(_KEY_POOL)) + + camera_bindings = {} + pre_existing = {} + for thing in things: + entry_kind = draw(st.sampled_from(["absent", "empty", "bound"])) + if entry_kind == "empty": + camera_bindings[thing] = {} + elif entry_kind == "bound": + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + camera_bindings[thing] = { + node_id: draw(_binding_values) for node_id in node_ids} + keys = draw(st.lists(st.sampled_from(_KEY_POOL), + unique=True, max_size=4)) + if keys: + pre_existing[thing] = { + key: {"n1": {"cameraSourceId": f"old-{thing}-{key}"}} + for key in keys} + deployed_keys = set(draw(st.lists(st.sampled_from(_KEY_POOL), + unique=True, max_size=4))) + return things, binding_key, camera_bindings, pre_existing, deployed_keys + + +@st.composite +def _failure_cases(draw): + """A delivery case plus the index of the thing whose shadow write + fails mid-list (possibly the first or the last).""" + case = draw(_delivery_cases()) + things = case[0] + fail_index = draw(st.integers(0, len(things) - 1)) + return case, fail_index + + +def _seed(pre_existing): + iot_data = FakeIotData() + for thing, keys in pre_existing.items(): + iot_data.bindings[thing] = dict(keys) + # A thing outside the deployment's target list: delivery must never + # touch it. + iot_data.bindings["bystander"] = { + _KEY_POOL[0]: {"n1": {"cameraSourceId": "bystander-cam"}}} + return iot_data + + +def _expected_shadow(pre_existing, thing, binding_key, camera_bindings, + deployed_keys): + """The post-delivery desired.bindings for one thing: pre-existing + keys survive exactly when still deployed, and the binding key holds + exactly the submitted bindings (empty when the device has none).""" + expected = {key: value + for key, value in pre_existing.get(thing, {}).items() + if key != binding_key and key in deployed_keys} + expected[binding_key] = camera_bindings.get(thing) or {} + return expected + + +# --------------------------------------------------------------------------- +# Property 19 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_delivery_cases()) +def test_delivery_round_trips_and_prunes_exactly(deployments, case): + """**Feature: camera-registry-sync, Property 19: Binding delivery + round trip** + + **Validates: Requirements 8.2, 8.6** + + Decoding each target thing's bindings shadow after delivery yields + exactly the submitted per-device bindings under the deployment's + {workflowId}/{version} key (8.2); pre-existing keys are pruned + exactly when their version is no longer deployed (8.6); and the + delivery path performs no operation besides get/update on the + targets' dda-camera-bindings shadows — the packaged artifact and + everything else are untouched by construction. + """ + things, binding_key, camera_bindings, pre_existing, deployed_keys = case + iot_data = _seed(pre_existing) + + written, failure = deployments.deliver_camera_bindings( + iot_data, things, binding_key, camera_bindings, deployed_keys) + + assert failure is None + assert written == list(things) + + # Round trip: the decoded shadow state is exactly the submitted + # bindings plus the still-deployed survivors — nothing else (8.2, 8.6). + for thing in things: + assert iot_data.bindings[thing] == _expected_shadow( + pre_existing, thing, binding_key, camera_bindings, deployed_keys) + + # Non-target things are never touched. + assert iot_data.bindings["bystander"] == { + _KEY_POOL[0]: {"n1": {"cameraSourceId": "bystander-cam"}}} + + # Artifact untouched: delivery consists solely of shadow get/update + # against the delivery targets' dda-camera-bindings shadows (8.6). + assert {operation for operation, _, _ in iot_data.calls} \ + <= {"get", "update"} + assert all(shadow == deployments.CAMERA_BINDINGS_SHADOW_NAME + for _, _, shadow in iot_data.calls) + assert {thing for _, thing, _ in iot_data.calls} == set(things) + + +@settings(deadline=None) +@given(_failure_cases()) +def test_mid_list_failure_aborts_and_prunes_written_targets(deployments, + case): + """**Feature: camera-registry-sync, Property 19: Binding delivery + round trip** + + **Validates: Requirements 8.2, 8.6** + + A shadow write failing for any thing mid-list aborts delivery with a + failure record naming that thing, returns exactly the things written + before it, best-effort prunes the deployment's binding key from + those already-written shadows, and leaves the failing and remaining + things' shadows unchanged. + """ + (things, binding_key, camera_bindings, pre_existing, + deployed_keys), fail_index = case + iot_data = _seed(pre_existing) + iot_data.fail_for.add(things[fail_index]) + + written, failure = deployments.deliver_camera_bindings( + iot_data, things, binding_key, camera_bindings, deployed_keys) + + assert failure is not None + assert failure["device"] == things[fail_index] + assert failure["error"] + assert written == things[:fail_index] + + # Already-written targets: the aborted deployment's key is pruned; + # still-deployed pre-existing keys survive the rollback. + for thing in written: + state = iot_data.bindings.get(thing, {}) + assert binding_key not in state + assert state == {key: value + for key, value in pre_existing.get(thing, {}).items() + if key != binding_key and key in deployed_keys} + + # The failing thing and every thing after it are untouched. + for thing in things[fail_index:]: + assert iot_data.bindings.get(thing, {}) == pre_existing.get(thing, {}) + assert iot_data.bindings["bystander"] == { + _KEY_POOL[0]: {"n1": {"cameraSourceId": "bystander-cam"}}} diff --git a/edge-cv-portal/backend/tests/test_camera_binding_existence_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_existence_properties.py new file mode 100644 index 00000000..03e50e7a --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_existence_properties.py @@ -0,0 +1,168 @@ +""" +Property-based test for deploy-time Camera_Binding existence +validation — validate_camera_bindings (functions/deployments.py). + +**Feature: camera-registry-sync, Property 14: Binding existence validation** + +*For any* bindings map and any per-device registry snapshots, validation +reports a missing-source error identifying the Camera_Source and +Edge_Device exactly for those bindings whose referenced `cameraSourceId` +is not present in that device's registry snapshot. + +**Validates: Requirements 9.1, 9.2** + +Task 11.3 (spec: camera-registry-sync). The example-based unit tests over +the same function live in test_camera_binding_validation.py (task 11.1); +the completeness property (task 11.2) lives in +test_camera_binding_completeness_properties.py. +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_DEVICES = ["line-a", "line-b", "line-c"] + + +@st.composite +def _registry_entries(draw): + """A registry entry in an arbitrary state: any type (compatible or + not), any sync status, any stale/absent flags. Existence is judged + purely by presence in the camera map, whatever other errors or + warnings the entry's state may produce.""" + params = {} + if draw(st.booleans()): + params["devicePath"] = draw(st.sampled_from( + ["/dev/video0", "/dev/video1"])) + return { + "type": draw(st.sampled_from( + ["Camera", "ICam", "NvidiaCSI", "Folder", "RTSP"])), + "params": params, + "sync_status": draw(st.sampled_from(["synced", "pending", "failed"])), + "absent": draw(st.booleans()), + "stale": draw(st.booleans()), + } + + +@st.composite +def _existence_cases(draw): + """A version with binding points, targets, per-device registries in + arbitrary states, and a bindings map where every (device, node) pair + references a cameraSourceId — some present in that device's registry, + some absent from it, some on devices missing from the snapshot + entirely (an empty registry by the fail-safe rule). + + Each referenced id is unique to its (device, node) pair + (``cfg-{device}-{node}``), so presence on one device never masks + absence on another — the per-device scoping of Requirement 9.1 is + exercised by construction. + """ + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + node_types = { + node_id: draw(st.sampled_from(["camera_source", "acme.cam_input"])) + for node_id in node_ids + } + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + bindings = {} + missing_refs = set() + for device in targets: + # A device may be missing from the snapshot entirely: treated as + # never synced with an empty registry, so every reference on it + # is a missing source. + snapshot_missing = draw(st.booleans()) + cameras = {} + device_bindings = {} + for node_id in node_ids: + camera_source_id = f"cfg-{device}-{node_id}" + present = draw(st.booleans()) and not snapshot_missing + if present: + cameras[camera_source_id] = draw(_registry_entries()) + else: + missing_refs.add((device, node_id, camera_source_id)) + device_bindings[node_id] = {"cameraSourceId": camera_source_id} + bindings[device] = device_bindings + if not snapshot_missing: + # Unreferenced entries must not affect the outcome. + if draw(st.booleans()): + cameras[f"extra-{device}"] = draw(_registry_entries()) + registry_snapshot[device] = { + "never_synced": draw(st.booleans()), + "cameras": cameras, + } + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": node_id, + "node_type": node_types[node_id], + "compiled_device_paths": {"x86_64": "/dev/video0"}} + for node_id in node_ids + ], + } + return version, targets, registry_snapshot, bindings, missing_refs + + +# --------------------------------------------------------------------------- +# Property 14 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_existence_cases()) +def test_missing_source_errors_identify_exactly_the_absent_references( + deployments, case): + """**Feature: camera-registry-sync, Property 14: Binding existence + validation** + + **Validates: Requirements 9.1, 9.2** + + A missing-source error is produced exactly for the (device, node) + pairs whose referenced cameraSourceId is absent from that device's + registry snapshot, each identifying the Camera_Source and the + Edge_Device; references present in the registry pass the existence + check regardless of the entry's type, sync status, or stale/absent + flags. + """ + version, targets, registry_snapshot, bindings, missing_refs = case + + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + missing_errors = [ + e for e in errors + if e["code"] == deployments.CAMERA_ERROR_SOURCE_MISSING] + + # Exactly one missing-source error per absent reference — nothing + # reported for ids present in the target's registry (9.1). + assert {(e["device"], e["nodeId"], e["cameraSourceId"]) + for e in missing_errors} == missing_refs + assert len(missing_errors) == len(missing_refs) + + # Each error identifies the missing Camera_Source and the + # Edge_Device (9.2). + for error in missing_errors: + assert error["cameraSourceId"] in error["message"] + assert error["device"] in error["message"] diff --git a/edge-cv-portal/backend/tests/test_camera_binding_hint_preselection_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_hint_preselection_properties.py new file mode 100644 index 00000000..8ae3ba68 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_hint_preselection_properties.py @@ -0,0 +1,235 @@ +""" +Property-based test for deploy-time binding hint pre-selection — +get_camera_binding_context (functions/deployments.py). + +**Feature: camera-registry-sync, Property 18: Binding hint pre-selection** + +*For any* Camera_Input_Node hint and any device registry snapshot, the +proposed pre-selection equals the registry entry whose id matches the +hint when such an entry exists, and is empty otherwise. + +**Validates: Requirements 8.5** + +Task 11.8 (spec: camera-registry-sync). Exercised route-level through +the binding-context view against the moto-backed registry table; the +example-based unit tests over the same endpoint live in +test_camera_binding_context.py (task 11.7). + +Every hypothesis example seeds its own workflow version and uniquely +named target devices into DynamoDB (the module-scoped Use_Case and user +are shared), so generated sizes are kept small: at most 3 nodes, 3 +devices, and 4 cameras per device. +""" +import json +import sys +import time +import uuid +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION, WorkflowStoreEnv + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-hint-preselection" +ACCOUNT_ID = "123456789012" + + +# --------------------------------------------------------------------------- +# Module import with the camera registry table in place +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def harness(aws_stack): + """Camera registry table plus deployments imported against it, and a + module-scoped Use_Case with a deploy-capable user shared by every + generated example. Examples isolate from one another through fresh + uuid-based workflow and device ids, never table truncation.""" + import os + + import boto3 + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + env = WorkflowStoreEnv(aws_stack) + resource = boto3.resource("dynamodb", region_name=REGION) + return SimpleNamespace( + deployments=deployments, + env=env, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + user=env.make_user(role="UseCaseAdmin"), + usecase_id=env.create_usecase(), + ) + + +# --------------------------------------------------------------------------- +# Per-example seeding and invocation +# --------------------------------------------------------------------------- + +def _seed_workflow(harness, workflow_id, camera_nodes): + harness.env.stack.tables.workflows.put_item(Item={ + "workflow_id": workflow_id, + "usecase_id": harness.usecase_id, + "name": "camera workflow", + "latest_version": 1, + }) + harness.env.stack.tables.versions.put_item(Item={ + "workflow_id": workflow_id, + "version": 1, + "validation_status": {"status": "passed", "validated_at": 1, + "findings": []}, + "component_arn": f"arn:aws:greengrass:{REGION}:{ACCOUNT_ID}:" + f"components:wf:versions:1", + "has_binding_points": True, + "camera_input_nodes": camera_nodes, + }) + + +def _seed_registry(harness, thing_name, camera_source_ids): + now_ms = int(time.time() * 1000) + harness.registry.put_item(Item={ + "device_id": thing_name, "sk": "META", + "usecase_id": harness.usecase_id, + "never_synced": False, "last_report_at": now_ms, + }) + for csid in camera_source_ids: + harness.registry.put_item(Item={ + "device_id": thing_name, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": harness.usecase_id, + "name": f"cam {csid}", "type": "Camera", + "params": {"devicePath": "/dev/video0"}, + "capabilities": {}, "origin": "edge-configured", + "version": 1, "sync_status": "synced", "absent": False, + "last_reported_at": now_ms, + }) + + +def _binding_context(harness, workflow_id, device_names): + query = {"view": "binding-context", "usecase_id": harness.usecase_id, + "workflow_id": workflow_id, + "target_devices": ",".join(device_names)} + event = harness.env.event("GET", "/deployments", harness.user, + query=query) + response = harness.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_CSID_POOL = ["cfg-1", "cfg-2", "cfg-3", "cfg-4"] + + +@st.composite +def _hint_cases(draw): + """A workflow version's Camera_Input_Nodes — each carrying a hint + referencing a Camera_Source id, a degenerate hint without an id, or + no hint at all — alongside per-device registry snapshots: devices + registering arbitrary subsets of the id pool (possibly none of the + hinted ids, possibly an empty registry) and devices never seen by + the registry at all.""" + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + nodes = [] + hinted = {} # node_id -> hinted cameraSourceId (only id-carrying hints) + for node_id in node_ids: + node = { + "node_id": node_id, + "node_type": draw(st.sampled_from( + ["camera_source", "acme.cam_input"])), + "compiled_device_paths": {"x86_64": "/dev/video0"}, + } + hint_kind = draw(st.sampled_from(["none", "id", "no-id"])) + if hint_kind == "id": + csid = draw(st.sampled_from(_CSID_POOL)) + node["binding_hint"] = {"cameraSourceId": csid, + "cameraName": f"cam {csid}", + "sourceDeviceId": "authoring-device"} + hinted[node_id] = csid + elif hint_kind == "no-id": + # Advisory hint that names no Camera_Source id: never + # pre-selects anything. + node["binding_hint"] = {"cameraName": "advisory only"} + nodes.append(node) + + # Per device: None means the device has never reported (no registry + # rows at all); a list is the device's registered Camera_Source ids + # (possibly empty). + device_cameras = draw(st.lists( + st.one_of(st.none(), + st.lists(st.sampled_from(_CSID_POOL), + unique=True, max_size=4)), + min_size=1, max_size=3)) + return nodes, hinted, device_cameras + + +# --------------------------------------------------------------------------- +# Property 18 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_hint_cases()) +def test_preselection_is_exactly_the_hint_matches(harness, case): + """**Feature: camera-registry-sync, Property 18: Binding hint + pre-selection** + + **Validates: Requirements 8.5** + + For every target Edge_Device, the binding context's proposed + pre-selection maps a Camera_Input_Node to its hinted cameraSourceId + exactly when that id is present in the device's Camera_Registry, and + proposes nothing for that node otherwise — including devices that + register other sources, devices with empty registries, never-synced + devices, hintless nodes, and hints naming no Camera_Source id. + """ + nodes, hinted, device_cameras = case + + workflow_id = f"wf-{uuid.uuid4()}" + _seed_workflow(harness, workflow_id, nodes) + stamp = uuid.uuid4().hex[:8] + device_names = [] + for index, cameras in enumerate(device_cameras): + thing_name = f"line-{stamp}-{index}" + device_names.append(thing_name) + if cameras is not None: + _seed_registry(harness, thing_name, cameras) + + status, payload = _binding_context(harness, workflow_id, device_names) + + assert status == 200, payload + assert set(payload["targets"]) == set(device_names) + for thing_name, cameras in zip(device_names, device_cameras): + target = payload["targets"][thing_name] + registered = set(cameras or []) + expected = {node_id: csid for node_id, csid in hinted.items() + if csid in registered} + # Pre-selection is exactly the hint matches present in THIS + # device's registry — empty when no hinted id is registered. + assert target["preselected"] == expected + # Coherence with 8.1: every proposed id is among the device's + # selectable Camera_Source options. + options = {c["camera_source_id"] for c in target["cameras"]} + assert options == registered + assert set(expected.values()) <= options diff --git a/edge-cv-portal/backend/tests/test_camera_binding_legacy_path_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_legacy_path_properties.py new file mode 100644 index 00000000..bcdcdbff --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_legacy_path_properties.py @@ -0,0 +1,225 @@ +""" +Property-based test for the deploy-time legacy compiled-path check — +validate_camera_bindings (functions/deployments.py). + +**Feature: camera-registry-sync, Property 17: Legacy compiled-path warning** + +*For any* workflow version without binding points carrying compiled-in +device paths, and any target device registry, validation emits a warning +for exactly those compiled-in paths that match no registered +Camera_Source's device path on that device, and never emits an error for +them. + +**Validates: Requirements 9.5, 11.1** + +Task 11.6 (spec: camera-registry-sync). The example-based unit tests over +the same function live in test_camera_binding_validation.py (task 11.1); +the sibling properties live in +test_camera_binding_completeness_properties.py (13), +test_camera_binding_existence_properties.py (14), +test_camera_binding_degraded_warning_properties.py (15), and +test_camera_binding_type_override_properties.py (16). +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_DEVICES = ["line-a", "line-b", "line-c"] +_ARCHES = ["aarch64", "armv7l", "x86_64"] + +#: Small device-path pool so compiled paths and registered paths overlap +#: often — both matched (silent) and unmatched (warning) paths occur. +_PATHS = ["/dev/video0", "/dev/video1", "/dev/video2", "/dev/video3"] + +#: Registry-entry param keys the validator accepts for the source's +#: device path (edge reports write devicePath; device is the node +#: parameter name). +_PATH_KEYS = ["devicePath", "device"] + + +@st.composite +def _legacy_cases(draw): + """A legacy workflow version (has_binding_points false) with + compiled-in device paths, targets, per-device registries, and the + expected unmatched-path warnings. + + Each Camera_Input_Node compiles a path for a drawn subset of + architectures; distinct architectures may share one path (the + warning groups them). Each target device is either absent from the + registry snapshot entirely (fail-safe: empty registry) or carries + Camera_Source entries whose device path sits under ``devicePath`` or + ``device`` — or carries no path at all — with degraded flags drawn + freely, since the legacy regime warns on unmatched paths and nothing + else. Also draws the warning subset to confirm in the + confirmation pass, and an arbitrary bindings map that the legacy + regime must ignore (11.1: bindings are never required). + """ + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + camera_nodes = [] + for node_id in node_ids: + arches = draw(st.lists(st.sampled_from(_ARCHES), + unique=True, min_size=1, max_size=3)) + compiled = {arch: draw(st.sampled_from(_PATHS)) for arch in arches} + if draw(st.booleans()): + # The packager records only rendered non-empty strings, but + # the validator tolerates blanks: they never warn. + compiled[draw(st.sampled_from(_ARCHES))] = "" + camera_nodes.append({ + "node_id": node_id, + "node_type": draw(st.sampled_from( + ["camera_source", "acme.cam_input"])), + "compiled_device_paths": compiled, + }) + + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + registered_paths = {} + for device in targets: + if draw(st.booleans()) and draw(st.booleans()): + # Absent from the snapshot: treated as an empty registry, so + # every non-empty compiled path is unmatched. + registered_paths[device] = set() + continue + cameras = {} + paths = set() + for index in range(draw(st.integers(0, 3))): + params = {} + if draw(st.booleans()): + key = draw(st.sampled_from(_PATH_KEYS)) + path = draw(st.sampled_from(_PATHS)) + params[key] = path + paths.add(path) + cameras[f"cfg-{device}-{index}"] = { + "type": draw(st.sampled_from(["Camera", "Folder"])), + "params": params, + "sync_status": draw(st.sampled_from( + ["synced", "pending", "failed"])), + "absent": draw(st.booleans()), + "stale": draw(st.booleans()), + } + registry_snapshot[device] = { + "never_synced": draw(st.booleans()) and not cameras, + "cameras": cameras, + } + registered_paths[device] = paths + + # (device, node_id, path) -> sorted architectures compiled to it + expected = {} + for device in targets: + for node in camera_nodes: + for arch in sorted(node["compiled_device_paths"]): + path = node["compiled_device_paths"][arch] + if path and path not in registered_paths[device]: + key = (device, node["node_id"], path) + expected.setdefault(key, []).append(arch) + + confirm_keys = {key for key in expected if draw(st.booleans())} + + # Arbitrary bindings the legacy regime must ignore entirely. + bindings = { + device: {node_id: draw(st.sampled_from([ + {"cameraSourceId": "cfg-anything"}, + {"override": {"device": "/dev/video9"}}, + ])) for node_id in node_ids if draw(st.booleans())} + for device in targets if draw(st.booleans()) + } + + version = {"has_binding_points": False, + "camera_input_nodes": camera_nodes} + return version, targets, registry_snapshot, bindings, expected, \ + confirm_keys + + +# --------------------------------------------------------------------------- +# Property 17 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_legacy_cases()) +def test_legacy_compiled_path_warning(deployments, case): + """**Feature: camera-registry-sync, Property 17: Legacy compiled-path + warning** + + **Validates: Requirements 9.5, 11.1** + + In the legacy regime a warning identifying the node, device, path, + and architectures is emitted for exactly the compiled-in paths that + match no registered Camera_Source device path on the target; matched + paths are silent, no errors are ever produced, and no bindings are + required (submitted bindings change nothing). + """ + (version, targets, registry_snapshot, bindings, expected, + confirm_keys) = case + + # ---------------------------------------- no-bindings, unconfirmed pass + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, {}, []) + + # Never an error, and no bindings required (9.5, 11.1). + assert errors == [] + + # A warning for exactly each unmatched (device, node, path), + # identifying the architectures compiled to it (9.5). + assert all(w["code"] == deployments.CAMERA_WARNING_LEGACY_PATH + for w in warnings) + assert {(w["device"], w["nodeId"], w["path"]): w["architectures"] + for w in warnings} == expected + assert len(warnings) == len(expected) + for warning in warnings: + assert warning["path"] in warning["message"] + assert warning["nodeId"] in warning["message"] + assert warning["device"] in warning["message"] + + # Warning ids are distinct, and nothing is confirmed when nothing + # was submitted. + ids = [w["id"] for w in warnings] + assert len(set(ids)) == len(ids) + assert all(not w["confirmed"] for w in warnings) + + # ------------------------------------------------- confirmation pass + submitted = [w["id"] for w in warnings + if (w["device"], w["nodeId"], w["path"]) in confirm_keys] + confirmed_errors, confirmed_warnings = \ + deployments.validate_camera_bindings( + version, targets, registry_snapshot, {}, + submitted + ["not-a-real-warning-id"]) + + assert confirmed_errors == [] + assert [w["id"] for w in confirmed_warnings] == ids + for warning in confirmed_warnings: + assert warning["confirmed"] == ( + (warning["device"], warning["nodeId"], warning["path"]) + in confirm_keys) + + # ------------------------------------------------ bindings-ignored pass + # 11.1: the legacy regime never requires nor inspects bindings — + # submitting an arbitrary bindings map changes nothing. + bound_errors, bound_warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + assert bound_errors == [] + assert bound_warnings == warnings diff --git a/edge-cv-portal/backend/tests/test_camera_binding_submission.py b/edge-cv-portal/backend/tests/test_camera_binding_submission.py new file mode 100644 index 00000000..452dd32c --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_submission.py @@ -0,0 +1,398 @@ +""" +Deployment submission behavior — create_workflow_deployment / +record_workflow_deployment (functions/deployments.py, camera-registry-sync +task 11.10). + +Fills the route-level gaps around task 11.7's examples +(test_camera_binding_context.py) and task 11.9's pure-function properties +(test_camera_binding_delivery_properties.py): + +- the deploy_workflow audit event of a deployment created with + Camera_Bindings carries the acting user, the deployment identifier, the + target Edge_Devices, and the delivered camera_bindings, with a timestamp + (Requirement 12.3); rejected submissions (REGISTRY_UNAVAILABLE, delivery + failure) log no deploy_workflow event and leave no deployment record; +- a shadow write failing on the FIRST target aborts with nothing written + and nothing to prune (Requirement 8.6); +- the confirmed-warnings path succeeds end to end with the warnings + echoed (confirmed) in the 201 response, while unconfirmed warnings + reject before any shadow write (Requirements 8.6, 9.3 route wiring); +- binding storage on the workflow-deployment record: numeric override + values survive DynamoDB via _dynamo_safe (floats stored as Decimal), + a route-level manual override round-trips through shadow and record, + and a version without Camera_Input_Nodes stores no camera_bindings + attribute and reports camera_bindings_delivered false (Requirement 12.3). +""" +import sys +import uuid +from decimal import Decimal + +import boto3 +import pytest +from boto3.dynamodb.conditions import Key + +from conftest import REGION, TEST_ENV +from test_camera_binding_context import BindingEnv, camera_node + +SUBMISSION_REGISTRY_TABLE = "test-camera-registry-submission" + + +# -------------------------------------------------------------------------- +# Module stack: own registry table name so this module can coexist with +# test_camera_binding_context.py in one moto session +# -------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def binding_stack(aws_stack): + import os + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=SUBMISSION_REGISTRY_TABLE, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + os.environ["CAMERA_REGISTRY_TABLE"] = SUBMISSION_REGISTRY_TABLE + + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + resource = boto3.resource("dynamodb", region_name=REGION) + yield { + "deployments": deployments, + "registry": resource.Table(SUBMISSION_REGISTRY_TABLE), + } + + +@pytest.fixture +def fleet(env, binding_stack, monkeypatch): + return BindingEnv(env, binding_stack, monkeypatch) + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +def audit_events(user_id, action=None): + """All audit records for one acting user (fresh uuid user per test).""" + table = boto3.resource("dynamodb", region_name=REGION).Table( + TEST_ENV["AUDIT_LOG_TABLE"]) + items, kwargs = [], {} + while True: + response = table.scan(**kwargs) + items.extend(response.get("Items", [])) + last_key = response.get("LastEvaluatedKey") + if not last_key: + break + kwargs["ExclusiveStartKey"] = last_key + events = [i for i in items if i.get("user_id") == user_id] + if action is not None: + events = [e for e in events if e.get("action") == action] + return events + + +def deployment_records(fleet): + """Workflow-deployment records of this test's fresh use case.""" + response = fleet.env.stack.tables.deployments.query( + IndexName="usecase-deployments-index", + KeyConditionExpression=Key("usecase_id").eq(fleet.usecase_id)) + return response.get("Items", []) + + +# -------------------------------------------------------------------------- +# Audit event payload (Requirement 12.3) +# -------------------------------------------------------------------------- + +class TestDeploymentAuditEvent: + def test_deploy_with_bindings_records_user_targets_and_bindings( + self, fleet): + """The deploy_workflow audit event carries the acting user, the + deployment identifier, the target Edge_Devices, and the delivered + camera_bindings, with a timestamp (12.3).""" + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.seed_registry("line-b", {"cfg-2": {}}) + fleet.gg.register_device("line-a") + fleet.gg.register_device("line-b") + bindings = {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}, + "line-b": {"n1": {"cameraSourceId": "cfg-2"}}} + + status, payload = fleet.deploy(["line-a", "line-b"], + camera_bindings=bindings) + + assert status == 201, payload + events = audit_events(fleet.user["user_id"], "deploy_workflow") + assert len(events) == 1 + record = events[0] + assert record["user_id"] == fleet.user["user_id"] + assert record["resource_type"] == "workflow" + assert record["resource_id"] == fleet.workflow_id + assert record["result"] == "success" + assert int(record["timestamp"]) > 0 + details = record["details"] + assert details["deployment_id"] == payload["deployment_id"] + assert details["usecase_id"] == fleet.usecase_id + assert details["target_devices"] == ["line-a", "line-b"] + assert details["camera_bindings"] == bindings + + def test_rejected_delivery_logs_no_deploy_event_and_no_record( + self, fleet): + """A submission aborted by a binding delivery failure logs no + deploy_workflow audit event and stores no deployment record — + only successful creations are audited (12.3).""" + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.gg.register_device("line-a") + fleet.iot_data.fail_for.add("line-a") + + status, payload = fleet.deploy( + ["line-a"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}) + + assert status == 502 + assert payload["error"]["code"] == "BINDING_DELIVERY_FAILED" + assert audit_events(fleet.user["user_id"], "deploy_workflow") == [] + assert deployment_records(fleet) == [] + + +# -------------------------------------------------------------------------- +# REGISTRY_UNAVAILABLE rejection side effects (Requirement 8.6 gate) +# -------------------------------------------------------------------------- + +class TestRegistryUnavailable: + def test_rejection_leaves_no_side_effects(self, fleet, monkeypatch): + """REGISTRY_UNAVAILABLE rejects before any shadow write, stores + no deployment record, and logs no deploy_workflow audit event.""" + fleet.seed_workflow([camera_node()]) + fleet.gg.register_device("line-a") + monkeypatch.setattr(fleet.deployments, "CAMERA_REGISTRY_TABLE", + "missing-table-name") + + status, payload = fleet.deploy( + ["line-a"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}) + + assert status == 503 + assert payload["error"]["code"] == "REGISTRY_UNAVAILABLE" + assert fleet.iot_data.bindings == {} + assert deployment_records(fleet) == [] + assert audit_events(fleet.user["user_id"], "deploy_workflow") == [] + + +# -------------------------------------------------------------------------- +# Partial shadow-write failure on the FIRST target (Requirement 8.6) +# -------------------------------------------------------------------------- + +class TestFirstTargetFailure: + def test_first_target_failure_writes_nothing_and_prunes_nothing( + self, fleet): + """A shadow write failing on the first target aborts with nothing + written and nothing to prune: every target's shadow (including + pre-existing keys on the failing device) is untouched and no + Greengrass deployment is created (8.6).""" + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.seed_registry("line-b", {"cfg-2": {}}) + fleet.gg.register_device("line-a") + fleet.gg.register_device("line-b") + pre_existing = {"other-wf/2": {"n1": {"cameraSourceId": "old"}}} + fleet.iot_data.bindings["line-a"] = dict(pre_existing) + fleet.iot_data.fail_for.add("line-a") + + status, payload = fleet.deploy( + ["line-a", "line-b"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}, + "line-b": {"n1": {"cameraSourceId": "cfg-2"}}}) + + assert status == 502 + assert payload["error"]["code"] == "BINDING_DELIVERY_FAILED" + assert payload["error"]["details"]["failed_device"] == "line-a" + assert payload["error"]["details"]["rolled_back_devices"] == [] + # Nothing was written anywhere: line-a keeps only its pre-existing + # keys, line-b's shadow was never created. + assert fleet.iot_data.bindings["line-a"] == pre_existing + assert "line-b" not in fleet.iot_data.bindings + assert fleet.gg.create_deployment_calls == [] + assert deployment_records(fleet) == [] + + +# -------------------------------------------------------------------------- +# Confirmed-warnings path end to end (route wiring of 8.8/9.3 into 8.6) +# -------------------------------------------------------------------------- + +class TestWarningsConfirmedPath: + WARNING_ID = "camera-degraded:line-a:n1:cfg-1:absent" + + def seed_degraded(self, fleet): + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {"absent": True, + "absent_since": 1}}) + fleet.gg.register_device("line-a") + + def test_unconfirmed_warning_rejects_before_any_shadow_write( + self, fleet): + self.seed_degraded(fleet) + status, payload = fleet.deploy( + ["line-a"], + camera_bindings={"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}) + assert status == 409 + assert payload["error"]["code"] == "CAMERA_WARNINGS_UNCONFIRMED" + [warning] = payload["error"]["details"]["warnings"] + assert warning["id"] == self.WARNING_ID + assert warning["confirmed"] is False + assert fleet.iot_data.bindings == {} + assert fleet.gg.create_deployment_calls == [] + + def test_confirmed_warning_succeeds_with_warnings_in_response( + self, fleet): + """With every warning id confirmed the deployment is created, + bindings are delivered, and the 201 response echoes the warnings + as confirmed.""" + self.seed_degraded(fleet) + bindings = {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}} + + status, payload = fleet.deploy(["line-a"], camera_bindings=bindings, + confirmed_warnings=[self.WARNING_ID]) + + assert status == 201, payload + assert payload["camera_bindings_delivered"] is True + [warning] = payload["camera_warnings"] + assert warning["id"] == self.WARNING_ID + assert warning["confirmed"] is True + assert fleet.iot_data.bindings["line-a"] == { + f"{fleet.workflow_id}/1": bindings["line-a"]} + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": payload["deployment_id"]})["Item"] + assert record["camera_bindings"] == bindings + + +# -------------------------------------------------------------------------- +# Binding storage on the deployment record (Requirement 12.3) +# -------------------------------------------------------------------------- + +class TestBindingRecordStorage: + def test_route_level_override_round_trips_shadow_and_record(self, fleet): + """A manual override with numeric values passes constraint + validation, is delivered to the shadow, and is stored on the + deployment record (numbers come back as DynamoDB Decimals equal + to the submitted values).""" + fleet.seed_workflow([camera_node()]) + fleet.seed_registry("line-a", {"cfg-1": {}}) + fleet.gg.register_device("line-a") + override = {"device": "/dev/video9", "gain": 7, "exposure": 100} + bindings = {"line-a": {"n1": {"override": override}}} + + status, payload = fleet.deploy(["line-a"], camera_bindings=bindings) + + assert status == 201, payload + assert payload["camera_bindings_delivered"] is True + assert fleet.iot_data.bindings["line-a"] == { + f"{fleet.workflow_id}/1": bindings["line-a"]} + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": payload["deployment_id"]})["Item"] + stored = record["camera_bindings"]["line-a"]["n1"]["override"] + assert stored == override # Decimal(7) == 7, Decimal(100) == 100 + assert stored["device"] == "/dev/video9" + + def test_record_storage_is_decimal_safe_for_float_override_values( + self, fleet): + """record_workflow_deployment stores float override values (e.g. + a camera-backed Custom_Node_Type's float parameter) through + _dynamo_safe: put_item accepts them and they come back as + Decimals — DynamoDB rejects raw Python floats.""" + deployment_id = f"dep-{uuid.uuid4()}" + bindings = {"line-a": {"n1": {"override": {"device": "/dev/video1", + "gain": 2.5}}}} + + fleet.deployments.record_workflow_deployment( + deployment_id, fleet.usecase_id, fleet.workflow_id, 1, + "arn:aws:iot:us-east-1:123456789012:thing/line-a", + ["line-a"], None, False, None, fleet.user, + camera_bindings=bindings) + + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": deployment_id})["Item"] + stored = record["camera_bindings"]["line-a"]["n1"]["override"] + assert stored["gain"] == Decimal("2.5") + assert isinstance(stored["gain"], Decimal) + + def test_version_without_camera_nodes_stores_no_bindings_attribute( + self, fleet): + """A version with no Camera_Input_Nodes deploys without bindings: + camera_bindings_delivered is false, no shadow is touched, and the + deployment record carries no camera_bindings attribute.""" + fleet.seed_workflow(None) + fleet.gg.register_device("line-a") + + status, payload = fleet.deploy(["line-a"]) + + assert status == 201, payload + assert payload["camera_bindings_delivered"] is False + assert fleet.iot_data.bindings == {} + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": payload["deployment_id"]})["Item"] + assert "camera_bindings" not in record + + +# -------------------------------------------------------------------------- +# Aravis binding delivery (aravis-camera-input task 8.4 — Requirement 5.5) +# -------------------------------------------------------------------------- + +class TestAravisBindingDelivery: + def test_aravis_binding_writes_shadow_and_leaves_artifact_untouched( + self, fleet): + """A submission binding an aravis_camera_source node to a + registered AravisDiscovered Camera_Source writes the expected + desired document into the target's dda-camera-bindings shadow — + keyed {workflowId}/{version}, carrying the node's binding — and + leaves the packaged artifact untouched: the Greengrass deployment + references only the component, and the staged artifact bytes are + byte-identical after submission (5.5).""" + aravis_node = {"node_id": "arv1", "node_type": "aravis_camera_source"} + fleet.seed_workflow([aravis_node]) + fleet.seed_registry("line-a", {"arv-1": { + "type": "AravisDiscovered", + "origin": "edge-discovered", + "params": {"cameraId": "Aravis-Fake-GV01", "serial": "GV01", + "protocol": "Fake", "address": "0.0.0.0"}, + }}) + fleet.gg.register_device("line-a") + + # The packaged Workflow_Component artifact as staged in portal S3 + # by the Component_Packager — bindings ride the shadow, never the + # artifact. + artifact_key = f"workflows/{fleet.workflow_id}/1/arm64_jp5/component.zip" + artifact_bytes = b"compiled-workflow-artifact-bytes" + fleet.env.s3.put_object(Bucket=fleet.env.bucket, Key=artifact_key, + Body=artifact_bytes) + + bindings = {"line-a": {"arv1": {"cameraSourceId": "arv-1"}}} + status, payload = fleet.deploy(["line-a"], camera_bindings=bindings) + + assert status == 201, payload + assert payload["camera_bindings_delivered"] is True + # The expected desired document (unchanged shadow mechanism): + # desired.bindings["{workflowId}/{version}"] = node bindings. + assert fleet.iot_data.bindings["line-a"] == { + f"{fleet.workflow_id}/1": {"arv1": {"cameraSourceId": "arv-1"}}} + # The Greengrass deployment carries only the component map — no + # binding data rides the component set. + [call] = fleet.gg.create_deployment_calls + assert call["components"] == { + f"dda.workflow.{fleet.workflow_id}": {"componentVersion": "1.0.0"}} + # The staged artifact bytes are untouched. + stored = fleet.env.s3.get_object( + Bucket=fleet.env.bucket, Key=artifact_key)["Body"].read() + assert stored == artifact_bytes + # The delivered bindings are recorded on the deployment record. + record = fleet.env.stack.tables.deployments.get_item( + Key={"deployment_id": payload["deployment_id"]})["Item"] + assert record["camera_bindings"] == bindings diff --git a/edge-cv-portal/backend/tests/test_camera_binding_type_override_properties.py b/edge-cv-portal/backend/tests/test_camera_binding_type_override_properties.py new file mode 100644 index 00000000..8dd39bd9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_type_override_properties.py @@ -0,0 +1,281 @@ +""" +Property-based test for deploy-time Camera_Binding type compatibility and +manual-override constraint validation — validate_camera_bindings +(functions/deployments.py). + +**Feature: camera-registry-sync, Property 16: Type compatibility and override constraint validation** + +*For any* Camera_Binding, validation rejects it identifying a type +mismatch exactly when the bound Camera_Source's type is outside the node +type's compatible set, and accepts a manual override exactly when every +override value satisfies the node type's declared parameter constraints +(as judged by the existing `workflow_core` parameter validator). + +**Validates: Requirements 8.4, 9.4** + +Task 11.5 (spec: camera-registry-sync). The example-based unit tests over +the same function live in test_camera_binding_validation.py (task 11.1); +the completeness property (task 11.2) lives in +test_camera_binding_completeness_properties.py, the existence property +(task 11.3) in test_camera_binding_existence_properties.py, and the +degraded-warning property (task 11.4) in +test_camera_binding_degraded_warning_properties.py. +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_NODE_IDS = ["n1", "n2", "n3"] +_DEVICES = ["line-a", "line-b", "line-c"] + +#: Camera_Source types seen in registries: the four camera-backed types +#: compatible with camera_source, plus Folder (Requirement 9.4's example +#: of a categorical mismatch) and network-stream types a camera-backed +#: Custom_Node_Type may accept. +_SOURCE_TYPES = ["Camera", "ICam", "NvidiaCSI", "V4L2Discovered", + "Folder", "RTSP", "HTTPPull"] + +#: Requirement 9.4 compatibility rule, restated from the design: the +#: built-in camera_source node captures from a device camera, so only +#: camera-backed source types may back it; a camera-backed +#: Custom_Node_Type declares no transport, so only the categorically +#: incompatible Folder type is rejected for it. +_CAMERA_SOURCE_COMPATIBLE = frozenset( + {"Camera", "ICam", "NvidiaCSI", "V4L2Discovered"}) + +#: Node types under test: the built-in (descriptor resolved from the +#: workflow_core catalog), a camera-backed Custom_Node_Type with a +#: caller-supplied descriptor, and a Custom_Node_Type whose descriptor is +#: unresolvable, for which override validation must fail closed. +_BUILTIN = "camera_source" +_CUSTOM = "acme.cam_input" +_UNRESOLVABLE = "acme.unresolvable" +_NODE_TYPES = [_BUILTIN, _CUSTOM, _UNRESOLVABLE] + + +def _type_compatible(node_type, source_type): + """The Requirement 9.4 expectation, independent of the implementation.""" + if node_type == _BUILTIN: + return source_type in _CAMERA_SOURCE_COMPATIBLE + return source_type != "Folder" + + +#: Arbitrary override values: wrong-typed values (bools are rejected for +#: int parameters, floats for int and string ones), out-of-range ints, +#: empty and non-empty strings — validity is judged by the +#: check_parameter_value oracle in the test, never assumed here. +_WILD_VALUES = st.one_of( + st.none(), + st.booleans(), + st.integers(min_value=-1000, max_value=1_000_000), + st.floats(allow_nan=False, allow_infinity=False), + st.sampled_from(["", "/dev/video0", "10", "caméra ☂"]), +) + +#: Values satisfying each declared camera_source constraint (device: +#: string min_length 1; gain: int 0-100; exposure: int >= 0), mixed with +#: the wild pool so fully valid overrides occur often. +_VALID_VALUES = { + "device": st.sampled_from(["/dev/video0", "/dev/video7", "caméra ☂"]), + "gain": st.integers(min_value=0, max_value=100), + "exposure": st.integers(min_value=0, max_value=10**9), +} + +#: The declared camera_source parameters plus an undeclared name, which +#: must be rejected whatever its value. +_OVERRIDE_NAMES = ["device", "gain", "exposure", "bogus"] + + +def _value_for(name): + valid = _VALID_VALUES.get(name) + if valid is None: + return _WILD_VALUES + return st.one_of(valid, _WILD_VALUES) + + +@st.composite +def _type_override_cases(draw): + """A version with binding points and, per (device, node), either a + registered-source binding to an entry of arbitrary source type + (exercising the type-compatibility half) or a manual override with + arbitrary values over declared and undeclared parameters (exercising + the constraint half, including the unresolvable-descriptor fail-closed + rule). Every referenced source is present and healthy so type + compatibility and override validity are the only error signals.""" + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=3)) + node_types = {node_id: draw(st.sampled_from(_NODE_TYPES)) + for node_id in node_ids} + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + bindings = {} + # (device, node_id) -> (camera_source_id, source_type) + source_refs = {} + # (device, node_id) -> override dict + overrides = {} + + for device in targets: + cameras = {} + device_bindings = {} + for node_id in node_ids: + if draw(st.booleans()): + source_type = draw(st.sampled_from(_SOURCE_TYPES)) + camera_source_id = f"cfg-{device}-{node_id}" + cameras[camera_source_id] = { + "type": source_type, + "params": {"devicePath": "/dev/video0"}, + "sync_status": "synced", + "absent": False, + "stale": False, + } + device_bindings[node_id] = { + "cameraSourceId": camera_source_id} + source_refs[(device, node_id)] = (camera_source_id, + source_type) + else: + names = draw(st.lists(st.sampled_from(_OVERRIDE_NAMES), + unique=True, max_size=3)) + override = {name: draw(_value_for(name)) for name in names} + device_bindings[node_id] = {"override": override} + overrides[(device, node_id)] = override + registry_snapshot[device] = {"never_synced": False, + "cameras": cameras} + bindings[device] = device_bindings + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": node_id, + "node_type": node_types[node_id], + "compiled_device_paths": {"x86_64": "/dev/video0"}} + for node_id in node_ids + ], + } + return version, targets, registry_snapshot, bindings, node_types, \ + source_refs, overrides + + +# --------------------------------------------------------------------------- +# Property 16 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_type_override_cases()) +def test_type_mismatch_and_override_violations_are_rejected_exactly( + deployments, case): + """**Feature: camera-registry-sync, Property 16: Type compatibility + and override constraint validation** + + **Validates: Requirements 8.4, 9.4** + + A type-mismatch error is produced exactly for the bindings whose + Camera_Source type is outside the node type's compatible set, + identifying the mismatch (9.4); an override error is produced exactly + for each override value the workflow_core parameter validator rejects + against the node type's declared constraints, for each undeclared + parameter name, and (fail-closed) for overrides on a node type with + no resolvable parameter declaration — so a binding is accepted + exactly when its source type is compatible or every override value + satisfies the declared constraints (8.4). + """ + from workflow_core.catalog import get_node_type + from workflow_core.validator import check_parameter_value + + (version, targets, registry_snapshot, bindings, node_types, + source_refs, overrides) = case + + descriptor = get_node_type(_BUILTIN) + parameters = {p.name: p for p in descriptor.parameters} + + errors, _ = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, [], + descriptors={_CUSTOM: descriptor}) + + # ------------------------------------------- type compatibility (9.4) + expected_mismatches = { + (device, node_id, camera_source_id, source_type) + for (device, node_id), (camera_source_id, source_type) + in source_refs.items() + if not _type_compatible(node_types[node_id], source_type) + } + type_errors = [ + e for e in errors + if e["code"] == deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE] + + # Exactly one type error per incompatible binding; compatible + # bindings pass the type check. + assert {(e["device"], e["nodeId"], e["cameraSourceId"], e["sourceType"]) + for e in type_errors} == expected_mismatches + assert len(type_errors) == len(expected_mismatches) + + # Each error identifies the mismatch: both sides of it (9.4). + for error in type_errors: + assert error["nodeType"] == node_types[error["nodeId"]] + assert error["sourceType"] in error["message"] + assert error["nodeType"] in error["message"] + + # ------------------------------------------ override constraints (8.4) + # Expected rejections, judged by the existing workflow_core parameter + # validator: (device, node, parameter, violation code); undeclared + # parameters carry no violation code, and the unresolvable-descriptor + # fail-closed rejection carries neither parameter nor code. + expected_violations = set() + for (device, node_id), override in overrides.items(): + if node_types[node_id] == _UNRESOLVABLE: + expected_violations.add((device, node_id, None, None)) + continue + for name, value in override.items(): + parameter = parameters.get(name) + if parameter is None: + expected_violations.add((device, node_id, name, None)) + continue + violation = check_parameter_value(parameter, value) + if violation is not None: + expected_violations.add( + (device, node_id, name, violation.code)) + + override_errors = [ + e for e in errors + if e["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID] + + # An override is accepted exactly when every value satisfies the + # declared constraints: one error per rejected value / undeclared + # name / unresolvable declaration, and none otherwise. + assert {(e["device"], e["nodeId"], e.get("parameter"), + e.get("violation")) for e in override_errors} \ + == expected_violations + assert len(override_errors) == len(expected_violations) + + for error in override_errors: + if error.get("parameter") is not None: + # The message names the offending parameter. + assert error["parameter"] in error["message"] + else: + # Fail-closed: the message names the undeclarable node type. + assert node_types[error["nodeId"]] in error["message"] + + # Type compatibility and override constraints are the only error + # signals in these cases. + assert len(errors) == len(type_errors) + len(override_errors) diff --git a/edge-cv-portal/backend/tests/test_camera_binding_validation.py b/edge-cv-portal/backend/tests/test_camera_binding_validation.py new file mode 100644 index 00000000..0a9b687a --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_binding_validation.py @@ -0,0 +1,448 @@ +""" +Deploy-time Camera_Binding validation — validate_camera_bindings +(functions/deployments.py). + +Task 11.1 (spec: camera-registry-sync). Example-based unit tests for the +pure pre-submit validator: + +- Errors (reject): unbound Camera_Input_Node on any target when the + version has binding points (8.7); referenced cameraSourceId absent from + the target's registry (9.1, 9.2); Camera_Source type incompatible with + the node type (9.4); override values violating the node type's declared + parameter constraints via the workflow_core parameter validator (8.4). +- Warnings (require matching confirmed_warnings ids): bound source + stale/absent/pending/failed (9.3); never-synced target restricted to + manual override (8.8); legacy compiled-path check when + has_binding_points is false — warnings only, never errors (9.5, 11.1). +- Distinct bindings per device for the same node accepted (8.3); versions + with no Camera_Input_Nodes produce no errors or warnings (8.9). + +The property tests over the same function are tasks 11.2-11.6. + +_Requirements: 8.3, 8.4, 8.7, 8.8, 8.9, 9.1, 9.2, 9.3, 9.4, 9.5, 11.1_ +""" +import sys + +import pytest + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# -------------------------------------------------------------------------- +# Fixture data builders +# -------------------------------------------------------------------------- + +def camera_node(node_id="n1", node_type="camera_source", **extra): + record = {"node_id": node_id, "node_type": node_type, + "compiled_device_paths": {"x86_64": "/dev/video0"}} + record.update(extra) + return record + + +def version_item(nodes=None, has_binding_points=None): + nodes = [] if nodes is None else nodes + if has_binding_points is None: + has_binding_points = bool(nodes) + return {"has_binding_points": has_binding_points, + "camera_input_nodes": nodes} + + +def registry_entry(source_type="Camera", device_path="/dev/video0", + sync_status="synced", absent=False, stale=False): + return {"type": source_type, + "params": {"devicePath": device_path}, + "sync_status": sync_status, + "absent": absent, + "stale": stale} + + +def snapshot(cameras, never_synced=False): + return {"never_synced": never_synced, "cameras": cameras} + + +# ========================================================================== +# No Camera_Input_Nodes (8.9) +# ========================================================================== + +class TestNoCameraNodes: + def test_version_without_camera_nodes_produces_nothing(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([]), ["line-a"], + {"line-a": snapshot({})}, {}, []) + assert errors == [] + assert warnings == [] + + def test_pre_feature_version_item_produces_nothing(self, deployments): + """A version packaged before the feature carries neither + discriminator attribute (11.1).""" + errors, warnings = deployments.validate_camera_bindings( + {}, ["line-a"], {}, {}, []) + assert errors == [] + assert warnings == [] + + +# ========================================================================== +# Binding completeness (8.3, 8.7) +# ========================================================================== + +class TestCompleteness: + def test_unbound_node_rejected_identifying_node_and_device(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry()})}, {}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_UNBOUND + assert error["nodeId"] == "n1" + assert error["device"] == "line-a" + assert "n1" in error["message"] and "line-a" in error["message"] + + def test_unbound_only_on_the_device_missing_the_binding(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a", "line-b"], + {"line-a": snapshot({"cfg-1": registry_entry()}), + "line-b": snapshot({"cfg-2": registry_entry()})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + [error] = errors + assert error["device"] == "line-b" + + def test_distinct_bindings_per_device_for_the_same_node(self, deployments): + """One node bound to different sources on different devices (8.3).""" + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a", "line-b"], + {"line-a": snapshot({"cfg-1": registry_entry()}), + "line-b": snapshot({"cfg-2": registry_entry("NvidiaCSI")})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}, + "line-b": {"n1": {"cameraSourceId": "cfg-2"}}}, []) + assert errors == [] + assert warnings == [] + + def test_empty_binding_entry_is_unbound(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry()})}, + {"line-a": {"n1": {}}}, []) + assert [e["code"] for e in errors] == [deployments.CAMERA_ERROR_UNBOUND] + + +# ========================================================================== +# Binding existence (9.1, 9.2) +# ========================================================================== + +class TestExistence: + def test_missing_source_rejected_identifying_source_and_device(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry()})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-gone"}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_SOURCE_MISSING + assert error["cameraSourceId"] == "cfg-gone" + assert error["device"] == "line-a" + + def test_present_source_accepted(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry()})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + assert errors == [] + assert warnings == [] + + +# ========================================================================== +# Type compatibility (9.4) and override constraints (8.4) +# ========================================================================== + +class TestTypeAndOverride: + def test_folder_source_bound_to_camera_source_rejected(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry("Folder")})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE + assert error["sourceType"] == "Folder" + assert error["nodeType"] == "camera_source" + + def test_rtsp_stream_cannot_back_camera_source(self, deployments): + """camera_source captures from a device camera; a network stream + cannot feed it (9.4).""" + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry("RTSP")})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + assert [e["code"] for e in errors] == \ + [deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE] + + def test_discovered_v4l2_source_compatible(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"disc-1": registry_entry("V4L2Discovered")})}, + {"line-a": {"n1": {"cameraSourceId": "disc-1"}}}, []) + assert errors == [] + + def test_custom_camera_backed_type_rejects_only_folder(self, deployments): + registry = {"line-a": snapshot({ + "cfg-r": registry_entry("RTSP"), + "cfg-f": registry_entry("Folder")})} + nodes = [camera_node("n1", "acme.rtsp_input")] + errors, _ = deployments.validate_camera_bindings( + version_item(nodes), ["line-a"], registry, + {"line-a": {"n1": {"cameraSourceId": "cfg-r"}}}, []) + assert errors == [] + errors, _ = deployments.validate_camera_bindings( + version_item(nodes), ["line-a"], registry, + {"line-a": {"n1": {"cameraSourceId": "cfg-f"}}}, []) + assert [e["code"] for e in errors] == \ + [deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE] + + def test_valid_override_accepted(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"device": "/dev/video2", + "gain": 8}}}}, []) + assert errors == [] + assert warnings == [] + + def test_override_violating_declared_constraints_rejected(self, deployments): + """camera_source declares gain 0-100 in the workflow_core catalog.""" + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"gain": 500}}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID + assert error["parameter"] == "gain" + assert error["violation"] == "PARAM_MAX" + + def test_override_with_undeclared_parameter_rejected(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"bogus": 1}}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID + assert error["parameter"] == "bogus" + + def test_override_for_unresolvable_node_type_fails_closed(self, deployments): + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node("n1", "acme.unknown")]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"device": "/dev/video1"}}}}, []) + assert [e["code"] for e in errors] == \ + [deployments.CAMERA_ERROR_OVERRIDE_INVALID] + + def test_caller_supplied_descriptor_validates_custom_type(self, deployments): + from workflow_core.catalog import get_node_type + descriptor = get_node_type("camera_source") + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node("n1", "acme.cam")]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"gain": 8}}}}, [], + descriptors={"acme.cam": descriptor}) + assert errors == [] + + +# ========================================================================== +# Aravis type compatibility and override constraints +# (aravis-camera-input task 8.1 — Requirements 5.2, 5.3, 5.4) +# ========================================================================== + +def aravis_node(node_id="n1"): + return camera_node(node_id, "aravis_camera_source") + + +class TestAravisTypeAndOverride: + def test_aravis_binding_to_aravis_discovered_accepted(self, deployments): + """An aravis_camera_source node binds to a discovered bus camera + (5.2).""" + errors, warnings = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({"arv-1": registry_entry("AravisDiscovered")})}, + {"line-a": {"n1": {"cameraSourceId": "arv-1"}}}, []) + assert errors == [] + assert warnings == [] + + def test_aravis_binding_to_configured_camera_accepted(self, deployments): + """A configured Camera-type Image_Source (Aravis-backed) is a + valid binding for the Aravis node (5.2).""" + errors, _ = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry("Camera")})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + assert errors == [] + + @pytest.mark.parametrize("source_type", [ + "V4L2Discovered", "ICam", "NvidiaCSI", "Folder", "RTSP"]) + def test_aravis_binding_to_incompatible_type_rejected( + self, deployments, source_type): + """Anything that is not Aravis-backed is rejected with + CAMERA_TYPE_INCOMPATIBLE (5.2).""" + errors, _ = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({"src-1": registry_entry(source_type)})}, + {"line-a": {"n1": {"cameraSourceId": "src-1"}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE + assert error["sourceType"] == source_type + assert error["nodeType"] == "aravis_camera_source" + + def test_camera_source_binding_to_aravis_discovered_accepted( + self, deployments): + """A registered GenICam camera is a legitimate camera-backed + source for the generic camera_source node (5.3).""" + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"arv-1": registry_entry("AravisDiscovered")})}, + {"line-a": {"n1": {"cameraSourceId": "arv-1"}}}, []) + assert errors == [] + + def test_valid_aravis_override_accepted(self, deployments): + """Override values within the aravis_camera_source descriptor's + declared constraints are accepted (5.4).""" + errors, warnings = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"camera_id": "Aravis-Fake-GV01", + "gain": 8, + "exposure": 100000}}}}, []) + assert errors == [] + assert warnings == [] + + def test_aravis_override_with_empty_camera_id_rejected(self, deployments): + """camera_id declares min_length 1 in the catalog (5.4).""" + errors, _ = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"camera_id": ""}}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID + assert error["parameter"] == "camera_id" + assert error["violation"] == "PARAM_MIN_LENGTH" + + def test_aravis_override_with_out_of_range_gain_rejected(self, deployments): + """gain declares max 100 in the catalog (5.4).""" + errors, _ = deployments.validate_camera_bindings( + version_item([aravis_node()]), ["line-a"], + {"line-a": snapshot({})}, + {"line-a": {"n1": {"override": {"camera_id": "Basler-12345678", + "gain": 500}}}}, []) + [error] = errors + assert error["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID + assert error["parameter"] == "gain" + assert error["violation"] == "PARAM_MAX" + + +# ========================================================================== +# Degraded-source and never-synced warnings (9.3, 8.8) +# ========================================================================== + +class TestWarnings: + @pytest.mark.parametrize("entry_kwargs,condition", [ + ({"stale": True}, "stale"), + ({"absent": True}, "absent"), + ({"sync_status": "pending"}, "pending"), + ({"sync_status": "failed"}, "failed"), + ]) + def test_degraded_source_warns_identifying_condition( + self, deployments, entry_kwargs, condition): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry(**entry_kwargs)})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + assert errors == [] + [warning] = warnings + assert warning["code"] == deployments.CAMERA_WARNING_SOURCE_DEGRADED + assert warning["conditions"] == [condition] + assert warning["confirmed"] is False + + def test_warning_confirmed_when_id_is_submitted(self, deployments): + _, [warning] = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry(stale=True)})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + _, [confirmed] = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry(stale=True)})}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, [warning["id"]]) + assert confirmed["id"] == warning["id"] + assert confirmed["confirmed"] is True + + def test_never_synced_target_warns_and_permits_only_override(self, deployments): + """8.8: the warning fires, and a registered-source binding on the + never-synced device fails the existence check (manual-override + restriction).""" + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({}, never_synced=True)}, + {"line-a": {"n1": {"cameraSourceId": "cfg-1"}}}, []) + codes = {w["code"] for w in warnings} + assert deployments.CAMERA_WARNING_NEVER_SYNCED in codes + assert [e["code"] for e in errors] == \ + [deployments.CAMERA_ERROR_SOURCE_MISSING] + + def test_never_synced_target_with_override_only_needs_confirmation( + self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], + {"line-a": snapshot({}, never_synced=True)}, + {"line-a": {"n1": {"override": {"device": "/dev/video1"}}}}, []) + assert errors == [] + [warning] = warnings + assert warning["code"] == deployments.CAMERA_WARNING_NEVER_SYNCED + assert warning["confirmed"] is False + + def test_device_missing_from_snapshot_treated_as_never_synced(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()]), ["line-a"], {}, + {"line-a": {"n1": {"override": {"device": "/dev/video1"}}}}, []) + assert errors == [] + assert [w["code"] for w in warnings] == \ + [deployments.CAMERA_WARNING_NEVER_SYNCED] + + +# ========================================================================== +# Legacy compiled-path check (9.5, 11.1) +# ========================================================================== + +class TestLegacyPathCheck: + def test_unmatched_compiled_path_warns_never_errors(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()], has_binding_points=False), + ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry( + device_path="/dev/video9")})}, + {}, []) + assert errors == [] + [warning] = warnings + assert warning["code"] == deployments.CAMERA_WARNING_LEGACY_PATH + assert warning["path"] == "/dev/video0" + assert warning["device"] == "line-a" + assert warning["confirmed"] is False + + def test_matched_compiled_path_produces_no_warning(self, deployments): + errors, warnings = deployments.validate_camera_bindings( + version_item([camera_node()], has_binding_points=False), + ["line-a"], + {"line-a": snapshot({"cfg-1": registry_entry( + device_path="/dev/video0")})}, + {}, []) + assert errors == [] + assert warnings == [] + + def test_legacy_version_never_requires_bindings(self, deployments): + """No unbound errors in the legacy regime even with no bindings + submitted (11.1).""" + errors, _ = deployments.validate_camera_bindings( + version_item([camera_node()], has_binding_points=False), + ["line-a", "line-b"], {}, {}, []) + assert errors == [] diff --git a/edge-cv-portal/backend/tests/test_camera_registry_api.py b/edge-cv-portal/backend/tests/test_camera_registry_api.py new file mode 100644 index 00000000..8427230f --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_api.py @@ -0,0 +1,838 @@ +""" +Camera_Registry API unit tests (camera-registry-sync task 6.6). + +Fills the gaps the route-level suites (test_camera_registry_read_routes, +test_camera_registry_mutation_routes, test_camera_registry_settings) +leave open: + +1. RBAC matrix per route: Viewer allowed on the read routes (GET cameras, + GET conflicts, POST refresh), denied on every mutating route + (POST/PUT/DELETE, conflict re-apply) with an `unauthorized_access` + audit event; Operator allowed everywhere (Reqs 5.7, 12.1, 12.2). +2. Cross-use-case scoping: an elevated role granted through the + user-roles table for a DIFFERENT Use_Case does not carry over — the + mutating routes return 403 scoped to the device's own usecase_id and + log `unauthorized_access` (Req 1.5). Note the portal's role model + resolves the JWT custom:role claim as a GLOBAL baseline (rbac_manager + falls back to it for any Use_Case), so per-use-case scoping is + exercised through user-roles table assignments, and every valid + baseline role includes view_devices — cross-use-case read denial does + not exist in this RBAC model. +3. Staleness boundaries: an entry exactly AT the threshold is NOT stale + (the check is strictly greater); one millisecond past it is (Req 4.1), + for the default and a configured threshold (Req 4.3). +4. Disconnected-status pass-through from the underlying Greengrass + core-device lookup (Req 4.2). +5. Settings default: unreadable/invalid stored threshold values fall + back to the default 24 (Req 4.3). +6. Never-synced response shape with pending portal entries (Req 1.6). +7. Absent display fields at boundary detail (Req 4.4). +8. Conflict re-apply edge cases: deletion-retained re-create, an + already-effective portal deletion, an event without a portal version + (Req 6.4). +9. Audit event payload per mutating route — update/delete/reapply + (create is covered by the mutation-routes suite): acting user, + device, camera source, timestamp (Reqs 12.2, 12.3). + +Runs against the moto-backed conftest stack with the sibling suites' +fixture pattern (own registry/settings tables, module re-import, +FakeIotDataClient for the sync channel). + +_Requirements: 1.5, 1.6, 4.1, 4.2, 4.3, 4.4, 5.7, 6.4, 12.1, 12.2, 12.3_ +""" +import io +import json +import os +import sys +import time +import uuid +from types import SimpleNamespace + +import pytest +from botocore.exceptions import ClientError + +from conftest import REGION + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-api" +SETTINGS_TABLE_NAME = "test-settings-camera-registry-api" + +HOUR_MS = 3600 * 1000 +STALENESS_SETTING_KEY = "camera_registry.staleness_threshold_hours" + + +@pytest.fixture(scope="module") +def camera_env(aws_stack): + """Camera registry + settings tables and a freshly bound handler module.""" + import boto3 + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + sys.modules.pop("camera_registry", None) + sys.modules.pop("camera_sync", None) + import camera_registry + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + settings=resource.Table(SETTINGS_TABLE_NAME), + ) + + +@pytest.fixture(autouse=True) +def clean_staleness_setting(camera_env): + """Each test starts from the default Staleness_Threshold.""" + camera_env.settings.delete_item( + Key={"setting_key": STALENESS_SETTING_KEY}) + yield + + +class FakeIotDataClient: + """Records update_thing_shadow writes; serves get_thing_shadow pulls.""" + + def __init__(self, shadow_document=None, fail_update=False): + self.updates = [] + self.shadow_document = shadow_document + self.fail_update = fail_update + + def update_thing_shadow(self, thingName, shadowName, payload): + if self.fail_update: + raise ClientError( + {"Error": {"Code": "UnauthorizedException", + "Message": "assumed role failure"}}, + "UpdateThingShadow") + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {} + + def get_thing_shadow(self, thingName, shadowName): + if self.shadow_document is None: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no shadow"}}, + "GetThingShadow") + return {"payload": io.BytesIO( + json.dumps(self.shadow_document).encode())} + + +@pytest.fixture +def fake_shadow(camera_env, monkeypatch): + """Recording fake for the sync channel; serves an empty valid report + so the refresh route succeeds for authorized callers.""" + client = FakeIotDataClient(shadow_document={ + "state": {"reported": {"schemaVersion": 1, + "reportedAt": now_ms(), + "cameras": {}}}, + }) + monkeypatch.setattr(camera_env.module, "iot_data_client", + lambda usecase_id: client) + return client + + +def make_event(method, device_id, user, sub_path="", query=None, body=None): + path = f"/devices/{device_id}/cameras{sub_path}" + path_parameters = {"id": device_id} + if sub_path.startswith("/conflicts/") and sub_path.endswith("/reapply"): + path_parameters["cid"] = sub_path.split("/")[2] + elif sub_path and sub_path not in ("/refresh", "/conflicts"): + path_parameters["csid"] = sub_path.lstrip("/") + return { + "httpMethod": method, + "path": path, + "pathParameters": path_parameters, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(camera_env, method, device_id, user, sub_path="", query=None, + body=None): + response = camera_env.module.handler( + make_event(method, device_id, user, sub_path, query, body), None) + return response["statusCode"], json.loads(response["body"]) + + +def now_ms(): + return int(time.time() * 1000) + + +def put_meta(camera_env, device_id, usecase_id, last_report_at=None, + never_synced=False): + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": "META", "usecase_id": usecase_id, + "last_report_at": last_report_at, "never_synced": never_synced, + }) + + +def put_camera(camera_env, device_id, usecase_id, csid, **attrs): + item = { + "device_id": device_id, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": usecase_id, + "name": attrs.pop("name", csid), "type": attrs.pop("type", "Camera"), + "params": attrs.pop("params", {"devicePath": "/dev/video0"}), + "capabilities": attrs.pop("capabilities", {}), + "origin": attrs.pop("origin", "edge-configured"), + "version": attrs.pop("version", 3), + "sync_status": attrs.pop("sync_status", "synced"), + } + item.update(attrs) + camera_env.registry.put_item( + Item={k: v for k, v in item.items() if v is not None}) + + +def put_conflict(camera_env, device_id, usecase_id, csid, + portal_version, created_at=100): + cid = str(uuid.uuid4()) + camera_env.registry.put_item( + Item={k: v for k, v in { + "device_id": device_id, "sk": f"CONFLICT#{created_at}#{cid}", + "usecase_id": usecase_id, "camera_source_id": csid, + "edge_version": {"name": "edge"}, + "portal_version": portal_version, + "resolution": "edge-retained", "created_at": created_at, + }.items() if v is not None}) + return cid + + +def audit_events(aws_stack, user, action): + return [e for e in aws_stack.tables.audit_log.scan()["Items"] + if e["user_id"] == user["user_id"] and e["action"] == action] + + +def get_camera_item(camera_env, device_id, csid): + return camera_env.registry.get_item( + Key={"device_id": device_id, "sk": f"CAMERA#{csid}"}).get("Item") + + +# --------------------------------------------------------------------------- +# Per-route preparation for the RBAC matrix. Each entry seeds the device +# state a route needs and returns the invoke arguments. +# --------------------------------------------------------------------------- + +def _route_get_cameras(camera_env, device_id, usecase_id): + return dict(method="GET", sub_path="") + + +def _route_get_conflicts(camera_env, device_id, usecase_id): + return dict(method="GET", sub_path="/conflicts") + + +def _route_refresh(camera_env, device_id, usecase_id): + return dict(method="POST", sub_path="/refresh") + + +def _route_create(camera_env, device_id, usecase_id): + return dict(method="POST", sub_path="", + body={"name": "matrix cam", "type": "Camera", + "params": {"devicePath": "/dev/video7"}}) + + +def _route_update(camera_env, device_id, usecase_id): + put_camera(camera_env, device_id, usecase_id, "cfg-m", + last_reported_at=now_ms()) + return dict(method="PUT", sub_path="/cfg-m", + body={"name": "renamed", "type": "Camera", + "params": {"devicePath": "/dev/video0"}}) + + +def _route_delete(camera_env, device_id, usecase_id): + put_camera(camera_env, device_id, usecase_id, "cfg-m", + last_reported_at=now_ms()) + return dict(method="DELETE", sub_path="/cfg-m") + + +def _route_reapply(camera_env, device_id, usecase_id): + put_camera(camera_env, device_id, usecase_id, "cfg-m", + last_reported_at=now_ms()) + cid = put_conflict(camera_env, device_id, usecase_id, "cfg-m", + {"op": "update", "name": "portal name", + "type": "Camera", + "params": {"devicePath": "/dev/video1"}}) + return dict(method="POST", sub_path=f"/conflicts/{cid}/reapply") + + +READ_ROUTES = { + "get_cameras": (_route_get_cameras, 200), + "get_conflicts": (_route_get_conflicts, 200), + "refresh": (_route_refresh, 200), +} + +MUTATING_ROUTES = { + "create": (_route_create, 201), + "update": (_route_update, 200), + "delete": (_route_delete, 200), + "reapply": (_route_reapply, 200), +} + +ALL_ROUTES = {**READ_ROUTES, **MUTATING_ROUTES} + + +def prepare_and_invoke(camera_env, env, route_name, user): + """Seed a fresh device for `route_name` and invoke it as `user`.""" + prepare, expected = ALL_ROUTES[route_name] + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + kwargs = prepare(camera_env, device_id, usecase_id) + status, body = invoke(camera_env, device_id=device_id, user=user, + **kwargs) + return status, body, expected, device_id, usecase_id + + +# =========================================================================== +# 1. RBAC matrix per route (Reqs 5.7, 12.1, 12.2) +# =========================================================================== + +class TestRbacMatrix: + + @pytest.mark.parametrize("route_name", sorted(READ_ROUTES)) + def test_viewer_allowed_on_read_routes(self, camera_env, env, + fake_shadow, route_name): + """Viewer-held view_devices grants every read route (Req 12.1).""" + user = env.make_user(role="Viewer") + status, _, expected, _, _ = prepare_and_invoke( + camera_env, env, route_name, user) + assert status == expected + + @pytest.mark.parametrize("route_name", sorted(MUTATING_ROUTES)) + @pytest.mark.parametrize("role", ["Viewer", "DataScientist"]) + def test_non_operator_denied_on_mutating_routes( + self, aws_stack, camera_env, env, fake_shadow, route_name, role): + """Roles without manage_devices get 403 on every mutating route, + nothing reaches the sync channel, and the denial is audited as + unauthorized_access (Reqs 5.7, 12.2).""" + user = env.make_user(role=role) + status, body, _, device_id, usecase_id = prepare_and_invoke( + camera_env, env, route_name, user) + + assert status == 403 + assert body["required_permission"] == "manage_devices" + assert fake_shadow.updates == [] + + denials = audit_events(aws_stack, user, "unauthorized_access") + assert len(denials) == 1 + denial = denials[0] + assert denial["result"] == "denied" + assert denial["resource_type"] == "camera_registry" + assert denial["resource_id"] == device_id + assert denial["details"]["required_permission"] == "manage_devices" + assert denial["details"]["usecase_id"] == usecase_id + assert denial["timestamp"] > 0 + + @pytest.mark.parametrize("route_name", sorted(ALL_ROUTES)) + def test_operator_allowed_on_every_route(self, camera_env, env, + fake_shadow, route_name): + """Operator-held permissions grant reads and mutations alike + (Reqs 12.1, 12.2).""" + user = env.make_user(role="Operator") + status, _, expected, _, _ = prepare_and_invoke( + camera_env, env, route_name, user) + assert status == expected + + +# =========================================================================== +# 2. Cross-use-case scoping (Req 1.5) +# =========================================================================== + +class TestCrossUseCaseScoping: + + @pytest.mark.parametrize("route_name", sorted(MUTATING_ROUTES)) + def test_operator_of_another_usecase_is_denied( + self, aws_stack, camera_env, env, fake_shadow, route_name): + """An Operator assignment in a DIFFERENT Use_Case does not reach + the device: 403 scoped to the device's own usecase_id with an + unauthorized_access audit event (Req 1.5).""" + user = env.make_user(role="Viewer") + other_usecase = env.create_usecase() + env.assign_role(user, other_usecase, "Operator") + + status, _, _, device_id, device_usecase = prepare_and_invoke( + camera_env, env, route_name, user) + + assert status == 403 + assert fake_shadow.updates == [] + denials = audit_events(aws_stack, user, "unauthorized_access") + assert len(denials) == 1 + # The denial is scoped to the device's own Use_Case, not the one + # the caller holds Operator in. + assert denials[0]["details"]["usecase_id"] == device_usecase + assert denials[0]["resource_id"] == device_id + + def test_operator_assignment_in_device_usecase_grants( + self, camera_env, env, fake_shadow): + """The same user shape succeeds once the user-roles table grants + Operator for the device's own Use_Case — scoping is per-use-case + (Reqs 1.5, 12.2).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + env.assign_role(user, usecase_id, "Operator") + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-s", + last_reported_at=now_ms()) + + status, body = invoke( + camera_env, "PUT", device_id, user, sub_path="/cfg-s", + body={"name": "granted", "type": "Camera"}) + + assert status == 200 + assert body["sync_status"] == "pending" + assert len(fake_shadow.updates) == 1 + + +# =========================================================================== +# 3. Staleness boundaries (Reqs 4.1, 4.3) +# =========================================================================== + +class TestStalenessBoundaries: + + @pytest.fixture + def frozen_now(self, camera_env, monkeypatch): + """Pin the route's clock so boundary math is exact.""" + fixed = now_ms() + monkeypatch.setattr(camera_env.module, "now_ms", lambda: fixed) + return fixed + + def test_exactly_at_default_threshold_is_not_stale( + self, camera_env, env, frozen_now): + """An entry whose age equals the threshold exactly is NOT stale — + the comparison is strictly greater (Req 4.1).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=frozen_now) + put_camera(camera_env, device_id, usecase_id, "cfg-b", + last_reported_at=frozen_now - 24 * HOUR_MS) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["cameras"][0]["stale"] is False + + def test_one_ms_past_default_threshold_is_stale( + self, camera_env, env, frozen_now): + """One millisecond past the threshold flips to stale (Req 4.1).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=frozen_now) + put_camera(camera_env, device_id, usecase_id, "cfg-b", + last_reported_at=frozen_now - 24 * HOUR_MS - 1) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["cameras"][0]["stale"] is True + + def test_boundary_follows_the_configured_threshold( + self, camera_env, env, frozen_now): + """The same strict boundary applies to a PortalAdmin-configured + threshold (Reqs 4.1, 4.3).""" + camera_env.settings.put_item(Item={ + "setting_key": STALENESS_SETTING_KEY, "value": 2}) + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=frozen_now) + put_camera(camera_env, device_id, usecase_id, "cfg-at", + name="a-at", last_reported_at=frozen_now - 2 * HOUR_MS) + put_camera(camera_env, device_id, usecase_id, "cfg-past", + name="b-past", + last_reported_at=frozen_now - 2 * HOUR_MS - 1) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["staleness_threshold_hours"] == 2 + by_id = {c["camera_source_id"]: c for c in body["cameras"]} + assert by_id["cfg-at"]["stale"] is False + assert by_id["cfg-past"]["stale"] is True + + def test_never_reported_entry_is_not_stale(self, camera_env, env, + frozen_now): + """Portal-created pending entries carry no last-reported timestamp; + staleness does not apply to them (Req 4.1).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=frozen_now) + put_camera(camera_env, device_id, usecase_id, "portal-p", + origin="portal-created", sync_status="pending") + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["cameras"][0]["last_reported_at"] is None + assert body["cameras"][0]["stale"] is False + + +# =========================================================================== +# 4. Disconnected-status pass-through (Req 4.2) +# =========================================================================== + +class FakeGreengrassClient: + def __init__(self, status=None, error=None): + self.status = status + self.error = error + self.calls = [] + + def get_core_device(self, coreDeviceThingName): + self.calls.append(coreDeviceThingName) + if self.error: + raise self.error + return {"status": self.status} + + +class TestDeviceStatusPassThrough: + + @pytest.fixture + def greengrass(self, camera_env, monkeypatch): + """Route the existing device-status lookup at a fake Greengrass + client (assumed-role chain stubbed at the module seams).""" + client = FakeGreengrassClient(status="DISCONNECTED") + monkeypatch.setattr(camera_env.module, "get_usecase", lambda uid: { + "usecase_id": uid, + "cross_account_role_arn": + "arn:aws:iam::123456789012:role/test-usecase-role", + "external_id": "test-external-id", + "region": REGION, + }) + monkeypatch.setattr(camera_env.module, "assume_cross_account_role", + lambda arn, ext: {"AccessKeyId": "AK", + "SecretAccessKey": "SK", + "SessionToken": "ST"}) + monkeypatch.setattr(camera_env.module, "create_boto3_client", + lambda service, creds, region: client) + return client + + def test_disconnected_status_surfaces_in_the_response( + self, camera_env, env, greengrass): + """The Greengrass core-device status passes through to the + cameras response unchanged (Req 4.2).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-1", + last_reported_at=now_ms()) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["device_status"] == "DISCONNECTED" + # The lookup targeted this device's thing name. + assert greengrass.calls == [device_id] + # The inventory itself is unaffected by connectivity (Req 4.2: + # status is indicated ALONGSIDE the inventory). + assert body["count"] == 1 + + def test_lookup_failure_degrades_to_unknown(self, camera_env, env, + greengrass): + """A failing status lookup never fails the request — the response + carries UNKNOWN and the inventory stays readable (Req 4.2).""" + greengrass.error = RuntimeError("core device lookup failed") + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-1", + last_reported_at=now_ms()) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["device_status"] == "UNKNOWN" + assert body["count"] == 1 + + +# =========================================================================== +# 5. Settings default fallback (Req 4.3) +# =========================================================================== + +class TestStalenessSettingFallback: + + @pytest.mark.parametrize("stored", [0, -3, "garbage"]) + def test_invalid_stored_values_fall_back_to_default( + self, camera_env, env, stored): + """Non-positive or unparseable stored thresholds keep the route on + the default 24 hours (Req 4.3).""" + camera_env.settings.put_item(Item={ + "setting_key": STALENESS_SETTING_KEY, "value": stored}) + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["staleness_threshold_hours"] == 24 + + +# =========================================================================== +# 6. Never-synced response shape (Req 1.6) +# =========================================================================== + +class TestNeverSyncedShape: + + def test_never_synced_meta_with_pending_portal_entries( + self, camera_env, env): + """A device whose META still says never_synced reports the explicit + never-synced state while listing queued portal-created entries so + operators see what they staged (Req 1.6).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=None, + never_synced=True) + put_camera(camera_env, device_id, usecase_id, "portal-q", + origin="portal-created", sync_status="pending") + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["state"] == "never-synced" + assert body["last_report_at"] is None + assert body["count"] == 1 + assert body["cameras"][0]["camera_source_id"] == "portal-q" + assert body["cameras"][0]["sync_status"] == "pending" + + +# =========================================================================== +# 7. Absent display fields at boundary detail (Req 4.4) +# =========================================================================== + +class TestAbsentDisplayFields: + + def test_present_entry_never_carries_absent_since(self, camera_env, env): + """absent_since is a display field of ABSENT entries only; a + present entry omits it even when the attribute lingers (Req 4.4).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + now = now_ms() + put_meta(camera_env, device_id, usecase_id, last_report_at=now) + put_camera(camera_env, device_id, usecase_id, "disc-back", + origin="edge-discovered", last_reported_at=now, + absent=False, absent_since=now - HOUR_MS) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + camera = body["cameras"][0] + assert camera["absent"] is False + assert "absent_since" not in camera + + def test_absent_entry_without_timestamp_omits_absent_since( + self, camera_env, env): + """An absent entry with no recorded absence timestamp shows + absent=true without a fabricated absent_since (Req 4.4).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + now = now_ms() + put_meta(camera_env, device_id, usecase_id, last_report_at=now) + put_camera(camera_env, device_id, usecase_id, "disc-gone", + origin="edge-discovered", last_reported_at=now, + absent=True) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + camera = body["cameras"][0] + assert camera["absent"] is True + assert "absent_since" not in camera + + +# =========================================================================== +# 8. Conflict re-apply edge cases (Req 6.4) +# =========================================================================== + +class TestConflictReapplyEdgeCases: + + def test_deletion_retained_conflict_reapplies_as_create( + self, camera_env, env, fake_shadow): + """Re-applying the portal version of a deletion-retained conflict + (entry gone) re-creates the source as a new pending change + (Reqs 6.4, 6.5 aftermath).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + cid = put_conflict(camera_env, device_id, usecase_id, "cfg-gone", + {"op": "update", "name": "portal name", + "type": "Camera", + "params": {"devicePath": "/dev/video3"}}) + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{cid}/reapply") + + assert status == 200 + change = (fake_shadow.updates[0]["payload"]["state"]["desired"] + ["changes"]["cfg-gone"]) + # The deleted entry cannot be updated: the portal version is + # re-issued as a create with no baseVersion. + assert change["op"] == "create" + assert "baseVersion" not in change + assert change["name"] == "portal name" + + item = get_camera_item(camera_env, device_id, "cfg-gone") + assert item["sync_status"] == "pending" + assert item["origin"] == "portal-created" + assert item["pending_content"]["op"] == "create" + + def test_reapply_of_effective_deletion_is_conflict_409( + self, camera_env, env, fake_shadow): + """A portal DELETE that already became effective (entry gone) + cannot be re-applied — 409 with no shadow write (Req 6.4).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + cid = put_conflict(camera_env, device_id, usecase_id, "cfg-del", + {"op": "delete"}) + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{cid}/reapply") + + assert status == 409 + assert "already effective" in body["error"] + assert fake_shadow.updates == [] + + def test_conflict_without_portal_version_is_400( + self, camera_env, env, fake_shadow): + """A conflict event carrying no portal version has nothing to + re-apply (Req 6.4).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + cid = put_conflict(camera_env, device_id, usecase_id, "cfg-x", + portal_version=None) + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{cid}/reapply") + + assert status == 400 + assert fake_shadow.updates == [] + + +# =========================================================================== +# 9. Audit event payload per mutating route (Reqs 12.2, 12.3) +# (create_camera_source is asserted by the mutation-routes suite) +# =========================================================================== + +class TestMutatingAuditPayloads: + + def _assert_payload(self, events, device_id, csid, usecase_id, + portal_change_id): + assert len(events) == 1 + event = events[0] + assert event["result"] == "success" + assert event["resource_type"] == "camera_registry" + assert event["resource_id"] == device_id + assert event["timestamp"] > 0 + details = event["details"] + assert details["device_id"] == device_id + assert details["camera_source_id"] == csid + assert details["usecase_id"] == usecase_id + assert details["portal_change_id"] == portal_change_id + + def test_update_audit_payload(self, aws_stack, camera_env, env, + fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-u", + last_reported_at=now_ms()) + + status, body = invoke( + camera_env, "PUT", device_id, user, sub_path="/cfg-u", + body={"name": "renamed", "type": "Camera"}) + + assert status == 200 + self._assert_payload( + audit_events(aws_stack, user, "update_camera_source"), + device_id, "cfg-u", usecase_id, body["portal_change_id"]) + + def test_delete_audit_payload(self, aws_stack, camera_env, env, + fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-d", + last_reported_at=now_ms()) + + status, body = invoke(camera_env, "DELETE", device_id, user, + sub_path="/cfg-d") + + assert status == 200 + self._assert_payload( + audit_events(aws_stack, user, "delete_camera_source"), + device_id, "cfg-d", usecase_id, body["portal_change_id"]) + + def test_reapply_audit_payload_carries_conflict_id( + self, aws_stack, camera_env, env, fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + put_camera(camera_env, device_id, usecase_id, "cfg-r", + last_reported_at=now_ms()) + cid = put_conflict(camera_env, device_id, usecase_id, "cfg-r", + {"op": "update", "name": "portal name", + "type": "Camera", "params": {}}) + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{cid}/reapply") + + assert status == 200 + events = audit_events(aws_stack, user, "reapply_camera_conflict") + self._assert_payload(events, device_id, "cfg-r", usecase_id, + body["portal_change_id"]) + assert events[0]["details"]["conflict_id"] == cid diff --git a/edge-cv-portal/backend/tests/test_camera_registry_discovery_managed_properties.py b/edge-cv-portal/backend/tests/test_camera_registry_discovery_managed_properties.py new file mode 100644 index 00000000..5f78d266 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_discovery_managed_properties.py @@ -0,0 +1,324 @@ +""" +Property-based test for the Camera_Registry API discovery-managed +immutability (camera-registry-sync task 6.4). + +**Feature: camera-registry-sync, Property 10: Discovery-managed sources are immutable from the Portal** + +*For any* Camera_Source entry and any mutation operation (update, +delete), the portal API rejects the mutation identifying the source as +discovery-managed exactly when the entry's origin is `edge-discovered`, +and accepts it (subject to other validation) for every other origin. +For the rejected case: the response is 409 with code +`DISCOVERY_MANAGED`, zero shadow writes occur, and the stored registry +item is byte-identical afterward. + +**Validates: Requirements 5.6** + +Generators: PUT (arbitrary valid bodies) and DELETE mutations against +entries across all origins (edge-discovered, edge-configured, +portal-created), arbitrary sync statuses (synced/pending/failed, +including pending with a prior portal_change_id and failed with a +reason), unicode/whitespace-heavy names, arbitrary param and +capability dicts, versions, and absent/absent_since markers. + +Runs against the moto-backed conftest stack with the real handler +module and a recording fake iot-data shadow client (the assumed-role +transport is the only faked piece). +""" +import itertools +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-discovery-managed-props" +SETTINGS_TABLE_NAME = "test-settings-camera-discovery-managed-props" + +_device_counter = itertools.count() + + +# --------------------------------------------------------------------------- +# Environment (module-scoped so hypothesis examples share the stack) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def camera_env(aws_stack): + """Camera registry + settings tables and a freshly bound handler module.""" + import boto3 + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + sys.modules.pop("camera_registry", None) + sys.modules.pop("camera_sync", None) + import camera_registry + + # Swap the assumed-role iot-data client for a per-example fake via a + # mutable holder (module-scoped fixture; each example installs a + # fresh recording client into the holder). + holder = {"client": None} + original = camera_registry.iot_data_client + camera_registry.iot_data_client = lambda usecase_id: holder["client"] + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + shadow_holder=holder, + ) + camera_registry.iot_data_client = original + + +@pytest.fixture(scope="module") +def operator(aws_stack): + """One Operator user and Use_Case shared by every example.""" + usecase_id = f"uc-{uuid.uuid4()}" + aws_stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Property 10 Use Case", + "account_id": "123456789012", + }) + user_id = f"user-{uuid.uuid4()}" + user = { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": "Operator", + } + return SimpleNamespace(user=user, usecase_id=usecase_id) + + +class FakeIotDataClient: + """Records update_thing_shadow writes.""" + + def __init__(self): + self.updates = [] + + def update_thing_shadow(self, thingName, shadowName, payload): + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {} + + +def make_event(method, device_id, user, sub_path="", body=None): + path_parameters = {"id": device_id} + if sub_path: + path_parameters["csid"] = sub_path.lstrip("/") + return { + "httpMethod": method, + "path": f"/devices/{device_id}/cameras{sub_path}", + "pathParameters": path_parameters, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(camera_env, method, device_id, user, sub_path="", body=None): + response = camera_env.module.handler( + make_event(method, device_id, user, sub_path, body), None) + return response["statusCode"], json.loads(response["body"]) + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# Unicode/whitespace-heavy names (any non-empty name is a valid mutation). +_names = st.text( + alphabet=st.characters( + codec="utf-8", categories=("L", "N", "P", "Zs", "S") + ), + min_size=1, + max_size=24, +) + +_types = st.sampled_from(["Camera", "Folder", "ICam", "NvidiaCSI", "RTSP"]) + +_param_keys = st.one_of( + st.sampled_from(["devicePath", "cameraId", "url", "gain", "exposure"]), + st.text(min_size=1, max_size=12), +) +_param_values = st.one_of( + st.booleans(), + st.integers(min_value=-10_000_000, max_value=10_000_000), + st.text(max_size=20), +) +_params = st.dictionaries(_param_keys, _param_values, max_size=5) + +# Discovered capability metadata (arbitrary content per Property 10). +_capabilities = st.dictionaries( + st.sampled_from(["formats", "driver", "busInfo", "cardName"]), + st.one_of(st.text(max_size=20), + st.lists(st.text(max_size=10), max_size=3)), + max_size=4, +) + +_csids = st.tuples( + st.sampled_from(["disc", "cfg", "portal"]), + st.text(alphabet="0123456789abcdef", min_size=4, max_size=12), +).map(lambda t: f"{t[0]}-{t[1]}") + +_origins = st.sampled_from( + ["edge-discovered", "edge-configured", "portal-created"]) + + +@st.composite +def _existing_entries(draw): + """A pre-existing registry camera entry (arbitrary content/status).""" + sync_status = draw(st.sampled_from(["synced", "pending", "failed"])) + entry = { + "csid": draw(_csids), + "name": draw(_names), + "type": draw(_types), + "params": draw(_params), + "capabilities": draw(_capabilities), + "origin": draw(_origins), + "version": draw(st.integers(min_value=0, max_value=50)), + "sync_status": sync_status, + "absent": draw(st.booleans()), + } + if entry["absent"]: + entry["absent_since"] = draw( + st.integers(min_value=1_600_000_000_000, + max_value=1_800_000_000_000)) + if sync_status == "pending": + entry["portal_change_id"] = f"pc-prior-{draw(st.integers(0, 999))}" + if sync_status == "failed": + entry["failure_reason"] = "prior failure" + return entry + + +@st.composite +def _mutation_cases(draw): + case = { + "op": draw(st.sampled_from(["update", "delete"])), + "existing": draw(_existing_entries()), + } + if case["op"] == "update": + case["body"] = { + "name": draw(_names), + "type": draw(_types), + "params": draw(_params), + } + return case + + +# --------------------------------------------------------------------------- +# Property 10 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_mutation_cases()) +def test_discovery_managed_sources_are_immutable_from_the_portal( + camera_env, operator, case): + """A PUT or DELETE is rejected 409 DISCOVERY_MANAGED with zero shadow + writes and a byte-identical stored item exactly when the entry's + origin is edge-discovered; every other origin is accepted + (Requirement 5.6).""" + device_id = f"thing-prop10-{next(_device_counter)}" + usecase_id = operator.usecase_id + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": "META", "usecase_id": usecase_id, + "last_report_at": 1_700_000_000_000, "never_synced": False, + }) + + existing = case["existing"] + csid = existing["csid"] + item = { + "device_id": device_id, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": usecase_id, + "name": existing["name"], "type": existing["type"], + "params": existing["params"], + "capabilities": existing["capabilities"], + "origin": existing["origin"], "version": existing["version"], + "sync_status": existing["sync_status"], + "absent": existing["absent"], + "last_reported_at": 1_700_000_000_000, + } + for optional in ("absent_since", "portal_change_id", "failure_reason"): + if optional in existing: + item[optional] = existing[optional] + camera_env.registry.put_item(Item=item) + + stored_before = camera_env.registry.get_item( + Key={"device_id": device_id, "sk": f"CAMERA#{csid}"})["Item"] + + fake = FakeIotDataClient() + camera_env.shadow_holder["client"] = fake + + if case["op"] == "update": + status, body = invoke(camera_env, "PUT", device_id, operator.user, + sub_path=f"/{csid}", body=case["body"]) + else: + status, body = invoke(camera_env, "DELETE", device_id, operator.user, + sub_path=f"/{csid}") + + if existing["origin"] == "edge-discovered": + # --- rejected, identified as discovery-managed --- + assert status == 409 + assert body["code"] == "DISCOVERY_MANAGED" + assert body["camera_source_id"] == csid + + # --- zero shadow writes --- + assert fake.updates == [] + + # --- stored registry item byte-identical --- + stored_after = camera_env.registry.get_item( + Key={"device_id": device_id, "sk": f"CAMERA#{csid}"})["Item"] + assert stored_after == stored_before + else: + # --- every other origin is accepted (Property 10 biconditional) --- + assert status == 200 + assert body.get("code") != "DISCOVERY_MANAGED" + assert body["sync_status"] == "pending" diff --git a/edge-cv-portal/backend/tests/test_camera_registry_mutation_properties.py b/edge-cv-portal/backend/tests/test_camera_registry_mutation_properties.py new file mode 100644 index 00000000..4553bf21 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_mutation_properties.py @@ -0,0 +1,376 @@ +""" +Property-based test for the Camera_Registry API mutation routes +(camera-registry-sync task 6.3). + +**Feature: camera-registry-sync, Property 9: Portal mutation produces pending state and matching desired document** + +*For any* valid create, update, or delete of a Camera_Source through the +portal API, the registry entry transitions to `pending` with a fresh +`portal_change_id`, and the shadow desired document written for the +device contains a change entry whose operation and content round-trip +to the submitted mutation. + +**Validates: Requirements 5.1** + +Generators: all three operations, unicode/whitespace-heavy names, +arbitrary param dicts (string/int/bool values), existing entries across +mutable origins and sync statuses (including already-pending entries +with a prior portal_change_id, to check freshness), and explicit vs +generated camera source ids on create. + +Runs against the moto-backed conftest stack with the real handler +module and a recording fake iot-data shadow client (the assumed-role +transport is the only faked piece). +""" +import itertools +import json +import os +import sys +import uuid +from decimal import Decimal +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-mutation-props" +SETTINGS_TABLE_NAME = "test-settings-camera-mutation-props" + +_device_counter = itertools.count() + +# Every portal_change_id ever issued during this test run; Property 9's +# "fresh portal_change_id" means a mutation never reuses one. +_issued_change_ids = set() + + +# --------------------------------------------------------------------------- +# Environment (module-scoped so hypothesis examples share the stack) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def camera_env(aws_stack): + """Camera registry + settings tables and a freshly bound handler module.""" + import boto3 + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + sys.modules.pop("camera_registry", None) + sys.modules.pop("camera_sync", None) + import camera_registry + + # Swap the assumed-role iot-data client for a per-example fake via a + # mutable holder (module-scoped fixture; each example installs a + # fresh recording client into the holder). + holder = {"client": None} + original = camera_registry.iot_data_client + camera_registry.iot_data_client = lambda usecase_id: holder["client"] + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + shadow_holder=holder, + ) + camera_registry.iot_data_client = original + + +@pytest.fixture(scope="module") +def operator(aws_stack): + """One Operator user and Use_Case shared by every example.""" + usecase_id = f"uc-{uuid.uuid4()}" + aws_stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Property 9 Use Case", + "account_id": "123456789012", + }) + user_id = f"user-{uuid.uuid4()}" + user = { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": "Operator", + } + return SimpleNamespace(user=user, usecase_id=usecase_id) + + +class FakeIotDataClient: + """Records update_thing_shadow writes.""" + + def __init__(self): + self.updates = [] + + def update_thing_shadow(self, thingName, shadowName, payload): + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {} + + +def make_event(method, device_id, user, sub_path="", body=None): + path_parameters = {"id": device_id} + if sub_path: + path_parameters["csid"] = sub_path.lstrip("/") + return { + "httpMethod": method, + "path": f"/devices/{device_id}/cameras{sub_path}", + "pathParameters": path_parameters, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(camera_env, method, device_id, user, sub_path="", body=None): + response = camera_env.module.handler( + make_event(method, device_id, user, sub_path, body), None) + return response["statusCode"], json.loads(response["body"]) + + +def normalize(value): + """Numeric-type-insensitive comparison shape (DynamoDB Decimals vs + JSON ints round-tripped through the shadow payload).""" + if isinstance(value, dict): + return {k: normalize(v) for k, v in value.items()} + if isinstance(value, list): + return [normalize(v) for v in value] + if isinstance(value, bool): + return value + if isinstance(value, (int, float, Decimal)): + return float(value) + return value + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# Unicode/whitespace-heavy names (any non-empty name is a valid mutation). +_names = st.text( + alphabet=st.characters( + codec="utf-8", categories=("L", "N", "P", "Zs", "S") + ), + min_size=1, + max_size=24, +) + +_types = st.sampled_from(["Camera", "Folder", "ICam", "NvidiaCSI", "RTSP"]) + +_param_keys = st.one_of( + st.sampled_from(["devicePath", "cameraId", "url", "gain", "exposure"]), + st.text(min_size=1, max_size=12), +) +_param_values = st.one_of( + st.booleans(), + st.integers(min_value=-10_000_000, max_value=10_000_000), + st.text(max_size=20), +) +_params = st.dictionaries(_param_keys, _param_values, max_size=5) + +_csids = st.tuples( + st.sampled_from(["cfg", "portal"]), + st.text(alphabet="0123456789abcdef", min_size=4, max_size=10), +).map(lambda t: f"{t[0]}-{t[1]}") + +# Origins a portal mutation may legally target (edge-discovered is the +# immutability property, task 6.4). +_mutable_origins = st.sampled_from(["edge-configured", "portal-created"]) + + +@st.composite +def _existing_entries(draw): + """A pre-existing registry camera entry an update/delete targets.""" + sync_status = draw(st.sampled_from(["synced", "pending", "failed"])) + entry = { + "csid": draw(_csids), + "name": draw(_names), + "type": draw(_types), + "params": draw(_params), + "origin": draw(_mutable_origins), + "version": draw(st.integers(min_value=0, max_value=50)), + "sync_status": sync_status, + } + if sync_status == "pending": + entry["portal_change_id"] = f"pc-prior-{draw(st.integers(0, 999))}" + if sync_status == "failed": + entry["failure_reason"] = "prior failure" + return entry + + +@st.composite +def _mutation_cases(draw): + op = draw(st.sampled_from(["create", "update", "delete"])) + case = {"op": op} + if op == "create": + case["body"] = { + "name": draw(_names), + "type": draw(_types), + "params": draw(_params), + } + explicit = draw(st.booleans()) + if explicit: + case["body"]["camera_source_id"] = draw(_csids) + else: + case["existing"] = draw(_existing_entries()) + if op == "update": + case["body"] = { + "name": draw(_names), + "type": draw(_types), + "params": draw(_params), + } + return case + + +# --------------------------------------------------------------------------- +# Property 9 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_mutation_cases()) +def test_portal_mutation_pending_state_and_matching_desired_document( + camera_env, operator, case): + """Every valid portal mutation marks the entry pending with a fresh + portal_change_id, and the shadow desired change round-trips the + submitted operation and content (Requirement 5.1).""" + device_id = f"thing-prop9-{next(_device_counter)}" + usecase_id = operator.usecase_id + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": "META", "usecase_id": usecase_id, + "last_report_at": 1_700_000_000_000, "never_synced": False, + }) + + op = case["op"] + existing = case.get("existing") + if existing is not None: + item = { + "device_id": device_id, "sk": f"CAMERA#{existing['csid']}", + "camera_source_id": existing["csid"], "usecase_id": usecase_id, + "name": existing["name"], "type": existing["type"], + "params": existing["params"], "capabilities": {}, + "origin": existing["origin"], "version": existing["version"], + "sync_status": existing["sync_status"], + "last_reported_at": 1_700_000_000_000, + } + if "portal_change_id" in existing: + item["portal_change_id"] = existing["portal_change_id"] + if "failure_reason" in existing: + item["failure_reason"] = existing["failure_reason"] + camera_env.registry.put_item(Item=item) + + fake = FakeIotDataClient() + camera_env.shadow_holder["client"] = fake + + if op == "create": + status, body = invoke(camera_env, "POST", device_id, operator.user, + body=case["body"]) + assert status == 201 + csid = body["camera_source_id"] + assert body["origin"] == "portal-created" + elif op == "update": + csid = existing["csid"] + status, body = invoke(camera_env, "PUT", device_id, operator.user, + sub_path=f"/{csid}", body=case["body"]) + assert status == 200 + else: + csid = existing["csid"] + status, body = invoke(camera_env, "DELETE", device_id, operator.user, + sub_path=f"/{csid}") + assert status == 200 + + # --- pending state with a fresh portal_change_id --- + assert body["sync_status"] == "pending" + change_id = body["portal_change_id"] + assert change_id.startswith("pc-") + assert change_id not in _issued_change_ids, \ + "portal_change_id was reused across mutations" + _issued_change_ids.add(change_id) + if existing is not None and "portal_change_id" in existing: + assert change_id != existing["portal_change_id"] + + registry_item = camera_env.registry.get_item( + Key={"device_id": device_id, "sk": f"CAMERA#{csid}"}).get("Item") + assert registry_item is not None + assert registry_item["sync_status"] == "pending" + assert registry_item["portal_change_id"] == change_id + # A fresh change supersedes any earlier failure. + assert "failure_reason" not in registry_item + + # --- exactly one desired document written for the device --- + assert len(fake.updates) == 1 + update = fake.updates[0] + assert update["thing_name"] == device_id + assert update["shadow_name"] == "dda-camera-registry" + changes = update["payload"]["state"]["desired"]["changes"] + assert list(changes.keys()) == [csid] + change = changes[csid] + + # --- operation and content round-trip the submitted mutation --- + assert change["op"] == op + assert change["portalChangeId"] == change_id + pending_content = registry_item["pending_content"] + assert pending_content["op"] == op + + if op == "delete": + assert normalize(dict(pending_content)) == {"op": "delete"} + assert change["baseVersion"] == existing["version"] + else: + submitted = { + "name": case["body"]["name"], + "type": case["body"]["type"], + "params": case["body"]["params"], + } + shadow_content = {k: change[k] for k in ("name", "type", "params")} + recorded_content = {k: pending_content[k] + for k in ("name", "type", "params")} + assert normalize(shadow_content) == normalize(submitted) + assert normalize(recorded_content) == normalize(submitted) + if op == "update": + assert change["baseVersion"] == existing["version"] + # Edge-reported content stays effective until the ack; the + # portal version travels only in pending_content. + assert registry_item["name"] == existing["name"] diff --git a/edge-cv-portal/backend/tests/test_camera_registry_mutation_routes.py b/edge-cv-portal/backend/tests/test_camera_registry_mutation_routes.py new file mode 100644 index 00000000..f35eb530 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_mutation_routes.py @@ -0,0 +1,481 @@ +""" +Camera_Registry API mutation, conflict-reapply, and refresh routes +(camera-registry-sync task 6.2). + +Minimal route-level verification against the moto-backed conftest stack +with a fake iot-data shadow client: + - POST/PUT/DELETE write the shadow desired.changes entry FIRST and mark + the registry entry pending with a fresh portal_change_id (Req 5.1) + - origin edge-discovered mutations rejected with DISCOVERY_MANAGED (5.6) + - shadow client failure -> 502 with registry state untouched + - Viewer denied on mutation routes (Reqs 5.7, 12.2) + - POST .../conflicts/{cid}/reapply re-issues the portal version (6.4) + - POST .../cameras/refresh pulls the shadow and runs the ingest reducer + +The full RBAC matrix, audit payload assertions, and boundary sweep are +task 6.6. + +Requirements: 5.1, 5.6, 5.7, 6.4, 12.2, 12.3 +""" +import io +import json +import os +import sys +import time +import uuid +from types import SimpleNamespace + +import pytest +from botocore.exceptions import ClientError + +from conftest import REGION + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-mutation-routes" +SETTINGS_TABLE_NAME = "test-settings-camera-mutation" + + +@pytest.fixture(scope="module") +def camera_env(aws_stack): + """Camera registry + settings tables and a freshly bound handler module.""" + import boto3 + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + sys.modules.pop("camera_registry", None) + sys.modules.pop("camera_sync", None) + import camera_registry + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + ) + + +class FakeIotDataClient: + """Records update_thing_shadow writes; serves get_thing_shadow pulls.""" + + def __init__(self, shadow_document=None, fail_update=False): + self.updates = [] + self.shadow_document = shadow_document + self.fail_update = fail_update + + def update_thing_shadow(self, thingName, shadowName, payload): + if self.fail_update: + raise ClientError( + {"Error": {"Code": "UnauthorizedException", + "Message": "assumed role failure"}}, + "UpdateThingShadow") + self.updates.append({ + "thing_name": thingName, + "shadow_name": shadowName, + "payload": json.loads(payload), + }) + return {} + + def get_thing_shadow(self, thingName, shadowName): + if self.shadow_document is None: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no shadow"}}, + "GetThingShadow") + return {"payload": io.BytesIO( + json.dumps(self.shadow_document).encode())} + + +@pytest.fixture +def fake_shadow(camera_env, monkeypatch): + """Replace the assumed-role iot-data client with a recording fake.""" + client = FakeIotDataClient() + monkeypatch.setattr(camera_env.module, "iot_data_client", + lambda usecase_id: client) + return client + + +def make_event(method, device_id, user, sub_path="", query=None, body=None): + path = f"/devices/{device_id}/cameras{sub_path}" + path_parameters = {"id": device_id} + if sub_path.startswith("/conflicts/") and sub_path.endswith("/reapply"): + path_parameters["cid"] = sub_path.split("/")[2] + elif sub_path and sub_path not in ("/refresh",): + path_parameters["csid"] = sub_path.lstrip("/") + return { + "httpMethod": method, + "path": path, + "pathParameters": path_parameters, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(camera_env, method, device_id, user, sub_path="", query=None, + body=None): + response = camera_env.module.handler( + make_event(method, device_id, user, sub_path, query, body), None) + return response["statusCode"], json.loads(response["body"]) + + +def now_ms(): + return int(time.time() * 1000) + + +def put_meta(camera_env, device_id, usecase_id, last_report_at=None): + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": "META", "usecase_id": usecase_id, + "last_report_at": last_report_at or now_ms(), "never_synced": False, + }) + + +def put_camera(camera_env, device_id, usecase_id, csid, **attrs): + item = { + "device_id": device_id, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": usecase_id, + "name": attrs.pop("name", csid), "type": attrs.pop("type", "Camera"), + "params": attrs.pop("params", {"devicePath": "/dev/video0"}), + "capabilities": attrs.pop("capabilities", {}), + "origin": attrs.pop("origin", "edge-configured"), + "version": attrs.pop("version", 3), + "sync_status": attrs.pop("sync_status", "synced"), + "last_reported_at": attrs.pop("last_reported_at", now_ms()), + } + item.update(attrs) + camera_env.registry.put_item(Item=item) + + +def get_camera_item(camera_env, device_id, csid): + response = camera_env.registry.get_item( + Key={"device_id": device_id, "sk": f"CAMERA#{csid}"}) + return response.get("Item") + + +class TestCreateCamera: + def test_create_writes_shadow_first_and_marks_pending( + self, camera_env, env, fake_shadow): + """POST creates origin portal-created, pending, with the desired + change delivered to the sync channel (Requirement 5.1).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, body = invoke( + camera_env, "POST", device_id, user, + body={"name": "Portal cam", "type": "Camera", + "params": {"devicePath": "/dev/video5"}}) + + assert status == 201 + assert body["origin"] == "portal-created" + assert body["sync_status"] == "pending" + csid = body["camera_source_id"] + change_id = body["portal_change_id"] + assert change_id.startswith("pc-") + + # Shadow desired.changes entry (written first). + assert len(fake_shadow.updates) == 1 + update = fake_shadow.updates[0] + assert update["thing_name"] == device_id + assert update["shadow_name"] == "dda-camera-registry" + change = update["payload"]["state"]["desired"]["changes"][csid] + assert change["op"] == "create" + assert change["portalChangeId"] == change_id + assert change["name"] == "Portal cam" + assert change["params"] == {"devicePath": "/dev/video5"} + + # Registry entry marked pending with the same portal_change_id. + item = get_camera_item(camera_env, device_id, csid) + assert item["sync_status"] == "pending" + assert item["portal_change_id"] == change_id + assert item["origin"] == "portal-created" + assert item["pending_content"]["op"] == "create" + + def test_shadow_failure_returns_502_and_leaves_registry_untouched( + self, camera_env, env, monkeypatch): + """An assumed-role shadow client failure returns 502 without + creating any registry state (task 6.2).""" + client = FakeIotDataClient(fail_update=True) + monkeypatch.setattr(camera_env.module, "iot_data_client", + lambda usecase_id: client) + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, body = invoke( + camera_env, "POST", device_id, user, + body={"name": "cam", "type": "Camera", + "camera_source_id": "portal-x"}) + + assert status == 502 + assert get_camera_item(camera_env, device_id, "portal-x") is None + + def test_viewer_cannot_mutate(self, camera_env, env, fake_shadow): + """Mutation routes require the Operator permission (Reqs 5.7, 12.2).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, body = invoke(camera_env, "POST", device_id, user, + body={"name": "cam", "type": "Camera"}) + + assert status == 403 + assert fake_shadow.updates == [] + + +class TestUpdateAndDeleteCamera: + def test_update_marks_pending_and_delivers_change( + self, camera_env, env, fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + put_camera(camera_env, device_id, usecase_id, "cfg-1", + name="old name", version=7) + + status, body = invoke( + camera_env, "PUT", device_id, user, sub_path="/cfg-1", + body={"name": "new name", "type": "Camera", + "params": {"devicePath": "/dev/video1"}}) + + assert status == 200 + change_id = body["portal_change_id"] + change = (fake_shadow.updates[0]["payload"]["state"]["desired"] + ["changes"]["cfg-1"]) + assert change["op"] == "update" + assert change["baseVersion"] == 7 + assert change["name"] == "new name" + + item = get_camera_item(camera_env, device_id, "cfg-1") + assert item["sync_status"] == "pending" + assert item["portal_change_id"] == change_id + # Edge-reported content stays effective until the ack; the portal + # version travels in pending_content. + assert item["name"] == "old name" + assert item["pending_content"]["name"] == "new name" + + def test_discovery_managed_sources_are_rejected( + self, camera_env, env, fake_shadow): + """Mutations of origin edge-discovered are rejected with + DISCOVERY_MANAGED and no shadow write (Requirement 5.6).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + put_camera(camera_env, device_id, usecase_id, "disc-abc", + origin="edge-discovered") + + for method, body in (("PUT", {"name": "x", "type": "Camera"}), + ("DELETE", None)): + status, response = invoke(camera_env, method, device_id, user, + sub_path="/disc-abc", body=body) + assert status == 409 + assert response["code"] == "DISCOVERY_MANAGED" + + assert fake_shadow.updates == [] + item = get_camera_item(camera_env, device_id, "disc-abc") + assert item["sync_status"] == "synced" + + def test_delete_issues_pending_delete(self, camera_env, env, fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + put_camera(camera_env, device_id, usecase_id, "cfg-del", version=4) + + status, body = invoke(camera_env, "DELETE", device_id, user, + sub_path="/cfg-del") + + assert status == 200 + change = (fake_shadow.updates[0]["payload"]["state"]["desired"] + ["changes"]["cfg-del"]) + assert change["op"] == "delete" + assert change["baseVersion"] == 4 + + item = get_camera_item(camera_env, device_id, "cfg-del") + assert item["sync_status"] == "pending" + assert item["pending_content"] == {"op": "delete"} + + def test_unknown_camera_is_404(self, camera_env, env, fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, _ = invoke(camera_env, "PUT", device_id, user, + sub_path="/missing", + body={"name": "x", "type": "Camera"}) + assert status == 404 + assert fake_shadow.updates == [] + + +class TestReapplyConflict: + def test_reapply_reissues_portal_version_as_new_pending_change( + self, camera_env, env, fake_shadow): + """Re-apply issues the overridden portal version with a fresh + portal_change_id and stamps the conflict item (Requirement 6.4).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + put_camera(camera_env, device_id, usecase_id, "cfg-c", + name="edge name", version=9) + + cid = str(uuid.uuid4()) + conflict_sk = f"CONFLICT#100#{cid}" + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": conflict_sk, + "usecase_id": usecase_id, "camera_source_id": "cfg-c", + "edge_version": {"name": "edge name"}, + "portal_version": {"op": "update", "name": "portal name", + "type": "Camera", + "params": {"devicePath": "/dev/video2"}}, + "resolution": "edge-retained", "created_at": 100, + }) + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{cid}/reapply") + + assert status == 200 + change_id = body["portal_change_id"] + change = (fake_shadow.updates[0]["payload"]["state"]["desired"] + ["changes"]["cfg-c"]) + assert change["op"] == "update" + assert change["portalChangeId"] == change_id + assert change["name"] == "portal name" + assert change["baseVersion"] == 9 + + item = get_camera_item(camera_env, device_id, "cfg-c") + assert item["sync_status"] == "pending" + assert item["portal_change_id"] == change_id + + conflict = camera_env.registry.get_item( + Key={"device_id": device_id, "sk": conflict_sk})["Item"] + assert conflict["reapplied_as"] == change_id + + def test_unknown_conflict_is_404(self, camera_env, env, fake_shadow): + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, _ = invoke(camera_env, "POST", device_id, user, + sub_path=f"/conflicts/{uuid.uuid4()}/reapply") + assert status == 404 + assert fake_shadow.updates == [] + + +class TestRefresh: + def test_refresh_pulls_shadow_and_runs_reducer( + self, camera_env, env, monkeypatch): + """POST .../cameras/refresh reduces the pulled reported state into + the registry exactly like the ingest path (task 6.2).""" + reported = { + "schemaVersion": 1, + "reportedAt": now_ms(), + "cameras": { + "cfg-r1": {"version": 2, "name": "Refreshed cam", + "type": "Camera", "origin": "edge-configured", + "params": {"devicePath": "/dev/video0"}, + "capabilities": {}, "absent": False}, + }, + } + client = FakeIotDataClient(shadow_document={ + "state": {"reported": reported}}) + monkeypatch.setattr(camera_env.module, "iot_data_client", + lambda usecase_id: client) + + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + + status, body = invoke(camera_env, "POST", device_id, user, + sub_path="/refresh", + query={"usecase_id": usecase_id}) + + assert status == 200 + assert body["state"] == "synced" + assert body["count"] == 1 + assert body["cameras"][0]["camera_source_id"] == "cfg-r1" + assert body["cameras"][0]["name"] == "Refreshed cam" + assert body["cameras"][0]["sync_status"] == "synced" + + def test_refresh_without_shadow_is_404(self, camera_env, env, + monkeypatch): + client = FakeIotDataClient(shadow_document=None) + monkeypatch.setattr(camera_env.module, "iot_data_client", + lambda usecase_id: client) + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + + status, _ = invoke(camera_env, "POST", device_id, user, + sub_path="/refresh", + query={"usecase_id": usecase_id}) + assert status == 404 + + +class TestAudit: + def test_mutations_log_audit_events(self, camera_env, env, aws_stack, + fake_shadow): + """Mutating routes record audit events carrying the acting user, + device, and camera source (Requirements 12.2, 12.3).""" + user = env.make_user(role="Operator") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id) + + status, body = invoke( + camera_env, "POST", device_id, user, + body={"name": "cam", "type": "Camera"}) + assert status == 201 + + events = aws_stack.tables.audit_log.scan()["Items"] + matching = [e for e in events + if e["user_id"] == user["user_id"] + and e["action"] == "create_camera_source"] + assert len(matching) == 1 + event = matching[0] + assert event["resource_id"] == device_id + assert event["details"]["camera_source_id"] == body["camera_source_id"] + assert event["details"]["device_id"] == device_id + assert event["timestamp"] > 0 diff --git a/edge-cv-portal/backend/tests/test_camera_registry_read_routes.py b/edge-cv-portal/backend/tests/test_camera_registry_read_routes.py new file mode 100644 index 00000000..c393ece3 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_read_routes.py @@ -0,0 +1,274 @@ +""" +Camera_Registry API read routes (camera-registry-sync task 6.1). + +Minimal route-level verification against the moto-backed conftest stack: +GET /devices/{id}/cameras (never-synced state, per-entry staleness against +the Staleness_Threshold, absent passthrough) and +GET /devices/{id}/cameras/conflicts (newest first). The full RBAC matrix, +audit assertions, and boundary sweep are task 6.6. + +Requirements: 1.3, 1.5, 1.6, 4.1, 4.2, 4.4, 6.3, 12.1 +""" +import json +import os +import sys +import time +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-read-routes" +SETTINGS_TABLE_NAME = "test-settings-camera-registry" + +HOUR_MS = 3600 * 1000 + + +@pytest.fixture(scope="module") +def camera_env(aws_stack): + """Camera registry + settings tables and a freshly bound handler module.""" + import boto3 + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + sys.modules.pop("camera_registry", None) + import camera_registry + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + settings=resource.Table(SETTINGS_TABLE_NAME), + ) + + +@pytest.fixture(autouse=True) +def clean_staleness_setting(camera_env): + """Each test starts from the default Staleness_Threshold.""" + camera_env.settings.delete_item( + Key={"setting_key": "camera_registry.staleness_threshold_hours"}) + yield + + +def make_event(method, device_id, user, sub_path="", query=None): + path = f"/devices/{device_id}/cameras{sub_path}" + return { + "httpMethod": method, + "resource": f"/devices/{{id}}/cameras{sub_path}", + "path": path, + "pathParameters": {"id": device_id}, + "queryStringParameters": query, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(camera_env, method, device_id, user, sub_path="", query=None): + response = camera_env.module.handler( + make_event(method, device_id, user, sub_path, query), None) + return response["statusCode"], json.loads(response["body"]) + + +def now_ms(): + return int(time.time() * 1000) + + +def put_meta(camera_env, device_id, usecase_id, last_report_at, + never_synced=False): + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": "META", "usecase_id": usecase_id, + "last_report_at": last_report_at, "never_synced": never_synced, + }) + + +def put_camera(camera_env, device_id, usecase_id, csid, **attrs): + item = { + "device_id": device_id, "sk": f"CAMERA#{csid}", + "camera_source_id": csid, "usecase_id": usecase_id, + "name": attrs.pop("name", csid), "type": attrs.pop("type", "Camera"), + "params": attrs.pop("params", {"devicePath": "/dev/video0"}), + "capabilities": attrs.pop("capabilities", {}), + "origin": attrs.pop("origin", "edge-configured"), + "version": attrs.pop("version", 1), + "sync_status": attrs.pop("sync_status", "synced"), + } + item.update(attrs) + camera_env.registry.put_item(Item=item) + + +class TestGetCameras: + def test_never_synced_device_returns_explicit_state(self, camera_env, env): + """A device with no completed synchronization reports + state=never-synced, not a bare empty list (Requirement 1.6).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + + status, body = invoke(camera_env, "GET", device_id, user, + query={"usecase_id": usecase_id}) + + assert status == 200 + assert body["state"] == "never-synced" + assert body["cameras"] == [] + assert body["device_status"] == "UNKNOWN" + + def test_unresolvable_usecase_is_rejected(self, camera_env, env): + """A device the registry has never seen needs the usecase_id + parameter for the authorization check.""" + user = env.make_user(role="Viewer") + device_id = f"thing-{uuid.uuid4()}" + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 400 + assert "usecase_id" in body["error"] + + def test_synced_device_lists_entries_with_staleness_and_absence( + self, camera_env, env): + """Registry entries carry computed stale (Req 4.1), absent with + absent_since (Req 4.4), META freshness, and device status (4.2).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + now = now_ms() + + put_meta(camera_env, device_id, usecase_id, last_report_at=now) + put_camera(camera_env, device_id, usecase_id, "cfg-fresh", + name="a-fresh", last_reported_at=now) + put_camera(camera_env, device_id, usecase_id, "cfg-old", + name="b-old", last_reported_at=now - 25 * HOUR_MS) + put_camera(camera_env, device_id, usecase_id, "disc-gone", + name="c-gone", origin="edge-discovered", + last_reported_at=now, absent=True, + absent_since=now - HOUR_MS) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["state"] == "synced" + assert body["usecase_id"] == usecase_id + assert body["last_report_at"] == now + assert body["staleness_threshold_hours"] == 24 + assert body["count"] == 3 + + by_id = {c["camera_source_id"]: c for c in body["cameras"]} + assert by_id["cfg-fresh"]["stale"] is False + assert by_id["cfg-fresh"]["absent"] is False + # 25 h old against the default 24 h Staleness_Threshold (Req 4.1) + assert by_id["cfg-old"]["stale"] is True + assert by_id["cfg-old"]["last_reported_at"] == now - 25 * HOUR_MS + assert by_id["disc-gone"]["absent"] is True + assert by_id["disc-gone"]["absent_since"] == now - HOUR_MS + assert by_id["disc-gone"]["origin"] == "edge-discovered" + + def test_staleness_threshold_setting_is_honored(self, camera_env, env): + """The settings entry overrides the default 24 h threshold.""" + camera_env.settings.put_item(Item={ + "setting_key": "camera_registry.staleness_threshold_hours", + "value": 1, + }) + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + now = now_ms() + + put_meta(camera_env, device_id, usecase_id, last_report_at=now) + put_camera(camera_env, device_id, usecase_id, "cfg-2h", + last_reported_at=now - 2 * HOUR_MS) + + status, body = invoke(camera_env, "GET", device_id, user) + + assert status == 200 + assert body["staleness_threshold_hours"] == 1 + assert body["cameras"][0]["stale"] is True + + def test_device_usecase_wins_over_query_parameter(self, camera_env, env): + """Authorization scopes to the device's own usecase_id from the + registry, not a caller-supplied parameter (Reqs 1.4, 1.5).""" + user = env.make_user(role="Viewer") + device_usecase = env.create_usecase() + other_usecase = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, device_usecase, + last_report_at=now_ms()) + + status, body = invoke(camera_env, "GET", device_id, user, + query={"usecase_id": other_usecase}) + + assert status == 200 + assert body["usecase_id"] == device_usecase + + +class TestGetConflicts: + def test_conflicts_newest_first(self, camera_env, env): + """Conflict events are returned newest first (Requirement 6.3).""" + user = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + device_id = f"thing-{uuid.uuid4()}" + put_meta(camera_env, device_id, usecase_id, last_report_at=now_ms()) + + older_id, newer_id = str(uuid.uuid4()), str(uuid.uuid4()) + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": f"CONFLICT#100#{older_id}", + "usecase_id": usecase_id, "camera_source_id": "cfg-a", + "edge_version": {"name": "edge"}, "portal_version": {"name": "portal"}, + "resolution": "edge-retained", "created_at": 100, + }) + camera_env.registry.put_item(Item={ + "device_id": device_id, "sk": f"CONFLICT#200#{newer_id}", + "usecase_id": usecase_id, "camera_source_id": "cfg-b", + "edge_version": None, "portal_version": {"name": "portal"}, + "resolution": "deletion-retained", "created_at": 200, + }) + + status, body = invoke(camera_env, "GET", device_id, user, + sub_path="/conflicts") + + assert status == 200 + assert body["count"] == 2 + assert [c["created_at"] for c in body["conflicts"]] == [200, 100] + assert body["conflicts"][0]["conflict_id"] == newer_id + assert body["conflicts"][0]["resolution"] == "deletion-retained" + assert body["conflicts"][1]["camera_source_id"] == "cfg-a" + assert body["conflicts"][1]["edge_version"] == {"name": "edge"} diff --git a/edge-cv-portal/backend/tests/test_camera_registry_settings.py b/edge-cv-portal/backend/tests/test_camera_registry_settings.py new file mode 100644 index 00000000..747ec014 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_registry_settings.py @@ -0,0 +1,245 @@ +""" +Camera_Registry Staleness_Threshold settings API tests +(camera-registry-sync Requirement 4.3). + +Task 6.5 (spec: camera-registry-sync). + +The Staleness_Threshold rides the existing PortalAdmin-only +/data-accounts/{id} settings routes with the reserved id +'camera-registry-configuration' (the same carrier as +'bedrock-configuration'; no new API Gateway routes), handled by +data_accounts.py. These tests cover: + +1. Authorization: only PortalAdmin may read or write; every other role + gets 403 and nothing is persisted. +2. Reads return the effective value (default 24 before anything is + stored, the stored value afterwards). +3. Writes validate a positive number of hours and persist the exact item + shape read by camera_registry.staleness_threshold_hours(): + {setting_key: 'camera_registry.staleness_threshold_hours', + value: } + so the cameras route picks the value up. +4. Successful updates write an audit record. + +Runs against the shared moto stack from conftest.py. + +_Requirements: 4.3_ +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION + +SETTINGS_TABLE_NAME = "test-settings-camera-registry-settings-api" +RESOURCE_ID = "camera-registry-configuration" +SETTING_KEY = "camera_registry.staleness_threshold_hours" + + +@pytest.fixture(scope="module") +def settings_env(aws_stack): + """Settings table + freshly imported data_accounts and camera_registry + modules inside moto (conftest re-import pattern).""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so both modules bind SETTINGS_TABLE_NAME and + # moto-intercepted boto3 resources. + for module_name in ("data_accounts", "camera_registry"): + sys.modules.pop(module_name, None) + import data_accounts + import camera_registry + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + data_accounts=data_accounts, + camera_registry=camera_registry, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + ) + + +@pytest.fixture(autouse=True) +def clean_setting(settings_env): + """Each test starts with no stored staleness threshold item.""" + settings_env.settings_table.delete_item(Key={"setting_key": SETTING_KEY}) + yield + + +def make_user(role): + user_id = f"user-{uuid.uuid4()}" + return {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": role} + + +def invoke(settings_env, method, user, body=None): + event = { + "httpMethod": method, + "resource": "/data-accounts/{id}", + "path": f"/data-accounts/{RESOURCE_ID}", + "pathParameters": {"id": RESOURCE_ID}, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = settings_env.data_accounts.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def stored_item(settings_env): + return settings_env.settings_table.get_item( + Key={"setting_key": SETTING_KEY}).get("Item") + + +# =========================================================================== +# 1. Authorization (Requirement 4.3: PortalAdmin only) +# =========================================================================== + +class TestCameraRegistrySettingAuthz: + + @pytest.mark.parametrize("role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + @pytest.mark.parametrize("method", ["GET", "PUT"]) + def test_non_portal_admin_is_denied(self, settings_env, role, method): + """Every non-PortalAdmin role gets 403 on read and write and no + setting is persisted (Requirement 4.3).""" + user = make_user(role) + status, payload = invoke(settings_env, method, user, + body={"staleness_threshold_hours": 12}) + assert status == 403 + assert "PortalAdmin" in payload["error"] + assert stored_item(settings_env) is None + + def test_portal_admin_is_allowed(self, settings_env): + """PortalAdmin can read and write (Requirement 4.3).""" + admin = make_user("PortalAdmin") + status, _ = invoke(settings_env, "GET", admin) + assert status == 200 + status, _ = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": 12}) + assert status == 200 + + +# =========================================================================== +# 2. Reads (default and stored values) +# =========================================================================== + +class TestCameraRegistrySettingRead: + + def test_read_returns_default_24_when_nothing_stored(self, settings_env): + admin = make_user("PortalAdmin") + status, payload = invoke(settings_env, "GET", admin) + assert status == 200 + assert payload["staleness_threshold_hours"] == 24 + assert payload["default_staleness_threshold_hours"] == 24 + + def test_read_returns_stored_value(self, settings_env): + admin = make_user("PortalAdmin") + status, _ = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": 48}) + assert status == 200 + status, payload = invoke(settings_env, "GET", admin) + assert status == 200 + assert payload["staleness_threshold_hours"] == 48 + assert payload["default_staleness_threshold_hours"] == 24 + + +# =========================================================================== +# 3. Writes: persisted shape (readable by the cameras route) and validation +# =========================================================================== + +class TestCameraRegistrySettingWrite: + + def test_write_persists_the_shape_the_cameras_route_reads(self, settings_env): + """The stored item is {setting_key, value} - exactly what + camera_registry.staleness_threshold_hours() expects - and the + cameras-route reader returns the updated value (Requirement 4.3).""" + admin = make_user("PortalAdmin") + status, _ = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": 6}) + assert status == 200 + + item = stored_item(settings_env) + assert item["setting_key"] == SETTING_KEY + assert float(item["value"]) == 6 + assert item["updated_by"] == admin["user_id"] + + # Readable by the cameras route (Requirement 4.3). + assert settings_env.camera_registry.staleness_threshold_hours() == 6 + + def test_fractional_hours_are_accepted(self, settings_env): + admin = make_user("PortalAdmin") + status, _ = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": 0.5}) + assert status == 200 + assert settings_env.camera_registry.staleness_threshold_hours() == 0.5 + + @pytest.mark.parametrize("hours", [0, -1, -0.5, "many", None, True, [24]]) + def test_invalid_values_are_rejected(self, settings_env, hours): + """The threshold must be a positive number of hours; anything else + is rejected with 400 and nothing is persisted.""" + admin = make_user("PortalAdmin") + status, payload = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": hours}) + assert status == 400 + assert any("staleness_threshold_hours" in e + for e in payload["validation_errors"]) + assert stored_item(settings_env) is None + # The readers keep serving the default. + assert settings_env.camera_registry.staleness_threshold_hours() == 24 + + def test_missing_key_is_rejected(self, settings_env): + admin = make_user("PortalAdmin") + status, payload = invoke(settings_env, "PUT", admin, body={}) + assert status == 400 + assert stored_item(settings_env) is None + + +# =========================================================================== +# 4. Audit records +# =========================================================================== + +@pytest.fixture +def audit_table(aws_stack): + """The moto-backed audit log table shared_utils.log_audit_event writes to.""" + import boto3 + from conftest import TEST_ENV + + return boto3.resource("dynamodb", region_name=REGION).Table( + TEST_ENV["AUDIT_LOG_TABLE"]) + + +class TestCameraRegistrySettingAudit: + + def test_update_writes_audit_record(self, settings_env, audit_table): + admin = make_user("PortalAdmin") + status, _ = invoke(settings_env, "PUT", admin, + body={"staleness_threshold_hours": 36}) + assert status == 200 + + records = [i for i in audit_table.scan()["Items"] + if i["user_id"] == admin["user_id"] + and i["action"] == "update_camera_registry_configuration"] + assert len(records) == 1 + assert records[0]["resource_id"] == SETTING_KEY + assert records[0]["result"] == "success" + assert float(records[0]["details"]["staleness_threshold_hours"]) == 36 diff --git a/edge-cv-portal/backend/tests/test_camera_shadow_sync_integration.py b/edge-cv-portal/backend/tests/test_camera_shadow_sync_integration.py new file mode 100644 index 00000000..19af0273 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_shadow_sync_integration.py @@ -0,0 +1,748 @@ +""" +Shadow sync integration tests (camera-registry-sync task 15.1). + +The single integration example over the AWS-owned transports: everything +else in the feature is fake-tested, so these two tests wire the REAL +Edge_Sync_Agent (src/backend/camera_sync) to the REAL Portal_Sync_Service +ingest handler and Camera_Registry API (functions/camera_sync.py, +functions/camera_registry.py) across an emulated ``dda-camera-registry`` +named shadow. + +moto does not emulate IoT named shadows or IoT rules, so the shadow is a +:class:`NamedShadowEmulator` — one in-memory shadow document implementing +both sides' accessor contracts (the edge ``IoTShadowAccessor``'s +``get/update_thing_shadow_state_request`` and the portal iot-data client's +``get_thing_shadow``/``update_thing_shadow``) with AWS shadow semantics: +recursive state merge, ``null`` deletes a key, a delta computed as desired +minus reported, and one ``/update/documents`` event per accepted update. + +Direction 1 (edge -> portal, Reqs 3.3, 12.4): the real agent, driven with +a fake clock, publishes the device inventory to the shadow — first while +disconnected (writes fail, the report is retained), then after reconnect +(the first successful write is the complete current state, Req 3.3). The +resulting documents event is wrapped exactly like the IoT topic rule +(``SELECT *, topic(3) AS thing_name``) and delivered as an SQS record to +the real ingest handler against the moto DynamoDB registry. + +Direction 2 (portal -> edge, Reqs 5.5, 12.4): the real mutation route +writes ``desired.changes`` (with the emulator standing in for the +assumed-role iot-data client) and marks the registry entry pending; the +delta is delivered to the real agent's ``on_delta``, applied through the +image-source accessor, acknowledged in the resulting report, and that +report travels back through the rule/SQS ingest path until the registry +entry is synced. The disconnected-then-reconnect case delivers the pending +change through the reconnect-time ``apply_desired_changes`` pass over the +shadow's current desired document, exactly as ``server_setup`` does. + +The device's IoT identity (Req 12.4) is represented structurally: the +shadow is scoped to one thing name (the emulator rejects access under any +other identity — on real AWS the device's IoT policy does the same), and +the ingest attribution comes from ``topic(3)`` of the thing-scoped shadow +topic, never from anything the payload claims. + +The real-LocalServer apply path (SQLite, accessor schema validation) is +already covered by test/backend-test/camera_sync/ +test_property_portal_change_round_trip.py; here a dict-backed accessor +keeps the focus on the transport wiring. + +Requirements: 3.3, 5.5, 12.4 +""" +import contextlib +import copy +import io +import json +import os +import pathlib +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION, TEST_ENV + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-shadow-sync" +SETTINGS_TABLE_NAME = "test-settings-shadow-sync" +DLQ_NAME = "test-camera-shadow-sync-dlq" + +SHADOW_NAME = "dda-camera-registry" + +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +_SRC_BACKEND = _REPO_ROOT / "src" / "backend" + + +# --- real edge modules (loaded by path) ---------------------------------------- + + +def _load_edge_modules(): + """Load the REAL edge packages from src/backend. + + The edge package and the portal Lambda module are both named + ``camera_sync``, so the edge package (whose submodules import each + other absolutely) is imported under its own name first and then + detached from sys.modules — the already-executed module objects keep + their internal references, and the portal module can own the name for + the rest of the suite (conftest re-import pattern). + """ + saved = { + name: sys.modules.pop(name) + for name in list(sys.modules) + if name == "camera_sync" or name.startswith("camera_sync.") + } + sys.path.insert(0, str(_SRC_BACKEND)) + try: + import camera_discovery + import camera_sync as edge_camera_sync + finally: + for name in list(sys.modules): + if name == "camera_sync" or name.startswith("camera_sync."): + del sys.modules[name] + sys.modules.update(saved) + sys.path.remove(str(_SRC_BACKEND)) + return camera_discovery, edge_camera_sync + + +_camera_discovery, _edge_camera_sync = _load_edge_modules() + +DiscoveredCamera = _camera_discovery.DiscoveredCamera +DiscoveryResult = _camera_discovery.DiscoveryResult +EdgeSyncAgent = _edge_camera_sync.EdgeSyncAgent +CameraSyncStateStore = _edge_camera_sync.CameraSyncStateStore +build_inventory = _edge_camera_sync.build_inventory + + +# --- the emulated named shadow -------------------------------------------------- + + +class NamedShadowEmulator: + """One in-memory ``dda-camera-registry`` named shadow serving both + sides' accessor contracts. + + Edge side (``IoTShadowAccessor`` contract): + - ``get_thing_shadow_state_request(thing_name, shadow_name)`` + - ``update_thing_shadow_state_request(thing_name, shadow_name, state)`` + Both honor :attr:`edge_online` — a disconnected device's shadow I/O + fails (Reqs 3.3, 5.5). + + Portal side (assumed-role iot-data client contract): + - ``update_thing_shadow(thingName, shadowName, payload)`` + - ``get_thing_shadow(thingName, shadowName)`` + Cloud-side access works regardless of device connectivity — the + shadow itself is the retention buffer for pending desired changes. + + Shadow document semantics follow AWS: state sections merge + recursively, an explicit ``null`` deletes a key, the delta is desired + minus reported, and every accepted update emits an + ``/update/documents`` event (recorded with which sections the update + touched, so tests can select the reported-state events the ingest + direction consumes). + + Req 12.4: the emulator is scoped to a single thing name — access + under any other identity raises, standing in for the device's IoT + policy restricting shadow access to the thing's own identity. + """ + + def __init__(self, thing_name): + self.thing_name = thing_name + self.desired = {} + self.reported = {} + self.version = 0 + self.edge_online = True + self.documents_events = [] # [(touched_sections, documents_payload)] + + # --- shared shadow-document core ----------------------------------- + + def _check_identity(self, thing_name, shadow_name): + assert thing_name == self.thing_name, ( + "shadow access under a foreign IoT identity: {!r} != {!r} " + "(Req 12.4)".format(thing_name, self.thing_name) + ) + assert shadow_name == SHADOW_NAME + + @staticmethod + def _merge(target, patch): + """AWS shadow merge: dicts merge recursively, null deletes.""" + for key, value in patch.items(): + if value is None: + target.pop(key, None) + elif isinstance(value, dict): + node = target.get(key) + if not isinstance(node, dict): + node = {} + target[key] = node + NamedShadowEmulator._merge(node, value) + else: + target[key] = value + + def _accept_update(self, state): + touched = set() + for section in ("desired", "reported"): + if section in state and state[section] is not None: + self._merge(getattr(self, section), state[section]) + touched.add(section) + self.version += 1 + self.documents_events.append(( + frozenset(touched), + { + "current": { + "state": { + "desired": copy.deepcopy(self.desired), + "reported": copy.deepcopy(self.reported), + }, + "version": self.version, + }, + }, + )) + + @staticmethod + def _diff(desired, reported): + out = {} + for key, value in desired.items(): + current = reported.get(key) if isinstance(reported, dict) else None + if isinstance(value, dict): + sub = NamedShadowEmulator._diff( + value, current if isinstance(current, dict) else {} + ) + if sub: + out[key] = sub + elif value != current: + out[key] = value + return out + + def delta(self): + """The shadow delta message (desired minus reported), or ``None`` + when nothing is pending — what the shadow service would publish on + ``.../update/delta`` to the connected device.""" + state = self._diff(self.desired, self.reported) + if not state: + return None + return {"state": copy.deepcopy(state), "version": self.version} + + def drain_reported_events(self): + """Documents events from updates that touched reported state, + drained — the events the edge->portal ingest direction forwards + (desired-only updates carry no new reported state to ingest).""" + events = [ + payload for touched, payload in self.documents_events + if "reported" in touched + ] + self.documents_events = [] + return events + + # --- edge side: IoTShadowAccessor contract --------------------------- + + def get_thing_shadow_state_request(self, thing_name, shadow_name): + self._check_identity(thing_name, shadow_name) + if not self.edge_online: + raise ConnectionError("device has no AWS IoT connectivity") + if not self.desired and not self.reported: + return None + return { + "desired": copy.deepcopy(self.desired), + "reported": copy.deepcopy(self.reported), + } + + def update_thing_shadow_state_request(self, thing_name, shadow_name, state): + self._check_identity(thing_name, shadow_name) + if not self.edge_online: + raise ConnectionError("device has no AWS IoT connectivity") + self._accept_update(state) + + # --- portal side: iot-data client contract --------------------------- + + def update_thing_shadow(self, thingName, shadowName, payload): + self._check_identity(thingName, shadowName) + self._accept_update(json.loads(payload).get("state") or {}) + return {} + + def get_thing_shadow(self, thingName, shadowName): + self._check_identity(thingName, shadowName) + document = { + "state": { + "desired": copy.deepcopy(self.desired), + "reported": copy.deepcopy(self.reported), + }, + "version": self.version, + } + return {"payload": io.BytesIO(json.dumps(document).encode())} + + +def iot_rule_record(thing_name, documents_event): + """One SQS record exactly as the IoT topic rule produces it. + + The rule is ``SELECT *, topic(3) AS thing_name FROM + '$aws/things/+/shadow/name/dda-camera-registry/update/documents'``: + the body is the documents payload plus the thing name parsed from the + topic. Req 12.4: the topic is thing-scoped and only the device's own + IoT identity may publish to its shadow, so ``topic(3)`` attributes the + report to the authenticated device — never to anything the payload + itself claims. + """ + body = dict(documents_event) + body["thing_name"] = thing_name # topic(3) + return {"messageId": str(uuid.uuid4()), "body": json.dumps(body)} + + +# --- edge-side fakes (same patterns as the sibling camera_sync suites) ---------- + + +class FakeClock: + def __init__(self, start=1_730_000_000.0): + self.now = start + + def __call__(self): + return self.now + + def advance(self, seconds): + self.now += seconds + + +class FakeDiscovery: + def __init__(self, snapshot): + self.latest_snapshot = snapshot + + +class FakeImageSourceAccessor: + """Dict-backed stand-in for the LocalServer ``ImageSourceAccessor``. + + The real-accessor apply path (SQLite, schema validation, verbatim + error messages) is exercised by the Property 7 suite in + test/backend-test/camera_sync/; this integration example is about the + transports, so a minimal accessor implementing the agent's contract + (list/create/update/delete) suffices. + """ + + def __init__(self): + self.sources = {} + self._next_id = 1 + + def list_image_sources(self, request, session): + return [copy.deepcopy(source) for source in self.sources.values()] + + def create_image_source(self, data, session): + image_source_id = str(self._next_id) + self._next_id += 1 + self.sources[image_source_id] = { + "imageSourceId": image_source_id, + "name": data.get("name"), + "type": data.get("type"), + "cameraId": data.get("cameraId"), + "location": data.get("location"), + "imageSourceConfiguration": {}, + } + return {"imageSourceId": image_source_id} + + def update_image_source(self, image_source_id, data, session): + source = self.sources[image_source_id] + for key, value in data.items(): + if key == "imageSourceConfiguration": + source["imageSourceConfiguration"].update(value) + else: + source[key] = value + + def delete_image_source(self, image_source_id, session): + del self.sources[image_source_id] + + +def _flush(agent, clock, max_iterations=20): + """Pump the agent past debounce/backoff waits until it goes idle.""" + for _ in range(max_iterations): + delay = agent.pump() + if delay is None: + return + clock.advance(delay + 0.001) + raise AssertionError("agent never went idle") + + +def _pump_offline(agent, clock, attempts=3): + """Let a disconnected agent burn a few failed write attempts; the + pending report stays retained (dirty) for the reconnect catch-up.""" + for _ in range(attempts): + delay = agent.pump() + assert delay is not None, "agent went idle while offline" + clock.advance(delay + 0.001) + + +# --- portal-side fixtures and helpers ------------------------------------------- + + +@pytest.fixture(scope="module") +def sync_env(aws_stack): + """Registry + settings tables, the shadow-report DLQ, and freshly + bound portal camera_sync / camera_registry modules (conftest pattern).""" + import boto3 + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "setting_key", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + sqs = boto3.client("sqs", region_name=REGION) + dlq_url = sqs.create_queue(QueueName=DLQ_NAME)["QueueUrl"] + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + os.environ["CAMERA_SHADOW_REPORT_DLQ_URL"] = dlq_url + + sys.modules.pop("camera_sync", None) + sys.modules.pop("camera_registry", None) + import camera_sync + import camera_registry + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + camera_sync=camera_sync, + camera_registry=camera_registry, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + devices=resource.Table(TEST_ENV["DEVICES_TABLE"]), + ) + + +def ingest(sync_env, thing_name, emulator): + """Deliver the emulator's new reported-state documents events through + the IoT-rule wrapper and the real SQS ingest handler.""" + events = emulator.drain_reported_events() + assert events, "no reported-state shadow events to ingest" + records = [iot_rule_record(thing_name, event) for event in events] + result = sync_env.camera_sync.handler({"Records": records}, None) + assert result == {"batchItemFailures": []} + return events + + +def device_items(sync_env, thing_name): + from boto3.dynamodb.conditions import Key + + response = sync_env.registry.query( + KeyConditionExpression=Key("device_id").eq(thing_name)) + return {item["sk"]: item for item in response["Items"]} + + +def make_event(method, device_id, user, sub_path="", query=None, body=None): + path_parameters = {"id": device_id} + if sub_path and sub_path != "/refresh": + path_parameters["csid"] = sub_path.lstrip("/") + return { + "httpMethod": method, + "path": f"/devices/{device_id}/cameras{sub_path}", + "pathParameters": path_parameters, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke_registry_api(sync_env, method, device_id, user, sub_path="", + body=None): + response = sync_env.camera_registry.handler( + make_event(method, device_id, user, sub_path, body=body), None) + return response["statusCode"], json.loads(response["body"]) + + +def make_edge_device(sync_env, env, tmp_path, snapshot=None): + """A registered device with a real EdgeSyncAgent over the emulated + shadow: fake clock, dict-backed accessor, real build_inventory.""" + usecase_id = env.create_usecase() + thing_name = f"thing-{uuid.uuid4()}" + sync_env.devices.put_item(Item={ + "device_id": thing_name, "usecase_id": usecase_id, + }) + + emulator = NamedShadowEmulator(thing_name) + accessor = FakeImageSourceAccessor() + discovery = FakeDiscovery(snapshot if snapshot is not None + else DiscoveryResult()) + clock = FakeClock() + agent = EdgeSyncAgent( + iot_shadow_accessor=emulator, + image_source_accessor=accessor, + camera_discovery=discovery, + db_session_factory=lambda: contextlib.nullcontext(), + state_store=CameraSyncStateStore( + str(tmp_path / f"camera_sync_state_{thing_name}.json")), + thing_name=thing_name, + clock=clock, + wall_clock=clock, + ) + return SimpleNamespace( + usecase_id=usecase_id, thing_name=thing_name, emulator=emulator, + accessor=accessor, discovery=discovery, agent=agent, clock=clock, + ) + + +def add_configured_camera(device, name="Line camera", device_path="/dev/video0", + gain=4, exposure=900): + """One configured Image_Source on the device; returns its cfg- csid.""" + source_id = device.accessor.create_image_source( + {"name": name, "type": "Camera", "cameraId": f"cam-{source_suffix()}"}, + None)["imageSourceId"] + device.accessor.update_image_source( + source_id, + {"imageSourceConfiguration": { + "device": device_path, "gain": gain, "exposure": exposure}}, + None) + return source_id, f"cfg-{source_id}" + + +_suffix_counter = iter(range(10_000)) + + +def source_suffix(): + return next(_suffix_counter) + + +def establish_synced_registry(sync_env, env, tmp_path): + """Direction-2 preamble: seed the registry through the real + edge->portal path (agent report -> rule -> SQS -> ingest handler).""" + device = make_edge_device(sync_env, env, tmp_path) + source_id, csid = add_configured_camera(device) + device.agent.report_inventory() + _flush(device.agent, device.clock) + ingest(sync_env, device.thing_name, device.emulator) + + items = device_items(sync_env, device.thing_name) + entry = items[f"CAMERA#{csid}"] + assert entry["sync_status"] == "synced" + return device, source_id, csid + + +@pytest.fixture +def portal_shadow_client(sync_env, monkeypatch): + """Route the mutation routes' assumed-role iot-data client to a + per-test emulator (bound by the test via ``bind``).""" + holder = {} + monkeypatch.setattr( + sync_env.camera_registry, "iot_data_client", + lambda usecase_id: holder["emulator"]) + return SimpleNamespace(bind=lambda emulator: holder.update( + emulator=emulator)) + + +# --- direction 1: edge -> portal over the rule/SQS path ------------------------- + + +class TestEdgeToPortal: + def test_edge_report_reaches_registry_through_rule_and_sqs( + self, sync_env, env, tmp_path): + """A disconnected device's inventory report is retained, published + as the complete current state on reconnect (Req 3.3), forwarded by + the thing-scoped IoT rule to SQS under the device's IoT identity + (Req 12.4), and ingested into the registry scoped to the device's + use case.""" + snapshot = DiscoveryResult(cameras=[ + DiscoveredCamera( + stable_id="disc-int000000001", device_path="/dev/video0", + card_name="Line sensor", bus_info="usb-0000:00:14.0-1", + driver="uvcvideo", kind="v4l2", + formats=[{"pixel_format": "YUYV", + "resolutions": [[1920, 1080], [1280, 720]]}]), + DiscoveredCamera( + stable_id="disc-int000000002", device_path="/dev/video1", + card_name="USB spare cam", bus_info="usb-0000:00:14.0-2", + driver="uvcvideo", kind="v4l2", + formats=[{"pixel_format": "MJPG", + "resolutions": [[640, 480]]}]), + ]) + device = make_edge_device(sync_env, env, tmp_path, snapshot=snapshot) + source_id, cfg_csid = add_configured_camera( + device, device_path="/dev/video0") + + # Disconnected: the write fails and the report is retained (3.3). + device.emulator.edge_online = False + device.agent.report_inventory() + _pump_offline(device.agent, device.clock) + assert device.emulator.reported == {} + + # Reconnect: the first successful write is the complete current + # state — one report, not a replay of queued deltas (3.3). + device.emulator.edge_online = True + _flush(device.agent, device.clock) + events = ingest(sync_env, device.thing_name, device.emulator) + assert len(events) == 1 + + # The registry mirrors the device inventory (build_inventory oracle). + expected = build_inventory( + device.accessor.list_image_sources(None, None), snapshot) + items = device_items(sync_env, device.thing_name) + camera_items = { + sk[len("CAMERA#"):]: item for sk, item in items.items() + if sk.startswith("CAMERA#") + } + assert set(camera_items) == { + entry.camera_source_id for entry in expected} + + # Configured source merged with its discovered hardware. + cfg = camera_items[cfg_csid] + assert cfg["origin"] == "edge-configured" + assert cfg["name"] == "Line camera" + assert cfg["type"] == "Camera" + assert cfg["sync_status"] == "synced" + assert cfg["params"]["devicePath"] == "/dev/video0" + assert cfg["params"]["gain"] == 4 + assert cfg["params"]["exposure"] == 900 + assert cfg["capabilities"]["driver"] == "uvcvideo" + assert cfg["capabilities"]["formats"][0]["pixelFormat"] == "YUYV" + + # Discovered-only hardware reports under its discovery stable id. + disc = camera_items["disc-int000000002"] + assert disc["origin"] == "edge-discovered" + assert disc["params"]["devicePath"] == "/dev/video1" + + # Ingest attribution and scoping come from the device's IoT + # identity: the thing name from the shadow topic keyed the + # registry partition and resolved the use case (Req 12.4). + reported_at = events[0]["current"]["state"]["reported"]["reportedAt"] + meta = items["META"] + assert meta["usecase_id"] == device.usecase_id + assert meta["last_report_at"] == reported_at + assert meta["never_synced"] is False + assert all(item["usecase_id"] == device.usecase_id + for item in camera_items.values()) + + +# --- direction 2: portal -> edge over the shadow delta -------------------------- + + +class TestPortalToEdge: + def test_portal_change_delta_applied_acked_and_synced( + self, sync_env, env, tmp_path, portal_shadow_client): + """A portal desired change is delivered as a shadow delta, applied + by the real agent, acknowledged in its report, and marked synced + by the real ingest handler (Reqs 5.1-5.3 wiring over the emulated + Sync_Channel).""" + device, source_id, csid = establish_synced_registry( + sync_env, env, tmp_path) + portal_shadow_client.bind(device.emulator) + operator = env.make_user(role="Operator") + + status, body = invoke_registry_api( + sync_env, "PUT", device.thing_name, operator, sub_path=f"/{csid}", + body={"name": "Portal name", "type": "Camera", + "params": {"devicePath": "/dev/video2", "gain": 8, + "exposure": 1200}}) + assert status == 200 + change_id = body["portal_change_id"] + + # Registry entry pending; the desired change sits in the shadow. + entry = device_items(sync_env, device.thing_name)[f"CAMERA#{csid}"] + assert entry["sync_status"] == "pending" + assert entry["portal_change_id"] == change_id + + # The shadow service publishes the delta (desired minus reported) + # to the connected device; the agent applies and reports. + delta = device.emulator.delta() + assert set(delta["state"]["changes"]) == {csid} + device.agent.on_delta(delta) + _flush(device.agent, device.clock) + + # Applied on the device through the accessor. + source = device.accessor.sources[source_id] + assert source["name"] == "Portal name" + assert source["imageSourceConfiguration"]["device"] == "/dev/video2" + assert source["imageSourceConfiguration"]["gain"] == 8 + + # The processed desired entry was cleared (null write), so the + # delta does not re-fire. + assert device.emulator.desired.get("changes", {}) == {} + assert device.emulator.delta() is None + + # The resulting report carries the ack and travels the same + # rule/SQS path back into the registry. + events = ingest(sync_env, device.thing_name, device.emulator) + reported = events[-1]["current"]["state"]["reported"] + assert reported["cameras"][csid]["ack"] == change_id + + entry = device_items(sync_env, device.thing_name)[f"CAMERA#{csid}"] + assert entry["sync_status"] == "synced" + assert entry["name"] == "Portal name" + assert entry["params"]["devicePath"] == "/dev/video2" + assert entry["params"]["gain"] == 8 + assert "pending_content" not in entry + assert "portal_change_id" not in entry + # No conflict was classified anywhere along the round trip. + assert not any(sk.startswith("CONFLICT#") for sk in + device_items(sync_env, device.thing_name)) + + def test_pending_change_is_delivered_after_reconnect( + self, sync_env, env, tmp_path, portal_shadow_client): + """A change issued while the device is disconnected stays pending + (Req 5.5) — the shadow retains the desired document — and is + applied on reconnect through the same apply_desired_changes pass + over the current desired document that server_setup runs, then + acknowledged and marked synced.""" + device, source_id, csid = establish_synced_registry( + sync_env, env, tmp_path) + portal_shadow_client.bind(device.emulator) + operator = env.make_user(role="Operator") + + # Device disconnected: cloud-side shadow writes still succeed — + # the shadow itself buffers the pending change (Req 5.5). + device.emulator.edge_online = False + status, body = invoke_registry_api( + sync_env, "PUT", device.thing_name, operator, sub_path=f"/{csid}", + body={"name": "Reconnect name", "type": "Camera", + "params": {"devicePath": "/dev/video3", "gain": 2, + "exposure": 700}}) + assert status == 200 + change_id = body["portal_change_id"] + + # Retained as pending while the device is offline: the registry + # entry stays pending, the device state untouched, the desired + # change parked in the shadow. + entry = device_items(sync_env, device.thing_name)[f"CAMERA#{csid}"] + assert entry["sync_status"] == "pending" + assert device.accessor.sources[source_id]["name"] == "Line camera" + assert csid in device.emulator.desired["changes"] + + # Reconnect: server_setup's reconnect pass reads the shadow's + # current desired document and applies the parked changes (5.5). + device.emulator.edge_online = True + state = device.emulator.get_thing_shadow_state_request( + device.thing_name, SHADOW_NAME) + changes = (state.get("desired") or {}).get("changes") + assert changes + device.agent.apply_desired_changes(changes) + _flush(device.agent, device.clock) + + assert device.accessor.sources[source_id]["name"] == "Reconnect name" + assert device.emulator.desired.get("changes", {}) == {} + + events = ingest(sync_env, device.thing_name, device.emulator) + reported = events[-1]["current"]["state"]["reported"] + assert reported["cameras"][csid]["ack"] == change_id + + entry = device_items(sync_env, device.thing_name)[f"CAMERA#{csid}"] + assert entry["sync_status"] == "synced" + assert entry["name"] == "Reconnect name" + assert entry["params"]["devicePath"] == "/dev/video3" + assert not any(sk.startswith("CONFLICT#") for sk in + device_items(sync_env, device.thing_name)) diff --git a/edge-cv-portal/backend/tests/test_camera_sync_ingest.py b/edge-cv-portal/backend/tests/test_camera_sync_ingest.py new file mode 100644 index 00000000..b115ce08 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_sync_ingest.py @@ -0,0 +1,467 @@ +""" +Portal_Sync_Service SQS ingest behavior (camera-registry-sync task 5.5). + +Unit tests for the `handler` in functions/camera_sync.py against the +moto-backed conftest stack (registry table + devices table + SQS DLQ): + + - duplicate SQS delivery idempotency: replaying a report reproduces the + identical registry state and never emits a second conflict event + - out-of-order delivery: an older-version report arriving after a newer + one is discarded, leaving the registry unchanged (Req 3.5) + - malformed / unparseable reports are explicitly dead-lettered with a + reason and are NOT reported as batch item failures + - a device unknown to the devices table (no usecase_id) dead-letters + - transient persistence failures produce a partial batch response + (batchItemFailures) so only the affected record retries + - every processed report stamps the device META item: last_report_at + set, never_synced cleared (Req 3.2) + +Requirements: 3.2, 3.5 +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION, TEST_ENV + +CAMERA_REGISTRY_TABLE_NAME = "test-camera-registry-ingest" +DLQ_NAME = "test-camera-shadow-report-dlq" + + +@pytest.fixture(scope="module") +def ingest_env(aws_stack): + """Registry table + DLQ and a freshly bound camera_sync module.""" + import boto3 + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CAMERA_REGISTRY_TABLE_NAME, + KeySchema=[ + {"AttributeName": "device_id", "KeyType": "HASH"}, + {"AttributeName": "sk", "KeyType": "RANGE"}, + ], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}, + {"AttributeName": "sk", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + + sqs = boto3.client("sqs", region_name=REGION) + dlq_url = sqs.create_queue(QueueName=DLQ_NAME)["QueueUrl"] + + os.environ["CAMERA_REGISTRY_TABLE"] = CAMERA_REGISTRY_TABLE_NAME + os.environ["CAMERA_SHADOW_REPORT_DLQ_URL"] = dlq_url + + # Re-import so the module binds inside the active moto mock + # (conftest pattern). + sys.modules.pop("camera_sync", None) + import camera_sync + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=camera_sync, + registry=resource.Table(CAMERA_REGISTRY_TABLE_NAME), + devices=resource.Table(TEST_ENV["DEVICES_TABLE"]), + sqs=sqs, + dlq_url=dlq_url, + ) + + +def drain_dlq(ingest_env): + """Receive-and-delete every message currently on the DLQ.""" + messages = [] + while True: + response = ingest_env.sqs.receive_message( + QueueUrl=ingest_env.dlq_url, + MaxNumberOfMessages=10, + WaitTimeSeconds=0, + MessageAttributeNames=["All"], + ) + batch = response.get("Messages", []) + if not batch: + return messages + for message in batch: + ingest_env.sqs.delete_message( + QueueUrl=ingest_env.dlq_url, + ReceiptHandle=message["ReceiptHandle"]) + messages.extend(batch) + + +@pytest.fixture(autouse=True) +def clean_dlq(ingest_env): + """Each test starts from an empty DLQ.""" + drain_dlq(ingest_env) + yield + + +def register_device(ingest_env, usecase_id): + """A device known to the portal devices table; returns its thing name.""" + thing_name = f"thing-{uuid.uuid4()}" + ingest_env.devices.put_item(Item={ + "device_id": thing_name, "usecase_id": usecase_id, + }) + return thing_name + + +def make_record(thing_name=None, reported=None, body=None, + message_id=None): + """One SQS record shaped like the IoT rule output (shadow documents + payload plus the rule-added thing_name).""" + if body is None: + payload = {"thing_name": thing_name, + "current": {"state": {"reported": reported}}} + body = json.dumps(payload) + return {"messageId": message_id or str(uuid.uuid4()), "body": body} + + +def camera(version, name, device_path="/dev/video0", **extra): + source = { + "version": version, + "name": name, + "type": "Camera", + "origin": "edge-configured", + "params": {"devicePath": device_path}, + "capabilities": {"formats": [ + {"pixelFormat": "YUYV", "resolutions": [[1920, 1080]]}]}, + } + source.update(extra) + return source + + +def report(cameras, reported_at, failures=None): + doc = {"schemaVersion": 1, "reportedAt": reported_at, "cameras": cameras} + if failures is not None: + doc["failures"] = failures + return doc + + +def device_items(ingest_env, thing_name): + from boto3.dynamodb.conditions import Key + + response = ingest_env.registry.query( + KeyConditionExpression=Key("device_id").eq(thing_name)) + return {item["sk"]: item for item in response["Items"]} + + +def camera_item(items, csid): + return items.get(f"CAMERA#{csid}") + + +def conflict_items(items): + return [item for sk, item in items.items() if sk.startswith("CONFLICT#")] + + +class TestDuplicateDeliveryIdempotency: + def test_duplicate_report_reproduces_identical_state( + self, ingest_env, env): + """Delivering the same report twice yields the identical registry + state - reduction is version-guarded and idempotent (Req 3.5).""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + record = make_record(thing_name, report( + {"cfg-a": camera(3, "line-1"), + "disc-3fe9c0d21ab4": camera( + 1, "usb-cam", "/dev/video2", origin="edge-discovered")}, + reported_at=1730000000000)) + + first = ingest_env.module.handler({"Records": [record]}, None) + after_first = device_items(ingest_env, thing_name) + second = ingest_env.module.handler({"Records": [record]}, None) + after_second = device_items(ingest_env, thing_name) + + assert first == {"batchItemFailures": []} + assert second == {"batchItemFailures": []} + assert after_second == after_first + assert camera_item(after_first, "cfg-a")["version"] == 3 + assert camera_item(after_first, "cfg-a")["sync_status"] == "synced" + assert conflict_items(after_first) == [] + + def test_duplicate_conflicting_report_emits_one_conflict_event( + self, ingest_env, env): + """A report conflicting with a pending portal change records + exactly one conflict event; replaying the same report reduces to + a plain upsert without a second event (Reqs 3.5, 6.1).""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + ingest_env.registry.put_item(Item={ + "device_id": thing_name, "sk": "CAMERA#cfg-a", + "camera_source_id": "cfg-a", "usecase_id": usecase_id, + "name": "portal-name", "type": "Camera", + "params": {"devicePath": "/dev/video0"}, + "origin": "edge-configured", "version": 3, + "sync_status": "pending", "portal_change_id": "pc-1", + "pending_content": { + "op": "update", "name": "portal-name", "type": "Camera", + "params": {"devicePath": "/dev/video9"}, + }, + }) + # Unacknowledged edge state diverging from the pending content. + record = make_record(thing_name, report( + {"cfg-a": camera(4, "edge-name")}, reported_at=1730000001000)) + + ingest_env.module.handler({"Records": [record]}, None) + after_first = device_items(ingest_env, thing_name) + ingest_env.module.handler({"Records": [record]}, None) + after_second = device_items(ingest_env, thing_name) + + # Edge wins, exactly one conflict event survives the replay. + assert camera_item(after_first, "cfg-a")["name"] == "edge-name" + assert camera_item(after_first, "cfg-a")["sync_status"] == "synced" + assert len(conflict_items(after_first)) == 1 + assert len(conflict_items(after_second)) == 1 + assert after_second == after_first + conflict = conflict_items(after_first)[0] + assert conflict["resolution"] == "edge-retained" + assert conflict["camera_source_id"] == "cfg-a" + + +class TestOutOfOrderDelivery: + def test_older_version_report_is_discarded(self, ingest_env, env): + """An older-version report arriving after a newer one leaves the + newer registry entry untouched (Req 3.5).""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + newer = make_record(thing_name, report( + {"cfg-a": camera(5, "newer", "/dev/video5")}, + reported_at=1730000005000)) + older = make_record(thing_name, report( + {"cfg-a": camera(3, "older", "/dev/video3")}, + reported_at=1730000003000)) + + ingest_env.module.handler({"Records": [newer]}, None) + after_newer = device_items(ingest_env, thing_name) + result = ingest_env.module.handler({"Records": [older]}, None) + after_older = device_items(ingest_env, thing_name) + + assert result == {"batchItemFailures": []} + entry = camera_item(after_older, "cfg-a") + assert entry["version"] == 5 + assert entry["name"] == "newer" + assert entry["params"]["devicePath"] == "/dev/video5" + assert entry["last_reported_at"] == 1730000005000 + assert camera_item(after_newer, "cfg-a") == entry + assert conflict_items(after_older) == [] + + +class TestMalformedReportDeadLettering: + def test_unparseable_body_is_dead_lettered_not_batch_failed( + self, ingest_env): + """An unparseable body goes to the DLQ with a reason and is NOT a + batch item failure (it would never succeed on retry).""" + record = make_record(body="{not json", message_id="mal-1") + + result = ingest_env.module.handler({"Records": [record]}, None) + + assert result == {"batchItemFailures": []} + messages = drain_dlq(ingest_env) + assert len(messages) == 1 + assert messages[0]["Body"] == "{not json" + reason = messages[0]["MessageAttributes"]["deadLetterReason"][ + "StringValue"] + assert "unparseable" in reason + + def test_missing_thing_name_is_dead_lettered(self, ingest_env): + """A shadow document without the rule-added thing_name can never + be attributed to a device: dead-letter it.""" + body = json.dumps( + {"current": {"state": {"reported": {"cameras": {}}}}}) + record = make_record(body=body) + + result = ingest_env.module.handler({"Records": [record]}, None) + + assert result == {"batchItemFailures": []} + messages = drain_dlq(ingest_env) + assert len(messages) == 1 + reason = messages[0]["MessageAttributes"]["deadLetterReason"][ + "StringValue"] + assert "thing_name" in reason + + def test_unknown_device_is_dead_lettered(self, ingest_env): + """A report from a device with no usecase_id in the devices table + cannot be scoped (Req 1.4): dead-letter, no registry write.""" + thing_name = f"thing-{uuid.uuid4()}" # never registered + record = make_record(thing_name, report( + {"cfg-a": camera(1, "cam")}, reported_at=1730000000000)) + + result = ingest_env.module.handler({"Records": [record]}, None) + + assert result == {"batchItemFailures": []} + messages = drain_dlq(ingest_env) + assert len(messages) == 1 + reason = messages[0]["MessageAttributes"]["deadLetterReason"][ + "StringValue"] + assert "usecase_id" in reason + assert device_items(ingest_env, thing_name) == {} + + +class TestTransientFailurePartialBatch: + def test_persistence_failure_reports_batch_item_failure( + self, ingest_env, env, monkeypatch): + """A transient persistence error (registry table unavailable) + returns the record in batchItemFailures for SQS retry - it is + not dead-lettered.""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + monkeypatch.setenv("CAMERA_REGISTRY_TABLE", + "test-camera-registry-missing") + record = make_record(thing_name, report( + {"cfg-a": camera(1, "cam")}, reported_at=1730000000000), + message_id="transient-1") + + result = ingest_env.module.handler({"Records": [record]}, None) + + assert result == {"batchItemFailures": [ + {"itemIdentifier": "transient-1"}]} + assert drain_dlq(ingest_env) == [] + + def test_malformed_record_does_not_block_valid_records( + self, ingest_env, env): + """One malformed record in a batch is dead-lettered while the + valid records in the same batch are processed normally.""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + malformed = make_record(body="not even json") + valid = make_record(thing_name, report( + {"cfg-a": camera(2, "cam")}, reported_at=1730000000000)) + + result = ingest_env.module.handler( + {"Records": [malformed, valid]}, None) + + assert result == {"batchItemFailures": []} + assert len(drain_dlq(ingest_env)) == 1 + assert camera_item( + device_items(ingest_env, thing_name), "cfg-a")["version"] == 2 + + +class TestMetaStamping: + def test_processed_report_stamps_meta_and_clears_never_synced( + self, ingest_env, env): + """Every processed report sets META.last_report_at and clears + never_synced (Req 3.2).""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + ingest_env.registry.put_item(Item={ + "device_id": thing_name, "sk": "META", + "usecase_id": usecase_id, "never_synced": True, + }) + record = make_record(thing_name, report( + {"cfg-a": camera(1, "cam")}, reported_at=1730000042000)) + + ingest_env.module.handler({"Records": [record]}, None) + + items = device_items(ingest_env, thing_name) + meta = items["META"] + assert meta["last_report_at"] == 1730000042000 + assert meta["never_synced"] is False + assert meta["usecase_id"] == usecase_id + entry = camera_item(items, "cfg-a") + assert entry["usecase_id"] == usecase_id + assert entry["last_reported_at"] == 1730000042000 + + +def aravis_discovered(version, camera_id="Aravis-Fake-GV01"): + """An AravisDiscovered Camera_Source exactly as the Edge_Sync_Agent + reports it (aravis-camera-input: build_inventory discovered-only + entry shape).""" + return { + "version": version, + "name": "Aravis Fake GV", + "type": "AravisDiscovered", + "origin": "edge-discovered", + "params": { + "cameraId": camera_id, + "serial": "SN-0001", + "protocol": "GigEVision", + "address": "192.168.1.20", + }, + "capabilities": {"aravis": { + "model": "Fake GV", + "address": "192.168.1.20", + "physicalId": "eth0", + "protocol": "GigEVision", + "serial": "SN-0001", + "vendor": "Aravis", + }}, + } + + +class TestAravisDiscoveredIngestion: + """Registry ingestion of AravisDiscovered reports (aravis-camera-input + Requirement 7.3): the existing reduce_report/handler path stores the + new type verbatim without changes to existing Camera_Source types.""" + + def test_aravis_discovered_entry_stored_verbatim(self, ingest_env, env): + """An AravisDiscovered entry flows through the ingest handler as + one more opaque type string: every declared field lands in the + registry exactly as reported (Req 7.3).""" + usecase_id = env.create_usecase() + thing_name = register_device(ingest_env, usecase_id) + csid = "arv-3fe9c0d21ab4" + incoming = aravis_discovered(1) + record = make_record(thing_name, report( + {csid: incoming}, reported_at=1730000000000)) + + result = ingest_env.module.handler({"Records": [record]}, None) + + assert result == {"batchItemFailures": []} + entry = camera_item(device_items(ingest_env, thing_name), csid) + assert entry is not None + assert entry["camera_source_id"] == csid + assert entry["name"] == incoming["name"] + assert entry["type"] == "AravisDiscovered" + assert entry["origin"] == "edge-discovered" + assert entry["params"] == incoming["params"] + assert entry["capabilities"] == incoming["capabilities"] + assert entry["version"] == 1 + assert entry["sync_status"] == "synced" + assert entry["usecase_id"] == usecase_id + assert entry["last_reported_at"] == 1730000000000 + + def test_existing_types_unaffected_by_aravis_entries( + self, ingest_env, env): + """A report carrying AravisDiscovered entries alongside existing + types stores the existing-type entries exactly as a report without + the Aravis entries does (Req 7.3).""" + usecase_id = env.create_usecase() + with_aravis = register_device(ingest_env, usecase_id) + without_aravis = register_device(ingest_env, usecase_id) + classic = { + "cfg-a": camera(2, "line-1"), + "disc-9a1b2c3d4e5f": camera( + 1, "usb-cam", "/dev/video2", + origin="edge-discovered", type="V4L2Discovered"), + } + mixed = dict(classic) + mixed["arv-3fe9c0d21ab4"] = aravis_discovered(1) + + ingest_env.module.handler({"Records": [ + make_record(with_aravis, report(mixed, + reported_at=1730000000000)), + make_record(without_aravis, report(classic, + reported_at=1730000000000)), + ]}, None) + + items_with = device_items(ingest_env, with_aravis) + items_without = device_items(ingest_env, without_aravis) + + def strip_device(item): + return {k: v for k, v in item.items() if k != "device_id"} + + # The Aravis entry is stored; every existing-type entry is + # byte-identical to the Aravis-free ingestion of the same report. + assert camera_item(items_with, "arv-3fe9c0d21ab4") is not None + assert camera_item(items_without, "arv-3fe9c0d21ab4") is None + for csid in classic: + assert strip_device(camera_item(items_with, csid)) == \ + strip_device(camera_item(items_without, csid)) diff --git a/edge-cv-portal/backend/tests/test_camera_sync_reducer.py b/edge-cv-portal/backend/tests/test_camera_sync_reducer.py new file mode 100644 index 00000000..c6f75cb6 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_sync_reducer.py @@ -0,0 +1,241 @@ +""" +Unit tests for the Portal_Sync_Service reduce_report pure reducer. + +Feature: camera-registry-sync, task 5.1. + +Covers: version-guarded staleness discard (Req 3.5), ack -> synced +(Req 5.3), failure -> failed with reason (Req 5.4), conflict +classification with edge-wins resolution and event contents +(Reqs 6.1, 6.2, 6.3), deletion-retained on reported deletion during a +pending portal update (Req 6.5), META stamping (Req 3.2), and +idempotency under duplicate delivery. + +Validates: Requirements 3.2, 3.5, 5.3, 5.4, 6.1, 6.2, 6.3, 6.5 +""" +import camera_sync as cs + +NOW = 1_730_000_000_000 + + +def edge_camera(**overrides): + """A camera entry in the shadow reported-document shape.""" + camera = { + "version": 7, + "name": "Line 1 inspection cam", + "type": "Camera", + "origin": "edge-configured", + "params": {"devicePath": "/dev/video0", "gain": 4}, + "capabilities": {"formats": [{"pixelFormat": "YUYV", + "resolutions": [[1920, 1080]]}]}, + "absent": False, + } + camera.update(overrides) + return camera + + +def registry_entry(**overrides): + """A Camera_Registry DDB entry per the design data model.""" + entry = { + "usecase_id": "uc-1", + "camera_source_id": "cfg-a1b2", + "name": "Line 1 inspection cam", + "type": "Camera", + "params": {"devicePath": "/dev/video0", "gain": 4}, + "capabilities": {}, + "origin": "edge-configured", + "version": 7, + "last_reported_at": NOW - 60_000, + "sync_status": "synced", + "absent": False, + } + entry.update(overrides) + return entry + + +def pending_entry(**overrides): + """A registry entry with an unacknowledged portal change.""" + defaults = { + "sync_status": "pending", + "portal_change_id": "pc-123", + "pending_content": { + "op": "update", + "name": "Portal name", + "type": "Camera", + "params": {"devicePath": "/dev/video0", "gain": 8}, + }, + } + defaults.update(overrides) + return registry_entry(**defaults) + + +class TestUpsert: + def test_new_source_upserts_all_declared_fields(self): + incoming = edge_camera() + outcome = cs.reduce_report(None, incoming, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.conflict_event is None + entry = outcome.entry + assert entry["name"] == incoming["name"] + assert entry["type"] == incoming["type"] + assert entry["params"] == incoming["params"] + assert entry["capabilities"] == incoming["capabilities"] + assert entry["origin"] == incoming["origin"] + assert entry["version"] == incoming["version"] + assert entry["last_reported_at"] == NOW + assert entry["sync_status"] == "synced" + assert entry["absent"] is False + + def test_upsert_preserves_usecase_scoping(self): + outcome = cs.reduce_report(registry_entry(), edge_camera(version=8), NOW) + assert outcome.entry["usecase_id"] == "uc-1" + assert outcome.entry["camera_source_id"] == "cfg-a1b2" + + def test_absent_source_carries_absent_since(self): + incoming = edge_camera(absent=True, absentSince=NOW - 5_000) + outcome = cs.reduce_report(registry_entry(), incoming, NOW) + assert outcome.entry["absent"] is True + assert outcome.entry["absent_since"] == NOW - 5_000 + + +class TestVersionGuard: + def test_older_version_is_discarded_and_entry_retained(self): + entry = registry_entry(version=7) + outcome = cs.reduce_report(entry, edge_camera(version=6), NOW) + assert outcome.action == cs.ACTION_DISCARD_STALE + assert outcome.entry == entry + assert outcome.conflict_event is None + + def test_equal_version_redelivery_is_idempotent(self): + incoming = edge_camera(version=7) + first = cs.reduce_report(registry_entry(), incoming, NOW) + second = cs.reduce_report(first.entry, incoming, NOW) + assert second.action == cs.ACTION_UPSERT + assert second.entry == first.entry + + +class TestAckAndFailure: + def test_matching_ack_marks_synced_and_clears_pending(self): + entry = pending_entry() + incoming = edge_camera(version=8, ack="pc-123", + params={"devicePath": "/dev/video0", "gain": 8}, + name="Portal name") + outcome = cs.reduce_report(entry, incoming, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.conflict_event is None + assert outcome.entry["sync_status"] == "synced" + assert "portal_change_id" not in outcome.entry + assert "pending_content" not in outcome.entry + + def test_failure_entry_marks_failed_with_reason(self): + entry = pending_entry() + failure = {"reason": "location is required", "portalChangeId": "pc-123"} + outcome = cs.reduce_report(entry, failure, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.entry["sync_status"] == "failed" + assert outcome.entry["failure_reason"] == "location is required" + + def test_failure_for_superseded_change_is_discarded(self): + entry = pending_entry(portal_change_id="pc-456") + failure = {"reason": "stale", "portalChangeId": "pc-123"} + outcome = cs.reduce_report(entry, failure, NOW) + assert outcome.action == cs.ACTION_DISCARD_STALE + assert outcome.entry == entry + + def test_failure_for_unknown_source_is_discarded(self): + outcome = cs.reduce_report(None, {"reason": "x", "portalChangeId": "p"}, NOW) + assert outcome.action == cs.ACTION_DISCARD_STALE + assert outcome.entry is None + + +class TestConflict: + def test_unacked_diverging_content_classifies_conflict_edge_wins(self): + entry = pending_entry() + incoming = edge_camera(version=8, name="Edge name") + outcome = cs.reduce_report(entry, incoming, NOW) + assert outcome.action == cs.ACTION_CONFLICT + # Edge wins: the retained entry is the edge state (Req 6.2). + assert outcome.entry["name"] == "Edge name" + assert outcome.entry["sync_status"] == "synced" + # Event carries both versions, resolution, timestamp (Req 6.3). + event = outcome.conflict_event + assert event.camera_source_id == "cfg-a1b2" + assert event.edge_version["name"] == "Edge name" + assert event.portal_version["name"] == "Portal name" + assert event.resolution == cs.RESOLUTION_EDGE_RETAINED + assert event.created_at == NOW + + def test_unacked_identical_content_is_not_a_conflict(self): + entry = pending_entry() + incoming = edge_camera( + version=8, + name="Portal name", + params={"devicePath": "/dev/video0", "gain": 8}, + ) + outcome = cs.reduce_report(entry, incoming, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.conflict_event is None + + def test_conflict_replay_after_resolution_is_plain_upsert(self): + entry = pending_entry() + incoming = edge_camera(version=8, name="Edge name") + first = cs.reduce_report(entry, incoming, NOW) + replay = cs.reduce_report(first.entry, incoming, NOW) + assert replay.action == cs.ACTION_UPSERT + assert replay.conflict_event is None + assert replay.entry == first.entry + + +class TestDeletion: + def test_deletion_with_pending_update_is_deletion_retained_conflict(self): + entry = pending_entry() + outcome = cs.reduce_report(entry, None, NOW) + assert outcome.action == cs.ACTION_CONFLICT + assert outcome.entry is None + event = outcome.conflict_event + assert event.resolution == cs.RESOLUTION_DELETION_RETAINED + assert event.edge_version is None + assert event.portal_version == entry["pending_content"] + assert event.created_at == NOW + + def test_deletion_marker_dict_is_equivalent(self): + outcome = cs.reduce_report(pending_entry(), {"deleted": True}, NOW) + assert outcome.action == cs.ACTION_CONFLICT + assert outcome.entry is None + + def test_deletion_matching_pending_delete_is_agreement(self): + entry = pending_entry(pending_content={"op": "delete"}) + outcome = cs.reduce_report(entry, None, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.entry is None + assert outcome.conflict_event is None + + def test_deletion_without_pending_change_deletes(self): + outcome = cs.reduce_report(registry_entry(), None, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.entry is None + assert outcome.conflict_event is None + + def test_deletion_of_unknown_source_is_idempotent_noop(self): + outcome = cs.reduce_report(None, None, NOW) + assert outcome.action == cs.ACTION_UPSERT + assert outcome.entry is None + assert outcome.conflict_event is None + + +class TestMetaStamp: + def test_stamps_last_report_at_and_clears_never_synced(self): + meta = cs.stamp_meta({"usecase_id": "uc-1", "never_synced": True}, NOW) + assert meta == { + "usecase_id": "uc-1", + "never_synced": False, + "last_report_at": NOW, + } + + def test_missing_meta_is_created(self): + meta = cs.stamp_meta(None, NOW) + assert meta["last_report_at"] == NOW + assert meta["never_synced"] is False + + def test_stamp_is_idempotent(self): + once = cs.stamp_meta({"never_synced": True}, NOW) + assert cs.stamp_meta(once, NOW) == once diff --git a/edge-cv-portal/backend/tests/test_camera_sync_reducer_conflict_properties.py b/edge-cv-portal/backend/tests/test_camera_sync_reducer_conflict_properties.py new file mode 100644 index 00000000..b14fcba8 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_sync_reducer_conflict_properties.py @@ -0,0 +1,221 @@ +""" +Property-based test for reduce_report conflict classification. + +**Feature: camera-registry-sync, Property 8: Conflict classification with edge-wins resolution** + +*For any* registry entry holding a pending portal change and any +incoming edge report for the same Camera_Source that does not +acknowledge that change, the reducer classifies a Conflict exactly +when the edge content differs from the pending portal content; on +every conflict the retained effective state equals the edge state +(including edge deletion winning over portal modification), and the +emitted conflict event contains both conflicting versions, the +resolution applied, and a timestamp. + +**Validates: Requirements 6.1, 6.2, 6.3, 6.5** + +Generators (per the design testing strategy): pending entries with +portal_change_id/pending_content across all ops, edge reports whose +content converges with or diverges from the pending content, matching +and non-matching acks, edge deletions, and whitespace/unicode names. +""" +from hypothesis import given, settings +from hypothesis import strategies as st + +import camera_sync as cs + +# --------------------------------------------------------------------------- +# Generators (shared id pools, mirroring the Property 1 conventions) +# --------------------------------------------------------------------------- + +_CAMERA_IDS = [ + "cfg-a1b2", "cfg-c3d4", "cfg-e5f6", + "disc-3fe9c0d21ab4", "disc-00aa11bb22cc", "portal-new-1", +] + +_CHANGE_IDS = ["pc-1", "pc-2", "pc-3"] + +_ORIGINS = ["edge-configured", "edge-discovered", "portal-created"] +_TYPES = ["Camera", "Folder", "ICam", "NvidiaCSI", "RTSP", "V4L2Discovered"] + +# Whitespace/unicode-heavy names. +_names = st.text( + alphabet=st.characters( + codec="utf-8", categories=("L", "N", "P", "Zs", "S") + ), + min_size=0, + max_size=24, +) + +_versions = st.integers(min_value=1, max_value=40) + +_params = st.dictionaries( + st.sampled_from(["devicePath", "cameraId", "url", "gain", "exposure"]), + st.one_of(st.text(max_size=16), st.integers(0, 10_000_000)), + max_size=4, +) + +_capabilities = st.one_of( + st.just({}), + st.fixed_dictionaries({ + "formats": st.lists( + st.fixed_dictionaries({ + "pixelFormat": st.sampled_from(["YUYV", "MJPG", "NV12"]), + "resolutions": st.lists( + st.tuples(st.integers(1, 4096), st.integers(1, 4096)) + .map(list), + max_size=3, + ), + }), + max_size=2, + ), + }), +) + + +def _content_of(source): + """Project onto the comparable content fields (mirrors the design).""" + if not source: + return {} + return {field: source.get(field) for field in ("name", "type", "params")} + + +@st.composite +def _conflict_cases(draw): + """A pending registry entry plus an incoming edge state. + + The incoming state is a camera report (converging with or diverging + from the pending content, with a matching, non-matching, or absent + ack) or an edge deletion. Incoming versions never regress below the + recorded version so the pending-change classification path — not the + staleness guard (Property 1) — is exercised. + """ + camera_id = draw(st.sampled_from(_CAMERA_IDS)) + change_id = draw(st.sampled_from(_CHANGE_IDS)) + pending_content = { + "op": draw(st.sampled_from(["create", "update", "delete"])), + "name": draw(_names), + "type": draw(st.sampled_from(_TYPES)), + "params": draw(_params), + } + entry_version = draw(_versions) + registry_entry = { + "usecase_id": draw(st.sampled_from(["uc-1", "uc-2"])), + "camera_source_id": camera_id, + "device_id": "thing-1", + "name": draw(_names), + "type": draw(st.sampled_from(_TYPES)), + "params": draw(_params), + "capabilities": draw(_capabilities), + "origin": draw(st.sampled_from(_ORIGINS)), + "version": entry_version, + "last_reported_at": draw(st.integers(1, 1_700_000_000_000)), + "sync_status": cs.SYNC_STATUS_PENDING, + "portal_change_id": change_id, + "pending_content": pending_content, + "absent": False, + } + + if draw(st.booleans()): + # Edge deletion: the source vanished from the full report (Req 6.5). + incoming = draw(st.sampled_from([None, {"deleted": True}])) + else: + incoming = { + "version": draw( + st.integers(entry_version, entry_version + 10)), + "origin": draw(st.sampled_from(_ORIGINS)), + "capabilities": draw(_capabilities), + "absent": False, + } + if draw(st.booleans()): + # Edge content converged onto the pending portal content. + incoming["name"] = pending_content["name"] + incoming["type"] = pending_content["type"] + incoming["params"] = dict(pending_content["params"]) + else: + incoming["name"] = draw(_names) + incoming["type"] = draw(st.sampled_from(_TYPES)) + incoming["params"] = draw(_params) + ack = draw(st.sampled_from( + [None, change_id] + [c for c in _CHANGE_IDS if c != change_id])) + if ack is not None: + incoming["ack"] = ack + + now_ms = draw(st.integers(1_700_000_000_001, 1_900_000_000_000)) + return registry_entry, incoming, now_ms + + +# --------------------------------------------------------------------------- +# Property +# --------------------------------------------------------------------------- + +_DECLARED_FIELDS = ("name", "type", "params", "capabilities", + "origin", "version") + + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_conflict_cases()) +def test_conflict_classification_with_edge_wins_resolution(case): + """**Feature: camera-registry-sync, Property 8: Conflict classification + with edge-wins resolution** + + **Validates: Requirements 6.1, 6.2, 6.3, 6.5** + """ + registry_entry, incoming, now_ms = case + pending_content = registry_entry["pending_content"] + change_id = registry_entry["portal_change_id"] + + outcome = cs.reduce_report(registry_entry, incoming, now_ms) + + if incoming is None or incoming.get("deleted") is True: + # Edge deletion while a portal change is pending (Req 6.5): + # the deletion is the effective state either way. + assert outcome.entry is None + if pending_content["op"] == "delete": + # Portal wanted the deletion too: agreement, not a Conflict. + assert outcome.action == cs.ACTION_UPSERT + assert outcome.conflict_event is None + else: + # Deletion wins over the pending portal modification, and a + # conflict event is recorded (Reqs 6.3, 6.5). + assert outcome.action == cs.ACTION_CONFLICT + event = outcome.conflict_event + assert event is not None + assert event.camera_source_id == registry_entry["camera_source_id"] + assert event.edge_version is None + assert event.portal_version == pending_content + assert event.resolution == cs.RESOLUTION_DELETION_RETAINED + assert event.created_at == now_ms + return + + # A camera report for the pending source. It acknowledges the pending + # change exactly when its ack carries that change's id. + acknowledged = incoming.get("ack") == change_id + content_differs = _content_of(incoming) != _content_of(pending_content) + + # Conflict exactly when the change is unacknowledged and the edge + # content differs from the pending portal content (Req 6.1). + if acknowledged or not content_differs: + assert outcome.action == cs.ACTION_UPSERT + assert outcome.conflict_event is None + else: + assert outcome.action == cs.ACTION_CONFLICT + event = outcome.conflict_event + assert event is not None + # The event contains both conflicting versions, the resolution + # applied, and the timestamp (Req 6.3). + assert event.camera_source_id == registry_entry["camera_source_id"] + assert event.edge_version == _content_of(incoming) + assert event.portal_version == pending_content + assert event.resolution == cs.RESOLUTION_EDGE_RETAINED + assert event.created_at == now_ms + + # The retained effective state equals the edge state — edge wins on + # conflict, and the applied/converged state on upsert (Req 6.2). + entry = outcome.entry + assert entry is not None + for field in _DECLARED_FIELDS: + assert entry[field] == incoming[field], field + assert entry["last_reported_at"] == now_ms diff --git a/edge-cv-portal/backend/tests/test_camera_sync_reducer_properties.py b/edge-cv-portal/backend/tests/test_camera_sync_reducer_properties.py new file mode 100644 index 00000000..00685227 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_camera_sync_reducer_properties.py @@ -0,0 +1,243 @@ +""" +Property-based test for the Portal_Sync_Service reduce_report reducer. + +**Feature: camera-registry-sync, Property 1: Sync reducer round trip with version guard** + +*For any* registry state and any incoming device report, processing the +report through `reduce_report` yields registry entries that preserve +every declared Camera_Source field (id, name, type, params, +capabilities, origin, version, last-reported timestamp) and the +device's `usecase_id`, entries not referenced by the report are +unchanged, entries whose incoming version is lower than the recorded +version are discarded leaving the recorded entry intact, and the +device meta's last-report timestamp is stamped. + +**Validates: Requirements 1.1, 1.2, 1.4, 3.2, 3.5** + +Generators (per the design testing strategy): multi-source inventories, +version sequences including regressions, all origins and sync statuses, +whitespace/unicode names, and degenerate registries (empty, +never-synced). +""" +from hypothesis import given, settings +from hypothesis import strategies as st + +import camera_sync as cs + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# A small shared id pool so registry entries and report entries overlap, +# miss each other, and collide in interesting ways. +_CAMERA_IDS = [ + "cfg-a1b2", "cfg-c3d4", "cfg-e5f6", + "disc-3fe9c0d21ab4", "disc-00aa11bb22cc", "portal-new-1", +] + +_CHANGE_IDS = ["pc-1", "pc-2", "pc-3"] + +_ORIGINS = ["edge-configured", "edge-discovered", "portal-created"] +_TYPES = ["Camera", "Folder", "ICam", "NvidiaCSI", "RTSP", "V4L2Discovered"] + +# Whitespace/unicode-heavy names (Req 1.1 field fidelity for any name). +_names = st.text( + alphabet=st.characters( + codec="utf-8", categories=("L", "N", "P", "Zs", "S") + ), + min_size=0, + max_size=24, +) + +_versions = st.integers(min_value=1, max_value=40) + +_params = st.dictionaries( + st.sampled_from(["devicePath", "cameraId", "url", "gain", "exposure"]), + st.one_of(st.text(max_size=16), st.integers(0, 10_000_000)), + max_size=4, +) + +_capabilities = st.one_of( + st.just({}), + st.fixed_dictionaries({ + "formats": st.lists( + st.fixed_dictionaries({ + "pixelFormat": st.sampled_from(["YUYV", "MJPG", "NV12"]), + "resolutions": st.lists( + st.tuples(st.integers(1, 4096), st.integers(1, 4096)) + .map(list), + max_size=3, + ), + }), + max_size=2, + ), + }), +) + + +@st.composite +def _incoming_cameras(draw): + """A camera entry in the shadow reported-document shape.""" + camera = { + "version": draw(_versions), + "name": draw(_names), + "type": draw(st.sampled_from(_TYPES)), + "origin": draw(st.sampled_from(_ORIGINS)), + "params": draw(_params), + "capabilities": draw(_capabilities), + "absent": draw(st.booleans()), + } + if camera["absent"] and draw(st.booleans()): + camera["absentSince"] = draw(st.integers(1, 2_000_000_000_000)) + if draw(st.booleans()): + camera["ack"] = draw(st.sampled_from(_CHANGE_IDS)) + return camera + + +@st.composite +def _registry_entries(draw, camera_source_id, usecase_id): + """A Camera_Registry entry per the design data model, any sync status.""" + entry = { + "usecase_id": usecase_id, + "camera_source_id": camera_source_id, + "device_id": "thing-1", + "name": draw(_names), + "type": draw(st.sampled_from(_TYPES)), + "params": draw(_params), + "capabilities": draw(_capabilities), + "origin": draw(st.sampled_from(_ORIGINS)), + "version": draw(_versions), + "last_reported_at": draw(st.integers(1, 1_700_000_000_000)), + "sync_status": draw(st.sampled_from( + [cs.SYNC_STATUS_SYNCED, cs.SYNC_STATUS_PENDING, + cs.SYNC_STATUS_FAILED] + )), + "absent": draw(st.booleans()), + } + if entry["sync_status"] == cs.SYNC_STATUS_PENDING: + entry["portal_change_id"] = draw(st.sampled_from(_CHANGE_IDS)) + entry["pending_content"] = { + "op": draw(st.sampled_from(["create", "update", "delete"])), + "name": draw(_names), + "type": draw(st.sampled_from(_TYPES)), + "params": draw(_params), + } + if entry["sync_status"] == cs.SYNC_STATUS_FAILED: + entry["failure_reason"] = draw(_names) + return entry + + +@st.composite +def _sync_cases(draw): + """A registry state, an incoming multi-source report, meta, and a clock. + + Degenerate registries (empty, never-synced) arise naturally: the + registry subset may be empty and meta may be None or never-synced. + """ + usecase_id = draw(st.sampled_from(["uc-1", "uc-2"])) + ids = draw(st.lists(st.sampled_from(_CAMERA_IDS), + unique=True, max_size=len(_CAMERA_IDS))) + registry = {} + report = {} + for camera_id in ids: + in_registry = draw(st.booleans()) + if in_registry: + registry[camera_id] = draw( + _registry_entries(camera_id, usecase_id)) + # Sources may be report-only, registry-only, or both. + if draw(st.booleans()) or not in_registry: + report[camera_id] = draw(_incoming_cameras()) + meta = draw(st.one_of( + st.none(), + st.fixed_dictionaries({ + "usecase_id": st.just(usecase_id), + "never_synced": st.booleans(), + }), + )) + now_ms = draw(st.integers(1_700_000_000_001, 1_900_000_000_000)) + return registry, report, meta, now_ms + + +# --------------------------------------------------------------------------- +# Property +# --------------------------------------------------------------------------- + +_DECLARED_FIELDS = ("name", "type", "params", "capabilities", + "origin", "version") + + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_sync_cases()) +def test_sync_reducer_round_trip_with_version_guard(case): + """**Feature: camera-registry-sync, Property 1: Sync reducer round trip + with version guard** + + **Validates: Requirements 1.1, 1.2, 1.4, 3.2, 3.5** + """ + registry, report, meta, now_ms = case + + new_registry = dict(registry) + for camera_id, incoming in report.items(): + prior = registry.get(camera_id) + outcome = cs.reduce_report(prior, incoming, now_ms) + + if prior is not None and incoming["version"] < prior["version"]: + # Version guard: stale state is discarded and the recorded + # entry is retained intact (Req 3.5). + assert outcome.action == cs.ACTION_DISCARD_STALE + assert outcome.entry == prior + assert outcome.conflict_event is None + continue + + # Fresh state is applied — either a plain upsert or an upsert + # with a conflict event (edge wins); each source is reduced + # independently (Req 1.2). + assert outcome.action in (cs.ACTION_UPSERT, cs.ACTION_CONFLICT) + entry = outcome.entry + assert entry is not None + + # Round trip: every declared Camera_Source field survives the + # reduction unchanged (Req 1.1). + for field in _DECLARED_FIELDS: + assert entry[field] == incoming[field], field + # Last-reported timestamp recorded from this report (Reqs 1.1, 3.2). + assert entry["last_reported_at"] == now_ms + assert entry["absent"] is incoming["absent"] + if incoming["absent"] and "absentSince" in incoming: + assert entry["absent_since"] == incoming["absentSince"] + + # Identity and Use_Case scoping carried from the existing + # entry (Req 1.4). + if prior is not None: + assert entry["usecase_id"] == prior["usecase_id"] + assert entry["camera_source_id"] == camera_id + + if outcome.action == cs.ACTION_CONFLICT: + assert outcome.conflict_event is not None + else: + assert outcome.conflict_event is None + + # Idempotency under duplicate delivery: re-reducing the persisted + # entry against the same incoming state reproduces it exactly. + replay = cs.reduce_report(entry, incoming, now_ms) + assert replay.entry == entry + assert replay.conflict_event is None + + new_registry[camera_id] = entry + + # Entries not referenced by the report are unchanged (Reqs 1.2, 3.5). + for camera_id, prior in registry.items(): + if camera_id not in report: + assert new_registry[camera_id] == prior + + # Every processed report stamps the device META item (Req 3.2): + # last-report timestamp set, never_synced cleared, scoping kept, + # idempotently. + stamped = cs.stamp_meta(meta, now_ms) + assert stamped["last_report_at"] == now_ms + assert stamped["never_synced"] is False + if meta is not None: + assert stamped["usecase_id"] == meta["usecase_id"] + assert cs.stamp_meta(stamped, now_ms) == stamped diff --git a/edge-cv-portal/backend/tests/test_code_assist.py b/edge-cv-portal/backend/tests/test_code_assist.py new file mode 100644 index 00000000..a7be2a63 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_code_assist.py @@ -0,0 +1,484 @@ +""" +Code assist endpoint unit tests (custom-node-code-assist, task 2.7). + +POST /code-assist (functions/code_assist.py, dispatched from +workflow_generator.handler) is exercised against the shared moto stack +from conftest.py with the Bedrock Converse API mocked. Covered: + +1. Handler route dispatch: POST /code-assist reaches + code_assist.handle_code_assist; unknown resources return 404. +2. Request-validation 400 matrix: MISSING_FIELDS, INVALID_SURFACE, + INVALID_CONTRACT, INVALID_PROMPT (whitespace-only / over 4,000 chars), + INVALID_JSON - all settled without any Bedrock client construction. +3. RBAC matrix per surface (6.1, 6.2): workflow create/edit permission for + workflow-builder (DataScientist / UseCaseAdmin / PortalAdmin allowed; + Viewer / Operator denied); UseCaseAdmin-in-Use_Case or PortalAdmin for + node-designer (DataScientist also denied). Denial returns the uniform + 403 FORBIDDEN envelope, writes the unauthorized_access audit entry + carrying the acting user, surface, Use_Case, and timestamp (6.3), and + constructs no Bedrock client. +4. Mocked Converse failure paths (5.1-5.3): a read timeout returns 504 + GENERATION_TIMEOUT with details.timeout_seconds echoing the clamped + configured value; a response without the provide_code tool call (or + with empty code) returns 422 NO_CODE_RETURNED. +5. Happy path: a valid provide_code tool response returns 200 with + {code, notes, model_id, contract}. + +_Requirements: 5.1, 5.2, 5.3, 6.1, 6.2, 6.3_ +""" +import json +import os +import sys +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ReadTimeoutError + +from conftest import REGION + +SETTINGS_TABLE_NAME = "test-settings-code-assist" + +TOOL_NAME = "provide_code" + +# Valid for every contract exercised here: process_frame_or_handle +# (exactly one of process_frame/handle), process_frame, and frame_hook +# all accept a lone top-level process_frame definition. +VALID_CODE = "def process_frame(frame, metadata):\n return None\n" + + +def tool_response(code, notes="One short paragraph."): + """A Converse API response whose assistant message calls provide_code.""" + return { + "output": {"message": {"role": "assistant", "content": [ + {"toolUse": {"toolUseId": "tool-1", "name": TOOL_NAME, + "input": {"code": code, "notes": notes}}}, + ]}}, + "stopReason": "tool_use", + } + + +TEXT_ONLY_RESPONSE = { + "output": {"message": {"role": "assistant", + "content": [{"text": "I cannot help."}]}}, + "stopReason": "end_turn", +} + + +@pytest.fixture(scope="module") +def ca(aws_stack): + """Settings table and freshly imported workflow_generator/code_assist + modules bound to it inside moto (test_workflow_generation pattern; + workflow_generator reloads bedrock_common and code_assist on import, + so both rebind SETTINGS_TABLE and moto-intercepted boto3 clients).""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + for module_name in ("workflow_generator", "workflow_validation", + "code_assist", "bedrock_common"): + sys.modules.pop(module_name, None) + import workflow_generator + import code_assist + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + generator=workflow_generator, + code_assist=code_assist, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + ) + + +@pytest.fixture(autouse=True) +def clean_bedrock_config(ca): + """Each test starts from the default Bedrock_Configuration.""" + ca.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + yield + + +@pytest.fixture +def bedrock(ca, monkeypatch): + """Mocked Converse API client injected through code_assist's + get_bedrock_client binding. ``client_requests`` records every + (region, timeout_seconds) pair so tests can assert the clamp.""" + client = MagicMock(name="bedrock-runtime") + client.converse.return_value = tool_response(VALID_CODE) + client_requests = [] + + def fake_get_bedrock_client(region, timeout_seconds): + client_requests.append((region, timeout_seconds)) + return client + + monkeypatch.setattr(ca.code_assist, "get_bedrock_client", + fake_get_bedrock_client) + return SimpleNamespace(client=client, client_requests=client_requests) + + +@pytest.fixture +def forbid_bedrock(ca, monkeypatch): + """Loudly fail if any Bedrock client is constructed: a tripped guard + surfaces as the handler's 500 INTERNAL_ERROR instead of the expected + 400/403 (Requirement 6.3: denial settles before any Bedrock client).""" + def explode(*args, **kwargs): + raise AssertionError("Bedrock client must not be constructed") + monkeypatch.setattr(ca.code_assist, "get_bedrock_client", explode) + + +@pytest.fixture +def ctx(env): + """A fresh Use_Case and a DataScientist authorized on it (holds the + workflow create/edit permissions for the workflow-builder surface).""" + usecase_id = env.create_usecase() + user = env.make_user() + env.assign_role(user, usecase_id, "DataScientist") + return SimpleNamespace(usecase_id=usecase_id, user=user, env=env) + + +def request_body(usecase_id, surface="workflow-builder", + contract="process_frame_or_handle", + prompt="Add a Gaussian blur filter", **extra): + body = {"usecase_id": usecase_id, "surface": surface, + "contract": contract, "prompt": prompt} + body.update(extra) + return body + + +def post_code_assist(ca, user, body, resource="/code-assist", + method="POST"): + """Invoke workflow_generator.handler with a POST /code-assist event + (exercising the route dispatch on every call); returns + (status_code, parsed_body).""" + event = { + "httpMethod": method, + "resource": resource, + "path": resource, + "body": body if isinstance(body, str) or body is None + else json.dumps(body), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = ca.generator.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def put_bedrock_config(ca, **values): + """Store a Bedrock_Configuration item the way the settings API does.""" + def to_ddb(value): + return Decimal(str(value)) if isinstance(value, float) else value + ca.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": {k: to_ddb(v) for k, v in values.items()}, + }) + + +def denial_audits(aws_stack, user): + """unauthorized_access audit entries for one acting user (fresh uuid + user per test, so no cross-test bleed).""" + return [item for item in aws_stack.tables.audit_log.scan()["Items"] + if item["user_id"] == user["user_id"] + and item["action"] == "unauthorized_access"] + + +# =========================================================================== +# 1. Handler route dispatch +# =========================================================================== + +class TestHandlerDispatch: + + def test_post_code_assist_dispatches_to_handle_code_assist( + self, ca, ctx, monkeypatch): + """The /code-assist resource branch delegates POSTs to + code_assist.handle_code_assist with the event and resolved user.""" + sentinel = {"statusCode": 299, "body": json.dumps({"ok": True})} + seen = {} + + def fake_handle(event, user): + seen["resource"] = event["resource"] + seen["user_id"] = user["user_id"] + return sentinel + + monkeypatch.setattr(ca.generator.code_assist, "handle_code_assist", + fake_handle) + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id)) + assert status == 299 + assert payload == {"ok": True} + assert seen == {"resource": "/code-assist", + "user_id": ctx.user["user_id"]} + + def test_unknown_resource_returns_404(self, ca, ctx): + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id), + resource="/no-such-route") + assert status == 404 + assert payload["error"]["code"] == "NOT_FOUND" + + +# =========================================================================== +# 2. Request-validation 400 matrix (no Bedrock traffic) +# =========================================================================== + +class TestRequestValidation: + + @pytest.mark.parametrize("missing_field", + ["usecase_id", "surface", "contract", "prompt"]) + def test_missing_required_field_returns_400(self, ca, ctx, + forbid_bedrock, + missing_field): + body = request_body(ctx.usecase_id) + del body[missing_field] + + status, payload = post_code_assist(ca, ctx.user, body) + assert status == 400 + assert payload["error"]["code"] == "MISSING_FIELDS" + assert missing_field in payload["error"]["message"] + + def test_invalid_surface_returns_400(self, ca, ctx, forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id, surface="sidebar")) + assert status == 400 + assert payload["error"]["code"] == "INVALID_SURFACE" + + def test_invalid_contract_returns_400(self, ca, ctx, forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, + request_body(ctx.usecase_id, contract="run_forever")) + assert status == 400 + assert payload["error"]["code"] == "INVALID_CONTRACT" + + def test_whitespace_only_prompt_returns_400(self, ca, ctx, + forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id, prompt=" \n\t ")) + assert status == 400 + assert payload["error"]["code"] == "INVALID_PROMPT" + + def test_prompt_over_4000_chars_returns_400(self, ca, ctx, + forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id, prompt="x" * 4001)) + assert status == 400 + assert payload["error"]["code"] == "INVALID_PROMPT" + assert payload["error"]["details"]["max_length"] == 4000 + + def test_non_string_current_code_returns_400(self, ca, ctx, + forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, + request_body(ctx.usecase_id, current_code=["not", "a", "str"])) + assert status == 400 + assert payload["error"]["code"] == "INVALID_JSON" + + def test_non_object_context_returns_400(self, ca, ctx, forbid_bedrock): + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id, context="params")) + assert status == 400 + assert payload["error"]["code"] == "INVALID_JSON" + + def test_malformed_json_body_returns_400(self, ca, ctx, forbid_bedrock): + status, payload = post_code_assist(ca, ctx.user, "{not json") + assert status == 400 + assert payload["error"]["code"] == "INVALID_JSON" + + +# =========================================================================== +# 3. RBAC matrix per surface (Requirements 6.1, 6.2, 6.3) +# =========================================================================== + +# Surface -> contract used on that surface. +SURFACE_CONTRACTS = {"workflow-builder": "process_frame_or_handle", + "node-designer": "frame_hook"} + +PERMITTED = [ + ("workflow-builder", "DataScientist"), # workflow:create / workflow:edit + ("workflow-builder", "UseCaseAdmin"), + ("workflow-builder", "PortalAdmin"), + ("node-designer", "UseCaseAdmin"), # UseCaseAdmin in the Use_Case + ("node-designer", "PortalAdmin"), +] + +DENIED = [ + ("workflow-builder", "Viewer"), + ("workflow-builder", "Operator"), + ("node-designer", "Viewer"), + ("node-designer", "Operator"), + ("node-designer", "DataScientist"), # workflow perms don't carry over +] + + +def make_actor(env, usecase_id, role): + """A user holding `role`: PortalAdmin globally from the JWT claim, + every other role assigned on the Use_Case (matching Team Management).""" + user = env.make_user(role=role) + if role != "PortalAdmin": + env.assign_role(user, usecase_id, role) + return user + + +class TestRBACMatrix: + + @pytest.mark.parametrize("surface,role", PERMITTED) + def test_permitted_role_reaches_generation(self, ca, env, aws_stack, + bedrock, surface, role): + """Roles holding the surface's permitting permission get 200 + (Requirements 6.1, 6.2).""" + usecase_id = env.create_usecase() + user = make_actor(env, usecase_id, role) + + status, payload = post_code_assist( + ca, user, request_body(usecase_id, surface=surface, + contract=SURFACE_CONTRACTS[surface])) + assert status == 200, payload + assert payload["code"] == VALID_CODE + assert denial_audits(aws_stack, user) == [] + + @pytest.mark.parametrize("surface,role", DENIED) + def test_denied_role_gets_403_with_audit_and_no_bedrock( + self, ca, env, aws_stack, forbid_bedrock, surface, role): + """Roles without the permitting permission get the uniform 403 + FORBIDDEN envelope, an unauthorized_access audit entry carrying + the acting user, surface, Use_Case, and timestamp, and no Bedrock + client is ever constructed (Requirement 6.3).""" + usecase_id = env.create_usecase() + user = make_actor(env, usecase_id, role) + + status, payload = post_code_assist( + ca, user, request_body(usecase_id, surface=surface, + contract=SURFACE_CONTRACTS[surface])) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + assert payload["error"]["details"]["surface"] == surface + assert payload["error"]["details"]["usecase_id"] == usecase_id + + (entry,) = denial_audits(aws_stack, user) + assert entry["user_id"] == user["user_id"] + assert entry["result"] == "denied" + assert entry["resource_type"] == "code_assist" + assert entry["details"]["surface"] == surface + assert entry["details"]["usecase_id"] == usecase_id + assert entry["timestamp"] > 0 + + def test_usecase_admin_of_another_usecase_is_denied(self, ca, env, + aws_stack, + forbid_bedrock): + """UseCaseAdmin authorizes node-designer use only within its own + Use_Case (Requirement 6.2).""" + home_usecase = env.create_usecase() + target_usecase = env.create_usecase() + user = env.make_user(role="Viewer") + env.assign_role(user, home_usecase, "UseCaseAdmin") + + status, payload = post_code_assist( + ca, user, request_body(target_usecase, surface="node-designer", + contract="frame_hook")) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + (entry,) = denial_audits(aws_stack, user) + assert entry["details"]["usecase_id"] == target_usecase + + +# =========================================================================== +# 4. Mocked Converse failure paths (Requirements 5.1, 5.2, 5.3) +# =========================================================================== + +class TestBedrockFailures: + + def test_read_timeout_returns_504_with_configured_timeout(self, ca, + bedrock, ctx): + """A Bedrock read timeout yields 504 GENERATION_TIMEOUT with + details.timeout_seconds equal to the configured value, and the + client was built with that timeout (Requirement 5.2).""" + put_bedrock_config(ca, timeout_seconds=45) + bedrock.client.converse.side_effect = ReadTimeoutError( + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com") + + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id)) + assert status == 504 + assert payload["error"]["code"] == "GENERATION_TIMEOUT" + assert payload["error"]["details"]["timeout_seconds"] == 45 + assert "45" in payload["error"]["message"] + assert bedrock.client_requests[-1][1] == 45 + + def test_read_timeout_echoes_clamped_timeout(self, ca, bedrock, ctx): + """A stored timeout above the 60-second maximum is clamped before + the client is built, and the 504 echoes the clamped value + (Requirement 5.2: the applied timeout, at most 60 seconds).""" + put_bedrock_config(ca, timeout_seconds=300) + bedrock.client.converse.side_effect = ReadTimeoutError( + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com") + + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id)) + assert status == 504 + assert payload["error"]["code"] == "GENERATION_TIMEOUT" + assert payload["error"]["details"]["timeout_seconds"] == 60 + assert bedrock.client_requests[-1][1] == 60 + + def test_response_without_tool_call_returns_422_no_code(self, ca, + bedrock, ctx): + """A model response with no provide_code tool call yields 422 + NO_CODE_RETURNED (Requirement 5.3).""" + bedrock.client.converse.return_value = TEXT_ONLY_RESPONSE + + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id)) + assert status == 422 + assert payload["error"]["code"] == "NO_CODE_RETURNED" + assert payload["error"]["details"]["stop_reason"] == "end_turn" + + def test_empty_tool_code_returns_422_no_code(self, ca, bedrock, ctx): + """A provide_code call whose code is empty/whitespace yields 422 + NO_CODE_RETURNED (Requirement 5.3: empty output).""" + bedrock.client.converse.return_value = tool_response(" \n") + + status, payload = post_code_assist( + ca, ctx.user, request_body(ctx.usecase_id)) + assert status == 422 + assert payload["error"]["code"] == "NO_CODE_RETURNED" + + +# =========================================================================== +# 5. Happy path +# =========================================================================== + +class TestHappyPath: + + def test_returns_code_notes_model_id_and_contract(self, ca, bedrock, + ctx): + """A valid provide_code tool response returns 200 with exactly + {code, notes, model_id, contract}: the generated module, the + model's notes, the configured model id, and the request's + contract echoed back.""" + put_bedrock_config(ca, model_id="amazon.nova-pro-v1:0") + bedrock.client.converse.return_value = tool_response( + VALID_CODE, notes="Blurs each frame with a 5x5 kernel.") + + status, payload = post_code_assist( + ca, ctx.user, + request_body(ctx.usecase_id, contract="process_frame_or_handle")) + assert status == 200 + assert payload == { + "code": VALID_CODE, + "notes": "Blurs each frame with a 5x5 kernel.", + "model_id": "amazon.nova-pro-v1:0", + "contract": "process_frame_or_handle", + } + assert bedrock.client.converse.call_count == 1 diff --git a/edge-cv-portal/backend/tests/test_components_plugin_listing.py b/edge-cv-portal/backend/tests/test_components_plugin_listing.py new file mode 100644 index 00000000..3a7d5c2b --- /dev/null +++ b/edge-cv-portal/backend/tests/test_components_plugin_listing.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Unit tests for the Plugin_Component listing extension of components.py +(custom-node-designer task 10.8, Requirement 16.2). + +Covers: +- target_architectures_from_platforms: the pure inverse of + plugin_components.platform_for, over both the recipe Manifest 'Platform' + shape and the describe_component API shape; +- list_components (scope PRIVATE) recognizing dda.plugin.* components, + joining them with the backing Plugin_Record via the registry tags, and + returning name, version, Lifecycle_State, and supported + Target_Architectures; +- non-plugin portal components staying untouched, and a missing + Plugin_Record degrading to lifecycle_state None. + +Runs the real components.py handler against the moto-backed stack from +conftest.py. moto does not implement greengrassv2 or the Resource Groups +Tagging API for Greengrass, so those two Use_Case-account clients are +MagicMocks (the test_plugin_components.py pattern); DynamoDB (use cases + +Plugin_Records) is real moto. +""" +import json +import sys +import uuid +from unittest.mock import MagicMock + +import pytest + +from conftest import TEST_ENV + +ACCOUNT = "123456789012" +REGION = "us-east-1" + +ALL_ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + + +@pytest.fixture(scope="module") +def components_module(aws_stack): + """Import components.py inside the moto mock so its module-level + shared_utils import (and boto3 clients) are intercepted.""" + sys.modules.pop("components", None) + import components + + return components + + +@pytest.fixture(scope="module") +def plugin_components_module(aws_stack): + """plugin_components.platform_for, for the inverse-derivation tests.""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + return plugin_components + + +def component_base_arn(name): + return f"arn:aws:greengrass:{REGION}:{ACCOUNT}:components:{name}" + + +class TestTargetArchitectureDerivation: + """Pure derivation of supported Target_Architectures (16.2).""" + + def test_inverse_of_platform_for_over_every_architecture( + self, components_module, plugin_components_module): + """Each arch's recipe platform block derives back to that arch.""" + for arch in ALL_ARCHS: + platform = plugin_components_module.platform_for(arch) + assert components_module.target_architectures_from_platforms( + [platform]) == [arch] + + def test_amd64_flavors_split_by_nvidia_runtime(self, components_module): + derived = components_module.target_architectures_from_platforms([ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}, + {"os": "linux", "architecture": "amd64"}, + ]) + assert derived == ["x86_64_nvidia", "x86_64"] + + def test_describe_component_attributes_shape(self, components_module): + """The API exposes each manifest's platform under 'attributes'.""" + platforms = [ + {"name": "linux-aarch64", "attributes": { + "os": "linux", "architecture": "aarch64", + "variant": "arm64_jp5"}}, + {"name": "linux-amd64", "attributes": { + "os": "linux", "architecture": "amd64", + "runtime": "nvidia"}}, + ] + assert components_module.target_architectures_from_platforms( + platforms) == ["arm64_jp5", "x86_64_nvidia"] + + def test_unknown_and_empty_platforms_derive_nothing(self, components_module): + assert components_module.target_architectures_from_platforms([]) == [] + assert components_module.target_architectures_from_platforms(None) == [] + assert components_module.target_architectures_from_platforms( + [{"os": "windows", "architecture": "x86"}, "not-a-dict"]) == [] + + def test_plugin_version_from_component_version(self, components_module): + assert components_module.plugin_version_from_component_version("3.0.0") == 3 + assert components_module.plugin_version_from_component_version("12.0.0") == 12 + assert components_module.plugin_version_from_component_version(None) is None + assert components_module.plugin_version_from_component_version("bad") is None + + +class ListingEnv: + """Facade for exercising list_components with fake Use_Case-account + Greengrass / Tagging clients over real moto DynamoDB.""" + + def __init__(self, stack, components, monkeypatch): + self.stack = stack + self.components = components + self.usecase_id = f"uc-{uuid.uuid4()}" + self.user = {"user_id": f"user-{uuid.uuid4()}"} + + stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Components Listing Test Use Case", + "account_id": ACCOUNT, + "region": REGION, + "cross_account_role_arn": + f"arn:aws:iam::{ACCOUNT}:role/DDAPortalAccessRole", + "external_id": "test-external-id", + }) + + monkeypatch.setattr(components, "check_user_access", + lambda user_id, usecase_id, *a, **k: True) + monkeypatch.setattr(components, "assume_cross_account_role", + lambda role_arn, external_id: {"AccessKeyId": "x"}) + + # Fake Use_Case-account clients (moto lacks greengrassv2, and its + # tagging API does not serve Greengrass components). + self.tagging = MagicMock(name="resourcegroupstaggingapi") + self.greengrass = MagicMock(name="greengrassv2") + self._tagged = [] # ResourceTagMappingList entries + self._versions = {} # base arn -> [component versions] + self._details = {} # describe arn -> response dict + self.tagging.get_resources.side_effect = lambda **kw: { + "ResourceTagMappingList": list(self._tagged)} + self.greengrass.list_component_versions.side_effect = lambda **kw: { + "componentVersions": [{"componentVersion": v} + for v in self._versions.get(kw["arn"], [])]} + self.greengrass.describe_component.side_effect = \ + lambda arn: self._details[arn] + + def fake_client(service, credentials, region=None): + return {"resourcegroupstaggingapi": self.tagging, + "greengrassv2": self.greengrass}[service] + monkeypatch.setattr(components, "create_boto3_client", fake_client) + + # ------------------------------------------------------------- setup + def add_component(self, name, versions, tags, platforms): + """Register a tagged portal component with the fake clients.""" + base_arn = component_base_arn(name) + latest = versions[-1] + self._tagged.append({ + "ResourceARN": f"{base_arn}:versions:{latest}", + "Tags": [{"Key": k, "Value": v} for k, v in tags.items()], + }) + self._versions[base_arn] = list(versions) + self._details[f"{base_arn}:versions:{latest}"] = { + "componentName": name, + "description": f"{name} description", + "platforms": platforms, + "creationTimestamp": "2025-01-01T00:00:00+00:00", + } + return base_arn + + def put_plugin_record(self, plugin_id, version, lifecycle_state): + self.stack.tables.plugin_records.put_item(Item={ + "plugin_id": plugin_id, + "version": version, + "usecase_id": self.usecase_id, + "name": f"Plugin {plugin_id}", + "created_at": 1, + "lifecycle_state": lifecycle_state, + }) + + def plugin_tags(self, plugin_id, plugin_version): + return { + "dda-portal:managed": "true", + "dda-portal:usecase-id": self.usecase_id, + "dda-portal:plugin-id": plugin_id, + "dda-portal:plugin-version": str(plugin_version), + } + + # ------------------------------------------------------------ invoke + def list_components(self): + response = self.components.list_components( + self.user, {"usecase_id": self.usecase_id, "scope": "PRIVATE"}, + {"Content-Type": "application/json"}) + assert response["statusCode"] == 200, response["body"] + return {c["component_name"]: c + for c in json.loads(response["body"])["components"]} + + +@pytest.fixture +def lenv(aws_stack, components_module, monkeypatch): + return ListingEnv(aws_stack, components_module, monkeypatch) + + +class TestPluginComponentListing: + """list_components joins dda.plugin.* with the Plugin_Record (16.2).""" + + def test_plugin_component_listed_with_lifecycle_and_architectures(self, lenv): + plugin_id = f"plg-{uuid.uuid4().hex[:8]}" + lenv.put_plugin_record(plugin_id, 3, "test") + lenv.add_component( + f"dda.plugin.{plugin_id}", ["1.0.0", "3.0.0"], + lenv.plugin_tags(plugin_id, 3), + platforms=[ + {"name": "linux-amd64", "attributes": { + "os": "linux", "architecture": "amd64", + "runtime": "nvidia"}}, + {"name": "linux-aarch64", "attributes": { + "os": "linux", "architecture": "aarch64", + "variant": "arm64_jp5"}}, + ]) + + entry = lenv.list_components()[f"dda.plugin.{plugin_id}"] + + assert entry["is_plugin_component"] is True + assert entry["plugin_id"] == plugin_id + assert entry["plugin_version"] == 3 + assert entry["latest_version"]["componentVersion"] == "3.0.0" + assert entry["lifecycle_state"] == "test" + assert entry["supported_architectures"] == [ + "x86_64_nvidia", "arm64_jp5"] + + def test_latest_version_wins_over_stale_version_tag(self, lenv): + """A stale version-level tag never pins the backing record: the + Plugin_Record version comes from the resolved latest component + version's major part.""" + plugin_id = f"plg-{uuid.uuid4().hex[:8]}" + lenv.put_plugin_record(plugin_id, 1, "dev") + lenv.put_plugin_record(plugin_id, 2, "prod") + # Tags name version 1, but version 2.0.0 is the registry's latest. + lenv.add_component( + f"dda.plugin.{plugin_id}", ["1.0.0", "2.0.0"], + lenv.plugin_tags(plugin_id, 1), + platforms=[{"name": "linux-amd64", "attributes": { + "os": "linux", "architecture": "amd64"}}]) + + entry = lenv.list_components()[f"dda.plugin.{plugin_id}"] + + assert entry["plugin_version"] == 2 + assert entry["lifecycle_state"] == "prod" + assert entry["supported_architectures"] == ["x86_64"] + + def test_missing_plugin_record_degrades_to_none(self, lenv): + plugin_id = f"plg-{uuid.uuid4().hex[:8]}" + lenv.add_component( + f"dda.plugin.{plugin_id}", ["1.0.0"], + lenv.plugin_tags(plugin_id, 1), + platforms=[{"name": "linux-amd64", "attributes": { + "os": "linux", "architecture": "amd64"}}]) + + entry = lenv.list_components()[f"dda.plugin.{plugin_id}"] + + assert entry["is_plugin_component"] is True + assert entry["lifecycle_state"] is None + assert entry["supported_architectures"] == ["x86_64"] + + def test_non_plugin_components_are_untouched(self, lenv): + """Ordinary portal-managed components carry none of the plugin + fields (existing listing behavior preserved).""" + lenv.add_component( + "com.example.model", ["1.0.115"], + {"dda-portal:managed": "true", + "dda-portal:model-name": "defect-detector"}, + platforms=[{"name": "linux-amd64", "attributes": { + "os": "linux", "architecture": "amd64"}}]) + + entry = lenv.list_components()["com.example.model"] + + assert "is_plugin_component" not in entry + assert "lifecycle_state" not in entry + assert "supported_architectures" not in entry + assert entry["model_name"] == "defect-detector" + assert entry["latest_version"]["componentVersion"] == "1.0.115" diff --git a/edge-cv-portal/backend/tests/test_custom_node_types.py b/edge-cv-portal/backend/tests/test_custom_node_types.py new file mode 100644 index 00000000..6cd03a0e --- /dev/null +++ b/edge-cv-portal/backend/tests/test_custom_node_types.py @@ -0,0 +1,561 @@ +""" +Unit tests for custom_node_types.py (custom-node-designer task 9.1). + +Covers Custom_Node_Type registration with declaration validation and +plugin-dependency recording (8.1, 8.2, 8.5, 8.6), versioned declaration +updates retaining prior versions (14.1), deprecation (14.3), and +reference-checked removal deleting catalog items, Plugin_Library +artifacts, and Plugin_Component versions or rejecting with the +referencing workflows listed (14.4, 14.5). + +Runs against the moto-backed stack from conftest.py (CustomNodeTypes, +PluginRecords, WorkflowVersions tables + portal artifacts bucket) with a +MagicMock standing in for the Use_Case account Greengrass registry. +""" +import hashlib +import json +import uuid +from unittest.mock import MagicMock + +import pytest + +from conftest import TEST_ENV + + +def make_declaration(type_id, archs=("x86_64",), **overrides): + """A valid Custom_Node_Type wire declaration (design data model).""" + declaration = { + "typeId": type_id, + "category": "preprocessing", + "displayName": "Blur Regions", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [{ + "name": "radius", + "paramType": "int", + "required": True, + "default": 5, + "constraints": {"min": 1, "max": 64}, + "description": "Blur kernel radius in pixels", + "examples": [5, 9], + }], + "mappings": [{ + "arch": arch, + "elementChain": [{"factory": "blurregions", + "argsTemplate": {"radius": "{radius}"}}], + } for arch in archs], + "hardwareDependent": False, + } + declaration.update(overrides) + return declaration + + +class NodeTypesEnv: + """Facade for invoking the Custom_Node_Type API in tests.""" + + def __init__(self, stack, monkeypatch): + self.stack = stack + self.module = stack.custom_node_types + self.records = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + self.monkeypatch = monkeypatch + + self.usecase_id = f"uc-{uuid.uuid4()}" + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Node Types Test Use Case", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + self.admin = self.make_user() + self.assign_role(self.admin, "UseCaseAdmin") + + # ------------------------------------------------------------- setup + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": self.usecase_id, + "role": role, + }) + + def seed_plugin(self, built_archs=("x86_64",), name="blur-regions"): + """Create a Plugin_Record with successful builds promoted to the + portal Plugin_Library.""" + response = self.records.handler({ + "httpMethod": "POST", + "resource": "/plugins", + "path": "/plugins", + "pathParameters": None, + "queryStringParameters": None, + "body": json.dumps({"usecase_id": self.usecase_id, + "name": name, "kind": "scaffold"}), + "requestContext": self._claims(self.admin), + }, None) + assert response["statusCode"] == 201, response["body"] + plugin = json.loads(response["body"])["plugin"] + + artifacts = {} + for arch in built_archs: + data = f"\x7fELF {name} {arch}".encode() + key = (f"workflow-plugins/custom/{self.usecase_id}/{arch}/" + f"{name}.so") + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + self.s3.put_object(Bucket=self.bucket, Key=key + ".sig", + Body=b"signature") + artifacts[arch] = { + "buildStatus": "succeeded", "s3Key": key, + "checksum": hashlib.sha256(data).hexdigest(), + "signature": "c2ln", "logTail": "", + } + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], "version": plugin["version"]}, + UpdateExpression="SET artifacts = :a", + ExpressionAttributeValues={":a": artifacts}, + ) + return plugin + + def seed_workflow_version(self, definition, workflow_id=None, version=1): + """A saved WorkflowVersions item with its S3 definition document.""" + workflow_id = workflow_id or f"wf-{uuid.uuid4()}" + s3_key = (f"workflows/{self.usecase_id}/{workflow_id}/versions/" + f"{version}/workflow.json") + self.s3.put_object(Bucket=self.bucket, Key=s3_key, + Body=json.dumps(definition).encode("utf-8")) + self.stack.tables.versions.put_item(Item={ + "workflow_id": workflow_id, + "version": version, + "s3_definition_key": s3_key, + }) + return workflow_id + + def patch_usecase_clients(self, greengrass=None): + """MagicMock Greengrass + moto S3 for the Use_Case account.""" + gg = greengrass or MagicMock(name="greengrassv2") + if greengrass is None: + gg.list_component_versions.return_value = {"componentVersions": []} + moto_s3 = self.s3 + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + return {"s3": moto_s3, "greengrassv2": gg}[service_name] + + self.monkeypatch.setattr(self.module, "get_usecase_client", + fake_get_usecase_client) + return gg + + def audit_entries(self, action): + response = self.stack.tables.audit_log.scan() + return [i for i in response["Items"] if i["action"] == action] + + # ----------------------------------------------------------- invoke + def _claims(self, user): + return {"authorizer": {"claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + }}} + + def invoke(self, method, resource, user, node_type_id=None, body=None, + query=None): + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", node_type_id or ""), + "pathParameters": {"id": node_type_id} if node_type_id else None, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": self._claims(user), + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ------------------------------------------------------ conveniences + def register(self, user, plugin, declaration, **extra): + body = {"plugin_id": plugin["plugin_id"], + "plugin_version": plugin["version"], + "declaration": declaration} + body.update(extra) + return self.invoke("POST", "/custom-node-types", user, body=body) + + def get(self, user, node_type_id): + return self.invoke("GET", "/custom-node-types/{id}", user, node_type_id) + + def list_by_plugin(self, user, plugin_id=None): + query = {"plugin_id": plugin_id} if plugin_id else None + return self.invoke("GET", "/custom-node-types", user, query=query) + + def update(self, user, node_type_id, **body): + return self.invoke("PUT", "/custom-node-types/{id}", user, + node_type_id, body=body) + + def deprecate(self, user, node_type_id, **body): + return self.invoke("POST", "/custom-node-types/{id}/deprecate", user, + node_type_id, body=body) + + def remove(self, user, node_type_id): + return self.invoke("DELETE", "/custom-node-types/{id}", user, + node_type_id) + + def table_items(self, node_type_id): + from boto3.dynamodb.conditions import Key + response = self.stack.tables.custom_node_types.query( + KeyConditionExpression=Key("node_type_id").eq(node_type_id)) + return response["Items"] + + +@pytest.fixture +def nenv(aws_stack, monkeypatch): + return NodeTypesEnv(aws_stack, monkeypatch) + + +# ---------------------------------------------------------------- 8.1/8.6 + +class TestRegistration: + def test_registration_collects_declaration_and_records_dependency(self, nenv): + """Registration stores the full declaration and records the plugin + dependency custom:{usecase_id}/{plugin_name} in every mapping + (8.1, 8.6).""" + plugin = nenv.seed_plugin(built_archs=("x86_64", "arm64_jp5")) + declaration = make_declaration("custom.blur_regions", + archs=("x86_64", "arm64_jp5")) + + status, body = nenv.register(nenv.admin, plugin, declaration) + + assert status == 201, body + node_type = body["nodeType"] + assert node_type["node_type_id"] == "custom.blur_regions" + assert node_type["version"] == 1 + assert node_type["plugin_id"] == plugin["plugin_id"] + assert node_type["plugin_version"] == plugin["version"] + assert node_type["usecase_ids"] == [nenv.usecase_id] + assert node_type["deprecated"] is False + + stored = node_type["declaration"] + assert stored["displayName"] == "Blur Regions" + assert stored["category"] == "preprocessing" + assert stored["parameters"][0]["description"] + assert stored["parameters"][0]["examples"] + assert stored["hardwareDependent"] is False + dependency = f"custom:{nenv.usecase_id}/blur-regions" + for mapping in stored["mappings"]: + assert dependency in mapping["pluginDependencies"] + + assert nenv.audit_entries("register_custom_node_type") + + def test_invalid_port_declaration_rejected_identifying_offense(self, nenv): + """An out-of-catalog Port type is rejected with the offending + field identified (8.5).""" + plugin = nenv.seed_plugin() + declaration = make_declaration( + "custom.bad_port", + inputs=[{"name": "in", "portType": "NotAPortType"}]) + + status, body = nenv.register(nenv.admin, plugin, declaration) + + assert status == 400 + assert body["error"]["code"] == "INVALID_DECLARATION" + assert body["error"]["details"]["field"] == "inputs[0].portType" + assert nenv.table_items("custom.bad_port") == [] + + def test_mapping_for_unbuilt_architecture_rejected(self, nenv): + """Mappings are collected per *built* Target_Architecture (8.1): + an arch without a successful Plugin_Artifact is rejected.""" + plugin = nenv.seed_plugin(built_archs=("x86_64",)) + declaration = make_declaration("custom.unbuilt", + archs=("x86_64", "arm64_jp6")) + + status, body = nenv.register(nenv.admin, plugin, declaration) + + assert status == 400 + assert body["error"]["code"] == "UNBUILT_ARCHITECTURE" + assert body["error"]["details"]["field"] == "mappings[1].arch" + assert body["error"]["details"]["built_architectures"] == ["x86_64"] + + def test_duplicate_type_id_conflicts(self, nenv): + plugin = nenv.seed_plugin() + declaration = make_declaration("custom.dupe") + status, _ = nenv.register(nenv.admin, plugin, declaration) + assert status == 201 + + status, body = nenv.register(nenv.admin, plugin, declaration) + assert status == 409 + assert body["error"]["code"] == "TYPE_ID_CONFLICT" + + def test_builtin_type_id_collision_rejected(self, nenv): + """A custom type may never collide with a built-in catalog type.""" + from workflow_core.catalog.nodes import NODE_CATALOG + plugin = nenv.seed_plugin() + declaration = make_declaration(NODE_CATALOG[0].type_id) + + status, body = nenv.register(nenv.admin, plugin, declaration) + assert status == 409 + assert body["error"]["code"] == "TYPE_ID_CONFLICT" + + def test_registration_requires_register_permission(self, nenv): + """DataScientist may read but not register (13.1/13.4).""" + plugin = nenv.seed_plugin() + scientist = nenv.make_user() + nenv.assign_role(scientist, "DataScientist") + + status, body = nenv.register(scientist, plugin, + make_declaration("custom.denied")) + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + +# ------------------------------------------------------------------- 14.1 + +class TestVersioning: + def test_update_creates_new_version_retaining_prior(self, nenv): + """A declaration update creates a new version item; prior versions + stay retrievable (14.1).""" + plugin = nenv.seed_plugin() + type_id = "custom.versioned" + status, body = nenv.register(nenv.admin, plugin, + make_declaration(type_id)) + assert status == 201 + + updated_declaration = make_declaration(type_id, + displayName="Blur Regions v2") + status, body = nenv.update(nenv.admin, type_id, + declaration=updated_declaration) + assert status == 201, body + assert body["nodeType"]["version"] == 2 + assert body["nodeType"]["declaration"]["displayName"] == "Blur Regions v2" + + status, body = nenv.get(nenv.admin, type_id) + assert status == 200 + versions = body["versions"] + assert [v["version"] for v in versions] == [2, 1] + assert body["nodeType"]["version"] == 2 + assert nenv.audit_entries("update_custom_node_type") + + def test_update_rejects_type_id_change(self, nenv): + plugin = nenv.seed_plugin() + status, _ = nenv.register(nenv.admin, plugin, + make_declaration("custom.fixed_id")) + assert status == 201 + + status, body = nenv.update( + nenv.admin, "custom.fixed_id", + declaration=make_declaration("custom.other_id")) + assert status == 400 + assert body["error"]["code"] == "TYPE_ID_MISMATCH" + + +# ------------------------------------------------------------------- 14.3 + +class TestDeprecation: + def test_deprecate_flips_flag_on_every_version(self, nenv): + plugin = nenv.seed_plugin() + type_id = "custom.deprecated" + status, _ = nenv.register(nenv.admin, plugin, make_declaration(type_id)) + assert status == 201 + status, _ = nenv.update(nenv.admin, type_id, + declaration=make_declaration(type_id)) + assert status == 201 + + status, body = nenv.deprecate(nenv.admin, type_id) + assert status == 200, body + assert body["nodeType"]["deprecated"] is True + assert all(v["deprecated"] for v in body["versions"]) + assert nenv.audit_entries("deprecate_custom_node_type") + + def test_deprecation_requires_manage_permission(self, nenv): + plugin = nenv.seed_plugin() + type_id = "custom.deprecate_denied" + status, _ = nenv.register(nenv.admin, plugin, make_declaration(type_id)) + assert status == 201 + + operator = nenv.make_user() + nenv.assign_role(operator, "Operator") + status, body = nenv.deprecate(operator, type_id) + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + +# -------------------------------------------------------------- 14.4/14.5 + +class TestRemoval: + def test_removal_with_zero_references_deletes_everything(self, nenv): + """Zero WorkflowVersions references: catalog items, Plugin_Library + artifacts, and Plugin_Component versions are deleted (14.4).""" + plugin = nenv.seed_plugin(built_archs=("x86_64", "arm64_jp5"), + name="removable") + type_id = "custom.removable" + status, _ = nenv.register(nenv.admin, plugin, + make_declaration(type_id, + archs=("x86_64", "arm64_jp5"))) + assert status == 201 + + # An unrelated saved workflow must not block removal. + nenv.seed_workflow_version( + {"nodes": [{"id": "n1", "type": "video_input"}]}) + + version_arn = ("arn:aws:greengrass:us-east-1:123456789012:components:" + f"dda.plugin.{plugin['plugin_id']}:versions:1.0.0") + gg = MagicMock(name="greengrassv2") + gg.list_component_versions.return_value = { + "componentVersions": [{"arn": version_arn}]} + nenv.patch_usecase_clients(gg) + + status, body = nenv.remove(nenv.admin, type_id) + assert status == 200, body + assert body["removed"] is True + assert body["versions_removed"] == [1] + + # Catalog items gone. + assert nenv.table_items(type_id) == [] + # Plugin_Library artifacts (.so + .sig) gone. + listed = nenv.s3.list_objects_v2( + Bucket=nenv.bucket, + Prefix=f"workflow-plugins/custom/{nenv.usecase_id}/") + remaining = [o["Key"] for o in listed.get("Contents", []) + if "/removable.so" in o["Key"]] + assert remaining == [] + # Plugin_Component versions deleted in the Use_Case registry. + gg.delete_component.assert_called_once_with(arn=version_arn) + assert nenv.audit_entries("remove_custom_node_type") + + def test_removal_with_references_rejected_listing_workflows(self, nenv): + """A referencing saved workflow blocks removal; the rejection lists + exactly the referencing workflows (14.5).""" + plugin = nenv.seed_plugin(name="in-use") + type_id = "custom.in_use" + status, _ = nenv.register(nenv.admin, plugin, make_declaration(type_id)) + assert status == 201 + + workflow_id = nenv.seed_workflow_version( + {"nodes": [{"id": "n1", "type": type_id}]}) + nenv.patch_usecase_clients() + + status, body = nenv.remove(nenv.admin, type_id) + assert status == 409, body + assert body["error"]["code"] == "CUSTOM_NODE_TYPE_IN_USE" + referencing = body["error"]["details"]["referencing_workflows"] + assert {"workflow_id": workflow_id, "version": 1} in referencing + + # Nothing deleted: catalog items and artifacts intact. + assert len(nenv.table_items(type_id)) == 1 + listed = nenv.s3.list_objects_v2( + Bucket=nenv.bucket, + Prefix=f"workflow-plugins/custom/{nenv.usecase_id}/x86_64/in-use.so") + assert listed.get("KeyCount", 0) >= 1 + + def test_reference_scan_honors_saved_reference_attribute(self, nenv): + """Items carrying the custom_node_types attribute recorded at save + (task 9.2) are honored without loading the definition.""" + plugin = nenv.seed_plugin(name="attr-ref") + type_id = "custom.attr_ref" + status, _ = nenv.register(nenv.admin, plugin, make_declaration(type_id)) + assert status == 201 + + workflow_id = f"wf-{uuid.uuid4()}" + nenv.stack.tables.versions.put_item(Item={ + "workflow_id": workflow_id, + "version": 3, + "custom_node_types": {type_id: 1}, + }) + + status, body = nenv.remove(nenv.admin, type_id) + assert status == 409 + referencing = body["error"]["details"]["referencing_workflows"] + assert {"workflow_id": workflow_id, "version": 3} in referencing + + +# ------------------------------------------------------------ pure helpers + +class TestPureHelpers: + def test_inject_plugin_dependency_is_idempotent(self, nenv): + module = nenv.module + declaration = make_declaration("custom.pure") + dependency = "custom:uc-1/pure" + + once = module.inject_plugin_dependency(declaration, dependency) + twice = module.inject_plugin_dependency(once, dependency) + assert once == twice + assert all(m["pluginDependencies"].count(dependency) == 1 + for m in twice["mappings"]) + # The input declaration is never mutated. + assert "pluginDependencies" not in declaration["mappings"][0] + + def test_evaluate_removal_decision(self, nenv): + module = nenv.module + assert module.evaluate_removal("custom.x", []) is None + + rejection = module.evaluate_removal( + "custom.x", [{"workflow_id": "wf-1", "version": 2}]) + assert rejection["code"] == "CUSTOM_NODE_TYPE_IN_USE" + assert rejection["details"]["referencing_workflows"] == [ + {"workflow_id": "wf-1", "version": 2}] + + def test_definition_reference_detection(self, nenv): + module = nenv.module + assert module.definition_references_node_type( + {"nodes": [{"id": "a", "type": "custom.x"}]}, "custom.x") + assert not module.definition_references_node_type( + {"nodes": [{"id": "a", "type": "video_input"}]}, "custom.x") + assert not module.definition_references_node_type(None, "custom.x") + + +# --------------------------------------------------- list by backing plugin + +class TestListByPlugin: + """GET /custom-node-types?plugin_id=... — the registration wizard's + duplicate detection: a plugin already backing a node type is updated + instead of re-registered.""" + + def test_lists_latest_version_of_types_backed_by_plugin(self, nenv): + # The moto CustomNodeTypes table persists across tests in the + # session-scoped stack, so the type id is unique to this test. + type_id = f"custom.listed_{uuid.uuid4().hex[:8]}" + plugin = nenv.seed_plugin(name="listed-plugin") + declaration = make_declaration(type_id) + status, _ = nenv.register(nenv.admin, plugin, declaration) + assert status == 201 + + status, body = nenv.list_by_plugin(nenv.admin, plugin["plugin_id"]) + assert status == 200 + assert body["count"] == 1 + summary = body["nodeTypes"][0] + assert summary["node_type_id"] == type_id + assert summary["version"] == 1 + assert summary["plugin_id"] == plugin["plugin_id"] + + # An update creates version 2; the list still returns one entry, + # now at the latest version (14.1). + status, _ = nenv.update(nenv.admin, type_id, declaration=declaration) + assert status == 201 + status, body = nenv.list_by_plugin(nenv.admin, plugin["plugin_id"]) + assert status == 200 + assert body["count"] == 1 + assert body["nodeTypes"][0]["version"] == 2 + + def test_plugin_without_registrations_lists_empty(self, nenv): + plugin = nenv.seed_plugin(name="fresh-plugin") + status, body = nenv.list_by_plugin(nenv.admin, plugin["plugin_id"]) + assert status == 200 + assert body == {"nodeTypes": [], "count": 0} + + def test_missing_plugin_id_is_rejected(self, nenv): + status, body = nenv.list_by_plugin(nenv.admin) + assert status == 400 + assert body["error"]["code"] == "MISSING_PLUGIN_ID" + + def test_unknown_plugin_is_not_found(self, nenv): + status, body = nenv.list_by_plugin(nenv.admin, "no-such-plugin") + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" diff --git a/edge-cv-portal/backend/tests/test_deployment_plugin_gates.py b/edge-cv-portal/backend/tests/test_deployment_plugin_gates.py new file mode 100644 index 00000000..49a08cf5 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_deployment_plugin_gates.py @@ -0,0 +1,539 @@ +""" +Plugin lifecycle and architecture deployment gates +(functions/deployments.py + functions/devices.py). + +Task 10.5 (spec: custom-node-designer). The pre-submit check that already +compares LocalServer versions per device gains two gates over the +deployment's Plugin_Component dependency closure: + +- Lifecycle gate (Requirements 9.7, 9.8, 9.11, 16.3): dev-state + components are rejected for any target; test-state components are + permitted only to devices flagged ``test_device`` (a Devices-table + attribute a UseCaseAdmin sets); prod deploys anywhere in the Use_Case. + Rejections carry PLUGIN_LIFECYCLE_VIOLATION identifying the + Plugin_Component and its Lifecycle_State. +- Architecture gate (Requirement 16.6): each target device's recorded + Target_Architecture is checked against the platform manifests of every + depended-on Plugin_Component version — x86_64 and x86_64_nvidia + matched distinctly, no fallback — rejecting with + PLUGIN_ARCH_UNSUPPORTED listing each offending + {pluginComponent, version, device, deviceArch}. + +Standalone Plugin_Component deployments are recorded in the Deployments +table with component_type: 'plugin'; workflow deployments rely on +Greengrass dependency resolution to deliver the depended-on +Plugin_Component versions (Requirement 16.5), so the deployment's +component set carries only the Workflow_Component. + +The property tests for the gate decision logic are tasks 10.6/10.7; +these are the example-based unit and integration tests. +""" +import json +import sys +import uuid + +import pytest +from boto3.dynamodb.conditions import Key + +from test_workflow_packaging_deployment_integration import ( + ACCOUNT_ID, REGION, FakeGreengrass, FakeIot, make_dewarp_definition) + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +@pytest.fixture(scope="module") +def devices_module(aws_stack): + sys.modules.pop("devices", None) + import devices + + return devices + + +# ========================================================================== +# Pure gate decision logic (evaluate_plugin_lifecycle_gate / +# evaluate_plugin_arch_gate) +# ========================================================================== + +class TestLifecycleGatePure: + def test_prod_deploys_anywhere(self, deployments): + violations = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.p1": "prod"}, + {"line-a": False, "bench-1": True}) + assert violations == [] + + def test_dev_rejected_for_any_target(self, deployments): + """dev-state components are rejected even when every target is a + Test_Device (9.7 fail-closed / 16.3).""" + violations = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.p1": "dev"}, + {"bench-1": True, "bench-2": True}) + [violation] = violations + assert violation["pluginComponent"] == "dda.plugin.p1" + assert violation["lifecycleState"] == "dev" + assert violation["devices"] == ["bench-1", "bench-2"] + + def test_unknown_state_fails_closed(self, deployments): + [violation] = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.p1": None}, {"bench-1": True}) + assert violation["lifecycleState"] is None + + def test_test_state_only_to_test_devices(self, deployments): + """test-state components reject exactly the unflagged targets, + identifying the component and its state (9.7, 9.8).""" + violations = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.p1": "test"}, + {"line-a": False, "bench-1": True, "line-b": False}) + [violation] = violations + assert violation["pluginComponent"] == "dda.plugin.p1" + assert violation["lifecycleState"] == "test" + assert violation["devices"] == ["line-a", "line-b"] + + def test_test_state_allowed_when_all_targets_flagged(self, deployments): + violations = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.p1": "test"}, + {"bench-1": True, "bench-2": True}) + assert violations == [] + + def test_mixed_closure_reports_every_violation(self, deployments): + violations = deployments.evaluate_plugin_lifecycle_gate( + {"dda.plugin.dev1": "dev", "dda.plugin.ok": "prod", + "dda.plugin.t1": "test"}, + {"line-a": False}) + assert [v["pluginComponent"] for v in violations] == \ + ["dda.plugin.dev1", "dda.plugin.t1"] + + +class TestArchGatePure: + MANIFEST = {"dda.plugin.p1": {"version": "2.0.0", + "architectures": ["x86_64", "arm64_jp5"]}} + + def test_covered_architectures_pass(self, deployments): + offending = deployments.evaluate_plugin_arch_gate( + self.MANIFEST, {"d1": "x86_64", "d2": "arm64_jp5"}) + assert offending == [] + + def test_x86_64_device_does_not_match_nvidia_only_manifest(self, deployments): + """x86_64 and x86_64_nvidia are matched distinctly — no fallback + in either direction (16.6).""" + offending = deployments.evaluate_plugin_arch_gate( + {"dda.plugin.p1": {"version": "1.0.0", + "architectures": ["x86_64_nvidia"]}}, + {"d1": "x86_64"}) + assert offending == [{"pluginComponent": "dda.plugin.p1", + "version": "1.0.0", "device": "d1", + "deviceArch": "x86_64"}] + + def test_nvidia_device_does_not_match_plain_x86_64_manifest(self, deployments): + offending = deployments.evaluate_plugin_arch_gate( + {"dda.plugin.p1": {"version": "1.0.0", + "architectures": ["x86_64"]}}, + {"d1": "x86_64_nvidia"}) + assert offending == [{"pluginComponent": "dda.plugin.p1", + "version": "1.0.0", "device": "d1", + "deviceArch": "x86_64_nvidia"}] + + def test_unrecorded_device_architecture_fails_closed(self, deployments): + offending = deployments.evaluate_plugin_arch_gate( + self.MANIFEST, {"d1": None}) + assert offending == [{"pluginComponent": "dda.plugin.p1", + "version": "2.0.0", "device": "d1", + "deviceArch": None}] + + def test_every_component_device_miss_is_listed(self, deployments): + offending = deployments.evaluate_plugin_arch_gate( + {"dda.plugin.a": {"version": "1.0.0", "architectures": ["x86_64"]}, + "dda.plugin.b": {"version": "3.0.0", "architectures": ["arm64_jp5"]}}, + {"d1": "x86_64", "d2": "arm64_jp5"}) + assert offending == [ + {"pluginComponent": "dda.plugin.a", "version": "1.0.0", + "device": "d2", "deviceArch": "arm64_jp5"}, + {"pluginComponent": "dda.plugin.b", "version": "3.0.0", + "device": "d1", "deviceArch": "x86_64"}, + ] + + +# ========================================================================== +# Integration harness +# ========================================================================== + +class PluginGateEnv: + """A validated + packaged workflow version whose recorded + Plugin_Component closure and target devices are seeded directly, with + the stateful Greengrass/IoT fakes on the deployment side.""" + + def __init__(self, env, deployments, monkeypatch): + self.env = env + self.deployments = deployments + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Plugin Gate Test", + "account_id": ACCOUNT_ID, + }) + + status, payload = env.invoke("POST", "/workflows", self.user, body={ + "usecase_id": self.usecase_id, + "name": "gated workflow", + "definition": make_dewarp_definition(), + }) + assert status == 201, payload + self.workflow_id = payload["workflow"]["workflow_id"] + + self.gg = FakeGreengrass() + self.iot = FakeIot() + + def deployment_client(service_name, usecase, session_name=None, + region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "greengrassv2": + return self.gg + if service_name == "iot": + return self.iot + raise AssertionError(f"unexpected client: {service_name}") + + monkeypatch.setattr(deployments, "get_usecase_client", + deployment_client) + + # ------------------------------------------------------------- setup + def mark_packaged(self, plugin_components, version=1): + """Validated + packaged version item with the recorded + Plugin_Component dependency closure (what workflow_packaging.py + persists).""" + self.env.stack.tables.versions.update_item( + Key={"workflow_id": self.workflow_id, "version": version}, + UpdateExpression=("SET validation_status = :v, " + "component_arn = :arn, plugin_components = :pc"), + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}, + ":arn": (f"arn:aws:greengrass:{REGION}:{ACCOUNT_ID}:components:" + f"dda.workflow.{self.workflow_id}:versions:1.0.0"), + ":pc": dict(plugin_components), + }, + ) + + def seed_plugin_record(self, plugin_id, version, lifecycle_state, archs): + """A backing Plugin_Record with a registered Plugin_Component whose + platform manifests cover exactly ``archs``.""" + self.env.stack.tables.plugin_records.put_item(Item={ + "plugin_id": plugin_id, + "version": version, + "usecase_id": self.usecase_id, + "created_at": 1, + "name": plugin_id, + "lifecycle_state": lifecycle_state, + "artifacts": {arch: {"buildStatus": "succeeded", + "s3Key": f"plugins/{plugin_id}/{arch}.so", + "checksum": "c" * 8, "signature": "s" * 8} + for arch in archs}, + "component": {"name": f"dda.plugin.{plugin_id}", + "version": f"{version}.0.0", + "arn": "arn:test", + "architectures": list(archs), + "status": "registered", + "packagedAt": 1, + "failure": None}, + }) + + def put_device_record(self, thing_name, test_device=False, arch=None): + item = {"device_id": thing_name, "usecase_id": self.usecase_id, + "test_device": test_device} + if arch is not None: + item["target_architecture"] = arch + self.env.stack.tables.devices.put_item(Item=item) + + def register_device(self, thing_name, local_server_version="1.2.0"): + self.gg.register_device(thing_name, + local_server_version=local_server_version) + + # ------------------------------------------------------------ invoke + def deploy_workflow(self, **body): + body = {"component_type": "workflow", "usecase_id": self.usecase_id, + "workflow_id": self.workflow_id, **body} + event = self.env.event("POST", "/deployments", self.user, body=body) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def deploy_components(self, components, **body): + body = {"usecase_id": self.usecase_id, "components": components, + **body} + event = self.env.event("POST", "/deployments", self.user, body=body) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def association_record(self, deployment_id): + return self.env.stack.tables.deployments.get_item( + Key={"deployment_id": deployment_id}).get("Item") + + +@pytest.fixture +def gate_env(env, deployments, monkeypatch): + return PluginGateEnv(env, deployments, monkeypatch) + + +# ========================================================================== +# Workflow deployments: lifecycle gate over the dependency closure +# (Requirements 9.7, 9.8, 9.11, 16.3, 16.5) +# ========================================================================== + +class TestWorkflowLifecycleGate: + def test_test_state_plugin_rejected_for_non_test_device(self, gate_env): + """A workflow depending on a test-state plugin may not be deployed + to a device not flagged test_device; the rejection identifies the + Plugin_Component and its Lifecycle_State (9.8).""" + gate_env.seed_plugin_record("edgefilter", 2, "test", ["x86_64"]) + gate_env.mark_packaged({"dda.plugin.edgefilter": "2.0.0"}) + gate_env.register_device("line-a-camera-01") + gate_env.put_device_record("line-a-camera-01", test_device=False, + arch="x86_64") + + status, payload = gate_env.deploy_workflow( + target_devices=["line-a-camera-01"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_LIFECYCLE_VIOLATION" + [violation] = payload["error"]["details"]["violations"] + assert violation["pluginComponent"] == "dda.plugin.edgefilter" + assert violation["lifecycleState"] == "test" + assert violation["version"] == "2.0.0" + assert violation["devices"] == ["line-a-camera-01"] + + # Rejected pre-submit: nothing reached Greengrass, no association. + assert gate_env.gg.create_deployment_calls == [] + + def test_test_state_plugin_deploys_to_flagged_test_device(self, gate_env): + """Test_Devices accept test-state plugins (9.7); the deployment's + component set carries only the Workflow_Component — Greengrass + dependency resolution delivers the Plugin_Component (16.5).""" + gate_env.seed_plugin_record("edgefilter", 2, "test", ["x86_64"]) + gate_env.mark_packaged({"dda.plugin.edgefilter": "2.0.0"}) + gate_env.register_device("bench-01") + gate_env.put_device_record("bench-01", test_device=True, + arch="x86_64") + + status, payload = gate_env.deploy_workflow(target_devices=["bench-01"]) + + assert status == 201, payload + [call] = gate_env.gg.create_deployment_calls + assert set(call["components"]) == \ + {f"dda.workflow.{gate_env.workflow_id}"} + + def test_dev_state_plugin_rejected_even_for_test_device(self, gate_env): + """dev-state components are rejected for any target (16.3 / + Requirement 9 dev gates).""" + gate_env.seed_plugin_record("edgefilter", 1, "dev", ["x86_64"]) + gate_env.mark_packaged({"dda.plugin.edgefilter": "1.0.0"}) + gate_env.register_device("bench-01") + gate_env.put_device_record("bench-01", test_device=True, + arch="x86_64") + + status, payload = gate_env.deploy_workflow(target_devices=["bench-01"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_LIFECYCLE_VIOLATION" + [violation] = payload["error"]["details"]["violations"] + assert violation["lifecycleState"] == "dev" + assert gate_env.gg.create_deployment_calls == [] + + def test_prod_state_plugin_deploys_anywhere(self, gate_env): + """prod-state plugins deploy to any device in the Use_Case (9.11).""" + gate_env.seed_plugin_record("edgefilter", 3, "prod", ["x86_64"]) + gate_env.mark_packaged({"dda.plugin.edgefilter": "3.0.0"}) + gate_env.register_device("line-a-camera-01") + gate_env.put_device_record("line-a-camera-01", test_device=False, + arch="x86_64") + + status, payload = gate_env.deploy_workflow( + target_devices=["line-a-camera-01"]) + + assert status == 201, payload + + def test_workflow_without_custom_plugins_is_unaffected(self, gate_env): + gate_env.mark_packaged({}) + gate_env.register_device("line-a-camera-01") + + status, payload = gate_env.deploy_workflow( + target_devices=["line-a-camera-01"]) + + assert status == 201, payload + + +# ========================================================================== +# Workflow deployments: architecture gate (Requirement 16.6) +# ========================================================================== + +class TestWorkflowArchitectureGate: + def test_device_arch_missing_from_manifests_rejects(self, gate_env): + """A device whose recorded Target_Architecture has no published + Plugin_Artifact in a depended-on Plugin_Component version rejects + the submission listing {pluginComponent, version, device, + deviceArch} — x86_64 does not fall back to x86_64_nvidia (16.6).""" + gate_env.seed_plugin_record("gpufilter", 1, "prod", ["x86_64_nvidia"]) + gate_env.mark_packaged({"dda.plugin.gpufilter": "1.0.0"}) + gate_env.register_device("line-a-camera-01") + gate_env.put_device_record("line-a-camera-01", test_device=False, + arch="x86_64") + + status, payload = gate_env.deploy_workflow( + target_devices=["line-a-camera-01"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_ARCH_UNSUPPORTED" + assert payload["error"]["details"]["unsupported"] == [{ + "pluginComponent": "dda.plugin.gpufilter", + "version": "1.0.0", + "device": "line-a-camera-01", + "deviceArch": "x86_64", + }] + assert gate_env.gg.create_deployment_calls == [] + + def test_matching_nvidia_arch_deploys(self, gate_env): + gate_env.seed_plugin_record("gpufilter", 1, "prod", ["x86_64_nvidia"]) + gate_env.mark_packaged({"dda.plugin.gpufilter": "1.0.0"}) + gate_env.register_device("gpu-station-01") + gate_env.put_device_record("gpu-station-01", test_device=False, + arch="x86_64_nvidia") + + status, payload = gate_env.deploy_workflow( + target_devices=["gpu-station-01"]) + + assert status == 201, payload + + +# ========================================================================== +# Standalone Plugin_Component deployments (Requirement 16.3 + the +# component_type: 'plugin' Deployments-table record) +# ========================================================================== + +class TestStandalonePluginDeployment: + COMPONENT = [{"component_name": "dda.plugin.edgefilter", + "component_version": "2.0.0"}] + + def test_standalone_deploy_records_plugin_association(self, gate_env): + gate_env.seed_plugin_record("edgefilter", 2, "prod", ["x86_64"]) + gate_env.put_device_record("line-a-camera-01", test_device=False, + arch="x86_64") + + status, payload = gate_env.deploy_components( + self.COMPONENT, target_devices=["line-a-camera-01"]) + + assert status == 201, payload + [call] = gate_env.gg.create_deployment_calls + assert call["components"] == { + "dda.plugin.edgefilter": {"componentVersion": "2.0.0"}} + + record = gate_env.association_record(payload["deployment_id"]) + assert record is not None + assert record["component_type"] == "plugin" + assert record["component_name"] == "dda.plugin.edgefilter" + assert record["component_version"] == "2.0.0" + assert record["plugin_components"] == \ + {"dda.plugin.edgefilter": "2.0.0"} + assert record["target_devices"] == ["line-a-camera-01"] + + def test_standalone_test_state_restricted_to_test_devices(self, gate_env): + """Standalone Plugin_Component deployments are subject to the same + lifecycle gate: test state only to Test_Devices (16.3).""" + gate_env.seed_plugin_record("edgefilter", 2, "test", ["x86_64"]) + gate_env.put_device_record("line-a-camera-01", test_device=False, + arch="x86_64") + + status, payload = gate_env.deploy_components( + self.COMPONENT, target_devices=["line-a-camera-01"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_LIFECYCLE_VIOLATION" + assert gate_env.gg.create_deployment_calls == [] + # Nothing was recorded for the rejected submission. + response = gate_env.env.stack.tables.deployments.query( + IndexName="usecase-deployments-index", + KeyConditionExpression=Key("usecase_id").eq(gate_env.usecase_id)) + assert response.get("Items", []) == [] + + def test_standalone_test_state_deploys_to_test_device(self, gate_env): + gate_env.seed_plugin_record("edgefilter", 2, "test", ["x86_64"]) + gate_env.put_device_record("bench-01", test_device=True, + arch="x86_64") + + status, payload = gate_env.deploy_components( + self.COMPONENT, target_devices=["bench-01"]) + + assert status == 201, payload + record = gate_env.association_record(payload["deployment_id"]) + assert record["component_type"] == "plugin" + + +# ========================================================================== +# Devices table test_device flag (set by a UseCaseAdmin) +# ========================================================================== + +class TestDeviceFlagEndpoint: + def _put(self, env, devices_module, user, usecase_id, device_id, body): + event = env.event("PUT", "/devices/{id}", user, + workflow_id=device_id, + body={"usecase_id": usecase_id, **body}) + response = devices_module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def test_usecase_admin_sets_test_device_flag(self, env, devices_module): + usecase_id = env.create_usecase() + admin = env.make_user(role="UseCaseAdmin") + + status, payload = self._put( + env, devices_module, admin, usecase_id, "bench-01", + {"test_device": True, "target_architecture": "x86_64"}) + + assert status == 200, payload + assert payload["test_device"] is True + assert payload["target_architecture"] == "x86_64" + + item = env.stack.tables.devices.get_item( + Key={"device_id": "bench-01"}).get("Item") + assert item["test_device"] is True + assert item["target_architecture"] == "x86_64" + assert item["usecase_id"] == usecase_id + + def test_flag_can_be_cleared(self, env, devices_module): + usecase_id = env.create_usecase() + admin = env.make_user(role="UseCaseAdmin") + self._put(env, devices_module, admin, usecase_id, "bench-02", + {"test_device": True}) + + status, payload = self._put( + env, devices_module, admin, usecase_id, "bench-02", + {"test_device": False}) + + assert status == 200, payload + assert payload["test_device"] is False + + @pytest.mark.parametrize("role", ["Operator", "DataScientist", "Viewer"]) + def test_non_admin_roles_are_denied(self, env, devices_module, role): + usecase_id = env.create_usecase() + user = env.make_user(role=role) + + status, payload = self._put( + env, devices_module, user, usecase_id, "bench-03", + {"test_device": True}) + + assert status == 403, payload + assert env.stack.tables.devices.get_item( + Key={"device_id": "bench-03"}).get("Item") is None + + def test_invalid_target_architecture_rejected(self, env, devices_module): + usecase_id = env.create_usecase() + admin = env.make_user(role="UseCaseAdmin") + + status, payload = self._put( + env, devices_module, admin, usecase_id, "bench-04", + {"target_architecture": "risc-v"}) + + assert status == 400, payload diff --git a/edge-cv-portal/backend/tests/test_deployment_shadow_manager.py b/edge-cv-portal/backend/tests/test_deployment_shadow_manager.py new file mode 100644 index 00000000..66d94c84 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_deployment_shadow_manager.py @@ -0,0 +1,185 @@ +""" +ShadowManager auto-include in Portal-created deployments +(functions/deployments.py create_deployment). + +Bugfix: Portal-created deployments never configured +aws.greengrass.ShadowManager, so it ran with its default config (no +``synchronize`` section) and the camera-registry-sync named shadows — +``dda-camera-registry`` (written on the edge by +src/backend/camera_sync/agent.py, device -> cloud) and +``dda-camera-bindings`` (read on the edge by +src/backend/workflow_engine/camera_binding_store.py, cloud -> device) — +never mirrored to IoT Core. The Portal then reported "Device has no +camera registry shadow to refresh from" and devices stayed +"Never synced". + +The fix auto-includes aws.greengrass.ShadowManager with a ``synchronize`` +configurationUpdate merge whenever a deployment carries a LocalServer +component (``needs_nucleus``), unless the caller already supplies +ShadowManager themselves. + +Follow-up bugfix: the auto-included entry was originally unpinned (no +``componentVersion``), which the real greengrassv2 CreateDeployment API +rejects ("Missing required parameter in +components.aws.greengrass.ShadowManager: componentVersion"). The entry is +now pinned — to the newest public version compatible with the device's +running Nucleus, falling back to SHADOW_MANAGER_VERSION — and the +FakeGreengrass fake enforces the real API's per-component componentVersion +requirement so an unpinned entry can't slip through again. +""" +import json +import sys +import uuid + +import pytest +from botocore.exceptions import ParamValidationError + +from test_workflow_packaging_deployment_integration import ( + ACCOUNT_ID, FakeGreengrass, FakeIot) + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +EXPECTED_SYNC_CONFIG = { + "synchronize": { + "direction": "betweenDeviceAndCloud", + "coreThing": { + "classic": True, + "namedShadows": ["dda-camera-registry", "dda-camera-bindings"], + }, + } +} + +LOCAL_SERVER_COMPONENT = { + "component_name": "aws.edgeml.dda.LocalServer.x86_64", + "component_version": "1.2.0", +} + + +class ShadowManagerEnv: + """Minimal create_deployment harness: a Use_Case with the stateful + Greengrass/IoT fakes wired in as the Use_Case-account clients.""" + + def __init__(self, env, deployments, monkeypatch): + self.env = env + self.deployments = deployments + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "ShadowManager Sync Test", + "account_id": ACCOUNT_ID, + }) + + self.gg = FakeGreengrass() + self.iot = FakeIot() + + def deployment_client(service_name, usecase, session_name=None, + region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "greengrassv2": + return self.gg + if service_name == "iot": + return self.iot + raise AssertionError(f"unexpected client: {service_name}") + + monkeypatch.setattr(deployments, "get_usecase_client", + deployment_client) + + def deploy_components(self, components, **body): + body = {"usecase_id": self.usecase_id, "components": components, + **body} + event = self.env.event("POST", "/deployments", self.user, body=body) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def sm_env(env, deployments, monkeypatch): + return ShadowManagerEnv(env, deployments, monkeypatch) + + +class TestShadowManagerAutoInclude: + def test_local_server_deployment_auto_includes_shadow_manager(self, sm_env): + """A deployment carrying a LocalServer component auto-includes + aws.greengrass.ShadowManager, pinned to a concrete version, with the + synchronize merge config for the two camera-registry-sync named + shadows.""" + status, payload = sm_env.deploy_components( + [LOCAL_SERVER_COMPONENT], target_devices=["line-a-camera-01"]) + + assert status == 201, payload + [call] = sm_env.gg.create_deployment_calls + assert "aws.greengrass.ShadowManager" in call["components"] + + shadow_manager = call["components"]["aws.greengrass.ShadowManager"] + # Pinned: the real CreateDeployment API requires componentVersion on + # every component entry and rejects unpinned entries. With no + # resolvable running Nucleus the static fallback pin is used. + assert shadow_manager.get("componentVersion") + assert (shadow_manager["componentVersion"] + == sm_env.deployments.SHADOW_MANAGER_VERSION) + + merged = json.loads(shadow_manager["configurationUpdate"]["merge"]) + assert merged == EXPECTED_SYNC_CONFIG + + # Reported to the caller alongside the other auto-included entries. + [entry] = [e for e in payload["auto_included"] + if e["component_name"] == "aws.greengrass.ShadowManager"] + assert entry["component_version"] == shadow_manager["componentVersion"] + assert "dda-camera-registry" in entry["reason"] + assert "dda-camera-bindings" in entry["reason"] + + def test_caller_supplied_shadow_manager_is_not_overridden(self, sm_env): + """When the caller already includes aws.greengrass.ShadowManager the + auto-include is skipped: their pinned version/config is submitted + untouched and no auto_included entry is reported.""" + status, payload = sm_env.deploy_components( + [LOCAL_SERVER_COMPONENT, + {"component_name": "aws.greengrass.ShadowManager", + "component_version": "2.3.5"}], + target_devices=["line-a-camera-01"]) + + assert status == 201, payload + [call] = sm_env.gg.create_deployment_calls + assert call["components"]["aws.greengrass.ShadowManager"] == { + "componentVersion": "2.3.5"} + assert [e for e in payload["auto_included"] + if e["component_name"] == "aws.greengrass.ShadowManager"] == [] + + def test_fake_rejects_component_without_version_like_real_api(self): + """Regression guard for the fake itself: the real CreateDeployment + API rejects any component entry lacking componentVersion, so the + FakeGreengrass fake must too — otherwise an unpinned auto-include + slips through the tests again.""" + gg = FakeGreengrass() + with pytest.raises(ParamValidationError, match="componentVersion"): + gg.create_deployment( + targetArn=f"arn:aws:iot:us-east-1:{ACCOUNT_ID}:thing/dev-1", + deploymentName="unpinned", + components={"aws.greengrass.ShadowManager": { + "configurationUpdate": {"merge": "{}"}}}) + # The rejected call is not recorded as submitted. + assert gg.create_deployment_calls == [] + + def test_not_included_without_local_server_component(self, sm_env): + """Deployments with no DDA/LocalServer component (needs_nucleus + false) don't pull in ShadowManager (nor the other DDA-driven + auto-includes).""" + status, payload = sm_env.deploy_components( + [{"component_name": "com.example.CustomComponent", + "component_version": "1.0.0"}], + target_devices=["line-a-camera-01"]) + + assert status == 201, payload + [call] = sm_env.gg.create_deployment_calls + assert "aws.greengrass.ShadowManager" not in call["components"] + assert payload["auto_included"] == [] diff --git a/edge-cv-portal/backend/tests/test_merged_catalog_consumers.py b/edge-cv-portal/backend/tests/test_merged_catalog_consumers.py new file mode 100644 index 00000000..a3fe8150 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_merged_catalog_consumers.py @@ -0,0 +1,517 @@ +""" +Merged Node_Type_Catalog resolution wiring (custom-node-designer task 9.2). + +Covers the existing catalog consumers against the moto stack from +conftest.py plus the pure merge/marker/exclusion logic in +functions/node_catalog_resolution.py: + +1. GET /workflows/node-catalog (workflow_validation.py): without + ``usecase_id`` the built-in catalog is served unchanged; with a + Use_Case the registered Custom_Node_Types merge in — backing + Plugin_Record Lifecycle_State test/prod only (dev excluded, 9.2), + deprecated excluded from the palette (14.3), and test-state entries + carrying ``lifecycleState: "test"`` (9.6). Unauthorized Use_Cases 403. +2. POST /workflows/{id}/validate validates against the merged catalog + for the workflow's Use_Case, so custom nodes produce no unknown-type + finding — including deprecated types (14.3). +3. Workflow save (workflows.py) records the Custom_Node_Type version + used on the WorkflowVersions item as the ``custom_node_types`` map + {typeId: typeVersion} (14.2), which the removal reference scan in + custom_node_types.py honors. No inverted-index GSI is created (one + scalar attribute cannot index multiple references per item). +4. workflow_generator.py embeds the merged palette catalog in the + generation system prompt. +5. Pure helpers: palette entry selection, resolution pinning, and + reference extraction. + +_Requirements: 8.2, 8.3, 9.2, 9.6, 14.2, 14.3_ +""" +import json +import sys +import uuid + +import pytest + +from conftest import TEST_ENV +from test_custom_node_types import NodeTypesEnv, make_declaration + + +@pytest.fixture +def nenv(aws_stack, monkeypatch): + return NodeTypesEnv(aws_stack, monkeypatch) + + +@pytest.fixture(scope="module") +def validation_module(aws_stack): + """functions/workflow_validation.py imported inside the moto stack.""" + sys.modules.pop("workflow_validation", None) + import workflow_validation + + return workflow_validation + + +def set_lifecycle_state(nenv, plugin, state): + """Force the backing Plugin_Record version's Lifecycle_State.""" + nenv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], "version": plugin["version"]}, + UpdateExpression="SET lifecycle_state = :s", + ExpressionAttributeValues={":s": state}, + ) + + +def register_custom_type(nenv, type_id, lifecycle_state="test", + built_archs=("x86_64",)): + """A registered Custom_Node_Type whose backing plugin is in the + given Lifecycle_State; returns (plugin, node_type_id).""" + plugin = nenv.seed_plugin(built_archs=built_archs, + name=f"plg-{uuid.uuid4().hex[:8]}") + status, body = nenv.register(nenv.admin, plugin, + make_declaration(type_id, archs=built_archs)) + assert status == 201, body + set_lifecycle_state(nenv, plugin, lifecycle_state) + return plugin + + +def catalog_request(module, user, usecase_id=None): + """GET /workflows/node-catalog[?usecase_id=...]; returns (status, body).""" + event = { + "httpMethod": "GET", + "resource": "/workflows/node-catalog", + "path": "/workflows/node-catalog", + "queryStringParameters": ( + {"usecase_id": usecase_id} if usecase_id else None), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def by_type_id(body): + return {n["typeId"]: n for n in body["nodeTypes"]} + + +def custom_workflow_definition(type_id): + """camera_source -> custom (VideoFrames in/out) -> capture.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": type_id, + "position": {"x": 350, "y": 100}, "parameters": {"radius": 5}}, + {"id": "n3", "type": "capture", + "position": {"x": 600, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + {"id": "c2", "from": {"node": "n2", "port": "out"}, + "to": {"node": "n3", "port": "in"}}, + ], + } + + +def seed_stored_workflow(nenv, definition, custom_node_types=None): + """A saved workflow (metadata + version item + S3 document) the + validate endpoint can load.""" + workflow_id = f"wf-{uuid.uuid4()}" + s3_key = (f"workflows/{nenv.usecase_id}/{workflow_id}/versions/1/" + f"workflow.json") + nenv.s3.put_object(Bucket=nenv.bucket, Key=s3_key, + Body=json.dumps(definition).encode("utf-8")) + nenv.stack.tables.workflows.put_item(Item={ + "workflow_id": workflow_id, + "usecase_id": nenv.usecase_id, + "name": "merged-catalog-test", + "latest_version": 1, + "created_at": 1, + "updated_at": 1, + }) + nenv.stack.tables.versions.put_item(Item={ + "workflow_id": workflow_id, + "version": 1, + "s3_definition_key": s3_key, + "validation_status": {"status": "none"}, + "custom_node_types": custom_node_types or {}, + }) + return workflow_id + + +def validate_request(module, nenv, user, workflow_id): + event = { + "httpMethod": "POST", + "resource": "/workflows/{id}/validate", + "path": f"/workflows/{workflow_id}/validate", + "pathParameters": {"id": workflow_id}, + "body": json.dumps({}), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +# =========================================================================== +# 1. GET /workflows/node-catalog (8.2, 8.3, 9.2, 9.6, 14.3) +# =========================================================================== + +class TestNodeCatalogMerge: + + def test_without_usecase_the_builtin_catalog_is_served_unchanged( + self, nenv, validation_module): + from workflow_core.catalog import NODE_CATALOG + + status, body = catalog_request(validation_module, nenv.admin) + assert status == 200 + assert body["count"] == len(NODE_CATALOG) + assert {n["typeId"] for n in body["nodeTypes"]} == \ + {d.type_id for d in NODE_CATALOG} + assert all("lifecycleState" not in n for n in body["nodeTypes"]) + + def test_test_state_custom_type_is_served_with_test_marker( + self, nenv, validation_module): + register_custom_type(nenv, "custom.merge_test", "test") + + status, body = catalog_request(validation_module, nenv.admin, + nenv.usecase_id) + assert status == 200 + entry = by_type_id(body)["custom.merge_test"] + assert entry["lifecycleState"] == "test" # 9.6 + assert entry["category"] == "preprocessing" # 8.3 + # Same declaration structure as built-in types (8.2). + assert entry["inputs"] == [{"name": "in", "portType": "VideoFrames"}] + assert entry["outputs"] == [{"name": "out", "portType": "VideoFrames"}] + # Built-in entries never carry a marker. + assert "lifecycleState" not in by_type_id(body)["camera_source"] + + def test_prod_state_custom_type_is_served_without_marker( + self, nenv, validation_module): + register_custom_type(nenv, "custom.merge_prod", "prod") + + status, body = catalog_request(validation_module, nenv.admin, + nenv.usecase_id) + assert status == 200 + entry = by_type_id(body)["custom.merge_prod"] + assert "lifecycleState" not in entry + + def test_dev_state_custom_type_is_excluded(self, nenv, validation_module): + register_custom_type(nenv, "custom.merge_dev", "dev") + + status, body = catalog_request(validation_module, nenv.admin, + nenv.usecase_id) + assert status == 200 + assert "custom.merge_dev" not in by_type_id(body) # 9.2 + + def test_deprecated_custom_type_is_excluded_from_the_palette( + self, nenv, validation_module): + register_custom_type(nenv, "custom.merge_deprecated", "prod") + status, _ = nenv.deprecate(nenv.admin, "custom.merge_deprecated") + assert status == 200 + + status, body = catalog_request(validation_module, nenv.admin, + nenv.usecase_id) + assert status == 200 + assert "custom.merge_deprecated" not in by_type_id(body) # 14.3 + + def test_other_usecases_do_not_see_the_custom_type( + self, nenv, env, validation_module): + """Custom_Node_Types are scoped to their Use_Cases (8.2).""" + register_custom_type(nenv, "custom.merge_scoped", "prod") + + other_usecase = env.create_usecase("Other Use Case") + outsider = env.make_user() + env.assign_role(outsider, other_usecase, "DataScientist") + + status, body = catalog_request(validation_module, outsider, + other_usecase) + assert status == 200 + assert "custom.merge_scoped" not in by_type_id(body) + + +# =========================================================================== +# 2. Validation against the merged catalog (14.2, 14.3) +# =========================================================================== + +class TestValidationAgainstMergedCatalog: + + def test_workflow_with_registered_custom_node_validates_clean( + self, nenv, validation_module): + register_custom_type(nenv, "custom.merge_validate", "test") + workflow_id = seed_stored_workflow( + nenv, custom_workflow_definition("custom.merge_validate"), + custom_node_types={"custom.merge_validate": 1}) + + status, body = validate_request(validation_module, nenv, + nenv.admin, workflow_id) + assert status == 200, body + assert body["error_count"] == 0, body["findings"] + assert not any(f["code"] == "UNKNOWN_NODE_TYPE" + for f in body["findings"]) + + def test_deprecated_custom_node_still_validates(self, nenv, + validation_module): + """Deprecated types stay resolvable for existing workflows (14.3).""" + register_custom_type(nenv, "custom.merge_val_deprecated", "prod") + workflow_id = seed_stored_workflow( + nenv, custom_workflow_definition("custom.merge_val_deprecated"), + custom_node_types={"custom.merge_val_deprecated": 1}) + status, _ = nenv.deprecate(nenv.admin, "custom.merge_val_deprecated") + assert status == 200 + + status, body = validate_request(validation_module, nenv, + nenv.admin, workflow_id) + assert status == 200, body + assert body["error_count"] == 0, body["findings"] + + def test_unregistered_custom_node_is_an_unknown_type(self, nenv, + validation_module): + workflow_id = seed_stored_workflow( + nenv, custom_workflow_definition("custom.never_registered")) + + status, body = validate_request(validation_module, nenv, + nenv.admin, workflow_id) + assert status == 200, body + assert any(f["code"] == "UNKNOWN_NODE_TYPE" and f["nodeId"] == "n2" + for f in body["findings"]) + + +# =========================================================================== +# 3. Workflow save records the Custom_Node_Type version (14.2) +# =========================================================================== + +class TestSaveRecordsCustomNodeTypeVersions: + + def _save(self, nenv, user, definition, name="merged-save-test"): + event = { + "httpMethod": "POST", + "resource": "/workflows", + "path": "/workflows", + "pathParameters": None, + "queryStringParameters": None, + "body": json.dumps({"usecase_id": nenv.usecase_id, + "name": name, "definition": definition}), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = nenv.stack.workflows.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def _version_item(self, nenv, workflow_id, version): + response = nenv.stack.tables.versions.get_item( + Key={"workflow_id": workflow_id, "version": version}) + return response["Item"] + + def test_save_records_the_custom_node_type_version_map(self, nenv): + register_custom_type(nenv, "custom.merge_save", "test") + + status, body = self._save( + nenv, nenv.admin, custom_workflow_definition("custom.merge_save")) + assert status == 201, body + + item = self._version_item(nenv, body["workflow"]["workflow_id"], 1) + assert item["custom_node_types"] == {"custom.merge_save": 1} + + def test_save_records_the_latest_registered_version(self, nenv): + plugin = register_custom_type(nenv, "custom.merge_save_v2", "test") + # A declaration update creates version 2 (14.1); a save afterwards + # pins version 2 (14.2: the version used). + status, _ = nenv.update( + nenv.admin, "custom.merge_save_v2", + declaration=make_declaration("custom.merge_save_v2"), + plugin_version=plugin["version"]) + assert status == 201 + + status, body = self._save( + nenv, nenv.admin, + custom_workflow_definition("custom.merge_save_v2"), + name="merged-save-v2") + assert status == 201, body + + item = self._version_item(nenv, body["workflow"]["workflow_id"], 1) + assert item["custom_node_types"] == {"custom.merge_save_v2": 2} + + def test_builtin_only_save_records_an_empty_map(self, nenv): + definition = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], + } + status, body = self._save(nenv, nenv.admin, definition, + name="builtin-only") + assert status == 201, body + + item = self._version_item(nenv, body["workflow"]["workflow_id"], 1) + assert item["custom_node_types"] == {} + + def test_recorded_references_block_custom_node_type_removal(self, nenv): + """The recorded map is what the removal reference scan honors + (14.4/14.5 wiring: no GSI, map attribute at save).""" + register_custom_type(nenv, "custom.merge_save_refs", "test") + status, body = self._save( + nenv, nenv.admin, + custom_workflow_definition("custom.merge_save_refs"), + name="merged-save-refs") + assert status == 201, body + workflow_id = body["workflow"]["workflow_id"] + + nenv.patch_usecase_clients() + status, body = nenv.remove(nenv.admin, "custom.merge_save_refs") + assert status == 409 + assert body["error"]["code"] == "CUSTOM_NODE_TYPE_IN_USE" + referencing = body["error"]["details"]["referencing_workflows"] + assert {"workflow_id": workflow_id, "version": 1} in referencing + + +# =========================================================================== +# 4. Generation system prompt embeds the merged catalog +# =========================================================================== + +class TestGeneratorMergedCatalog: + + def test_system_prompt_embeds_registered_custom_types(self, nenv): + sys.modules.pop("workflow_generator", None) + import workflow_generator + + register_custom_type(nenv, "custom.merge_generate", "test") + + catalog, _ = workflow_generator.palette_catalog_for_usecase( + nenv.usecase_id) + prompt = workflow_generator.build_system_prompt(catalog) + assert '"custom.merge_generate"' in prompt + # Built-ins remain embedded alongside the custom types. + assert '"camera_source"' in prompt + + def test_default_prompt_stays_builtin_only(self, nenv): + sys.modules.pop("workflow_generator", None) + import workflow_generator + + prompt = workflow_generator.build_system_prompt() + assert workflow_generator.serialized_catalog_json() in prompt + + +# =========================================================================== +# 5. Pure merge/marker/exclusion logic (tasks 9.3/9.4 property-test these) +# =========================================================================== + +def make_item(type_id, version=1, plugin_id="p1", plugin_version=1, + deprecated=False): + return { + "node_type_id": type_id, + "version": version, + "usecase_id": "uc-1", + "usecase_ids": ["uc-1"], + "plugin_id": plugin_id, + "plugin_version": plugin_version, + "declaration": make_declaration(type_id), + "deprecated": deprecated, + } + + +class TestPureResolutionLogic: + + def test_palette_entries_filter_lifecycle_and_deprecation(self): + from node_catalog_resolution import palette_entries + + items = [ + make_item("custom.dev", plugin_id="pd"), + make_item("custom.test", plugin_id="pt"), + make_item("custom.prod", plugin_id="pp"), + make_item("custom.deprecated", plugin_id="px", deprecated=True), + make_item("custom.unknown_state", plugin_id="pu"), + ] + states = {("pd", 1): "dev", ("pt", 1): "test", ("pp", 1): "prod", + ("px", 1): "prod"} + + entries = palette_entries(items, states) + assert [(i["node_type_id"], m) for i, m in entries] == [ + ("custom.prod", None), + ("custom.test", "test"), + ] + + def test_palette_serves_only_the_latest_version(self): + from node_catalog_resolution import palette_entries + + items = [make_item("custom.v", version=1), + make_item("custom.v", version=3), + make_item("custom.v", version=2)] + entries = palette_entries(items, {("p1", 1): "prod"}) + assert [(i["node_type_id"], i["version"]) for i, _ in entries] == \ + [("custom.v", 3)] + + def test_resolution_honors_pinned_versions(self): + from node_catalog_resolution import resolution_items + + items = [make_item("custom.pin", version=1), + make_item("custom.pin", version=2)] + assert [i["version"] for i in resolution_items(items)] == [2] + assert [i["version"] + for i in resolution_items(items, {"custom.pin": 1})] == [1] + # A pin to a vanished version falls back to the latest. + assert [i["version"] + for i in resolution_items(items, {"custom.pin": 9})] == [2] + + def test_resolution_keeps_deprecated_and_dev_types(self): + from node_catalog_resolution import resolution_items + + items = [make_item("custom.gone", deprecated=True)] + assert [i["node_type_id"] for i in resolution_items(items)] == \ + ["custom.gone"] + + def test_referenced_node_type_versions_ignores_builtins(self): + from node_catalog_resolution import referenced_node_type_versions + + items = [make_item("custom.ref", version=4)] + definition = custom_workflow_definition("custom.ref") + assert referenced_node_type_versions(definition, items) == \ + {"custom.ref": 4} + + def test_referenced_node_type_versions_skips_unregistered_types(self): + from node_catalog_resolution import referenced_node_type_versions + + definition = custom_workflow_definition("custom.unregistered") + assert referenced_node_type_versions(definition, []) == {} + + def test_builtins_win_on_type_id_collision(self): + from node_catalog_resolution import resolve_palette_catalog + from workflow_core.catalog import NODE_CATALOG + + impostor = make_item("camera_source") + merged, markers = resolve_palette_catalog( + [impostor], {("p1", 1): "test"}) + assert merged == NODE_CATALOG + assert markers == {} diff --git a/edge-cv-portal/backend/tests/test_node_catalog_wire.py b/edge-cv-portal/backend/tests/test_node_catalog_wire.py new file mode 100644 index 00000000..66a451d0 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_node_catalog_wire.py @@ -0,0 +1,147 @@ +""" +Node catalog wire serialization (workflow-manager Requirement 2.8 plus +the per-parameter help descriptions). + +GET /workflows/node-catalog (functions/workflow_validation.py) serves the +catalog in its camelCase wire form for the frontend Node_Palette and the +node configuration panel. Covered here: + +1. Every catalog parameter serializes a non-empty ``description`` string + (field-level help rendered under the control's label). +2. The wire shape of one parameter carries exactly the documented keys, + with snake_case constraint keys mapped to camelCase. +3. The ``conditional`` node type is served with its two typed output + ports ("true"/"false") and a ``condition`` parameter whose description + documents the rule-expression language (fields, operators, and the + worked example) exactly as the shared evaluator supports it. +""" +import json +import sys + +import pytest + + +@pytest.fixture(scope="module") +def validation_module(aws_stack): + """functions/workflow_validation.py imported inside the moto stack.""" + sys.modules.pop("workflow_validation", None) + import workflow_validation + + return workflow_validation + + +def catalog_response(module): + event = { + "httpMethod": "GET", + "resource": "/workflows/node-catalog", + "path": "/workflows/node-catalog", + "requestContext": { + "authorizer": { + "claims": { + "sub": "user-1", + "email": "user-1@example.com", + "cognito:username": "user-1", + "custom:role": "DataScientist", + } + } + }, + } + response = module.handler(event, None) + assert response["statusCode"] == 200 + return json.loads(response["body"]) + + +class TestParameterDescriptions: + def test_every_parameter_serializes_a_nonempty_description(self, validation_module): + body = catalog_response(validation_module) + assert body["count"] == len(body["nodeTypes"]) + for node_type in body["nodeTypes"]: + for parameter in node_type["parameters"]: + description = parameter.get("description") + assert isinstance(description, str) and description.strip(), ( + "{0}.{1} has no description".format( + node_type["typeId"], parameter["name"])) + + def test_every_parameter_serializes_nonempty_examples(self, validation_module): + # Field-level help: every parameter ships at least one working + # example value the configuration panel can offer verbatim. + body = catalog_response(validation_module) + for node_type in body["nodeTypes"]: + for parameter in node_type["parameters"]: + examples = parameter.get("examples") + assert isinstance(examples, list) and examples, ( + "{0}.{1} has no examples".format( + node_type["typeId"], parameter["name"])) + + def test_parameter_wire_shape(self, validation_module): + from workflow_core.catalog import ParameterDescriptor + + wire = validation_module.parameter_to_wire(ParameterDescriptor( + "location", "string", required=True, default=None, + constraints={"min_length": 1}, + description="Path of the folder to read, e.g. /data/images.", + examples=["/data/images"], + )) + assert wire == { + "name": "location", + "paramType": "string", + "required": True, + "default": None, + "constraints": {"minLength": 1}, + "dependsOn": None, + "description": "Path of the folder to read, e.g. /data/images.", + "examples": ["/data/images"], + } + + def test_help_fields_default_to_none_for_older_descriptors(self, validation_module): + # Backward compatibility: descriptors built without a description + # or examples serialize description/examples: null rather than + # failing. + from workflow_core.catalog import ParameterDescriptor + + wire = validation_module.parameter_to_wire( + ParameterDescriptor("pin", "int", required=True)) + assert wire["description"] is None + assert wire["examples"] is None + + +class TestConditionalNodeOnTheWire: + def _node_type(self, body, type_id): + by_id = {n["typeId"]: n for n in body["nodeTypes"]} + assert type_id in by_id, sorted(by_id) + return by_id[type_id] + + def test_conditional_two_typed_output_ports(self, validation_module): + body = catalog_response(validation_module) + conditional = self._node_type(body, "conditional") + assert conditional["displayName"] == "Conditional" + assert conditional["category"] == "post_processing" + assert conditional["inputs"] == [ + {"name": "in", "portType": "InferenceMeta"}] + assert conditional["outputs"] == [ + {"name": "true", "portType": "InferenceMeta"}, + {"name": "false", "portType": "InferenceMeta"}, + ] + for mapping in conditional["mappings"]: + assert mapping["executorBinding"] == "conditional" + + @pytest.mark.parametrize("type_id", ["conditional", "inference_filter"]) + def test_condition_description_documents_the_expression_language( + self, validation_module, type_id): + body = catalog_response(validation_module) + node_type = self._node_type(body, type_id) + condition = next(p for p in node_type["parameters"] + if p["name"] == "condition") + description = condition["description"] + # Fields the shared evaluator resolves from the inference metadata. + assert "is_anomalous" in description + assert "confidence" in description + # Operators (comparisons and logic). + for operator in ("==", "!=", ">=", "<=", "&&", "||"): + assert operator in description, operator + # A worked example in the documented grammar. + assert "is_anomalous == true && confidence >= 0.8" in description + # Plus 3+ working example expressions served as `examples`. + examples = condition["examples"] + assert isinstance(examples, list) and len(examples) >= 3 + assert "is_anomalous == true" in examples diff --git a/edge-cv-portal/backend/tests/test_node_designer_rbac_audit.py b/edge-cv-portal/backend/tests/test_node_designer_rbac_audit.py new file mode 100644 index 00000000..f6540fdf --- /dev/null +++ b/edge-cv-portal/backend/tests/test_node_designer_rbac_audit.py @@ -0,0 +1,381 @@ +""" +RBAC and audit tests for the Node_Designer feature area +(custom-node-designer task 3.4). + +Covers Requirements 10.3, 13.1, 13.2, 13.3, 13.4, and 13.5 with: + +1. A parameterized role x action permission-resolution matrix over the + ten Requirement-13 actions (create, generate, import, simulate, + register, promote, demote, approve, update, remove) for every role + (UseCaseAdmin, PortalAdmin, DataScientist, Operator, Viewer), + exercised directly against `rbac_manager` / `Permission`. This layer + covers actions whose HTTP handlers land in later tasks (4.x import, + 5.x generate, 8.x simulate, 9.x register/deprecate/remove). + +2. A request-level matrix against the operations implemented today in + plugin_records.py (create / update / promote / demote / approve / + reject), asserting permitted roles succeed and denied roles receive + the standard authorization error envelope (13.4). + +3. Audit-log assertions: every implemented operation writes the + existing AuditLog table with action, acting user, and timestamp + (10.3, 13.5), and denials write an `unauthorized_access` entry. + +Extension point: when the pending handlers exist, add entries to +REQUEST_OPERATIONS below (prepare / invoke / allowed_roles / +denied_permission / audit_action) and the request-level and audit +matrices pick them up automatically. The pending operations and their +expected owners are listed next to REQUEST_OPERATIONS. + +Runs against the moto-backed stack from conftest.py, exercising the +real RBAC / audit / persistence code paths. +""" +import pytest + +from test_plugin_records import PluginRecordsEnv + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture +def penv(aws_stack): + return PluginRecordsEnv(aws_stack) + + +@pytest.fixture +def shared(aws_stack): + """The real shared_utils module imported inside the moto mock. + + Imported lazily (after aws_stack re-imports it) so the Permission / + Role enum classes match the ones rbac_manager was built with. + """ + import shared_utils + return shared_utils + + +def actor_with_role(penv, usecase_id, role_name): + """A fresh user holding `role_name` for `usecase_id`. + + PortalAdmin arrives via the JWT custom:role claim (global role); + every other role via a Use_Case assignment in the UserRoles table, + mirroring production role resolution. + """ + if role_name == "PortalAdmin": + return penv.make_user(role="PortalAdmin") + user = penv.make_user(role="Viewer") + penv.assign_role(user, usecase_id, role_name) + return user + + +# ----------------------------------------------------- role x action matrix + +ROLES = ("UseCaseAdmin", "PortalAdmin", "DataScientist", "Operator", "Viewer") + +# The ten Requirement-13 actions and the registered RBAC permission each +# one resolves to (task 3.3 / design "Access control and audit"). +MATRIX_ACTIONS = { + "create": "node-designer:create", + "generate": "node-designer:generate", + "import": "node-designer:import", + "simulate": "node-designer:simulate", + "register": "node-designer:register", + "promote": "node-designer:promote-demote", + "demote": "node-designer:promote-demote", + "approve": "node-designer:security-review", + "update": "node-designer:manage", + "remove": "node-designer:manage", +} + +# Expected grants per role: UseCaseAdmin holds the manage family within +# its own Use_Case (13.1); PortalAdmin holds everything including +# security review (13.1, 13.2); DataScientist / Operator / Viewer hold +# none of the mutating actions (13.3, 13.4). +ROLE_ALLOWED_ACTIONS = { + "UseCaseAdmin": set(MATRIX_ACTIONS) - {"approve"}, + "PortalAdmin": set(MATRIX_ACTIONS), + "DataScientist": set(), + "Operator": set(), + "Viewer": set(), +} + + +class TestPermissionResolutionMatrix: + """Role x action resolution through rbac_manager (13.1-13.4). + + Runs directly against permission resolution so it covers actions + whose HTTP handlers are not implemented yet. + """ + + @pytest.mark.parametrize("role_name", ROLES) + @pytest.mark.parametrize("action", sorted(MATRIX_ACTIONS)) + def test_role_action_matrix(self, penv, shared, role_name, action): + usecase_id = penv.create_usecase() + actor = actor_with_role(penv, usecase_id, role_name) + permission = shared.Permission(MATRIX_ACTIONS[action]) + expected = action in ROLE_ALLOWED_ACTIONS[role_name] + granted = shared.rbac_manager.has_permission( + actor["user_id"], usecase_id, permission, user_info=actor) + assert granted is expected, ( + f"{role_name} {'should' if expected else 'should not'} " + f"hold {permission.value} (action '{action}')" + ) + + @pytest.mark.parametrize("role_name", ROLES) + def test_every_role_holds_read(self, penv, shared, role_name): + """node-designer:read is granted to every role (13.3).""" + usecase_id = penv.create_usecase() + actor = actor_with_role(penv, usecase_id, role_name) + assert shared.rbac_manager.has_permission( + actor["user_id"], usecase_id, + shared.Permission.NODE_DESIGNER_READ, user_info=actor) + + def test_usecase_admin_grants_do_not_cross_usecases(self, penv, shared): + """UseCaseAdmin manage grants are scoped to the own Use_Case (13.1).""" + usecase_a = penv.create_usecase("Use Case A") + usecase_b = penv.create_usecase("Use Case B") + admin_of_a = actor_with_role(penv, usecase_a, "UseCaseAdmin") + for value in sorted({MATRIX_ACTIONS[a] for a in MATRIX_ACTIONS + if a != "approve"}): + permission = shared.Permission(value) + assert shared.rbac_manager.has_permission( + admin_of_a["user_id"], usecase_a, permission, + user_info=admin_of_a) + assert not shared.rbac_manager.has_permission( + admin_of_a["user_id"], usecase_b, permission, + user_info=admin_of_a) + + +# ------------------------------------------- request-level operation registry + +def _prepare_none(penv, usecase_id, admin): + return {} + + +def _prepare_dev_record(penv, usecase_id, admin): + status, body = penv.create_plugin(admin, usecase_id) + assert status == 201 + return {"plugin_id": body["plugin"]["plugin_id"]} + + +def _prepare_dev_record_with_build(penv, usecase_id, admin): + ctx = _prepare_dev_record(penv, usecase_id, admin) + penv.seed_artifact(ctx["plugin_id"], 1) + return ctx + + +def _prepare_test_record(penv, usecase_id, admin): + ctx = _prepare_dev_record_with_build(penv, usecase_id, admin) + assert penv.promote(admin, ctx["plugin_id"], 1)[0] == 200 + return ctx + + +# Operations whose handlers exist today (plugin_records.py, task 3.1). +# Pending operations and their owning handlers -- add an entry here when +# each handler lands so it joins the request-level and audit matrices: +# generate -> node_generator.py (task 5.x) audit: generate_plugin_scaffold +# import -> plugin_importer.py (task 4.x) audit: import_plugin +# simulate -> plugin_simulator.py (task 8.x) audit: simulate_plugin +# register -> custom_node_types.py (task 9.x) audit: register_custom_node_type +# deprecate -> custom_node_types.py (task 9.x) audit: deprecate_custom_node_type +# remove -> custom_node_types.py (task 9.x) audit: remove_custom_node_type +REQUEST_OPERATIONS = { + "create": dict( + audit_action="create_plugin_record", + success_status=201, + prepare=_prepare_none, + invoke=lambda penv, actor, usecase_id, ctx: + penv.create_plugin(actor, usecase_id), + allowed_roles={"UseCaseAdmin", "PortalAdmin"}, + denied_permission="node-designer:create", + ), + "update": dict( + audit_action="update_plugin_record", + success_status=200, + prepare=_prepare_dev_record, + invoke=lambda penv, actor, usecase_id, ctx: + penv.invoke("PUT", "/plugins/{id}", actor, ctx["plugin_id"], + body={"description": "updated by rbac matrix"}), + allowed_roles={"UseCaseAdmin", "PortalAdmin"}, + denied_permission="node-designer:manage", + ), + "promote": dict( + audit_action="promote_plugin_record", + success_status=200, + prepare=_prepare_dev_record_with_build, + invoke=lambda penv, actor, usecase_id, ctx: + penv.promote(actor, ctx["plugin_id"], 1), + allowed_roles={"UseCaseAdmin", "PortalAdmin"}, + denied_permission="node-designer:promote-demote", + ), + "demote": dict( + audit_action="demote_plugin_record", + success_status=200, + prepare=_prepare_test_record, + invoke=lambda penv, actor, usecase_id, ctx: + penv.demote(actor, ctx["plugin_id"], 1), + allowed_roles={"UseCaseAdmin", "PortalAdmin"}, + denied_permission="node-designer:promote-demote", + ), + "approve": dict( + audit_action="security_review_approved", + success_status=200, + prepare=_prepare_dev_record, + invoke=lambda penv, actor, usecase_id, ctx: + penv.review(actor, ctx["plugin_id"], 1, "approved"), + allowed_roles={"PortalAdmin"}, + denied_permission="node-designer:security-review", + ), + "reject": dict( + audit_action="security_review_rejected", + success_status=200, + prepare=_prepare_dev_record, + invoke=lambda penv, actor, usecase_id, ctx: + penv.review(actor, ctx["plugin_id"], 1, "rejected"), + allowed_roles={"PortalAdmin"}, + denied_permission="node-designer:security-review", + ), +} + + +# --------------------------------------------- request-level RBAC matrix + +class TestRequestLevelMatrix: + """Role x operation over the live plugin_records endpoints. + + Permitted roles complete the operation (13.1, 13.2); every other + role is denied with the standard authorization error envelope + (13.4). + """ + + @pytest.mark.parametrize("role_name", ROLES) + @pytest.mark.parametrize("op_name", sorted(REQUEST_OPERATIONS)) + def test_role_operation(self, penv, op_name, role_name): + op = REQUEST_OPERATIONS[op_name] + usecase_id = penv.create_usecase() + setup_admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = op["prepare"](penv, usecase_id, setup_admin) + actor = actor_with_role(penv, usecase_id, role_name) + + status, body = op["invoke"](penv, actor, usecase_id, ctx) + + if role_name in op["allowed_roles"]: + assert status == op["success_status"], ( + f"{role_name} should be permitted to {op_name}: {body}") + else: + # Standard authorization error envelope (13.4) + assert status == 403, ( + f"{role_name} should be denied {op_name}, got {status}: {body}") + assert body["error"]["code"] == "FORBIDDEN" + assert body["error"]["message"] == "Insufficient permissions" + assert body["error"]["details"]["required_permissions"] == [ + op["denied_permission"]] + + @pytest.mark.parametrize("op_name", + sorted(set(REQUEST_OPERATIONS) - {"create"})) + def test_usecase_admin_of_other_usecase_denied(self, penv, op_name): + """A UseCaseAdmin of a different Use_Case cannot manage this + Use_Case's Plugin_Records (13.1: own Use_Case only).""" + op = REQUEST_OPERATIONS[op_name] + usecase_id = penv.create_usecase("Owning Use Case") + setup_admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = op["prepare"](penv, usecase_id, setup_admin) + + other_usecase = penv.create_usecase("Other Use Case") + outsider = actor_with_role(penv, other_usecase, "UseCaseAdmin") + + status, body = op["invoke"](penv, outsider, usecase_id, ctx) + assert status in (403, 404), ( + f"Foreign UseCaseAdmin must not {op_name}, got {status}: {body}") + + @pytest.mark.parametrize("role_name", ROLES) + def test_every_role_reads_plugin_records(self, penv, role_name): + """All five roles view Plugin_Records read-only (13.3).""" + usecase_id = penv.create_usecase() + setup_admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = _prepare_dev_record(penv, usecase_id, setup_admin) + actor = actor_with_role(penv, usecase_id, role_name) + + status, body = penv.invoke("GET", "/plugins", actor, + query={"usecase_id": usecase_id}) + assert status == 200 + assert ctx["plugin_id"] in [p["plugin_id"] for p in body["plugins"]] + + status, body = penv.invoke("GET", "/plugins/{id}", actor, + ctx["plugin_id"]) + assert status == 200 + assert body["plugin"]["plugin_id"] == ctx["plugin_id"] + + +# ------------------------------------------------- audit writes (10.3, 13.5) + +class TestAuditWrites: + """Every operation writes the existing AuditLog table with the + action, the acting user, and a timestamp (10.3, 13.5).""" + + @pytest.mark.parametrize("op_name", sorted(REQUEST_OPERATIONS)) + def test_operation_writes_audit_entry(self, penv, op_name): + op = REQUEST_OPERATIONS[op_name] + usecase_id = penv.create_usecase() + setup_admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = op["prepare"](penv, usecase_id, setup_admin) + role = "PortalAdmin" if op["allowed_roles"] == {"PortalAdmin"} \ + else "UseCaseAdmin" + actor = actor_with_role(penv, usecase_id, role) + + status, body = op["invoke"](penv, actor, usecase_id, ctx) + assert status == op["success_status"], body + + resource_id = ctx.get("plugin_id") or body["plugin"]["plugin_id"] + entries = [e for e in penv.audit_entries(op["audit_action"]) + if e["resource_id"] == resource_id + and e["user_id"] == actor["user_id"]] + assert len(entries) == 1, ( + f"expected exactly one '{op['audit_action']}' audit entry " + f"for {resource_id} by {actor['user_id']}") + entry = entries[0] + # Action, acting user, timestamp (13.5) + assert entry["action"] == op["audit_action"] + assert entry["user_id"] == actor["user_id"] + assert entry["timestamp"] > 0 + assert entry["result"] == "success" + assert entry["details"]["usecase_id"] == usecase_id + + def test_new_version_update_writes_audit_entry(self, penv): + """Creating a new version via PUT (the 'update' flavor that + versions the record) is audited (13.5).""" + usecase_id = penv.create_usecase() + admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = _prepare_dev_record(penv, usecase_id, admin) + + status, _ = penv.invoke("PUT", "/plugins/{id}", admin, + ctx["plugin_id"], body={"new_version": True}) + assert status == 201 + + entries = [e for e in penv.audit_entries("create_plugin_record_version") + if e["resource_id"] == ctx["plugin_id"]] + assert len(entries) == 1 + assert entries[0]["user_id"] == admin["user_id"] + assert entries[0]["timestamp"] > 0 + + @pytest.mark.parametrize("op_name", sorted(REQUEST_OPERATIONS)) + def test_denied_operation_writes_unauthorized_access_entry(self, penv, op_name): + """RBAC denials are themselves audited as unauthorized_access.""" + op = REQUEST_OPERATIONS[op_name] + usecase_id = penv.create_usecase() + setup_admin = actor_with_role(penv, usecase_id, "UseCaseAdmin") + ctx = op["prepare"](penv, usecase_id, setup_admin) + denied_role = "Viewer" if "UseCaseAdmin" in op["allowed_roles"] \ + else "UseCaseAdmin" + actor = actor_with_role(penv, usecase_id, denied_role) + + status, _ = op["invoke"](penv, actor, usecase_id, ctx) + assert status == 403 + + entries = [e for e in penv.audit_entries("unauthorized_access") + if e["user_id"] == actor["user_id"]] + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "denied" + assert entry["timestamp"] > 0 + assert entry["details"]["required_permissions"] == [ + op["denied_permission"]] diff --git a/edge-cv-portal/backend/tests/test_node_generator.py b/edge-cv-portal/backend/tests/test_node_generator.py new file mode 100644 index 00000000..303e3fbf --- /dev/null +++ b/edge-cv-portal/backend/tests/test_node_generator.py @@ -0,0 +1,566 @@ +""" +Node_Generator sanity tests (custom-node-designer, task 5.1). + +Basic unit coverage of functions/node_generator.py: prompt/tool assembly +helpers, request validation, RBAC denial, and the asynchronous start/poll +generation flow - the start routes return 202 and dispatch a self-invoked +worker; GET /plugins/generate/{session} polls the turn state until it +settles (with invoke_generation stubbed and the async dispatch wired to +run the worker synchronously in-process - the full mocked-Converse +integration suite is task 5.2). + +_Requirements: 2.1, 2.2, 2.4, 2.5, 2.6, 2.7_ +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION, TEST_ENV + +NODE_GEN_SESSIONS_TABLE_NAME = "test-node-gen-sessions" +SETTINGS_TABLE_NAME = "test-settings-node-generator" + +TOOL_NAME = "create_plugin_scaffold" + + +def make_declaration(**overrides): + """A valid Custom_Node_Type declaration (wire shape + architectures).""" + declaration = { + "typeId": "custom_blur", + "displayName": "Custom Blur", + "description": "Blurs each frame.", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [ + { + "name": "radius", + "paramType": "int", + "required": True, + "default": 3, + "constraints": {"min": 1, "max": 99}, + "description": "Blur radius in pixels.", + "examples": [3], + }, + ], + "architectures": ["x86_64", "arm64_jp5"], + } + declaration.update(overrides) + return declaration + + +@pytest.fixture(scope="module") +def gen_env(aws_stack): + """NodeGenSessions + settings tables and a freshly imported + node_generator module bound to them inside moto.""" + import boto3 + + os.environ["NODE_GEN_SESSIONS_TABLE"] = NODE_GEN_SESSIONS_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + os.environ["PLUGIN_SOURCES_PREFIX"] = "plugin-sources" + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=NODE_GEN_SESSIONS_TABLE_NAME, + KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "session_id", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + for module_name in ("node_generator", "workflow_generator", + "workflow_validation"): + sys.modules.pop(module_name, None) + import node_generator + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=node_generator, + stack=aws_stack, + sessions_table=resource.Table(NODE_GEN_SESSIONS_TABLE_NAME), + s3=boto3.client("s3", region_name=REGION), + bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + ) + + +def make_user(): + user_id = f"user-{uuid.uuid4()}" + return {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": "UseCaseAdmin"} + + +def make_usecase(gen_env): + usecase_id = f"uc-{uuid.uuid4()}" + gen_env.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, "name": "UC", "account_id": "123456789012", + }) + return usecase_id + + +def grant_admin(gen_env, user, usecase_id): + gen_env.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], "usecase_id": usecase_id, + "role": "UseCaseAdmin", + }) + + +def api_event(resource, user, body=None, session_id=None, method="POST"): + return { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{session}", session_id or ""), + "pathParameters": {"session": session_id} if session_id else None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + +def invoke(gen_env, resource, user, body=None, session_id=None, method="POST"): + response = gen_env.module.handler( + api_event(resource, user, body, session_id, method), None) + return response["statusCode"], json.loads(response["body"]) + + +def poll(gen_env, user, session_id): + """GET /plugins/generate/{session} - poll the current generation turn.""" + return invoke(gen_env, "/plugins/generate/{session}", user, + session_id=session_id, method="GET") + + +def wire_synchronous_worker(gen_env, monkeypatch): + """Replace the async self-invocation with an in-process worker run, so + the dispatched turn settles before the start route's 202 returns.""" + def dispatch(session_id, prompt, user): + gen_env.module.handler({ + "node_gen_worker": True, + "session_id": session_id, + "prompt": prompt, + "user": user, + }, None) + monkeypatch.setattr(gen_env.module, "dispatch_generation_worker", dispatch) + + +# --------------------------------------------------------------------------- +# Prompt and tool assembly (2.2, 2.4) +# --------------------------------------------------------------------------- + +class TestPromptAndToolAssembly: + def test_tool_config_forces_create_plugin_scaffold(self, gen_env): + from workflow_core.scaffold import render_scaffold + declaration = make_declaration() + reference = render_scaffold(declaration) + config = gen_env.module.build_tool_config(declaration, reference) + + assert config["toolChoice"] == {"tool": {"name": TOOL_NAME}} + assert config["tools"][0]["toolSpec"]["name"] == TOOL_NAME + + def test_tool_schema_requires_every_scaffold_file(self, gen_env): + from workflow_core.scaffold import ( + HOOK_FILE, README_FILE, build_config_path, c_source_path, + render_scaffold, + ) + declaration = make_declaration() + reference = render_scaffold(declaration) + schema = gen_env.module.build_tool_config( + declaration, reference)["tools"][0]["toolSpec"]["inputSchema"]["json"] + + files_schema = schema["properties"]["files"] + assert "files" in schema["required"] + expected = {HOOK_FILE, README_FILE, c_source_path(declaration), + build_config_path("x86_64"), build_config_path("arm64_jp5")} + assert set(files_schema["required"]) == expected + assert set(files_schema["properties"]) == expected + + def test_system_prompt_embeds_conventions_and_hook_contract(self, gen_env): + from workflow_core.scaffold import HOOK_FILE, render_scaffold + declaration = make_declaration() + reference = render_scaffold(declaration) + prompt = gen_env.module.build_system_prompt(declaration, reference) + + # Frame_Processing_Hook contract (2.2) + assert "process_frame(frame, params)" in prompt + assert HOOK_FILE in prompt + # Declaration and the rendered template conventions (2.2) + assert json.dumps(declaration, sort_keys=True) in prompt + assert json.dumps(reference, sort_keys=True) in prompt + + def test_first_user_message_is_the_bare_prompt(self, gen_env): + assert gen_env.module.build_user_message("blur frames", None) == "blur frames" + + def test_followup_message_embeds_source_and_instructs_modification(self, gen_env): + source_json = json.dumps({"plugin/frame_processing_hook.py": "code"}) + text = gen_env.module.build_user_message("make it stronger", source_json) + assert "make it stronger" in text + assert source_json in text + assert "rather" in text and "scratch" in text # modify, not regenerate (2.4) + + +# --------------------------------------------------------------------------- +# Request validation and RBAC +# --------------------------------------------------------------------------- + +class TestRequestValidation: + def test_missing_fields_rejected(self, gen_env): + user = make_user() + status, body = invoke(gen_env, "/plugins/generate", user, + {"usecase_id": "uc-x"}) + assert status == 400 + assert body["error"]["code"] == "MISSING_FIELDS" + + def test_invalid_declaration_identifies_field(self, gen_env): + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": make_declaration(typeId="---"), + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_DECLARATION" + assert body["error"]["details"]["field"] == "typeId" + + def test_generate_denied_without_node_designer_generate(self, gen_env): + user = make_user() + user["role"] = "Viewer" + usecase_id = make_usecase(gen_env) + gen_env.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], "usecase_id": usecase_id, + "role": "Viewer", + }) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": make_declaration(), + }) + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + def test_unknown_route_is_404(self, gen_env): + user = make_user() + response = gen_env.module.handler({ + "httpMethod": "GET", + "resource": "/plugins/generate", + "requestContext": api_event("/x", user)["requestContext"], + }, None) + assert response["statusCode"] == 404 + + def test_unknown_session_is_404(self, gen_env): + user = make_user() + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + user, {"prompt": "again"}, session_id="nope") + assert status == 404 + assert body["error"]["code"] == "SESSION_NOT_FOUND" + + +# --------------------------------------------------------------------------- +# Async start/poll flow: turn persistence, scaffold-validation rejection, +# and error surfacing through the poll route (2.3, 2.6, 2.7) +# (invoke_generation stubbed; the mocked-Converse suite is task 5.2) +# --------------------------------------------------------------------------- + +def stub_generation(gen_env, monkeypatch, tool_input): + monkeypatch.setattr( + gen_env.module, "invoke_generation", + lambda config, system_prompt, messages, tool_config: + (tool_input, "done", None)) + + +class TestGenerationTurn: + def _generate(self, gen_env, monkeypatch, declaration=None): + from workflow_core.scaffold import render_scaffold + declaration = declaration or make_declaration() + files = render_scaffold(declaration) + stub_generation(gen_env, monkeypatch, {"files": files}) + wire_synchronous_worker(gen_env, monkeypatch) + + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": declaration, + }) + return user, usecase_id, files, status, body + + def test_start_returns_202_and_poll_returns_files(self, gen_env, monkeypatch): + user, usecase_id, files, status, body = self._generate(gen_env, monkeypatch) + # The start route answers 202 immediately (well under the API + # Gateway 29 s cap); the result arrives through the poll route. + assert status == 202 + assert body["turn_status"] == "pending" + assert body["usecase_id"] == usecase_id + + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "completed" + assert turn["files"] == files + assert turn["assistant_text"] == "done" + assert turn["usecase_id"] == usecase_id + + session = gen_env.sessions_table.get_item( + Key={"session_id": body["session_id"]}).get("Item") + assert session is not None + assert session["user_id"] == user["user_id"] + assert session["turn_status"] == "completed" + assert [m["role"] for m in session["messages"]] == ["user", "assistant"] + assert session["messages"][0]["text"] == "blur frames" + + snapshot = gen_env.s3.get_object( + Bucket=gen_env.bucket, Key=session["current_source_key"]) + assert json.loads(snapshot["Body"].read()) == files + + def test_start_dispatches_worker_and_stays_pending_until_it_runs( + self, gen_env, monkeypatch): + from workflow_core.scaffold import render_scaffold + declaration = make_declaration() + dispatched = {} + monkeypatch.setattr( + gen_env.module, "dispatch_generation_worker", + lambda session_id, prompt, user: dispatched.update( + {"session_id": session_id, "prompt": prompt, "user": user})) + + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": declaration, + }) + assert status == 202 + assert dispatched["session_id"] == body["session_id"] + assert dispatched["prompt"] == "blur frames" + + # The turn stays pending until the (not yet run) worker settles it. + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "pending" + assert "files" not in turn + + # A second prompt cannot race the in-flight turn. + status, conflict = invoke( + gen_env, "/plugins/generate/{session}/message", user, + {"prompt": "again"}, session_id=body["session_id"]) + assert status == 409 + assert conflict["error"]["code"] == "GENERATION_IN_PROGRESS" + + # Running the dispatched worker settles the turn. + stub_generation(gen_env, monkeypatch, {"files": render_scaffold(declaration)}) + gen_env.module.handler({ + "node_gen_worker": True, + "session_id": dispatched["session_id"], + "prompt": dispatched["prompt"], + "user": dispatched["user"], + }, None) + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "completed" + + def test_invalid_output_fails_turn_with_defects_and_untouched_history( + self, gen_env, monkeypatch): + # Missing hook file -> not a buildable Plugin_Scaffold (2.6) + declaration = make_declaration() + from workflow_core.scaffold import HOOK_FILE, render_scaffold + files = dict(render_scaffold(declaration)) + files.pop(HOOK_FILE) + stub_generation(gen_env, monkeypatch, {"files": files}) + wire_synchronous_worker(gen_env, monkeypatch) + + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": declaration, + }) + assert status == 202 + + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "failed" + error = turn["turn_error"] + assert error["code"] == "GENERATED_SCAFFOLD_INVALID" + assert error["http_status"] == 422 + assert any(HOOK_FILE in d for d in error["details"]["defects"]) + + # History/snapshot untouched: the prompt is preserved client-side + # for retry (2.6). + session = gen_env.sessions_table.get_item( + Key={"session_id": body["session_id"]}).get("Item") + assert session["messages"] == [] + assert session.get("current_source_key") is None + + def test_bedrock_failure_surfaces_through_poll(self, gen_env, monkeypatch): + declaration = make_declaration() + monkeypatch.setattr( + gen_env.module, "invoke_generation", + lambda config, system_prompt, messages, tool_config: + (None, None, gen_env.module.error_response( + 504, "GENERATION_TIMEOUT", + "Scaffold generation timed out after 60 seconds."))) + wire_synchronous_worker(gen_env, monkeypatch) + + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": declaration, + }) + assert status == 202 + + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "failed" + assert turn["turn_error"]["code"] == "GENERATION_TIMEOUT" + assert turn["turn_error"]["http_status"] == 504 + + def test_worker_exception_marks_turn_failed(self, gen_env, monkeypatch): + declaration = make_declaration() + + def boom(config, system_prompt, messages, tool_config): + raise RuntimeError("unexpected") + + monkeypatch.setattr(gen_env.module, "invoke_generation", boom) + wire_synchronous_worker(gen_env, monkeypatch) + + user = make_user() + usecase_id = make_usecase(gen_env) + grant_admin(gen_env, user, usecase_id) + status, body = invoke(gen_env, "/plugins/generate", user, { + "usecase_id": usecase_id, + "prompt": "blur frames", + "declaration": declaration, + }) + assert status == 202 + + status, turn = poll(gen_env, user, body["session_id"]) + assert status == 200 + assert turn["turn_status"] == "failed" + assert turn["turn_error"]["code"] == "INTERNAL_ERROR" + + def test_followup_embeds_current_source(self, gen_env, monkeypatch): + user, usecase_id, files, status, body = self._generate(gen_env, monkeypatch) + assert status == 202 + session_id = body["session_id"] + + captured = {} + + def capture(config, system_prompt, messages, tool_config): + captured["messages"] = messages + return {"files": files}, "done", None + + monkeypatch.setattr(gen_env.module, "invoke_generation", capture) + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + user, {"prompt": "stronger blur"}, + session_id=session_id) + assert status == 202 + assert body["turn_status"] == "pending" + + status, turn = poll(gen_env, user, session_id) + assert status == 200 + assert turn["turn_status"] == "completed" + + user_turn = captured["messages"][-1]["content"][0]["text"] + assert "stronger blur" in user_turn + assert json.dumps(files, sort_keys=True) in user_turn # 2.4 + + def test_failed_followup_keeps_prior_source_and_history(self, gen_env, monkeypatch): + user, usecase_id, files, status, body = self._generate(gen_env, monkeypatch) + assert status == 202 + session_id = body["session_id"] + status, turn = poll(gen_env, user, session_id) + assert turn["turn_status"] == "completed" + + # Follow-up whose generation fails validation (empty file map). + stub_generation(gen_env, monkeypatch, {"files": {}}) + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + user, {"prompt": "stronger blur"}, + session_id=session_id) + assert status == 202 + status, turn = poll(gen_env, user, session_id) + assert turn["turn_status"] == "failed" + assert turn["turn_error"]["code"] == "GENERATED_SCAFFOLD_INVALID" + + # The prior successful turn's history and snapshot survive, so a + # retried follow-up still modifies the current source (2.6, 2.7). + session = gen_env.sessions_table.get_item( + Key={"session_id": session_id}).get("Item") + assert [m["role"] for m in session["messages"]] == ["user", "assistant"] + snapshot = gen_env.s3.get_object( + Bucket=gen_env.bucket, Key=session["current_source_key"]) + assert json.loads(snapshot["Body"].read()) == files + + stub_generation(gen_env, monkeypatch, {"files": files}) + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + user, {"prompt": "stronger blur"}, + session_id=session_id) + assert status == 202 + status, turn = poll(gen_env, user, session_id) + assert turn["turn_status"] == "completed" + assert turn["files"] == files + + def test_poll_unknown_session_is_404(self, gen_env): + user = make_user() + status, body = poll(gen_env, user, "nope") + assert status == 404 + assert body["error"]["code"] == "SESSION_NOT_FOUND" + + def test_accept_creates_generated_plugin_record_with_prompt_provenance( + self, gen_env, monkeypatch): + user, usecase_id, files, status, body = self._generate(gen_env, monkeypatch) + assert status == 202 + session_id = body["session_id"] + status, turn = poll(gen_env, user, session_id) + assert turn["turn_status"] == "completed" + + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + user, {"accept": True}, session_id=session_id) + assert status == 201 + plugin = body["plugin"] + # Standard build/simulate/lifecycle entry point (2.5) + assert plugin["kind"] == "generated" + assert plugin["lifecycle_state"] == "dev" + assert plugin["review"]["decision"] == "pending" + assert plugin["provenance"]["prompt"] == "blur frames" + assert plugin["provenance"]["prompts"] == ["blur frames"] + assert plugin["provenance"]["generatedBy"] == user["user_id"] + + # Source written under the standard plugin-sources layout + prefix = plugin["source_s3_prefix"] + listing = gen_env.s3.list_objects_v2( + Bucket=gen_env.bucket, Prefix=prefix) + stored = {obj["Key"][len(prefix):] for obj in listing["Contents"]} + assert stored == set(files) + + # And the Plugin_Record is readable through the records API + record = gen_env.stack.tables.plugin_records.get_item( + Key={"plugin_id": plugin["plugin_id"], "version": 1}).get("Item") + assert record is not None + assert record["usecase_id"] == usecase_id diff --git a/edge-cv-portal/backend/tests/test_node_generator_integration.py b/edge-cv-portal/backend/tests/test_node_generator_integration.py new file mode 100644 index 00000000..eda82adb --- /dev/null +++ b/edge-cv-portal/backend/tests/test_node_generator_integration.py @@ -0,0 +1,664 @@ +""" +Node_Generator integration tests with the Bedrock Converse API mocked +(custom-node-designer, task 5.2). + +POST /plugins/generate, GET /plugins/generate/{session}, and +POST /plugins/generate/{session}/message (functions/node_generator.py) +are exercised against the shared moto stack from conftest.py with +``get_bedrock_client`` replaced by a mocked Converse client, so +``invoke_generation`` itself runs. The asynchronous self-invocation of +the start/poll flow is wired to run the worker synchronously in-process, +and the ``generate``/``followup`` helpers below start a turn (202) and +poll it to a settled state, returning the effective outcome. Covered: + +1. Prompt/template-convention assembly (2.2): the converse() call + carries a system prompt embedding the Custom_Node_Type declaration, + the rendered reference scaffold (the template conventions), and the + Frame_Processing_Hook contract; a toolConfig forcing the + create_plugin_scaffold tool whose inputSchema requires every scaffold + file; and the configured model id / inference parameters / region. +2. Tool-use output handling: a valid create_plugin_scaffold tool input + completes the turn with the file map in the poll response, persisting + the chat session (DynamoDB) and the source snapshot (S3); a response + without the tool call fails the turn with NO_SCAFFOLD_RETURNED (502) + with no history or snapshot persisted. +3. Follow-up source inclusion (2.4): a follow-up prompt in the same + session replays the history and embeds the current generated source + with an instruction to modify rather than regenerate; the snapshot is + updated to the modified file map. +4. Scaffold-validation rejection (2.6): tool output that does not form a + buildable Plugin_Scaffold fails the turn with + GENERATED_SCAFFOLD_INVALID (422) naming the defects, with the session + history and snapshot untouched so the prompt is preserved for retry. +5. Timeout and invocation failure (2.7): a read timeout fails the turn + with GENERATION_TIMEOUT (504) carrying the configured timeout + (clamped to at most 60 seconds when building the client); ClientError + fails with BEDROCK_INVOCATION_FAILED (502); an unreachable endpoint + fails with BEDROCK_UNREACHABLE (502) - all without mutating the + session history or snapshot, so the prompt is preserved for retry. + +_Requirements: 2.2, 2.4, 2.6, 2.7_ +""" +import json +import os +import sys +import uuid +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ( + ClientError, + EndpointConnectionError, + ReadTimeoutError, +) + +from conftest import REGION, TEST_ENV + +NODE_GEN_SESSIONS_TABLE_NAME = "test-node-gen-sessions-integration" +SETTINGS_TABLE_NAME = "test-settings-node-gen-integration" + +TOOL_NAME = "create_plugin_scaffold" + +ARCHITECTURES = ["x86_64", "arm64_jp5"] + + +def make_declaration(**overrides): + """A valid Custom_Node_Type declaration (wire shape + architectures).""" + declaration = { + "typeId": "custom_blur", + "displayName": "Custom Blur", + "description": "Blurs each frame.", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [ + { + "name": "radius", + "paramType": "int", + "required": True, + "default": 3, + "constraints": {"min": 1, "max": 99}, + "description": "Blur radius in pixels.", + "examples": [3], + }, + ], + "architectures": list(ARCHITECTURES), + } + declaration.update(overrides) + return declaration + + +def converse_tool_response(tool_input, text=None): + """A Converse API response whose assistant message calls the tool.""" + content = [] + if text is not None: + content.append({"text": text}) + content.append({"toolUse": {"toolUseId": "tool-1", "name": TOOL_NAME, + "input": tool_input}}) + return { + "output": {"message": {"role": "assistant", "content": content}}, + "stopReason": "tool_use", + } + + +@pytest.fixture(scope="module") +def gen_env(aws_stack): + """NodeGenSessions + settings tables and a freshly imported + node_generator module bound to them inside moto.""" + import boto3 + + os.environ["NODE_GEN_SESSIONS_TABLE"] = NODE_GEN_SESSIONS_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + os.environ["PLUGIN_SOURCES_PREFIX"] = "plugin-sources" + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=NODE_GEN_SESSIONS_TABLE_NAME, + KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "session_id", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the modules bind the table names above and + # moto-intercepted boto3 clients (conftest pattern). workflow_generator + # must be re-imported too: it reads SETTINGS_TABLE at import time and + # node_generator takes get_bedrock_configuration from it. + for module_name in ("node_generator", "workflow_generator", + "workflow_validation"): + sys.modules.pop(module_name, None) + import node_generator + + from workflow_core.scaffold import render_scaffold + declaration = make_declaration() + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=node_generator, + stack=aws_stack, + sessions_table=resource.Table(NODE_GEN_SESSIONS_TABLE_NAME), + settings_table=resource.Table(SETTINGS_TABLE_NAME), + s3=boto3.client("s3", region_name=REGION), + bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + declaration=declaration, + reference_files=render_scaffold(declaration), + ) + + +@pytest.fixture(autouse=True) +def clean_bedrock_config(gen_env): + """Each test starts from the default Bedrock_Configuration.""" + gen_env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + yield + + +@pytest.fixture(autouse=True) +def synchronous_worker(gen_env, monkeypatch): + """Wire the asynchronous self-invocation to run the generation worker + synchronously in-process, so a dispatched turn is settled by the time + the start route's 202 returns and the first poll sees the outcome.""" + def dispatch(session_id, prompt, user): + gen_env.module.handler({ + "node_gen_worker": True, + "session_id": session_id, + "prompt": prompt, + "user": user, + }, None) + monkeypatch.setattr(gen_env.module, "dispatch_generation_worker", + dispatch) + + +@pytest.fixture +def bedrock(gen_env, monkeypatch): + """Mocked Converse API client injected through get_bedrock_client. + + ``client_requests`` records every (region, timeout_seconds) the + handler asked a client for, so tests can assert the clamped timeout. + The default response returns the reference scaffold for the default + declaration (a valid, buildable file map). + """ + client = MagicMock(name="bedrock-runtime") + client.converse.return_value = converse_tool_response( + {"files": dict(gen_env.reference_files)}, + text="Here is the scaffold.") + client_requests = [] + + def fake_get_bedrock_client(region, timeout_seconds): + client_requests.append((region, timeout_seconds)) + return client + + monkeypatch.setattr(gen_env.module, "get_bedrock_client", + fake_get_bedrock_client) + return SimpleNamespace(client=client, client_requests=client_requests) + + +@pytest.fixture +def ctx(gen_env): + """A fresh Use_Case and a UseCaseAdmin authorized on it.""" + usecase_id = f"uc-{uuid.uuid4()}" + gen_env.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, "name": "UC", "account_id": "123456789012", + }) + user_id = f"user-{uuid.uuid4()}" + user = {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": "UseCaseAdmin"} + gen_env.stack.tables.user_roles.put_item(Item={ + "user_id": user_id, "usecase_id": usecase_id, "role": "UseCaseAdmin", + }) + return SimpleNamespace(usecase_id=usecase_id, user=user) + + +def invoke(gen_env, resource, user, body=None, session_id=None, + method="POST"): + """Invoke the handler; returns (status_code, parsed_body).""" + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{session}", session_id or ""), + "pathParameters": {"session": session_id} if session_id else None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = gen_env.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def settle(gen_env, ctx, status, body): + """Resolve a started turn to its effective outcome via the poll route. + + A 202 start response is polled (the synchronous_worker fixture already + settled the turn); a completed turn returns (200, turn payload) - the + former synchronous success shape - and a failed turn returns + (turn_error.http_status, {"error": turn_error}) - the former + synchronous error shape. Non-202 start responses pass through. + """ + if status != 202: + return status, body + poll_status, turn = invoke( + gen_env, "/plugins/generate/{session}", ctx.user, + session_id=body["session_id"], method="GET") + assert poll_status == 200 + assert turn["turn_status"] in ("completed", "failed") + if turn["turn_status"] == "completed": + return 200, turn + error = turn["turn_error"] + return error.get("http_status", 502), {"error": error} + + +def generate(gen_env, ctx, prompt, declaration=None): + """Start a generation turn (POST /plugins/generate) and poll it to + its settled outcome.""" + status, body = invoke(gen_env, "/plugins/generate", ctx.user, { + "usecase_id": ctx.usecase_id, + "prompt": prompt, + "declaration": declaration or gen_env.declaration, + }) + return settle(gen_env, ctx, status, body) + + +def followup(gen_env, ctx, session_id, prompt): + """Start a follow-up turn (POST .../message) and poll it to its + settled outcome.""" + status, body = invoke(gen_env, "/plugins/generate/{session}/message", + ctx.user, {"prompt": prompt}, + session_id=session_id) + return settle(gen_env, ctx, status, body) + + +def put_bedrock_config(gen_env, **values): + """Store a Bedrock_Configuration item the way the settings API does.""" + def to_ddb(value): + return Decimal(str(value)) if isinstance(value, float) else value + gen_env.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": {k: to_ddb(v) for k, v in values.items()}, + }) + + +def sessions_for(gen_env, usecase_id): + """Chat session items belonging to a Use_Case (fresh per test).""" + items = gen_env.sessions_table.scan()["Items"] + return [i for i in items if i.get("usecase_id") == usecase_id] + + +def s3_keys_for(gen_env, usecase_id): + """All portal-S3 keys under the Use_Case's plugin-sources prefix.""" + response = gen_env.s3.list_objects_v2( + Bucket=gen_env.bucket, Prefix=f"plugin-sources/{usecase_id}/") + return [obj["Key"] for obj in response.get("Contents", [])] + + +def assert_failed_first_turn_untouched(gen_env, usecase_id): + """A failed first turn leaves the session record with no message + history and no source snapshot, so the prompt stays retryable (2.6, + 2.7). (The session record itself exists: it carries the turn state + for the poll route in the async start/poll flow.)""" + (session,) = sessions_for(gen_env, usecase_id) + assert session["turn_status"] == "failed" + assert session["messages"] == [] + assert session.get("current_source_key") is None + assert s3_keys_for(gen_env, usecase_id) == [] + + +def read_snapshot(gen_env, usecase_id, session_id): + obj = gen_env.s3.get_object( + Bucket=gen_env.bucket, + Key=(f"plugin-sources/{usecase_id}/gen-sessions/{session_id}/" + "current_source.json"), + ) + return json.loads(obj["Body"].read().decode("utf-8")) + + +# =========================================================================== +# 1. Prompt and template-convention assembly (Requirement 2.2) +# =========================================================================== + +class TestPromptAndConventionAssembly: + + def test_system_prompt_embeds_conventions_declaration_and_hook_contract( + self, gen_env, bedrock, ctx): + """The converse() call carries the declaration, the rendered + reference scaffold (the Plugin_Scaffold template conventions), + and the Frame_Processing_Hook contract in the system prompt, and + the bare prompt as the user turn (Requirement 2.2).""" + from workflow_core.scaffold import HOOK_FILE + + prompt = "Blur each incoming frame" + status, _ = generate(gen_env, ctx, prompt) + assert status == 200 + + assert bedrock.client.converse.call_count == 1 + kwargs = bedrock.client.converse.call_args.kwargs + system_text = kwargs["system"][0]["text"] + # Frame_Processing_Hook contract + assert "process_frame(frame, params)" in system_text + assert HOOK_FILE in system_text + # Declaration and template conventions (the reference scaffold) + assert json.dumps(gen_env.declaration, sort_keys=True) in system_text + assert json.dumps(gen_env.reference_files, + sort_keys=True) in system_text + + messages = kwargs["messages"] + assert messages[-1]["role"] == "user" + assert messages[-1]["content"][0]["text"] == prompt + + def test_tool_config_forces_scaffold_tool_requiring_every_file( + self, gen_env, bedrock, ctx): + """The toolConfig forces create_plugin_scaffold and its + inputSchema requires every file of the reference scaffold, so the + model must return a complete buildable file map (2.2).""" + from workflow_core.scaffold import ( + HOOK_FILE, README_FILE, build_config_path, c_source_path, + ) + + status, _ = generate(gen_env, ctx, "Blur frames") + assert status == 200 + + tool_config = bedrock.client.converse.call_args.kwargs["toolConfig"] + assert tool_config["toolChoice"] == {"tool": {"name": TOOL_NAME}} + + (tool,) = tool_config["tools"] + assert tool["toolSpec"]["name"] == TOOL_NAME + schema = tool["toolSpec"]["inputSchema"]["json"] + assert "files" in schema["required"] + expected = {HOOK_FILE, README_FILE, + c_source_path(gen_env.declaration)} + expected.update(build_config_path(arch) for arch in ARCHITECTURES) + files_schema = schema["properties"]["files"] + assert set(files_schema["required"]) == expected + assert set(files_schema["properties"]) == expected + + def test_configured_model_and_inference_parameters_are_used( + self, gen_env, bedrock, ctx): + """The Bedrock_Configuration from the settings table drives the + model id, inference parameters, region, and timeout; temperature + wins over top_p when both are configured (2.2, 2.7).""" + put_bedrock_config(gen_env, model_id="amazon.nova-pro-v1:0", + region="eu-west-1", max_tokens=1024, + temperature=0.7, top_p=0.5, timeout_seconds=30) + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 200 + assert payload["model_id"] == "amazon.nova-pro-v1:0" + + kwargs = bedrock.client.converse.call_args.kwargs + assert kwargs["modelId"] == "amazon.nova-pro-v1:0" + assert kwargs["inferenceConfig"] == {"maxTokens": 1024, + "temperature": 0.7} + assert "topP" not in kwargs["inferenceConfig"] + assert bedrock.client_requests[-1] == ("eu-west-1", 30) + + +# =========================================================================== +# 2. Tool-use output handling +# =========================================================================== + +class TestToolUseOutputHandling: + + def test_valid_tool_output_returns_files_and_persists_session_snapshot( + self, gen_env, bedrock, ctx): + """A valid create_plugin_scaffold tool input yields 200 with the + generated file map, a persisted chat session, and the source + snapshot in S3 (Requirements 2.2, 2.3).""" + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 200 + assert payload["files"] == gen_env.reference_files + assert payload["assistant_text"] == "Here is the scaffold." + + (session,) = sessions_for(gen_env, ctx.usecase_id) + assert session["session_id"] == payload["session_id"] + assert session["user_id"] == ctx.user["user_id"] + assert [m["role"] for m in session["messages"]] == ["user", + "assistant"] + assert session["messages"][0]["text"] == "Blur frames" + + snapshot = read_snapshot(gen_env, ctx.usecase_id, + payload["session_id"]) + assert snapshot == gen_env.reference_files + + def test_response_without_tool_call_returns_502(self, gen_env, bedrock, + ctx): + """A model response with no create_plugin_scaffold tool call + yields 502 NO_SCAFFOLD_RETURNED and persists nothing, preserving + the prompt for retry.""" + bedrock.client.converse.return_value = { + "output": {"message": {"role": "assistant", + "content": [{"text": "I cannot help."}]}}, + "stopReason": "end_turn", + } + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 502 + assert payload["error"]["code"] == "NO_SCAFFOLD_RETURNED" + assert payload["error"]["details"]["stop_reason"] == "end_turn" + + assert_failed_first_turn_untouched(gen_env, ctx.usecase_id) + + +# =========================================================================== +# 3. Follow-up source inclusion (Requirement 2.4) +# =========================================================================== + +class TestFollowUpSourceInclusion: + + def test_followup_embeds_current_source_and_replays_history( + self, gen_env, bedrock, ctx): + """A follow-up prompt replays the session history and sends a + user turn embedding the current generated source with an + instruction to modify rather than regenerate (Requirement 2.4).""" + status, first = generate(gen_env, ctx, "Blur frames") + assert status == 200 + session_id = first["session_id"] + stored_snapshot = read_snapshot(gen_env, ctx.usecase_id, session_id) + + status, second = followup(gen_env, ctx, session_id, + "Make the blur stronger") + assert status == 200 + assert second["session_id"] == session_id + + assert bedrock.client.converse.call_count == 2 + messages = bedrock.client.converse.call_args.kwargs["messages"] + + # History replayed: first user turn, assistant turn, new user turn. + assert [m["role"] for m in messages] == ["user", "assistant", "user"] + assert messages[0]["content"][0]["text"] == "Blur frames" + + followup_text = messages[2]["content"][0]["text"] + assert followup_text.startswith("Make the blur stronger") + assert "CURRENT PLUGIN SCAFFOLD SOURCE (JSON file map):" \ + in followup_text + # The session's stored source snapshot is embedded verbatim. + assert json.dumps(stored_snapshot, sort_keys=True) in followup_text + assert ("rather than generating new source from scratch" + in followup_text) + + # Session history now holds both exchanges. + (session,) = sessions_for(gen_env, ctx.usecase_id) + assert [m["role"] for m in session["messages"]] == \ + ["user", "assistant", "user", "assistant"] + + def test_followup_updates_snapshot_to_modified_source(self, gen_env, + bedrock, ctx): + """The modified file map returned for a follow-up becomes the + session's new source snapshot (2.4).""" + from workflow_core.scaffold import HOOK_FILE + + status, first = generate(gen_env, ctx, "Blur frames") + assert status == 200 + session_id = first["session_id"] + + modified = dict(gen_env.reference_files) + modified[HOOK_FILE] = modified[HOOK_FILE] + "\n# stronger blur\n" + bedrock.client.converse.return_value = converse_tool_response( + {"files": modified}, text="Strengthened the blur.") + + status, payload = followup(gen_env, ctx, session_id, + "Make the blur stronger") + assert status == 200 + assert payload["files"] == modified + assert read_snapshot(gen_env, ctx.usecase_id, session_id) == modified + + +# =========================================================================== +# 4. Scaffold-validation rejection with prompt preservation (Requirement 2.6) +# =========================================================================== + +class TestScaffoldValidationRejection: + + def test_unbuildable_first_output_fails_turn_with_422_and_no_history( + self, gen_env, bedrock, ctx): + """Tool output that does not form a buildable Plugin_Scaffold + (missing hook file) fails the turn with GENERATED_SCAFFOLD_INVALID + (422) naming the defect and persists no history or snapshot, so + the prompt is preserved for retry (Requirement 2.6).""" + from workflow_core.scaffold import HOOK_FILE + + files = dict(gen_env.reference_files) + files.pop(HOOK_FILE) + bedrock.client.converse.return_value = converse_tool_response( + {"files": files}) + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 422 + assert payload["error"]["code"] == "GENERATED_SCAFFOLD_INVALID" + assert any(HOOK_FILE in d + for d in payload["error"]["details"]["defects"]) + + assert_failed_first_turn_untouched(gen_env, ctx.usecase_id) + + def test_unbuildable_followup_output_preserves_session_and_snapshot( + self, gen_env, bedrock, ctx): + """An unbuildable follow-up output rejects with 422 while the + session history and the current source snapshot stay exactly as + they were, preserving the prompt for retry (2.6).""" + from workflow_core.scaffold import HOOK_FILE + + status, first = generate(gen_env, ctx, "Blur frames") + assert status == 200 + session_id = first["session_id"] + (session_before,) = sessions_for(gen_env, ctx.usecase_id) + + # An emptied hook file is not a buildable Plugin_Scaffold. + broken = dict(gen_env.reference_files) + broken[HOOK_FILE] = "" + bedrock.client.converse.return_value = converse_tool_response( + {"files": broken}) + + status, payload = followup(gen_env, ctx, session_id, + "Make the blur stronger") + assert status == 422 + assert payload["error"]["code"] == "GENERATED_SCAFFOLD_INVALID" + assert payload["error"]["details"]["defects"] + + # Session untouched: same two messages, same snapshot content. + (session_after,) = sessions_for(gen_env, ctx.usecase_id) + assert session_after["messages"] == session_before["messages"] + assert read_snapshot(gen_env, ctx.usecase_id, session_id) == \ + gen_env.reference_files + + +# =========================================================================== +# 5. Timeout and invocation failure behavior (Requirement 2.7) +# =========================================================================== + +class TestTimeoutAndFailure: + + def test_read_timeout_returns_504_with_configured_timeout(self, gen_env, + bedrock, ctx): + """A Bedrock read timeout yields 504 GENERATION_TIMEOUT carrying + the configured timeout, the client was built with that timeout, + and no session is persisted so the prompt can be retried (2.7).""" + put_bedrock_config(gen_env, timeout_seconds=45) + bedrock.client.converse.side_effect = ReadTimeoutError( + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com") + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 504 + assert payload["error"]["code"] == "GENERATION_TIMEOUT" + assert payload["error"]["details"]["timeout_seconds"] == 45 + assert bedrock.client_requests[-1][1] == 45 + + assert_failed_first_turn_untouched(gen_env, ctx.usecase_id) + + def test_configured_timeout_is_clamped_to_60_seconds(self, gen_env, + bedrock, ctx): + """A stored timeout above the 60-second maximum is clamped before + the client is built (2.7: at most 60 seconds).""" + put_bedrock_config(gen_env, timeout_seconds=300) + + status, _ = generate(gen_env, ctx, "Blur frames") + assert status == 200 + assert bedrock.client_requests[-1][1] == 60 + + def test_invocation_client_error_returns_502(self, gen_env, bedrock, + ctx): + """A Bedrock invocation failure yields 502 with the Bedrock error + code identified and persists no session (2.7).""" + bedrock.client.converse.side_effect = ClientError( + {"Error": {"Code": "ThrottlingException", + "Message": "Rate exceeded"}}, + "Converse", + ) + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 502 + assert payload["error"]["code"] == "BEDROCK_INVOCATION_FAILED" + assert payload["error"]["details"]["bedrock_error_code"] == \ + "ThrottlingException" + + assert_failed_first_turn_untouched(gen_env, ctx.usecase_id) + + def test_unreachable_endpoint_returns_502(self, gen_env, bedrock, ctx): + """An unreachable Bedrock endpoint yields 502 BEDROCK_UNREACHABLE + identifying the region, with nothing persisted (2.7).""" + put_bedrock_config(gen_env, region="eu-central-1") + bedrock.client.converse.side_effect = EndpointConnectionError( + endpoint_url="https://bedrock-runtime.eu-central-1.amazonaws.com") + + status, payload = generate(gen_env, ctx, "Blur frames") + assert status == 502 + assert payload["error"]["code"] == "BEDROCK_UNREACHABLE" + assert payload["error"]["details"]["region"] == "eu-central-1" + + assert_failed_first_turn_untouched(gen_env, ctx.usecase_id) + + def test_followup_timeout_preserves_session_and_snapshot(self, gen_env, + bedrock, ctx): + """A timeout on a follow-up turn leaves the session history and + the current source snapshot untouched, so the prompt is preserved + for retry (2.7).""" + status, first = generate(gen_env, ctx, "Blur frames") + assert status == 200 + session_id = first["session_id"] + (session_before,) = sessions_for(gen_env, ctx.usecase_id) + + bedrock.client.converse.side_effect = ReadTimeoutError( + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com") + status, payload = followup(gen_env, ctx, session_id, + "Make the blur stronger") + assert status == 504 + assert payload["error"]["code"] == "GENERATION_TIMEOUT" + + (session_after,) = sessions_for(gen_env, ctx.usecase_id) + assert session_after["messages"] == session_before["messages"] + assert read_snapshot(gen_env, ctx.usecase_id, session_id) == \ + gen_env.reference_files diff --git a/edge-cv-portal/backend/tests/test_packaging_deployment_fixtures.py b/edge-cv-portal/backend/tests/test_packaging_deployment_fixtures.py new file mode 100644 index 00000000..d67b83e9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_packaging_deployment_fixtures.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +Packaging and deployment unit-test fixtures +(custom-node-designer task 10.9, Requirements 16.1, 16.2, 16.6). + +Explicit example fixtures over the pure packaging / deployment functions, +completing the coverage audit of the neighbouring suites: + +- amd64 manifest ordering and attribute matching with the x86_64 and + x86_64_nvidia flavors present and absent (x86_64 only, nvidia only, + both, neither + arm) in BOTH recipe builders — + plugin_components.build_plugin_recipe and workflow_packaging.build_recipe + (16.1). test_plugin_components.py and + test_workflow_packaging_custom_plugins.py already cover "both present" + ordering and plain-x86_64-only; the nvidia-only and arm-only combos are + pinned here, plus the ordering-helper agreement between the two modules. +- Packaging rejection messages identifying the Custom_Node_Type, the + missing architecture, and the lifecycle state in the human-readable + message (pure workflow_packaging.custom_plugin_gate_findings; the API + envelope variants live in test_workflow_packaging_custom_plugins.py + TestPackagingGates). +- Deployment gate rejection entries per device: the exact + {pluginComponent, version, device, deviceArch} shape of + deployments.evaluate_plugin_arch_gate, and both amd64 flavors matching + distinctly when both manifests are present (16.6; single-flavor + no-fallback cases live in test_deployment_plugin_gates.py + TestArchGatePure). +- Plugin_Component listing fields (16.2): the listing's architecture + derivation round-trips the recipes build_plugin_recipe actually + produces, and the component-version inverse; the full listing + field set is integration-tested in test_components_plugin_listing.py + TestPluginComponentListing. + +All tests are pure over module functions; aws_stack is only used so the +modules import inside the moto mock (their module-level boto3 clients). +""" +import sys + +import pytest + + +@pytest.fixture(scope="module") +def plugin_components_module(aws_stack): + """plugin_components inside the moto mock (module-level clients).""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + return plugin_components + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """workflow_packaging inside the moto mock.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """deployments inside the moto mock.""" + for name in ("deployments", "workflow_guards"): + sys.modules.pop(name, None) + import deployments + + return deployments + + +@pytest.fixture(scope="module") +def components_module(aws_stack): + """components inside the moto mock.""" + sys.modules.pop("components", None) + import components + + return components + + +def platforms_of(recipe): + return [m["Platform"] for m in recipe["Manifests"]] + + +# ========================================================================== +# 16.1 — amd64 manifest ordering / attribute matching: +# plugin_components.build_plugin_recipe +# ========================================================================== + +class TestPluginRecipeAmd64Matrix: + """Plugin_Component recipes across the x86_64 / x86_64_nvidia matrix.""" + + def build(self, module, archs): + return module.build_plugin_recipe( + "plg-m", 4, "acct-bucket", {a: "p.so" for a in archs}) + + def test_x86_64_only(self, plugin_components_module): + """Plain flavor alone: one amd64 manifest, no runtime attribute, so + attribute-less amd64 devices match it.""" + recipe = self.build(plugin_components_module, ["x86_64"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64"}] + + def test_x86_64_nvidia_only(self, plugin_components_module): + """NVIDIA flavor alone still carries 'runtime: nvidia' so an + attribute-less amd64 device can never match it (no fallback).""" + recipe = self.build(plugin_components_module, ["x86_64_nvidia"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}] + + def test_both_flavors_nvidia_ordered_first(self, plugin_components_module): + """Both flavors: the more specific nvidia manifest precedes the + attribute-less plain manifest.""" + recipe = self.build(plugin_components_module, + ["x86_64", "x86_64_nvidia"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}, + {"os": "linux", "architecture": "amd64"}, + ] + + def test_neither_flavor_arm_only(self, plugin_components_module): + """No amd64 flavor built: no amd64 manifest exists at all; the + JetPack manifest always carries its 'variant' attribute.""" + recipe = self.build(plugin_components_module, ["arm64_jp5"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "aarch64", "variant": "arm64_jp5"}] + + def test_full_matrix_ordering_with_arm(self, plugin_components_module): + """arm + both amd64 flavors: sorted order except plain x86_64 + re-ordered directly after x86_64_nvidia.""" + recipe = self.build(plugin_components_module, + ["x86_64", "arm64_jp5", "x86_64_nvidia"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "aarch64", "variant": "arm64_jp5"}, + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}, + {"os": "linux", "architecture": "amd64"}, + ] + + +# ========================================================================== +# 16.1 — amd64 manifest ordering / attribute matching: +# workflow_packaging.build_recipe +# ========================================================================== + +class TestWorkflowRecipeAmd64Matrix: + """Workflow_Component recipes across the same amd64 flavor matrix.""" + + def build(self, module, archs): + final_keys = { + a: f"workflows/components/wf-m/2/{a}/workflow-{a}.zip" + for a in archs} + return module.build_recipe("wf-m", 2, "acct-bucket", final_keys) + + def test_x86_64_only(self, packaging): + recipe = self.build(packaging, ["x86_64"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64"}] + + def test_x86_64_nvidia_only(self, packaging): + """nvidia-only workflow package still carries 'runtime: nvidia' + (no fallback for plain amd64 devices).""" + recipe = self.build(packaging, ["x86_64_nvidia"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}] + + def test_both_flavors_nvidia_ordered_first(self, packaging): + recipe = self.build(packaging, ["x86_64", "x86_64_nvidia"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}, + {"os": "linux", "architecture": "amd64"}, + ] + + def test_neither_flavor_arm_only(self, packaging): + """No amd64 flavor selected: no amd64 manifest; a single arm arch + needs no 'variant' disambiguation in workflow recipes.""" + recipe = self.build(packaging, ["arm64_jp5"]) + assert platforms_of(recipe) == [ + {"os": "linux", "architecture": "aarch64"}] + + def test_full_matrix_ordering_with_arm(self, packaging): + recipe = self.build(packaging, + ["x86_64", "arm64_jp5", "x86_64_nvidia"]) + architectures = [p["architecture"] for p in platforms_of(recipe)] + runtimes = [p.get("runtime") for p in platforms_of(recipe)] + assert architectures == ["aarch64", "amd64", "amd64"] + assert runtimes == [None, "nvidia", None] + + def test_ordering_helpers_agree_across_modules( + self, packaging, plugin_components_module): + """Both packagers order the amd64 flavors identically, so a device + matches the same flavor of a workflow and its plugin dependencies.""" + combos = ( + ["x86_64"], + ["x86_64_nvidia"], + ["x86_64", "x86_64_nvidia"], + ["arm64_jp5"], + ["x86_64", "arm64_jp5", "x86_64_nvidia"], + ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6"], + ) + for archs in combos: + assert (packaging.recipe_manifest_order(archs) + == plugin_components_module.manifest_arch_order(archs)), archs + + +# ========================================================================== +# Packaging rejection messages identify node / arch / state +# (custom_plugin_gate_findings; API envelope in +# test_workflow_packaging_custom_plugins.py TestPackagingGates) +# ========================================================================== + +def make_record(plugin_id="plg-1", version=2, state="test", + archs=("x86_64",), component_status="registered"): + """A minimal backing Plugin_Record item as the gates consume it.""" + return { + "plugin_id": plugin_id, + "version": version, + "lifecycle_state": state, + "artifacts": { + arch: {"buildStatus": "succeeded", + "s3Key": f"workflow-plugins/custom/uc/{arch}/p.so", + "checksum": "aa" * 32, "signature": "c2ln"} + for arch in archs + }, + "component": {"status": component_status}, + } + + +class TestPackagingRejectionMessages: + DEP = "custom:blur-regions" + INDEX = {DEP: {"node_type_id": "nt-blur", "plugin_id": "plg-1", + "plugin_version": 2}} + + def test_dev_state_message_names_node_and_state(self, packaging): + """Lifecycle rejection: message identifies the Custom_Node_Type and + the offending Lifecycle_State (11.3 wording carried by the 409).""" + record = make_record(state="dev") + [finding] = packaging.custom_plugin_gate_findings( + {"x86_64": [self.DEP]}, self.INDEX, {self.DEP: record}) + + assert finding["code"] == "PLUGIN_LIFECYCLE_VIOLATION" + assert finding["node_type_id"] == "nt-blur" + assert finding["lifecycle_state"] == "dev" + assert "'nt-blur'" in finding["message"] + assert "'dev'" in finding["message"] + assert "'plg-1' v2" in finding["message"] + + def test_missing_arch_message_names_node_and_arch(self, packaging): + """Artifact rejection: message identifies the Custom_Node_Type and + the missing Target_Architecture (11.2 wording).""" + record = make_record(archs=("x86_64",)) + [finding] = packaging.custom_plugin_gate_findings( + {"x86_64": [self.DEP], "arm64_jp5": [self.DEP]}, + self.INDEX, {self.DEP: record}) + + assert finding["code"] == "PLUGIN_ARTIFACT_MISSING" + assert finding["node_type_id"] == "nt-blur" + assert finding["arch"] == "arm64_jp5" + assert "'nt-blur'" in finding["message"] + assert "'arm64_jp5'" in finding["message"] + + def test_missing_component_message_names_node_and_component(self, packaging): + """Unregistered Plugin_Component rejects packaging naming the node + and the dda.plugin.* component (16.4 dependency prerequisite).""" + record = make_record(component_status="failed") + [finding] = packaging.custom_plugin_gate_findings( + {"x86_64": [self.DEP]}, self.INDEX, {self.DEP: record}) + + assert finding["code"] == "PLUGIN_COMPONENT_MISSING" + assert finding["node_type_id"] == "nt-blur" + assert "'nt-blur'" in finding["message"] + assert "dda.plugin.plg-1" in finding["message"] + + def test_clean_record_produces_no_findings(self, packaging): + record = make_record(archs=("x86_64", "arm64_jp5")) + findings = packaging.custom_plugin_gate_findings( + {"x86_64": [self.DEP], "arm64_jp5": [self.DEP]}, + self.INDEX, {self.DEP: record}) + assert findings == [] + + +# ========================================================================== +# 16.6 — deployment gate rejection entries per device +# ========================================================================== + +class TestDeploymentArchGateFixtures: + """The per-device rejection shape of evaluate_plugin_arch_gate; the + single-flavor no-fallback cases live in test_deployment_plugin_gates.py.""" + + def test_rejection_entry_shape_per_device(self, deployments): + """Every offending pair carries exactly pluginComponent, version, + device, and deviceArch (the PLUGIN_ARCH_UNSUPPORTED details).""" + offending = deployments.evaluate_plugin_arch_gate( + {"dda.plugin.p1": {"version": "2.0.0", + "architectures": ["arm64_jp5"]}}, + {"line-a": "x86_64", "line-b": "x86_64_nvidia"}) + + assert offending == [ + {"pluginComponent": "dda.plugin.p1", "version": "2.0.0", + "device": "line-a", "deviceArch": "x86_64"}, + {"pluginComponent": "dda.plugin.p1", "version": "2.0.0", + "device": "line-b", "deviceArch": "x86_64_nvidia"}, + ] + for entry in offending: + assert set(entry) == {"pluginComponent", "version", + "device", "deviceArch"} + + def test_both_amd64_flavors_present_match_both_device_kinds(self, deployments): + """A component packaged with both amd64 flavors deploys to plain + x86_64 and x86_64_nvidia devices alike (matched distinctly).""" + offending = deployments.evaluate_plugin_arch_gate( + {"dda.plugin.p1": {"version": "1.0.0", + "architectures": ["x86_64", "x86_64_nvidia"]}}, + {"d-plain": "x86_64", "d-nvidia": "x86_64_nvidia"}) + assert offending == [] + + +# ========================================================================== +# 16.2 — Plugin_Component listing fields +# ========================================================================== + +class TestListingFieldDerivationRoundTrip: + """The deployment-screen listing derives its fields from exactly what + build_plugin_recipe publishes (integration in + test_components_plugin_listing.py TestPluginComponentListing).""" + + @pytest.mark.parametrize("archs", [ + ["x86_64"], + ["x86_64_nvidia"], + ["x86_64", "x86_64_nvidia"], + ["arm64_jp5"], + ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6"], + ], ids=lambda a: "+".join(a)) + def test_supported_architectures_round_trip_the_recipe( + self, plugin_components_module, components_module, archs): + """target_architectures_from_platforms over a freshly built recipe's + manifests recovers the packaged Target_Architectures exactly.""" + recipe = plugin_components_module.build_plugin_recipe( + "plg-rt", 7, "b", {a: "p.so" for a in archs}) + derived = components_module.target_architectures_from_platforms( + [m["Platform"] for m in recipe["Manifests"]]) + assert sorted(derived) == sorted(archs) + + def test_listing_version_inverts_recipe_component_version( + self, plugin_components_module, components_module): + """The listing's plugin_version is the inverse of the packaged + ComponentVersion for the Plugin_Record versions the recipe emits.""" + for plugin_version in (1, 7, 12): + component_version = plugin_components_module.component_version_for( + plugin_version) + assert components_module.plugin_version_from_component_version( + component_version) == plugin_version + + def test_listing_name_matches_recipe_component_name( + self, plugin_components_module, components_module): + """The recipe's ComponentName is what the listing recognizes as a + Plugin_Component (name field on the deployment screen).""" + name = plugin_components_module.component_name_for("plg-rt") + assert name == "dda.plugin.plg-rt" + assert components_module.is_plugin_component(name) is True + assert components_module.is_plugin_component("com.example.model") is False diff --git a/edge-cv-portal/backend/tests/test_plugin_builds.py b/edge-cv-portal/backend/tests/test_plugin_builds.py new file mode 100644 index 00000000..2b937f93 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_builds.py @@ -0,0 +1,967 @@ +""" +Unit tests for plugin_builds.py (custom-node-designer task 6.2). + +Covers build submission (per-arch building status + StartBuild, 3.1), +the EventBridge result handler (idempotent per-arch artifact recording: +SHA-256 checksum, KMS signature, Plugin_Library promotion on success, +CloudWatch log tail with no artifact on failure — 3.3, 3.4), the +prebuilt-binary upload path (3.6), the DeepStream architecture +restriction (5.1), the per-arch build status endpoint (3.5), and the +plugin_components.py trigger on settlement with >= 1 success (16.1 +hand-off; absence of the function never fails the build). + +Runs against the moto-backed stack from conftest.py (real KMS ECC key, +real CodeBuild projects, real DynamoDB/S3). +""" +import base64 +import hashlib +import json +import time +import uuid + +import pytest + +from conftest import TEST_ENV + + +class PluginBuildsEnv: + """Facade for invoking the Plugin_Build_Service API in tests.""" + + def __init__(self, stack): + self.stack = stack + self.module = stack.plugin_builds + self.records = stack.plugin_records + self.s3 = stack.s3 + self.kms = stack.kms + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + # ------------------------------------------------------------- setup + def create_usecase(self): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Builds Test Use Case", + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + def make_admin(self, usecase_id): + admin = self.make_user() + self.assign_role(admin, usecase_id, "UseCaseAdmin") + return admin + + def create_plugin(self, user, usecase_id, name="blur-regions", **extra): + status, body = self.invoke_records( + "POST", "/plugins", user, + body={"usecase_id": usecase_id, "name": name, + "kind": "scaffold", **extra}) + assert status == 201, body + return body["plugin"] + + def get_item(self, plugin_id, version): + return self.records.get_version_item(plugin_id, version) + + # ----------------------------------------------------------- invoke + def _event(self, method, resource, user, plugin_id, version, body=None): + return { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", plugin_id or "") + .replace("{v}", str(version or "")), + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + def invoke_records(self, method, resource, user, plugin_id=None, + version=None, body=None): + event = self._event(method, resource, user, plugin_id, version, body) + response = self.records.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def post_build(self, user, plugin_id, version, body=None): + event = self._event("POST", "/plugins/{id}/versions/{v}/build", + user, plugin_id, version, body or {}) + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def get_builds(self, user, plugin_id, version): + event = self._event("GET", "/plugins/{id}/versions/{v}/builds", + user, plugin_id, version) + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ------------------------------------------------------- EventBridge + def codebuild_event(self, arch, build_id, status, plugin_id, version, + usecase_id, plugin_name, logs=None): + project = f"dda-plugin-build-{arch}" + env = { + "USECASE_ID": usecase_id, + "PLUGIN_ID": plugin_id, + "PLUGIN_VERSION": str(version), + "PLUGIN_NAME": plugin_name, + "TARGET_ARCH": arch, + } + return { + "source": "aws.codebuild", + "detail-type": "CodeBuild Build State Change", + "detail": { + "build-status": status, + "project-name": project, + "build-id": ( + f"arn:aws:codebuild:us-east-1:123456789012:build/{build_id}"), + "additional-information": { + "environment": { + "environment-variables": [ + {"name": k, "value": v} for k, v in env.items() + ], + }, + "logs": logs or {}, + }, + }, + } + + def deliver_result(self, **kwargs): + return self.module.handler(self.codebuild_event(**kwargs), None) + + # ------------------------------------------------------ conveniences + def library_key(self, usecase_id, arch, plugin_name): + return f"workflow-plugins/custom/{usecase_id}/{arch}/{plugin_name}.so" + + def put_promoted_artifact(self, usecase_id, arch, plugin_name, data): + key = self.library_key(usecase_id, arch, plugin_name) + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + return key + + def submit_and_succeed(self, admin, plugin, arch="x86_64", + data=b"\x7fELF-shared-object"): + """POST /build for one arch, then deliver its SUCCEEDED result.""" + plugin_id, version = plugin["plugin_id"], plugin["version"] + status, body = self.post_build(admin, plugin_id, version, + {"architectures": [arch]}) + assert status == 202, body + item = self.get_item(plugin_id, version) + build_id = item["artifacts"][arch]["buildId"] + self.put_promoted_artifact(plugin["usecase_id"], arch, + "blur-regions", data) + result = self.deliver_result( + arch=arch, build_id=build_id, status="SUCCEEDED", + plugin_id=plugin_id, version=version, + usecase_id=plugin["usecase_id"], plugin_name="blur-regions") + return build_id, result + + +@pytest.fixture +def benv(aws_stack): + return PluginBuildsEnv(aws_stack) + + +class TestBuildSubmission: + """POST /plugins/{id}/versions/{v}/build (3.1).""" + + def test_marks_building_and_starts_one_build_per_architecture(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + archs = ["x86_64", "arm64_jp5"] + + status, body = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": archs}) + + assert status == 202 + assert body["requested_architectures"] == sorted(archs) + item = benv.get_item(plugin["plugin_id"], plugin["version"]) + started_ids = [] + for arch in archs: + entry = item["artifacts"][arch] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + started_ids.append(entry["buildId"]) + # The CodeBuild builds actually exist (StartBuild was called). + builds = benv.stack.codebuild.batch_get_builds(ids=started_ids)["builds"] + assert len(builds) == 2 + + def test_defaults_to_previously_requested_architectures(self, benv): + """Queued entries (e.g. from an import) build without an explicit + architectures list.""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + benv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], "version": plugin["version"]}, + UpdateExpression="SET requested_architectures = :r, artifacts = :a", + ExpressionAttributeValues={ + ":r": ["arm64_jp6"], + ":a": {"arm64_jp6": {"buildStatus": "queued"}}, + }, + ) + + status, body = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], {}) + + assert status == 202 + assert body["builds"]["arm64_jp6"]["buildStatus"] == "building" + + def test_rejects_unknown_architecture(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + + status, body = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["sparc"]}) + + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCHITECTURES" + + def test_deepstream_record_restricted_to_jetpack_architectures(self, benv): + """DeepStream-flagged records may only select arm64_jp4/jp5/jp6 (5.1).""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id, deepstream=True) + + status, body = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64", "arm64_jp5"]}) + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCHITECTURES" + assert body["error"]["details"]["invalid"] == ["x86_64"] + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["arm64_jp4", "arm64_jp6"]}) + assert status == 202 + + def test_viewer_cannot_submit_builds(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + viewer = benv.make_user() + benv.assign_role(viewer, usecase_id, "Viewer") + + status, body = benv.post_build(viewer, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64"]}) + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + +class TestBuildResults: + """EventBridge result recording (3.3, 3.4), idempotent on build id.""" + + def test_success_records_checksum_signature_and_library_key(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + data = b"\x7fELF fake shared object bytes" + + _, result = benv.submit_and_succeed(admin, plugin, "x86_64", data) + + assert result["recorded"] is True + item = benv.get_item(plugin["plugin_id"], plugin["version"]) + entry = item["artifacts"]["x86_64"] + so_key = benv.library_key(usecase_id, "x86_64", "blur-regions") + assert entry["buildStatus"] == "succeeded" + assert entry["s3Key"] == so_key + assert entry["checksum"] == hashlib.sha256(data).hexdigest() + # The signature verifies against the artifact digest with the + # portal signing key (3.3). + verified = benv.kms.verify( + KeyId=TEST_ENV.get("PLUGIN_SIGNING_KEY_ARN") + or benv.module.PLUGIN_SIGNING_KEY_ARN, + Message=hashlib.sha256(data).digest(), + MessageType="DIGEST", + Signature=base64.b64decode(entry["signature"]), + SigningAlgorithm="ECDSA_SHA_256", + ) + assert verified["SignatureValid"] is True + # The detached signature was stored alongside the artifact. + sig = benv.s3.get_object(Bucket=benv.bucket, Key=so_key + ".sig") + assert sig["Body"].read() == base64.b64decode(entry["signature"]) + + def test_failure_records_log_tail_and_no_artifact(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + plugin_id, version = plugin["plugin_id"], plugin["version"] + benv.post_build(admin, plugin_id, version, + {"architectures": ["arm64_jp4"]}) + build_id = benv.get_item(plugin_id, version)["artifacts"]["arm64_jp4"]["buildId"] + + # Stage the CloudWatch build log the handler tails (3.4). + logs = benv.stack.plugin_builds.logs_client + group, stream = "/aws/codebuild/dda-plugin-build-arm64_jp4", build_id.split(":")[1] + logs.create_log_group(logGroupName=group) + logs.create_log_stream(logGroupName=group, logStreamName=stream) + now = int(time.time() * 1000) + logs.put_log_events(logGroupName=group, logStreamName=stream, logEvents=[ + {"timestamp": now, "message": "meson setup build"}, + {"timestamp": now + 1, "message": "error: undefined reference to gst_pad_new"}, + ]) + + result = benv.deliver_result( + arch="arm64_jp4", build_id=build_id, status="FAILED", + plugin_id=plugin_id, version=version, usecase_id=usecase_id, + plugin_name="blur-regions", + logs={"group-name": group, "stream-name": stream}) + + assert result["recorded"] is True + entry = benv.get_item(plugin_id, version)["artifacts"]["arm64_jp4"] + assert entry["buildStatus"] == "failed" + assert "undefined reference" in entry["logTail"] + assert "s3Key" not in entry and "checksum" not in entry \ + and "signature" not in entry + + def test_duplicate_delivery_is_idempotent_on_build_id(self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + invocations = [] + monkeypatch.setattr( + benv.module, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + + build_id, first = benv.submit_and_succeed(admin, plugin, "x86_64") + entry_after_first = benv.get_item( + plugin["plugin_id"], plugin["version"])["artifacts"]["x86_64"] + + duplicate = benv.deliver_result( + arch="x86_64", build_id=build_id, status="SUCCEEDED", + plugin_id=plugin["plugin_id"], version=plugin["version"], + usecase_id=usecase_id, plugin_name="blur-regions") + + assert first["recorded"] is True + assert duplicate["recorded"] is False + assert duplicate["reason"] == "already recorded" + entry_after_dup = benv.get_item( + plugin["plugin_id"], plugin["version"])["artifacts"]["x86_64"] + assert entry_after_dup == entry_after_first + assert len(invocations) == 1 # component packaging triggered once + + def test_fetch_project_events_delegate_to_the_import_result_handler( + self, benv): + """dda-plugin-fetch state changes route to + plugin_importer.handle_fetch_result (async import) instead of + the per-arch recording; without attribution env vars nothing is + recorded. The full fetch-result flow is covered in + test_plugin_importer.py.""" + event = { + "source": "aws.codebuild", + "detail-type": "CodeBuild Build State Change", + "detail": {"build-status": "SUCCEEDED", + "project-name": "dda-plugin-fetch", + "build-id": "arn:aws:codebuild:us-east-1:1:build/dda-plugin-fetch:x"}, + } + result = benv.module.handler(event, None) + assert result == {"recorded": False, + "reason": "missing fetch metadata"} + + +class TestComponentPackagingTrigger: + """plugin_components.py trigger on settlement with >= 1 success (16.1).""" + + def test_triggers_once_when_all_requested_builds_settle(self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + plugin_id, version = plugin["plugin_id"], plugin["version"] + invocations = [] + monkeypatch.setattr( + benv.module, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + + benv.post_build(admin, plugin_id, version, + {"architectures": ["x86_64", "arm64_jp4"]}) + item = benv.get_item(plugin_id, version) + + # First arch succeeds: not settled yet, no trigger. + benv.put_promoted_artifact(usecase_id, "x86_64", "blur-regions", b"so") + r1 = benv.deliver_result( + arch="x86_64", build_id=item["artifacts"]["x86_64"]["buildId"], + status="SUCCEEDED", plugin_id=plugin_id, version=version, + usecase_id=usecase_id, plugin_name="blur-regions") + assert r1["component_packaging_triggered"] is False + assert invocations == [] + + # Second arch fails: settled with one success -> single trigger. + r2 = benv.deliver_result( + arch="arm64_jp4", build_id=item["artifacts"]["arm64_jp4"]["buildId"], + status="FAILED", plugin_id=plugin_id, version=version, + usecase_id=usecase_id, plugin_name="blur-regions") + assert r2["component_packaging_triggered"] is True + assert len(invocations) == 1 + payload = json.loads(invocations[0]["Payload"]) + assert payload["plugin_id"] == plugin_id + assert payload["version"] == version + assert invocations[0]["FunctionName"] == "test-plugin-components" + + def test_all_failed_never_triggers(self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + plugin_id, version = plugin["plugin_id"], plugin["version"] + invocations = [] + monkeypatch.setattr( + benv.module, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + + benv.post_build(admin, plugin_id, version, + {"architectures": ["x86_64"]}) + item = benv.get_item(plugin_id, version) + result = benv.deliver_result( + arch="x86_64", build_id=item["artifacts"]["x86_64"]["buildId"], + status="FAILED", plugin_id=plugin_id, version=version, + usecase_id=usecase_id, plugin_name="blur-regions") + + assert result["component_packaging_triggered"] is False + assert invocations == [] + + def test_missing_components_function_never_fails_the_build(self, benv): + """plugin_components.py does not exist yet (task 6.3): the real + Lambda invoke raises ResourceNotFoundException, which must be + tolerated (auto-packaging failure never fails the build).""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + + _, result = benv.submit_and_succeed(admin, plugin, "x86_64") + + assert result["recorded"] is True + assert result["buildStatus"] == "succeeded" + assert result["component_packaging_triggered"] is False + + +class TestPrebuiltUpload: + """Prebuilt binary path: checksummed and signed identically (3.6).""" + + def test_inline_prebuilt_binary_is_stored_signed_with_provenance(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + data = b"\x7fELF prebuilt jetson binary" + + status, body = benv.post_build( + admin, plugin["plugin_id"], plugin["version"], + {"architectures": [], + "prebuilt": {"arm64_jp5": { + "content_base64": base64.b64encode(data).decode()}}}) + + assert status == 202, body + item = benv.get_item(plugin["plugin_id"], plugin["version"]) + entry = item["artifacts"]["arm64_jp5"] + so_key = benv.library_key(usecase_id, "arm64_jp5", "blur-regions") + assert entry["buildStatus"] == "succeeded" + assert entry["prebuilt"] is True + assert entry["s3Key"] == so_key + assert entry["checksum"] == hashlib.sha256(data).hexdigest() + assert item["provenance"]["prebuilt"] is True + # Artifact + detached signature promoted to the Plugin_Library. + stored = benv.s3.get_object(Bucket=benv.bucket, Key=so_key)["Body"].read() + assert stored == data + sig = benv.s3.get_object(Bucket=benv.bucket, + Key=so_key + ".sig")["Body"].read() + assert sig == base64.b64decode(entry["signature"]) + + def test_prebuilt_from_source_tree_and_immediate_settlement(self, benv, monkeypatch): + """A prebuilt-only submission settles immediately and triggers + component packaging (16.1).""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + invocations = [] + monkeypatch.setattr( + benv.module, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + data = b"prebuilt so from imported repo" + item = benv.get_item(plugin["plugin_id"], plugin["version"]) + benv.s3.put_object(Bucket=benv.bucket, + Key=item["source_s3_prefix"] + "prebuilt/x86_64/p.so", + Body=data) + + status, body = benv.post_build( + admin, plugin["plugin_id"], plugin["version"], + {"prebuilt": {"x86_64": {"source_key": "prebuilt/x86_64/p.so"}}}) + + assert status == 202, body + assert body["settled"] is True + assert body["builds"]["x86_64"]["checksum"] == \ + hashlib.sha256(data).hexdigest() + assert len(invocations) == 1 + + def test_prebuilt_rejects_missing_source_key(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + + status, body = benv.post_build( + admin, plugin["plugin_id"], plugin["version"], + {"prebuilt": {"x86_64": {"source_key": "no/such/file.so"}}}) + + assert status == 400 + assert body["error"]["code"] == "INVALID_PREBUILT" + + +class TestBuildStatusEndpoint: + """GET /plugins/{id}/versions/{v}/builds for the UI (3.5).""" + + def test_viewer_reads_per_arch_status(self, benv): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + benv.submit_and_succeed(admin, plugin, "x86_64") + viewer = benv.make_user() + benv.assign_role(viewer, usecase_id, "Viewer") + + status, body = benv.get_builds(viewer, plugin["plugin_id"], + plugin["version"]) + + assert status == 200 + assert body["builds"]["x86_64"]["buildStatus"] == "succeeded" + assert body["builds"]["x86_64"]["checksum"] + assert body["settled"] is True + + def test_outsider_cannot_submit_builds(self, benv): + """A user with no role in the Use_Case cannot submit builds.""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + outsider = benv.make_user() + + status, body = benv.post_build(outsider, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64"]}) + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + +class _RecordingCodeBuild: + """Proxy over the real (moto) CodeBuild client that records every + StartBuild call's kwargs.""" + + def __init__(self, real): + self._real = real + self.calls = [] + + def start_build(self, **kwargs): + self.calls.append(kwargs) + return self._real.start_build(**kwargs) + + def __getattr__(self, name): + return getattr(self._real, name) + + +class TestPluginTargetsPassThrough: + """A plugin-set import's recorded selected_plugins are passed to + CodeBuild as the PLUGIN_TARGETS env override (custom-node-designer + import selection enhancement).""" + + def _env_overrides(self, call): + return {var["name"]: var["value"] + for var in call["environmentVariablesOverride"]} + + def test_plugin_targets_value_joins_the_selection(self, benv): + mod = benv.module + assert mod.plugin_targets_value( + {"selected_plugins": ["rtp", "udp", "v4l2"]}) == "rtp,udp,v4l2" + assert mod.plugin_targets_value({"selected_plugins": []}) == "" + assert mod.plugin_targets_value({}) == "" + + def test_selected_plugins_pass_through_as_plugin_targets( + self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + benv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET selected_plugins = :sp", + ExpressionAttributeValues={":sp": ["rtp", "udp"]}, + ) + recorder = _RecordingCodeBuild(benv.module.codebuild) + monkeypatch.setattr(benv.module, "codebuild", recorder) + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64", + "arm64_jp5"]}) + + assert status == 202 + assert len(recorder.calls) == 2 + for call in recorder.calls: + env = self._env_overrides(call) + # The selection reaches every per-arch StartBuild as the + # comma-separated PLUGIN_TARGETS override the build image + # entrypoint consumes. + assert env["PLUGIN_TARGETS"] == "rtp,udp" + + def test_plugin_targets_is_empty_without_a_selection( + self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + recorder = _RecordingCodeBuild(benv.module.codebuild) + monkeypatch.setattr(benv.module, "codebuild", recorder) + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64"]}) + + assert status == 202 + env = self._env_overrides(recorder.calls[0]) + # No selection (scaffold/generated/single-plugin records): + # PLUGIN_TARGETS is passed empty, meaning "build everything". + assert env["PLUGIN_TARGETS"] == "" + + +class TestSelectedPluginsPassThrough: + """A provenance-recorded plugin selection (import-time + selected_plugins on POST /plugins/import) is passed to CodeBuild as + the SELECTED_PLUGINS env override so the build entrypoint can + meson-enable only those plugins.""" + + def _env_overrides(self, call): + return {var["name"]: var["value"] + for var in call["environmentVariablesOverride"]} + + def test_selected_plugins_value_prefers_provenance(self, benv): + mod = benv.module + assert mod.selected_plugins_value( + {"provenance": {"selectedPlugins": ["rtp", "udp"]}}) == "rtp,udp" + # Fallback to the record-level field (select-plugins endpoint + # writes both). + assert mod.selected_plugins_value( + {"selected_plugins": ["jpeg"]}) == "jpeg" + assert mod.selected_plugins_value({"provenance": {}}) == "" + assert mod.selected_plugins_value({}) == "" + + def test_provenance_selection_passes_through_as_selected_plugins( + self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + benv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET provenance.selectedPlugins = :sp", + ExpressionAttributeValues={":sp": ["rtp", "udp"]}, + ) + recorder = _RecordingCodeBuild(benv.module.codebuild) + monkeypatch.setattr(benv.module, "codebuild", recorder) + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64", + "arm64_jp5"]}) + + assert status == 202 + assert len(recorder.calls) == 2 + for call in recorder.calls: + env = self._env_overrides(call) + # Comma-separated selection on every per-arch StartBuild. + assert env["SELECTED_PLUGINS"] == "rtp,udp" + + def test_selected_plugins_is_absent_without_a_selection( + self, benv, monkeypatch): + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + recorder = _RecordingCodeBuild(benv.module.codebuild) + monkeypatch.setattr(benv.module, "codebuild", recorder) + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": ["x86_64"]}) + + assert status == 202 + env = self._env_overrides(recorder.calls[0]) + # No selection: the env var is not added at all (whole-module + # builds are indistinguishable from today's). + assert "SELECTED_PLUGINS" not in env + + +class TestErrorExcerpt: + """extract_error_excerpt: the failed-build logTail shows the actual + error, not the post-failure upload boilerplate (3.4).""" + + def _lines(self, benv, *sections): + return benv.module.extract_error_excerpt( + [line for section in sections for line in section]) + + def test_cuts_post_failure_boilerplate(self, benv): + build = [ + "[dda-plugin-build] meson setup /tmp/build .", + "Dependency gstreamer-1.0 found: NO found 1.14.5 but need: '>= 1.19.0'", + "meson.build:246:10: ERROR: Neither a subproject directory nor a gstreamer.wrap file was found.", + "[Container] Phase complete: BUILD State: FAILED", + "[Container] Phase context status code: COMMAND_EXECUTION_ERROR Message: exit status 1", + ] + boilerplate = [ + "[Container] Expanding base directory path: .", + "[Container] Assembling file list", + "[Container] Expanding **/*", + "[Container] No matching auto discover report paths found", + "[Container] Phase complete: UPLOAD_ARTIFACTS State: SUCCEEDED", + ] * 30 + excerpt = self._lines(benv, build, boilerplate) + assert "ERROR: Neither a subproject directory" in excerpt + assert "found 1.14.5 but need" in excerpt + assert "COMMAND_EXECUTION_ERROR" in excerpt + assert "UPLOAD_ARTIFACTS" not in excerpt + + def test_keeps_context_before_last_error(self, benv): + filler = [f"[42/630] Compiling C object plugin{i}.c.o" + for i in range(200)] + build = filler + [ + "FAILED: gst/rtsp/libgstrtsp.so", + "error: undefined reference to gst_pad_new", + "ninja: build stopped: subcommand failed.", + "[Container] Phase complete: BUILD State: FAILED", + ] + excerpt = self._lines(benv, build) + assert "undefined reference to gst_pad_new" in excerpt + # A bounded context window, not the whole 200-line compile log. + assert "plugin0.c.o" not in excerpt + assert "plugin199.c.o" in excerpt + + def test_falls_back_to_plain_tail_without_error_lines(self, benv): + lines = [f"line {i}" for i in range(10)] + assert benv.module.extract_error_excerpt(lines) == "\n".join(lines) + + def test_empty_lines(self, benv): + assert benv.module.extract_error_excerpt([]) == "" + + +class TestGstIntrospectionStanza: + """gstIntrospection stanza recording on the x86_64 artifact entry + (gst-parameter-prepopulation task 4.4, Requirements 1.4, 1.6). + + The SUCCEEDED path validates the report the build uploaded next to + the promoted .so ({plugin}.so.gstinspect.json) and records either a + captured stanza {status, s3Key, gstVersion, capturedAt} or a failed + stanza {status, message}; recording is best-effort and never alters + the build's succeeded status (1.4).""" + + #: A valid captured Introspection_Report (gst_properties shape v1). + CAPTURED_REPORT = { + "reportVersion": 1, + "status": "captured", + "gstVersion": "1.20.3", + "capturedAt": "2025-01-15T10:30:00Z", + "elements": [{ + "factory": "blurregions", + "elementGType": "GstBlurRegions", + "properties": [{ + "name": "strength", + "gtype": "gint", + "owner": "GstBlurRegions", + "writable": True, + "blurb": "Blur strength", + "default": 5, + "min": 0, + "max": 100, + }], + }], + } + + def _succeed_with_report(self, benv, report_body=None, arch="x86_64"): + """POST /build for one arch, stage the promoted .so (and the + introspection report when given), deliver SUCCEEDED, and return + (result, artifact entry, report key).""" + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + plugin_id, version = plugin["plugin_id"], plugin["version"] + status, body = benv.post_build(admin, plugin_id, version, + {"architectures": [arch]}) + assert status == 202, body + build_id = benv.get_item(plugin_id, version)["artifacts"][arch]["buildId"] + so_key = benv.put_promoted_artifact(usecase_id, arch, "blur-regions", + b"\x7fELF-shared-object") + report_key = so_key + ".gstinspect.json" + if report_body is not None: + benv.s3.put_object(Bucket=benv.bucket, Key=report_key, + Body=report_body) + result = benv.deliver_result( + arch=arch, build_id=build_id, status="SUCCEEDED", + plugin_id=plugin_id, version=version, + usecase_id=usecase_id, plugin_name="blur-regions") + entry = benv.get_item(plugin_id, version)["artifacts"][arch] + return result, entry, report_key + + def _assert_build_untouched(self, result, entry): + """The SUCCEEDED handling is unaffected by stanza recording (1.4): + status, artifact key, checksum, and signature are all recorded.""" + assert result["recorded"] is True + assert entry["buildStatus"] == "succeeded" + assert entry["s3Key"] and entry["checksum"] and entry["signature"] + + def test_captured_report_records_captured_stanza(self, benv): + """A valid captured report yields {status: captured, s3Key, + gstVersion, capturedAt} on the x86_64 entry.""" + result, entry, report_key = self._succeed_with_report( + benv, json.dumps(self.CAPTURED_REPORT).encode()) + + assert entry["gstIntrospection"] == { + "status": "captured", + "s3Key": report_key, + "gstVersion": "1.20.3", + "capturedAt": "2025-01-15T10:30:00Z", + } + self._assert_build_untouched(result, entry) + + def test_failed_report_carries_its_message(self, benv): + """A report that itself recorded a capture failure becomes a + failed stanza carrying the report's message.""" + failed_report = { + "reportVersion": 1, + "status": "failed", + "message": "Gst.init failed: no registry", + "elements": [], + } + result, entry, _ = self._succeed_with_report( + benv, json.dumps(failed_report).encode()) + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert stanza["message"] == "Gst.init failed: no registry" + self._assert_build_untouched(result, entry) + + def test_missing_report_object_records_failed_stanza(self, benv): + """No uploaded report (e.g. the build image predates the + introspection step) -> failed stanza with a diagnostic.""" + result, entry, _ = self._succeed_with_report(benv, report_body=None) + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert "No introspection report" in stanza["message"] + self._assert_build_untouched(result, entry) + + def test_oversized_report_records_failed_stanza(self, benv): + """A report over the 256 KiB cap is rejected with a failed + stanza naming the cap.""" + oversized = b"x" * (benv.module.GST_REPORT_MAX_BYTES + 1) + result, entry, _ = self._succeed_with_report(benv, oversized) + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert "size cap" in stanza["message"] + self._assert_build_untouched(result, entry) + + # -------- extended (pads-bearing) report shape + # (port-guidance-and-pad-prepopulation task 4.3, Requirement 3.3) + + def _pads_bearing_report(self, pad_count, caps_len): + """CAPTURED_REPORT extended with `pad_count` valid always-pads + whose caps strings are `caps_len` characters each (the extended + version-1 report shape of port-guidance-and-pad-prepopulation).""" + report = json.loads(json.dumps(self.CAPTURED_REPORT)) + element = report["elements"][0] + prefix = "video/x-raw, format=(string)" + element["pads"] = [{ + "name": f"{'sink' if i % 2 == 0 else 'src'}_{i}", + "direction": "sink" if i % 2 == 0 else "src", + "presence": "always", + "caps": (prefix + "R" * caps_len)[:caps_len], + "capsTruncated": False, + } for i in range(pad_count)] + element["padsError"] = None + return report + + def test_pads_bearing_report_records_captured_stanza(self, benv): + """The unchanged stanza code accepts the extended report shape: + a valid captured report carrying pad data under the size cap + records the ordinary captured stanza.""" + report = self._pads_bearing_report(pad_count=2, caps_len=64) + result, entry, report_key = self._succeed_with_report( + benv, json.dumps(report).encode()) + + assert entry["gstIntrospection"] == { + "status": "captured", + "s3Key": report_key, + "gstVersion": "1.20.3", + "capturedAt": "2025-01-15T10:30:00Z", + } + self._assert_build_untouched(result, entry) + + def test_oversized_pads_bearing_report_records_failed_stanza(self, benv): + """A pads-bearing report over the 256 KiB cap — likelier now + that pad data (caps up to 4096 chars per pad) rides along — + yields the failed stanza with the size-cap diagnostic while the + build status stays succeeded (3.3).""" + report = self._pads_bearing_report(pad_count=80, caps_len=4000) + body = json.dumps(report).encode() + assert len(body) > benv.module.GST_REPORT_MAX_BYTES + # The document itself is a valid extended-shape report — only + # the size cap fails it, not pad validation. + benv.module.parse_report(json.loads(body.decode())) + + result, entry, _ = self._succeed_with_report(benv, body) + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert "size cap" in stanza["message"] + self._assert_build_untouched(result, entry) + + def test_invalid_json_records_failed_stanza(self, benv): + """A report object that is not JSON at all -> failed stanza.""" + result, entry, _ = self._succeed_with_report(benv, b"not json {{{") + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert "not valid JSON" in stanza["message"] + self._assert_build_untouched(result, entry) + + def test_malformed_report_shape_records_failed_stanza(self, benv): + """Valid JSON that fails gst_properties.parse_report (wrong + reportVersion here) -> failed stanza with the shape diagnostic.""" + bad_shape = {"reportVersion": 99, "status": "captured"} + result, entry, _ = self._succeed_with_report( + benv, json.dumps(bad_shape).encode()) + + stanza = entry["gstIntrospection"] + assert stanza["status"] == "failed" + assert "malformed" in stanza["message"] + self._assert_build_untouched(result, entry) + + def test_non_x86_64_entry_gets_no_stanza(self, benv): + """Property_Introspection is x86_64-only: another arch's entry + never carries a gstIntrospection stanza even when a report + object exists next to its artifact.""" + result, entry, _ = self._succeed_with_report( + benv, json.dumps(self.CAPTURED_REPORT).encode(), arch="arm64_jp5") + + assert "gstIntrospection" not in entry + self._assert_build_untouched(result, entry) diff --git a/edge-cv-portal/backend/tests/test_plugin_components.py b/edge-cv-portal/backend/tests/test_plugin_components.py new file mode 100644 index 00000000..ddf802d9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_components.py @@ -0,0 +1,421 @@ +""" +Unit tests for plugin_components.py (custom-node-designer task 6.3). + +Covers Plugin_Component auto-packaging (Requirements 16.1, 16.7): +install-only recipe assembly with one platform manifest per successfully +built Target_Architecture (ARCH_TO_GG_PLATFORM + 'variant' for JetPack +arm64, 'runtime: nvidia' for x86_64_nvidia, plain x86_64 ordered after +x86_64_nvidia), artifact staging/promotion to the Use_Case account +bucket (signed .so + plugin-manifest.json), registry tags, the +Plugin_Record 'component' status pointer, failed-registration cleanup, +retry idempotency (registered short-circuit, ConflictException +re-describe), and the GET /plugins/{id}/versions/{v}/component route. + +Runs against the moto-backed stack from conftest.py with a MagicMock +Use_Case-account Greengrass client (moto does not implement +greengrassv2). +""" +import hashlib +import json +import sys +import uuid +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +from conftest import TEST_ENV + + +@pytest.fixture(scope="module") +def components_module(aws_stack): + """Import plugin_components inside the moto mock so its module-level + boto3 clients (and workflow_packaging's) are intercepted.""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + return plugin_components + + +class PluginComponentsEnv: + """Facade for exercising Plugin_Component packaging in tests.""" + + def __init__(self, stack, module, monkeypatch): + self.stack = stack + self.module = module + self.records = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + self.monkeypatch = monkeypatch + monkeypatch.setattr(module, "COMPONENT_STATUS_POLL_SECONDS", 0) + + # Per-test Use_Case with its own account bucket. + self.usecase_id = f"uc-{uuid.uuid4()}" + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Components Test Use Case", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + self.admin = self.make_user() + self.assign_role(self.admin, "UseCaseAdmin") + + # ------------------------------------------------------------- setup + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": self.usecase_id, + "role": role, + }) + + def seed_plugin(self, built_archs, failed_archs=(), name="blur-regions"): + """Create a Plugin_Record and record per-arch artifact entries with + the .so bytes promoted to the portal Plugin_Library.""" + event = { + "httpMethod": "POST", + "resource": "/plugins", + "path": "/plugins", + "pathParameters": None, + "queryStringParameters": None, + "body": json.dumps({"usecase_id": self.usecase_id, + "name": name, "kind": "scaffold"}), + "requestContext": {"authorizer": {"claims": { + "sub": self.admin["user_id"], + "email": self.admin["email"], + "cognito:username": self.admin["username"], + "custom:role": self.admin["role"], + }}}, + } + response = self.records.handler(event, None) + assert response["statusCode"] == 201, response["body"] + plugin = json.loads(response["body"])["plugin"] + + artifacts, self.so_bytes = {}, {} + for arch in built_archs: + data = f"\x7fELF {name} {arch}".encode() + key = (f"workflow-plugins/custom/{self.usecase_id}/{arch}/" + f"{name}.so") + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + artifacts[arch] = { + "buildStatus": "succeeded", "s3Key": key, + "checksum": hashlib.sha256(data).hexdigest(), + "signature": "c2ln", "logTail": "", + } + self.so_bytes[arch] = data + for arch in failed_archs: + artifacts[arch] = {"buildStatus": "failed", + "logTail": "compile error"} + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], "version": plugin["version"]}, + UpdateExpression="SET artifacts = :a, requested_architectures = :r", + ExpressionAttributeValues={ + ":a": artifacts, + ":r": sorted(list(built_archs) + list(failed_archs)), + }, + ) + return plugin + + def make_greengrass(self, state="DEPLOYABLE"): + gg = MagicMock(name="greengrassv2") + gg.meta.region_name = "us-east-1" + gg.create_component_version.return_value = { + "arn": ("arn:aws:greengrass:us-east-1:123456789012:components:" + f"test:versions:{uuid.uuid4()}") + } + gg.describe_component.return_value = { + "status": {"componentState": state, "message": "simulated"} + } + return gg + + def patch_usecase_clients(self, greengrass=None): + gg = greengrass or self.make_greengrass() + moto_s3 = self.s3 + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + return {"s3": moto_s3, "greengrassv2": gg}[service_name] + + self.monkeypatch.setattr(self.module, "get_usecase_client", + fake_get_usecase_client) + return gg + + # ----------------------------------------------------------- invoke + def package(self, plugin): + return self.module.handler({ + "action": "package_plugin_component", + "plugin_id": plugin["plugin_id"], + "version": plugin["version"], + "usecase_id": self.usecase_id, + }, None) + + def get_component(self, user, plugin_id, version): + event = { + "httpMethod": "GET", + "resource": "/plugins/{id}/versions/{v}/component", + "path": f"/plugins/{plugin_id}/versions/{version}/component", + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": None, + "requestContext": {"authorizer": {"claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + }}}, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ------------------------------------------------------ conveniences + def get_item(self, plugin): + return self.records.get_version_item(plugin["plugin_id"], + plugin["version"]) + + def account_keys(self, prefix): + listed = self.s3.list_objects_v2(Bucket=self.usecase_bucket, + Prefix=prefix) + return sorted(o["Key"] for o in listed.get("Contents", [])) + + def sent_recipe(self, gg): + kwargs = gg.create_component_version.call_args.kwargs + return json.loads(kwargs["inlineRecipe"]), kwargs.get("tags", {}) + + +@pytest.fixture +def cenv(aws_stack, components_module, monkeypatch): + return PluginComponentsEnv(aws_stack, components_module, monkeypatch) + + +class TestRecipeAssembly: + """build_plugin_recipe is pure over the built architectures (16.1).""" + + def test_one_manifest_per_built_architecture(self, components_module): + recipe = components_module.build_plugin_recipe( + "plg-1", 3, "acct-bucket", + {"x86_64": "p.so", "arm64_jp5": "p.so"}) + + assert recipe["ComponentName"] == "dda.plugin.plg-1" + assert recipe["ComponentVersion"] == "3.0.0" + platforms = [m["Platform"] for m in recipe["Manifests"]] + assert [p["architecture"] for p in platforms] == ["aarch64", "amd64"] + + def test_install_only_lifecycle_and_install_path(self, components_module): + recipe = components_module.build_plugin_recipe( + "plg-1", 2, "acct-bucket", {"x86_64": "blur.so"}) + + assert recipe["Lifecycle"] == {} # no top-level Run lifecycle + manifest = recipe["Manifests"][0] + assert list(manifest["Lifecycle"].keys()) == ["Install"] + script = manifest["Lifecycle"]["Install"]["Script"] + assert "/aws_dda/plugins/plg-1/2/x86_64" in script + uris = [a["Uri"] for a in manifest["Artifacts"]] + assert uris == [ + "s3://acct-bucket/plugins/components/plg-1/2/x86_64/blur.so", + "s3://acct-bucket/plugins/components/plg-1/2/x86_64/plugin-manifest.json", + ] + + def test_platform_attributes_variant_and_nvidia_runtime(self, components_module): + recipe = components_module.build_plugin_recipe( + "plg-1", 1, "b", + {a: "p.so" for a in + ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6")}) + + by_arch = {} + for manifest in recipe["Manifests"]: + platform = manifest["Platform"] + if platform["architecture"] == "aarch64": + by_arch[platform["variant"]] = platform + elif platform.get("runtime") == "nvidia": + by_arch["x86_64_nvidia"] = platform + else: + by_arch["x86_64"] = platform + assert set(by_arch) == {"x86_64", "x86_64_nvidia", + "arm64_jp4", "arm64_jp5", "arm64_jp6"} + for jp in ("arm64_jp4", "arm64_jp5", "arm64_jp6"): + assert by_arch[jp] == {"os": "linux", "architecture": "aarch64", + "variant": jp} + assert by_arch["x86_64_nvidia"] == {"os": "linux", + "architecture": "amd64", + "runtime": "nvidia"} + assert by_arch["x86_64"] == {"os": "linux", "architecture": "amd64"} + + def test_plain_x86_64_manifest_ordered_after_x86_64_nvidia(self, components_module): + order = components_module.manifest_arch_order( + ["x86_64", "arm64_jp6", "x86_64_nvidia"]) + assert order == ["arm64_jp6", "x86_64_nvidia", "x86_64"] + + recipe = components_module.build_plugin_recipe( + "plg-1", 1, "b", {"x86_64": "p.so", "x86_64_nvidia": "p.so"}) + runtimes = [m["Platform"].get("runtime") for m in recipe["Manifests"]] + assert runtimes == ["nvidia", None] + + +class TestAutoPackaging: + """Async packaging trigger: stage -> promote -> register (16.1).""" + + def test_successful_packaging_registers_and_records_pointer(self, cenv): + plugin = cenv.seed_plugin(["x86_64", "arm64_jp5"], + failed_archs=["arm64_jp4"]) + gg = cenv.patch_usecase_clients() + + result = cenv.package(plugin) + + assert result["packaged"] is True, result + assert result["component_name"] == f"dda.plugin.{plugin['plugin_id']}" + assert result["component_version"] == f"{plugin['version']}.0.0" + # Manifests cover exactly the successfully built architectures. + recipe, tags = cenv.sent_recipe(gg) + assert len(recipe["Manifests"]) == 2 + assert tags == { + "dda-portal:managed": "true", + "dda-portal:usecase-id": cenv.usecase_id, + "dda-portal:plugin-id": plugin["plugin_id"], + "dda-portal:plugin-version": str(plugin["version"]), + } + # Component pointer recorded on the Plugin_Record. + component = cenv.get_item(plugin)["component"] + assert component["status"] == "registered" + assert component["arn"] == result["component_arn"] + assert component["architectures"] == ["arm64_jp5", "x86_64"] + + def test_artifacts_promoted_to_account_bucket_and_stage_cleaned(self, cenv): + plugin = cenv.seed_plugin(["x86_64"]) + cenv.patch_usecase_clients() + pid, ver = plugin["plugin_id"], plugin["version"] + + result = cenv.package(plugin) + + assert result["packaged"] is True, result + final_prefix = f"plugins/components/{pid}/{ver}/x86_64/" + assert cenv.account_keys(final_prefix) == [ + final_prefix + "blur-regions.so", + final_prefix + "plugin-manifest.json", + ] + # Signed .so bytes copied verbatim; plugin-manifest.json carries + # name, version, arch, and checksum (16.1). + so = cenv.s3.get_object(Bucket=cenv.usecase_bucket, + Key=final_prefix + "blur-regions.so") + assert so["Body"].read() == cenv.so_bytes["x86_64"] + manifest = json.loads(cenv.s3.get_object( + Bucket=cenv.usecase_bucket, + Key=final_prefix + "plugin-manifest.json")["Body"].read()) + assert manifest == { + "name": "blur-regions", + "version": ver, + "arch": "x86_64", + "checksum": hashlib.sha256(cenv.so_bytes["x86_64"]).hexdigest(), + } + assert cenv.account_keys(f"plugins/staging/{pid}/") == [] + + def test_failed_registration_deletes_component_and_artifacts(self, cenv): + """A version that never becomes DEPLOYABLE is deleted and no + promoted artifacts remain (all-or-nothing).""" + plugin = cenv.seed_plugin(["x86_64"]) + gg = cenv.patch_usecase_clients(cenv.make_greengrass(state="BROKEN")) + pid, ver = plugin["plugin_id"], plugin["version"] + + result = cenv.package(plugin) + + assert result["packaged"] is False + gg.delete_component.assert_called_once_with( + arn=gg.create_component_version.return_value["arn"]) + assert cenv.account_keys(f"plugins/components/{pid}/{ver}/") == [] + assert cenv.account_keys(f"plugins/staging/{pid}/") == [] + component = cenv.get_item(plugin)["component"] + assert component["status"] == "failed" + assert "DEPLOYABLE" in component["failure"] + + def test_registered_pointer_short_circuits_retry(self, cenv): + plugin = cenv.seed_plugin(["x86_64"]) + gg = cenv.patch_usecase_clients() + + first = cenv.package(plugin) + retry = cenv.package(plugin) + + assert first["packaged"] is True + assert retry["packaged"] is True + assert retry["short_circuited"] is True + assert retry["component_arn"] == first["component_arn"] + assert gg.create_component_version.call_count == 1 + + def test_conflict_exception_re_describes_existing_version(self, cenv): + """ConflictException (already registered by a previous attempt) + resolves idempotently without deleting the existing version (16.7).""" + plugin = cenv.seed_plugin(["x86_64"]) + gg = cenv.make_greengrass() + gg.create_component_version.side_effect = ClientError( + {"Error": {"Code": "ConflictException", "Message": "exists"}}, + "CreateComponentVersion") + cenv.patch_usecase_clients(gg) + + result = cenv.package(plugin) + + assert result["packaged"] is True, result + expected_arn = ("arn:aws:greengrass:us-east-1:123456789012:components:" + f"dda.plugin.{plugin['plugin_id']}:versions:" + f"{plugin['version']}.0.0") + assert result["component_arn"] == expected_arn + gg.delete_component.assert_not_called() + assert cenv.get_item(plugin)["component"]["status"] == "registered" + + def test_no_successful_builds_records_nothing(self, cenv): + plugin = cenv.seed_plugin([], failed_archs=["x86_64"]) + gg = cenv.patch_usecase_clients() + + result = cenv.package(plugin) + + assert result == {"packaged": False, "reason": "no successful builds"} + gg.create_component_version.assert_not_called() + + def test_missing_record_never_raises(self, cenv): + result = cenv.module.handler({ + "action": "package_plugin_component", + "plugin_id": "no-such-plugin", "version": 1, + "usecase_id": cenv.usecase_id, + }, None) + assert result["packaged"] is False + + +class TestComponentStatusEndpoint: + """GET /plugins/{id}/versions/{v}/component.""" + + def test_viewer_reads_component_pointer(self, cenv): + plugin = cenv.seed_plugin(["x86_64"]) + cenv.patch_usecase_clients() + cenv.package(plugin) + viewer = cenv.make_user() + cenv.assign_role(viewer, "Viewer") + + status, body = cenv.get_component(viewer, plugin["plugin_id"], + plugin["version"]) + + assert status == 200 + assert body["component"]["status"] == "registered" + assert body["component"]["name"] == f"dda.plugin.{plugin['plugin_id']}" + assert body["component"]["architectures"] == ["x86_64"] + + def test_unpackaged_version_returns_empty_pointer(self, cenv): + plugin = cenv.seed_plugin(["x86_64"]) + + status, body = cenv.get_component(cenv.admin, plugin["plugin_id"], + plugin["version"]) + + assert status == 200 + assert body["component"] == {} + + def test_missing_record_returns_404(self, cenv): + status, body = cenv.get_component(cenv.admin, "no-such-plugin", 1) + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" diff --git a/edge-cv-portal/backend/tests/test_plugin_gst_properties_route.py b/edge-cv-portal/backend/tests/test_plugin_gst_properties_route.py new file mode 100644 index 00000000..592b1dc7 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_gst_properties_route.py @@ -0,0 +1,633 @@ +""" +Unit tests for GET /plugins/{id}/versions/{v}/gst-properties +(gst-parameter-prepopulation task 3.2). + +Covers each machine-readable unavailability reason — `no_x86_64_build` +(no artifacts, failed x86_64 build, other-arch-only build), `not_captured` +(successful build predating Property_Introspection, 7.4), and +`introspection_failed` (failed capture stanza, missing stored report, +malformed stored JSON, non-conforming report document, 8.3) — plus the +available path serving the stored Introspection_Report with derived +per-element Parameter_Suggestions (1.5, 1.6), and the route's access +behavior: uniform 404 for missing records and for users without +node-designer read access, so record existence never leaks across +tenants. + +Runs against the moto-backed stack from conftest.py, exercising the +real RBAC / persistence code paths (test_plugin_simulator.py +conventions). +""" +import json +import uuid + +import pytest + +from conftest import TEST_ENV + + +def captured_report_document(factory="myblur", element_gtype="GstMyBlur"): + """A valid version-1 Introspection_Report document mixing an own + ranged int property, an own GEnum property, an own unmappable + property (skipped), and a base-class property (filtered out).""" + return { + "reportVersion": 1, + "status": "captured", + "message": None, + "gstVersion": "1.20.3", + "capturedAt": "2026-02-14T12:00:00Z", + "elements": [{ + "factory": factory, + "elementGType": element_gtype, + "instantiationError": None, + "properties": [ + {"name": "radius", "gtype": "gint", "owner": element_gtype, + "writable": True, "blurb": "Blur radius in pixels", + "default": 5, "min": 0, "max": 100, "enumValues": None}, + {"name": "mode", "gtype": "GstMyBlurMode", + "owner": element_gtype, "writable": True, + "blurb": "Blur mode", "default": "gaussian", + "min": None, "max": None, + "enumValues": [{"value": 0, "nick": "gaussian"}, + {"value": 1, "nick": "box"}]}, + {"name": "filter-caps", "gtype": "GstCaps", + "owner": element_gtype, "writable": True, + "blurb": "Restrict caps", "default": None, + "min": None, "max": None, "enumValues": None}, + # Base_Class_Property: owned by GstObject, never served. + {"name": "name", "gtype": "gchararray", "owner": "GstObject", + "writable": True, "blurb": "The name of the object", + "default": None, "min": None, "max": None, + "enumValues": None}, + ], + }], + } + + +class GstPropertiesEnv: + """Facade for invoking the gst-properties route in tests.""" + + def __init__(self, stack): + self.stack = stack + self.module = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + # ------------------------------------------------------------- setup + def create_usecase(self): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Gst Properties Test Use Case", + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + def make_admin(self, usecase_id): + admin = self.make_user() + self.assign_role(admin, usecase_id, "UseCaseAdmin") + return admin + + def create_plugin(self, user, usecase_id, name="blur-regions"): + event = self._event("POST", "/plugins", user, body={ + "usecase_id": usecase_id, "name": name, "kind": "scaffold"}) + response = self.module.handler(event, None) + body = json.loads(response["body"]) + assert response["statusCode"] == 201, body + return body["plugin"] + + def report_key(self, plugin, plugin_name="blur-regions"): + return (f"workflow-plugins/custom/{plugin['usecase_id']}" + f"/x86_64/{plugin_name}.so.gstinspect.json") + + def seed_artifact(self, plugin, arch="x86_64", build_status="succeeded", + gst_introspection=None): + """Record a per-arch Plugin_Artifact entry, optionally carrying a + gstIntrospection stanza, directly on the version item.""" + entry = { + "buildStatus": build_status, + "s3Key": (f"workflow-plugins/custom/{plugin['usecase_id']}" + f"/{arch}/blur-regions.so"), + "checksum": "aa" * 32, + "signature": "sig", + } + if gst_introspection is not None: + entry["gstIntrospection"] = gst_introspection + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET artifacts.#a = :entry", + ExpressionAttributeNames={"#a": arch}, + ExpressionAttributeValues={":entry": entry}, + ) + + def captured_stanza(self, plugin): + return { + "status": "captured", + "s3Key": self.report_key(plugin), + "gstVersion": "1.20.3", + "capturedAt": "2026-02-14T12:00:00Z", + } + + def put_report(self, plugin, body): + """Store the report object next to the promoted artifact.""" + if isinstance(body, dict): + body = json.dumps(body).encode("utf-8") + self.s3.put_object(Bucket=self.bucket, + Key=self.report_key(plugin), Body=body) + + def plugin_with_report(self, document): + """A use case + admin + plugin whose x86_64 artifact carries a + captured stanza pointing at the stored `document`.""" + usecase_id = self.create_usecase() + admin = self.make_admin(usecase_id) + plugin = self.create_plugin(admin, usecase_id) + self.seed_artifact(plugin, + gst_introspection=self.captured_stanza(plugin)) + self.put_report(plugin, document) + return usecase_id, admin, plugin + + # ----------------------------------------------------------- invoke + def _event(self, method, resource, user, plugin_id=None, version=None, + body=None): + path_params = {} + if plugin_id is not None: + path_params = {"id": plugin_id, "v": str(version)} + return { + "httpMethod": method, + "resource": resource, + "path": resource, + "pathParameters": path_params or None, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + def get_gst_properties(self, user, plugin_id, version): + event = self._event( + "GET", "/plugins/{id}/versions/{v}/gst-properties", + user, plugin_id=plugin_id, version=version) + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def genv(aws_stack): + return GstPropertiesEnv(aws_stack) + + +@pytest.fixture +def plugin_setup(genv): + """A Use_Case with an admin and a fresh Plugin_Record (no artifacts).""" + usecase_id = genv.create_usecase() + admin = genv.make_admin(usecase_id) + plugin = genv.create_plugin(admin, usecase_id) + return usecase_id, admin, plugin + + +# ------------------------------------------- unavailability reasons (1.6) + +class TestUnavailabilityReasons: + """Each degraded case answers 200 {available: false, reason} — a + machine-readable outcome, never an error (1.6, 7.4, 8.3).""" + + def test_no_artifacts_reports_no_build(self, genv, plugin_setup): + _, admin, plugin = plugin_setup + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body == {"available": False, "reason": "no_x86_64_build"} + + def test_failed_x86_64_build_reports_no_build(self, genv, plugin_setup): + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, build_status="failed") + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body == {"available": False, "reason": "no_x86_64_build"} + + def test_other_arch_build_does_not_count_as_x86_64(self, genv, + plugin_setup): + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, arch="arm64_jp5") + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body == {"available": False, "reason": "no_x86_64_build"} + + def test_absent_stanza_reports_not_captured(self, genv, plugin_setup): + """A successful build predating Property_Introspection (7.4).""" + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin) # succeeded, no gstIntrospection stanza + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body == {"available": False, "reason": "not_captured"} + + def test_failed_stanza_reports_introspection_failed(self, genv, + plugin_setup): + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, gst_introspection={ + "status": "failed", + "message": "no element factories registered"}) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + # The capture diagnostic reaches the caller (7.2). + assert body["message"] == "no element factories registered" + + def test_missing_report_object_reports_introspection_failed( + self, genv, plugin_setup): + """Captured stanza, but the S3 report object is gone.""" + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, + gst_introspection=genv.captured_stanza(plugin)) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + + def test_malformed_stored_json_reports_introspection_failed( + self, genv, plugin_setup): + """A stored document that is not JSON at all maps to the + unavailability reason, never a 500 (8.3).""" + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, + gst_introspection=genv.captured_stanza(plugin)) + genv.put_report(plugin, b"{not valid json!") + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + + def test_nonconforming_report_document_reports_introspection_failed( + self, genv, plugin_setup): + """Valid JSON that fails parse_report (wrong reportVersion) also + maps to introspection_failed (8.3).""" + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, + gst_introspection=genv.captured_stanza(plugin)) + genv.put_report(plugin, {"reportVersion": 999, "status": "captured", + "elements": []}) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + + def test_failed_report_document_reports_introspection_failed( + self, genv, plugin_setup): + """A well-formed stored report whose own status is 'failed' + surfaces the capture diagnostic (7.2).""" + _, admin, plugin = plugin_setup + genv.seed_artifact(plugin, + gst_introspection=genv.captured_stanza(plugin)) + genv.put_report(plugin, { + "reportVersion": 1, "status": "failed", + "message": "plugin registered no element factories", + "gstVersion": None, "capturedAt": None, "elements": []}) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + assert body["message"] == "plugin registered no element factories" + + +# ------------------------------------------------- the available path (1.5) + +class TestAvailablePath: + def test_serves_report_with_derived_suggestions(self, genv): + _, admin, plugin = genv.plugin_with_report(captured_report_document()) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + assert body["gstVersion"] == "1.20.3" + assert body["capturedAt"] == "2026-02-14T12:00:00Z" + assert len(body["elements"]) == 1 + + element = body["elements"][0] + assert element["factory"] == "myblur" + + # Own writable mappable properties become suggestions in the + # ParameterDeclaration wire shape, in property order (1.5). + assert element["suggestions"] == [ + {"name": "radius", "paramType": "int", "required": False, + "default": 5, "constraints": {"min": 0, "max": 100}, + "description": "Blur radius in pixels", "examples": [5]}, + {"name": "mode", "paramType": "enum", "required": False, + "default": "gaussian", + "constraints": {"values": ["gaussian", "box"]}, + "description": "Blur mode", "examples": ["gaussian"]}, + ] + + # The unmappable own property is skipped with a reason (2.5)... + assert element["skipped"] == [ + {"name": "filter-caps", + "reason": "no parameter type mapping for GType 'GstCaps'"}, + ] + # ...and the base-class property appears nowhere (4.1). + served_names = ([s["name"] for s in element["suggestions"]] + + [s["name"] for s in element["skipped"]]) + assert "name" not in served_names + + def test_viewer_role_can_read_gst_properties(self, genv): + """node-designer read access suffices (1.5): every role of the + Use_Case can run the Parameter_Scan.""" + usecase_id, _, plugin = genv.plugin_with_report( + captured_report_document()) + viewer = genv.make_user() + genv.assign_role(viewer, usecase_id, "Viewer") + + status, body = genv.get_gst_properties( + viewer, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + + +# ------------------------------------- uniform 404 / RBAC (cross-tenant) + +class TestAccessControl: + """The route answers the same uniform 404 for a missing record and + for a caller without node-designer read access, so Plugin_Record + existence never leaks across tenants.""" + + def test_unknown_plugin_is_uniform_404(self, genv, plugin_setup): + _, admin, _ = plugin_setup + status, body = genv.get_gst_properties(admin, "no-such-plugin", 1) + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + + def test_unknown_version_is_uniform_404(self, genv, plugin_setup): + _, admin, plugin = plugin_setup + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], 999) + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + + def test_read_denied_user_gets_the_same_uniform_404( + self, genv, monkeypatch): + """RBAC denial of node-designer:read yields a 404 identical to + the missing-record response (cross-tenant safe). The shared RBAC + layer resolves users without a role record to read-only Viewer, + so the denial is pinned at the module's permission seam — the + same seam authorize_record_access consults — to lock the route's + uniform-404 contract for denied readers.""" + _, admin, plugin = genv.plugin_with_report(captured_report_document()) + other_usecase = genv.create_usecase() + outsider = genv.make_admin(other_usecase) + + real_check = genv.module.has_node_designer_permission + + def deny_outsider(user, usecase_id, permission): + if user["user_id"] == outsider["user_id"]: + return False + return real_check(user, usecase_id, permission) + + monkeypatch.setattr(genv.module, "has_node_designer_permission", + deny_outsider) + + status, body = genv.get_gst_properties( + outsider, plugin["plugin_id"], plugin["version"]) + missing_status, missing_body = genv.get_gst_properties( + admin, "no-such-plugin", 1) + + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + # Byte-for-byte the same envelope as a missing record: existence + # is never leaked to a denied caller. + assert (status, body) == (missing_status, missing_body) + + def test_cross_tenant_admin_never_sees_a_500_or_403_leak(self, genv): + """With the real RBAC layer (no-role users resolve to read-only + Viewer), an admin of a different Use_Case gets a normal read + outcome or the uniform 404 — never an error that would confirm + or deny the record's existence differently.""" + _, _, plugin = genv.plugin_with_report(captured_report_document()) + other_usecase = genv.create_usecase() + outsider = genv.make_admin(other_usecase) + + status, body = genv.get_gst_properties( + outsider, plugin["plugin_id"], plugin["version"]) + + assert status in (200, 404) + if status == 404: + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + + +# --------------------------- pad-derived port fields (4.4, 4.5, 4.6, 4.7, 4.8) + +def pads_bearing_report_document(): + """The captured report document extended with a per-element pad list: + one always/sink video pad (confident input suggestion), one always/src + non-video pad (unconfirmed output suggestion), and one request pad + (Unmapped_Pad).""" + document = captured_report_document() + document["elements"][0]["pads"] = [ + {"name": "sink", "direction": "sink", "presence": "always", + "caps": "video/x-raw, format=(string){ RGB, BGR }", + "capsTruncated": False}, + {"name": "src", "direction": "src", "presence": "always", + "caps": "application/x-custom", "capsTruncated": False}, + {"name": "req_%u", "direction": "src", "presence": "request", + "caps": "ANY", "capsTruncated": False}, + ] + document["elements"][0]["padsError"] = None + return document + + +class TestPadDerivedPortFields: + """The route's additive per-element pad fields (`portSuggestions`, + `unmappedPads`, `padsReason`, `padsMessage`) ride alongside the + unchanged `suggestions`/`skipped` (port-guidance-and-pad-prepopulation + 4.4-4.8).""" + + def test_pads_bearing_report_serves_port_fields_per_element(self, genv): + """A stored pads-bearing report answers available:true with the + derived Port_Suggestions and Unmapped_Pads per element (4.5).""" + _, admin, plugin = genv.plugin_with_report( + pads_bearing_report_document()) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + element = body["elements"][0] + + # Always-pads with valid names become Port_Suggestions in pad + # order: sink -> input, src -> output; confidence follows the + # video/x-raw caps prefix. + assert element["portSuggestions"] == [ + {"name": "sink", "direction": "input", + "portType": "VideoFrames", "confident": True, + "caps": "video/x-raw, format=(string){ RGB, BGR }", + "capsTruncated": False, + "reason": "the pad's caps begin with video/x-raw"}, + {"name": "src", "direction": "output", + "portType": "VideoFrames", "confident": False, + "caps": "application/x-custom", "capsTruncated": False, + "reason": ("InferenceMeta and EventSignal are DDA semantic " + "concepts that GStreamer caps cannot express; " + "confirm the Port_Type yourself if this pad does " + "not carry raw video")}, + ] + # The request pad is unmapped with its runtime-pads caveat. + assert element["unmappedPads"] == [ + {"name": "req_%u", "direction": "src", "presence": "request", + "caveat": ("request pads are created at runtime and do not " + "correspond to fixed declared Ports")}, + ] + # Non-empty pads: no reason, no message. + assert element["padsReason"] is None + assert element["padsMessage"] is None + + def test_suggestions_and_skipped_identical_to_pad_free_control(self, + genv): + """Pad data never changes the parameter-scan fields: the + pads-bearing report's `suggestions`/`skipped` are byte-identical + to a pad-free control of the same properties (4.6).""" + _, admin_pads, plugin_pads = genv.plugin_with_report( + pads_bearing_report_document()) + _, admin_ctrl, plugin_ctrl = genv.plugin_with_report( + captured_report_document()) + + _, body_pads = genv.get_gst_properties( + admin_pads, plugin_pads["plugin_id"], plugin_pads["version"]) + _, body_ctrl = genv.get_gst_properties( + admin_ctrl, plugin_ctrl["plugin_id"], plugin_ctrl["version"]) + + pads_element = body_pads["elements"][0] + ctrl_element = body_ctrl["elements"][0] + assert (json.dumps(pads_element["suggestions"], sort_keys=True) + == json.dumps(ctrl_element["suggestions"], sort_keys=True)) + assert (json.dumps(pads_element["skipped"], sort_keys=True) + == json.dumps(ctrl_element["skipped"], sort_keys=True)) + + def test_legacy_report_answers_available_with_pads_not_captured(self, + genv): + """A stored legacy report (no `pads` key) stays fully available; + each element reports padsReason 'pads_not_captured' with empty + port lists (4.7).""" + _, admin, plugin = genv.plugin_with_report( + captured_report_document()) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + element = body["elements"][0] + # The parameter scan still works (available path unchanged)... + assert element["suggestions"] + # ...while the pad side is explicitly not captured. + assert element["padsReason"] == "pads_not_captured" + assert element["padsMessage"] is None + assert element["portSuggestions"] == [] + assert element["unmappedPads"] == [] + + def test_malformed_pads_report_introspection_failed(self, genv): + """A stored report whose pad data is malformed (invalid + direction) fails parse_report and maps to the existing + introspection_failed unavailability reason (4.4).""" + document = pads_bearing_report_document() + document["elements"][0]["pads"][0]["direction"] = "north" + _, admin, plugin = genv.plugin_with_report(document) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is False + assert body["reason"] == "introspection_failed" + + def test_empty_pad_list_answers_no_pad_templates(self, genv): + """An element with `pads: []` and no padsError declares no static + pad templates (4.8).""" + document = captured_report_document() + document["elements"][0]["pads"] = [] + document["elements"][0]["padsError"] = None + _, admin, plugin = genv.plugin_with_report(document) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + element = body["elements"][0] + assert element["padsReason"] == "no_pad_templates" + assert element["padsMessage"] is None + assert element["portSuggestions"] == [] + assert element["unmappedPads"] == [] + + def test_pads_read_failed_element_carries_its_message(self, genv): + """An element whose pad capture failed (`pads: []` with a + padsError diagnostic) surfaces the diagnostic as padsMessage + (3.2 surfacing).""" + document = captured_report_document() + document["elements"][0]["pads"] = [] + document["elements"][0]["padsError"] = ( + "reading static pad templates raised TypeError") + _, admin, plugin = genv.plugin_with_report(document) + + status, body = genv.get_gst_properties( + admin, plugin["plugin_id"], plugin["version"]) + + assert status == 200 + assert body["available"] is True + element = body["elements"][0] + assert element["padsReason"] == "pads_read_failed" + assert element["padsMessage"] == ( + "reading static pad templates raised TypeError") + assert element["portSuggestions"] == [] + assert element["unmappedPads"] == [] diff --git a/edge-cv-portal/backend/tests/test_plugin_import_arch_revisions.py b/edge-cv-portal/backend/tests/test_plugin_import_arch_revisions.py new file mode 100644 index 00000000..b969a689 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_import_arch_revisions.py @@ -0,0 +1,605 @@ +""" +Unit tests for per-architecture import revisions (plugin_importer +arch_revisions + plugin_builds per-arch source resolution). + +Motivating scenario: importing gst-plugins-good for every platform +needs DIFFERENT source revisions per platform generation — the default +branch for the GStreamer 1.20+ platforms (x86_64 / x86_64_nvidia / +arm64_jp6), branch '1.16' for arm64_jp5, branch '1.14' for arm64_jp4. + +Covers: +- validation of the optional POST /plugins/import `arch_revisions` + map (unknown architectures, non-string / empty values) +- the pure revision plan (revision_slug, revision_fetch_plan): a single + distinct effective revision collapses to today's single-fetch flat + layout exactly; multiple distinct revisions fetch once each into + rev-{slug}/ prefixes with every arch mapped to its slug +- multi-revision fetch fan-out: one StartBuild per DISTINCT effective + revision with the right DEST_PREFIX / REVISION / REVISION_SLUG env + overrides, archs sharing a revision sharing the fetch +- handle_fetch_result partial settling: one fetch settles -> the record + stays 'fetching' with the slug recorded; all succeed -> imported (or + pending_selection for plugin sets) with builds started; any failure -> + 'failed' with a finding naming the failing revision while per-fetch + statuses stay visible; per-slug idempotency on duplicate deliveries +- plugin_builds.start_builds resolving each arch's + sourceLocationOverride through arch_revisions -> fetches[slug] + .source_prefix, falling back to source_s3_prefix +- version_detail exposing arch_revisions and fetches when present + +Runs against the moto-backed stack from conftest.py, reusing the +facades from test_plugin_importer.py / test_plugin_builds.py. +""" +import pytest + +from conftest import TEST_ENV +from test_plugin_importer import ( + ImporterEnv, + MESON_PLUGIN, + RecordingCodeBuild, + REPO_URL, +) +from test_plugin_builds import PluginBuildsEnv + + +@pytest.fixture +def ienv(aws_stack): + return ImporterEnv(aws_stack) + + +@pytest.fixture +def admin_setup(ienv): + usecase_id = ienv.create_usecase() + admin = ienv.make_user(role="Viewer") + ienv.assign_role(admin, usecase_id, "UseCaseAdmin") + return usecase_id, admin + + +#: gst-plugins-good style plugin-set tree: two individual plugin +#: targets under gst/, so the import lands in pending_selection. +PLUGIN_SET_FILES = { + "meson.build": MESON_PLUGIN, + "gst/rtsp/meson.build": MESON_PLUGIN, + "gst/udp/meson.build": MESON_PLUGIN, +} + + +def multi_fetch_detail(plugin, build_id, slug, status="SUCCEEDED"): + """EventBridge fetch detail carrying the REVISION_SLUG override.""" + return { + "build-status": status, + "project-name": TEST_ENV["FETCH_PROJECT_NAME"], + "build-id": ImporterEnv.FETCH_BUILD_ARN_PREFIX + build_id, + "additional-information": { + "environment": { + "environment-variables": [ + {"name": "USECASE_ID", "value": plugin["usecase_id"]}, + {"name": "PLUGIN_ID", "value": plugin["plugin_id"]}, + {"name": "PLUGIN_VERSION", + "value": str(plugin["version"])}, + {"name": "REVISION_SLUG", "value": slug}, + ], + }, + }, + } + + +# ===================================================================== +# Pure functions (no AWS) +# ===================================================================== + +class TestValidateArchRevisions: + ARCHS = ["arm64_jp4", "arm64_jp5", "x86_64"] + + def test_absent_is_valid(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.validate_arch_revisions(None, self.ARCHS) is None + + def test_subset_of_requested_architectures_is_valid(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.validate_arch_revisions( + {"arm64_jp5": "1.16"}, self.ARCHS) is None + + def test_non_dict_rejected(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.validate_arch_revisions(["1.16"], self.ARCHS) is not None + + def test_unknown_architecture_rejected(self, aws_stack): + mod = aws_stack.plugin_importer + error = mod.validate_arch_revisions( + {"sparc64": "1.16"}, self.ARCHS) + assert error is not None and "sparc64" in error + + def test_unrequested_architecture_rejected(self, aws_stack): + """Keys must be a subset of the REQUESTED architectures.""" + mod = aws_stack.plugin_importer + error = mod.validate_arch_revisions( + {"arm64_jp6": "1.16"}, self.ARCHS) + assert error is not None and "arm64_jp6" in error + + @pytest.mark.parametrize("value", [None, 1.16, "", " ", ["1.16"]]) + def test_non_string_or_empty_values_rejected(self, aws_stack, value): + mod = aws_stack.plugin_importer + error = mod.validate_arch_revisions( + {"arm64_jp5": value}, self.ARCHS) + assert error is not None and "arm64_jp5" in error + + +class TestRevisionSlug: + def test_absent_revision_is_the_default_slug(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.revision_slug(None) == mod.DEFAULT_REVISION + assert mod.revision_slug("") == mod.DEFAULT_REVISION + + def test_safe_revisions_pass_through(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.revision_slug("1.16") == "1.16" + assert mod.revision_slug("main") == "main" + + def test_unsafe_characters_collapse_to_hyphens(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.revision_slug("feature/per arch") == "feature-per-arch" + assert mod.revision_slug("///") == "rev" + + +class TestRevisionFetchPlan: + BASE = "plugin-sources/uc/p/1/" + + def _plan(self, aws_stack, revision, arch_revisions, archs): + return aws_stack.plugin_importer.revision_fetch_plan( + revision, arch_revisions, archs, self.BASE) + + def test_no_overrides_is_single_mode(self, aws_stack): + plan = self._plan(aws_stack, "main", None, ["x86_64", "arm64_jp5"]) + assert plan == {"mode": "single", "revision": "main"} + + def test_overrides_all_equal_to_the_revision_collapse_to_single( + self, aws_stack): + plan = self._plan(aws_stack, "1.16", + {"x86_64": "1.16", "arm64_jp5": "1.16"}, + ["x86_64", "arm64_jp5"]) + assert plan == {"mode": "single", "revision": "1.16"} + + def test_uniform_overrides_without_top_revision_collapse_to_single( + self, aws_stack): + """All archs overridden to the same revision: one fetch of THAT + revision, flat layout.""" + plan = self._plan(aws_stack, None, + {"x86_64": "1.16", "arm64_jp5": "1.16"}, + ["x86_64", "arm64_jp5"]) + assert plan == {"mode": "single", "revision": "1.16"} + + def test_distinct_revisions_fetch_once_each(self, aws_stack): + archs = ["arm64_jp4", "arm64_jp5", "arm64_jp6", "x86_64"] + plan = self._plan(aws_stack, None, + {"arm64_jp4": "1.14", "arm64_jp5": "1.16"}, archs) + + assert plan["mode"] == "multi" + # One fetch per DISTINCT revision; archs sharing one share it. + assert sorted(plan["fetches"]) == ["1.14", "1.16", "default"] + assert plan["arch_revisions"] == { + "arm64_jp4": "1.14", + "arm64_jp5": "1.16", + "arm64_jp6": "default", + "x86_64": "default", + } + assert plan["fetches"]["1.16"] == { + "revision": "1.16", + "source_prefix": self.BASE + "rev-1.16/", + "status": "fetching", + } + # The default tree is the top-level revision's (default branch). + assert plan["default_slug"] == "default" + assert plan["fetches"]["default"]["revision"] == \ + aws_stack.plugin_importer.DEFAULT_REVISION + + def test_default_slug_falls_back_deterministically(self, aws_stack): + """Every arch overridden: the top-level revision is never + fetched, so the first slug (sorted) is the default tree.""" + plan = self._plan(aws_stack, "main", + {"arm64_jp4": "1.14", "arm64_jp5": "1.16"}, + ["arm64_jp4", "arm64_jp5"]) + assert sorted(plan["fetches"]) == ["1.14", "1.16"] + assert plan["default_slug"] == "1.14" + + def test_slug_collisions_disambiguate(self, aws_stack): + plan = self._plan(aws_stack, None, + {"arm64_jp4": "a/b", "arm64_jp5": "a b"}, + ["arm64_jp4", "arm64_jp5"]) + assert plan["mode"] == "multi" + assert sorted(plan["fetches"]) == ["a-b", "a-b-2"] + # Both archs still resolve to their own revision's slug. + revisions = {plan["fetches"][plan["arch_revisions"][arch]]["revision"] + for arch in ("arm64_jp4", "arm64_jp5")} + assert revisions == {"a/b", "a b"} + + +# ===================================================================== +# POST /plugins/import (moto-backed) +# ===================================================================== + +class TestImportArchRevisionsValidation: + def test_unknown_architecture_rejected(self, ienv, admin_setup): + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64", "arm64_jp5"], + "arch_revisions": {"arm64_jp6": "1.20"}, + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCH_REVISIONS" + assert "arm64_jp6" in body["error"]["message"] + assert ienv.records_for(usecase_id) == [] + + def test_non_string_revision_rejected(self, ienv, admin_setup): + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["arm64_jp5"], + "arch_revisions": {"arm64_jp5": 1.16}, + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCH_REVISIONS" + assert ienv.records_for(usecase_id) == [] + + +class TestSingleRevisionUnchanged: + """Absent (or collapsing) arch_revisions keep today's behavior + exactly: one fetch, flat source_s3_prefix, top-level + fetch_build_id, no fetches/arch_revisions on the record.""" + + def test_absent_arch_revisions_is_todays_flow( + self, ienv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + recorder = RecordingCodeBuild(ienv.module.codebuild) + monkeypatch.setattr(ienv.module, "codebuild", recorder) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "1.24.2", + "architectures": ["x86_64", "arm64_jp5"], + }) + + assert status == 202 + assert len(recorder.calls) == 1 + env = {v["name"]: v["value"] + for v in recorder.calls[0]["environmentVariablesOverride"]} + assert "REVISION_SLUG" not in env + plugin = body["plugin"] + assert "/rev-" not in plugin["source_s3_prefix"] + assert "fetches" not in plugin + assert "arch_revisions" not in plugin + assert body["import"]["buildId"] + record = ienv.get_record(plugin["plugin_id"]) + assert record["fetch_build_id"] == body["import"]["buildId"] + assert "fetches" not in record + + def test_uniform_overrides_collapse_to_one_fetch( + self, ienv, admin_setup, monkeypatch): + """arch_revisions naming one revision for every arch is a + single-revision import of that revision.""" + usecase_id, admin = admin_setup + recorder = RecordingCodeBuild(ienv.module.codebuild) + monkeypatch.setattr(ienv.module, "codebuild", recorder) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["arm64_jp4", "arm64_jp5"], + "arch_revisions": {"arm64_jp4": "1.16", "arm64_jp5": "1.16"}, + }) + + assert status == 202 + assert len(recorder.calls) == 1 + env = {v["name"]: v["value"] + for v in recorder.calls[0]["environmentVariablesOverride"]} + assert env["REVISION"] == "1.16" + assert "REVISION_SLUG" not in env + assert body["plugin"]["provenance"]["revision"] == "1.16" + assert "fetches" not in body["plugin"] + + def test_single_revision_import_still_completes( + self, ienv, admin_setup): + """End to end: the settled single-revision flow is untouched.""" + usecase_id, admin = admin_setup + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + "arch_revisions": {"x86_64": "1.24.2"}, + }, files={"meson.build": MESON_PLUGIN}) + + assert result == {"recorded": True, "import_status": "imported"} + entry = record["artifacts"]["x86_64"] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith("dda-plugin-build-x86_64:") + + +class MultiImportEnv: + """One multi-revision import: default branch for x86_64/arm64_jp6, + '1.16' for arm64_jp5, '1.14' for arm64_jp4 (the gst-plugins-good + scenario).""" + + ARCHS = ["arm64_jp4", "arm64_jp5", "arm64_jp6", "x86_64"] + OVERRIDES = {"arm64_jp4": "1.14", "arm64_jp5": "1.16"} + + def __init__(self, ienv, admin_setup, body_extra=None): + self.ienv = ienv + usecase_id, admin = admin_setup + self.usecase_id, self.admin = usecase_id, admin + status, self.body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": self.ARCHS, + "arch_revisions": self.OVERRIDES, + **(body_extra or {}), + }) + assert status == 202, self.body + self.plugin = self.body["plugin"] + self.build_ids = self.body["import"]["fetchBuildIds"] + + def record(self): + return self.ienv.get_record(self.plugin["plugin_id"]) + + def deliver(self, slug, status="SUCCEEDED"): + return self.ienv.deliver_fetch_result(multi_fetch_detail( + self.plugin, self.build_ids[slug], slug, status=status)) + + def sync_default_tree(self, files): + """Sync `files` under the DEFAULT revision's tree (the record's + source_s3_prefix), like the fetch CodeBuild step would.""" + self.ienv.sync_source(self.plugin, files) + + +class TestMultiRevisionFanOut: + def test_one_fetch_per_distinct_revision_with_slug_and_prefix( + self, ienv, admin_setup, monkeypatch): + recorder = RecordingCodeBuild(ienv.module.codebuild) + monkeypatch.setattr(ienv.module, "codebuild", recorder) + + env_setup = MultiImportEnv(ienv, admin_setup) + plugin = env_setup.plugin + + # Three DISTINCT effective revisions -> exactly three fetches. + assert len(recorder.calls) == 3 + by_slug = {} + for call in recorder.calls: + assert call["projectName"] == TEST_ENV["FETCH_PROJECT_NAME"] + env = {v["name"]: v["value"] + for v in call["environmentVariablesOverride"]} + by_slug[env["REVISION_SLUG"]] = env + base = (f"plugin-sources/{env_setup.usecase_id}/" + f"{plugin['plugin_id']}/1") + assert by_slug["default"]["REVISION"] == "" + assert by_slug["default"]["DEST_PREFIX"] == f"{base}/rev-default" + assert by_slug["1.16"]["REVISION"] == "1.16" + assert by_slug["1.16"]["DEST_PREFIX"] == f"{base}/rev-1.16" + assert by_slug["1.14"]["REVISION"] == "1.14" + assert by_slug["1.14"]["DEST_PREFIX"] == f"{base}/rev-1.14" + + # The record maps every arch to its slug; archs sharing the + # default revision share the fetch. + record = env_setup.record() + assert record["arch_revisions"] == { + "arm64_jp4": "1.14", "arm64_jp5": "1.16", + "arm64_jp6": "default", "x86_64": "default", + } + assert record["import_status"] == "fetching" + assert "fetch_build_id" not in record + for slug in ("default", "1.14", "1.16"): + entry = record["fetches"][slug] + assert entry["status"] == "fetching" + assert entry["fetch_build_id"] == env_setup.build_ids[slug] + # Scan/inspection read the DEFAULT revision's tree. + assert record["source_s3_prefix"] == f"{base}/rev-default/" + assert record["default_fetch_slug"] == "default" + # version_detail exposes the new fields (record display). + detail = ienv.stack.plugin_records.version_detail(record) + assert detail["arch_revisions"] == record["arch_revisions"] + assert set(detail["fetches"]) == {"default", "1.14", "1.16"} + + +class TestMultiRevisionFetchSettling: + def test_one_settled_fetch_keeps_the_record_fetching( + self, ienv, admin_setup): + env_setup = MultiImportEnv(ienv, admin_setup) + + result = env_setup.deliver("1.16") + + assert result["recorded"] is True + assert result["import_status"] == "fetching" + record = env_setup.record() + assert record["import_status"] == "fetching" + assert record["fetches"]["1.16"]["status"] == "succeeded" + assert record["fetches"]["1.14"]["status"] == "fetching" + assert record["fetches"]["default"]["status"] == "fetching" + assert record.get("artifacts") == {} # no builds yet + + def test_all_succeeded_advances_to_imported_with_builds_started( + self, ienv, admin_setup, monkeypatch): + builds_module = ienv.stack.plugin_builds + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + env_setup = MultiImportEnv(ienv, admin_setup) + env_setup.sync_default_tree({"meson.build": MESON_PLUGIN}) + + env_setup.deliver("1.16") + env_setup.deliver("1.14") + result = env_setup.deliver("default") + + assert result == {"recorded": True, "import_status": "imported"} + record = env_setup.record() + assert record["import_status"] == "imported" + # Builds start for every requested arch once the last fetch + # settles, each arch building from its own revision's tree. + assert sorted(record["artifacts"]) == MultiImportEnv.ARCHS + for arch, entry in record["artifacts"].items(): + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + overrides = {call["projectName"]: call["sourceLocationOverride"] + for call in recorder.calls} + flat = record["source_s3_prefix"].replace("rev-default/", "") + assert overrides["dda-plugin-build-arm64_jp4"] == \ + f"{bucket}/{flat}rev-1.14/" + assert overrides["dda-plugin-build-arm64_jp5"] == \ + f"{bucket}/{flat}rev-1.16/" + assert overrides["dda-plugin-build-x86_64"] == \ + f"{bucket}/{flat}rev-default/" + assert overrides["dda-plugin-build-arm64_jp6"] == \ + f"{bucket}/{flat}rev-default/" + + def test_plugin_set_default_tree_lands_in_pending_selection( + self, ienv, admin_setup): + """Enumeration runs on the DEFAULT revision's tree; a plugin + set waits for selection exactly like a single-revision import.""" + env_setup = MultiImportEnv(ienv, admin_setup) + env_setup.sync_default_tree(PLUGIN_SET_FILES) + + env_setup.deliver("1.14") + env_setup.deliver("1.16") + result = env_setup.deliver("default") + + assert result == {"recorded": True, + "import_status": "pending_selection"} + record = env_setup.record() + assert record["import_status"] == "pending_selection" + assert sorted(p["name"] for p in record["plugins_found"]) == \ + ["rtsp", "udp"] + assert record.get("artifacts") == {} # selection first + + def test_one_failed_fetch_fails_the_import_naming_the_revision( + self, ienv, admin_setup): + env_setup = MultiImportEnv(ienv, admin_setup) + env_setup.sync_default_tree({"meson.build": MESON_PLUGIN}) + + env_setup.deliver("default") + env_setup.deliver("1.14") + result = env_setup.deliver("1.16", status="FAILED") + + assert result == {"recorded": True, "import_status": "failed"} + record = env_setup.record() + assert record["import_status"] == "failed" + assert record["import_error_code"] == "REPO_FETCH_FAILED" + # The finding names the failing revision... + assert "1.16" in record["import_finding"] + assert "1.14" not in record["import_finding"] + # ...and the per-fetch statuses stay visible for the UI. + assert record["fetches"]["1.16"]["status"] == "failed" + assert record["fetches"]["1.14"]["status"] == "succeeded" + assert record["fetches"]["default"]["status"] == "succeeded" + assert record.get("artifacts") == {} # no builds submitted + + def test_duplicate_slug_delivery_is_idempotent(self, ienv, admin_setup): + env_setup = MultiImportEnv(ienv, admin_setup) + + first = env_setup.deliver("1.16") + record_after_first = env_setup.record() + duplicate = env_setup.deliver("1.16") + + assert first["recorded"] is True + assert duplicate == {"recorded": False, "reason": "already recorded"} + assert env_setup.record() == record_after_first + + def test_superseded_slug_build_is_skipped(self, ienv, admin_setup): + env_setup = MultiImportEnv(ienv, admin_setup) + + result = env_setup.ienv.deliver_fetch_result(multi_fetch_detail( + env_setup.plugin, "dda-plugin-fetch:someone-else", "1.16", + status="FAILED")) + + assert result == {"recorded": False, "reason": "superseded build"} + assert env_setup.record()["fetches"]["1.16"]["status"] == "fetching" + + def test_unknown_slug_is_skipped(self, ienv, admin_setup): + env_setup = MultiImportEnv(ienv, admin_setup) + + result = env_setup.ienv.deliver_fetch_result(multi_fetch_detail( + env_setup.plugin, env_setup.build_ids["1.16"], "no-such-slug")) + + assert result == {"recorded": False, + "reason": "missing fetch metadata"} + assert env_setup.record()["import_status"] == "fetching" + + +# ===================================================================== +# plugin_builds.start_builds per-arch source resolution +# ===================================================================== + +class TestPerArchBuildSource: + def test_arch_source_prefix_resolves_the_slug_tree(self, aws_stack): + mod = aws_stack.plugin_builds + item = { + "source_s3_prefix": "plugin-sources/uc/p/1/rev-default/", + "arch_revisions": {"arm64_jp5": "1.16", "x86_64": "default"}, + "fetches": { + "default": {"source_prefix": + "plugin-sources/uc/p/1/rev-default/"}, + "1.16": {"source_prefix": "plugin-sources/uc/p/1/rev-1.16/"}, + }, + } + assert mod.arch_source_prefix(item, "arm64_jp5") == \ + "plugin-sources/uc/p/1/rev-1.16/" + assert mod.arch_source_prefix(item, "x86_64") == \ + "plugin-sources/uc/p/1/rev-default/" + # Arch without a mapping falls back to source_s3_prefix. + assert mod.arch_source_prefix(item, "arm64_jp6") == \ + "plugin-sources/uc/p/1/rev-default/" + + def test_flat_records_fall_back_to_source_s3_prefix(self, aws_stack): + mod = aws_stack.plugin_builds + item = {"source_s3_prefix": "plugin-sources/uc/p/1/"} + assert mod.arch_source_prefix(item, "x86_64") == \ + "plugin-sources/uc/p/1/" + + def test_start_builds_uses_each_archs_revision_tree( + self, aws_stack, monkeypatch): + """POST /build StartBuilds each arch with its own revision's + sourceLocationOverride (arch_revisions -> fetches[slug] + .source_prefix, falling back to source_s3_prefix).""" + benv = PluginBuildsEnv(aws_stack) + usecase_id = benv.create_usecase() + admin = benv.make_admin(usecase_id) + plugin = benv.create_plugin(admin, usecase_id) + flat_prefix = plugin["source_s3_prefix"] + benv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression=("SET arch_revisions = :ar, fetches = :f, " + "source_s3_prefix = :sp"), + ExpressionAttributeValues={ + ":ar": {"arm64_jp5": "1.16", "x86_64": "default"}, + ":f": { + "default": {"revision": "default", + "source_prefix": f"{flat_prefix}rev-default/", + "status": "succeeded"}, + "1.16": {"revision": "1.16", + "source_prefix": f"{flat_prefix}rev-1.16/", + "status": "succeeded"}, + }, + ":sp": f"{flat_prefix}rev-default/", + }, + ) + from test_plugin_builds import _RecordingCodeBuild + recorder = _RecordingCodeBuild(benv.module.codebuild) + monkeypatch.setattr(benv.module, "codebuild", recorder) + + status, _ = benv.post_build(admin, plugin["plugin_id"], + plugin["version"], + {"architectures": + ["x86_64", "arm64_jp5", "arm64_jp6"]}) + + assert status == 202 + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + overrides = {call["projectName"]: call["sourceLocationOverride"] + for call in recorder.calls} + assert overrides["dda-plugin-build-arm64_jp5"] == \ + f"{bucket}/{flat_prefix}rev-1.16/" + assert overrides["dda-plugin-build-x86_64"] == \ + f"{bucket}/{flat_prefix}rev-default/" + # Unmapped arch falls back to the record's source_s3_prefix. + assert overrides["dda-plugin-build-arm64_jp6"] == \ + f"{bucket}/{flat_prefix}rev-default/" diff --git a/edge-cv-portal/backend/tests/test_plugin_import_selection.py b/edge-cv-portal/backend/tests/test_plugin_import_selection.py new file mode 100644 index 00000000..8f3d390e --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_import_selection.py @@ -0,0 +1,761 @@ +""" +Unit tests for the plugin-set import selection flow (custom-node-designer +import enhancement). + +Covers: +- enumerate_plugins as a pure function over the source-tree file + mapping: the meson monorepo plugin-set layout (individual plugin + directories under gst/, ext/, sys/), single-plugin repositories + (one entry, selection skipped), and empty/no-plugin trees +- validate_plugin_selection: non-empty and a subset of what the + enumeration found +- POST /plugins/import against the moto-backed stack (asynchronous + flow: 202 'fetching', then the EventBridge fetch result advances the + record): a plugin-set import lands in import status pending_selection + with plugins_found, submitting no builds; a single-plugin import + skips selection and queues builds as before +- POST /plugins/{id}/versions/{v}/select-plugins: a valid subset is + recorded as selected_plugins (record + provenance) and builds start + for the requested Target_Architectures; empty or non-subset + selections are rejected; the endpoint conflicts when no selection is + pending and enforces node-designer:import permission + +The PLUGIN_TARGETS pass-through to CodeBuild lives in +test_plugin_builds.py (TestPluginTargetsPassThrough). +""" +import json + +import pytest + +from test_plugin_importer import ImporterEnv, MESON_PLUGIN + +REPO_URL = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git" + + +def plugin_meson(name): + """A plugin directory's meson.build declaring a GStreamer plugin + library target, mirroring the gst-plugins-good layout.""" + return ( + f"gst{name}_sources = ['gst{name}.c']\n" + f"gst{name} = library('gst{name}', gst{name}_sources,\n" + " dependencies : [gst_dep, gstbase_dep],\n" + " install : true,\n" + ")\n" + ) + + +#: gst-plugins-good style monorepo: individual plugin directories under +#: gst/, ext/, and sys/, plus non-plugin meson.build files that must +#: not enumerate (root, gst-libs, tests, deeper nesting). +MONOREPO_FILES = { + "meson.build": ( + "project('gst-plugins-good', 'c', version : '1.24.2')\n" + "subdir('gst')\nsubdir('ext')\nsubdir('sys')\n" + ), + "gst/rtp/meson.build": plugin_meson("rtp"), + "gst/rtp/gstrtp.c": None, + "gst/udp/meson.build": plugin_meson("udp"), + "gst/udp/gstudp.c": None, + "ext/jpeg/meson.build": plugin_meson("jpeg"), + "sys/v4l2/meson.build": plugin_meson("v4l2"), + # Non-plugin meson.build files: no plugin library target, wrong + # root, or nested deeper than {root}/{plugin}/meson.build. + "gst/nonplugin/meson.build": "install_data('helpers.txt')\n", + "gst-libs/gst/meson.build": plugin_meson("shared"), + "tests/check/meson.build": "test_sources = ['check.c']\n", + "gst/rtp/tests/meson.build": plugin_meson("rtptests"), + "README.md": None, +} + + +# ===================================================================== +# Pure functions (no AWS) +# ===================================================================== + +class TestEnumeratePlugins: + def test_meson_monorepo_enumerates_one_entry_per_plugin_dir( + self, aws_stack): + entries = aws_stack.plugin_importer.enumerate_plugins(MONOREPO_FILES) + assert entries == [ + {"name": "jpeg", "path": "ext/jpeg"}, + {"name": "rtp", "path": "gst/rtp"}, + {"name": "udp", "path": "gst/udp"}, + {"name": "v4l2", "path": "sys/v4l2"}, + ] + + def test_non_plugin_and_nested_meson_files_do_not_enumerate( + self, aws_stack): + entries = aws_stack.plugin_importer.enumerate_plugins(MONOREPO_FILES) + names = [e["name"] for e in entries] + assert "nonplugin" not in names # no plugin library target + assert "shared" not in names # gst-libs/ is not a plugin root + assert "check" not in names # tests/ is not a plugin root + assert "rtptests" not in names # deeper than {root}/{plugin}/ + + def test_single_plugin_repo_enumerates_as_one_entry(self, aws_stack): + entries = aws_stack.plugin_importer.enumerate_plugins( + {"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}, + single_plugin_name="gst-myfilter") + assert entries == [{"name": "gst-myfilter", "path": ""}] + + def test_prebuilt_only_repo_enumerates_as_one_entry(self, aws_stack): + entries = aws_stack.plugin_importer.enumerate_plugins( + {"prebuilt/libgstmyfilter.so": None}, + single_plugin_name="myfilter") + assert entries == [{"name": "myfilter", "path": ""}] + + def test_unbuildable_tree_enumerates_nothing(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.enumerate_plugins({}) == [] + assert mod.enumerate_plugins( + {"README.md": None, "src/main.c": None}) == [] + + +class TestValidatePluginSelection: + FOUND = ["jpeg", "rtp", "udp", "v4l2"] + + def _validate(self, aws_stack, selected): + return aws_stack.plugin_importer.validate_plugin_selection( + selected, self.FOUND) + + def test_accepts_a_non_empty_subset(self, aws_stack): + assert self._validate(aws_stack, ["rtp"]) is None + assert self._validate(aws_stack, ["rtp", "udp", "jpeg"]) is None + assert self._validate(aws_stack, list(self.FOUND)) is None + + @pytest.mark.parametrize("selected", [None, [], "rtp", {"rtp": True}]) + def test_rejects_empty_or_non_list_selection(self, aws_stack, selected): + assert self._validate(aws_stack, selected) is not None + + @pytest.mark.parametrize("selected", [[""], [None], ["rtp", 3]]) + def test_rejects_non_string_entries(self, aws_stack, selected): + assert self._validate(aws_stack, selected) is not None + + def test_rejects_names_outside_the_enumeration(self, aws_stack): + error = self._validate(aws_stack, ["rtp", "nope"]) + assert error is not None + assert "nope" in error + + +# ===================================================================== +# Handler tests (moto-backed) +# ===================================================================== + +class SelectionEnv(ImporterEnv): + """ImporterEnv plus the select-plugins route invocation.""" + + def select_plugins(self, user, plugin_id, version, body): + event = { + "httpMethod": "POST", + "resource": "/plugins/{id}/versions/{v}/select-plugins", + "path": f"/plugins/{plugin_id}/versions/{version}/select-plugins", + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": json.dumps(body), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def get_record(self, plugin_id, version): + return self.stack.plugin_records.get_version_item(plugin_id, version) + + +@pytest.fixture +def senv(aws_stack): + return SelectionEnv(aws_stack) + + +@pytest.fixture +def admin_setup(senv): + usecase_id = senv.create_usecase("Selection Test Use Case") + admin = senv.make_user(role="Viewer") + senv.assign_role(admin, usecase_id, "UseCaseAdmin") + return usecase_id, admin + + +def import_plugin_set(senv, usecase_id, admin, + architectures=("x86_64", "arm64_jp5"), files=None, + extra=None): + """Asynchronously import the monorepo fixture (POST /plugins/import + answers 202 'fetching'; the delivered fetch result advances the + record); returns the settled Plugin_Record item.""" + _, result, record = senv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": list(architectures), + **(extra or {}), + }, files=files or MONOREPO_FILES) + assert result["recorded"] is True, result + return record + + +class TestPluginSetImport: + def test_plugin_set_import_pends_selection_without_queuing_builds( + self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set(senv, usecase_id, admin) + + # The enumeration is persisted for the selection dialog... + assert record["import_status"] == "pending_selection" + assert [p["name"] for p in record["plugins_found"]] == \ + ["jpeg", "rtp", "udp", "v4l2"] + # ...and no builds are submitted until the user selects. + assert record["artifacts"] == {} + # The version detail the UI polls carries both fields. + detail = senv.stack.plugin_records.version_detail(record) + assert detail["import_status"] == "pending_selection" + assert [p["name"] for p in detail["plugins_found"]] == \ + ["jpeg", "rtp", "udp", "v4l2"] + + def test_single_plugin_import_skips_selection_and_queues_builds( + self, senv, admin_setup): + usecase_id, admin = admin_setup + + _, result, record = senv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": "https://example.com/gst-myfilter.git", + "architectures": ["x86_64"], + }, files={"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + + # Single-plugin repositories enumerate as one entry and skip + # the selection step: builds queue immediately as before. + assert result == {"recorded": True, "import_status": "imported"} + assert record["import_status"] == "imported" + assert len(record["plugins_found"]) == 1 + assert record["plugins_found"][0]["path"] == "" + entry = record["artifacts"]["x86_64"] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith("dda-plugin-build-x86_64:") + + +class TestSelectPlugins: + def test_valid_subset_is_recorded_and_builds_queue( + self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + status, body = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["udp", "rtp"]}) + + assert status == 200 + assert body["import"]["status"] == "imported" + # Selection normalized to the enumeration's display order. + assert body["import"]["selected_plugins"] == ["rtp", "udp"] + assert sorted(body["import"]["submitted_architectures"]) == \ + ["arm64_jp5", "x86_64"] + + record = senv.get_record(plugin_id, 1) + # The chosen subset is recorded on the Plugin_Record and in + # provenance, and builds start for the requested architectures. + assert record["selected_plugins"] == ["rtp", "udp"] + assert record["provenance"]["selectedPlugins"] == ["rtp", "udp"] + assert record["import_status"] == "imported" + for arch in ("x86_64", "arm64_jp5"): + entry = record["artifacts"][arch] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + + def test_selection_auto_starts_builds_with_the_plugin_targets( + self, senv, admin_setup, monkeypatch): + """select-plugins auto-starts the builds it queues, passing the + recorded selection to CodeBuild as PLUGIN_TARGETS.""" + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + from test_plugin_importer import RecordingCodeBuild + builds_module = senv.stack.plugin_builds + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + + status, _ = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["udp", "rtp"]}) + + assert status == 200 + assert sorted(c["projectName"] for c in recorder.calls) == \ + ["dda-plugin-build-arm64_jp5", "dda-plugin-build-x86_64"] + for call in recorder.calls: + env = {v["name"]: v["value"] + for v in call["environmentVariablesOverride"]} + assert env["PLUGIN_TARGETS"] == "rtp,udp" + assert env["SELECTED_PLUGINS"] == "rtp,udp" + + def test_empty_selection_is_rejected(self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + status, body = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": []}) + + assert status == 400 + assert body["error"]["code"] == "INVALID_PLUGIN_SELECTION" + record = senv.get_record(plugin_id, 1) + assert record["import_status"] == "pending_selection" + assert record["artifacts"] == {} + + def test_selection_outside_the_enumeration_is_rejected( + self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + status, body = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["rtp", "not-a-plugin"]}) + + assert status == 400 + assert body["error"]["code"] == "INVALID_PLUGIN_SELECTION" + assert "not-a-plugin" in body["error"]["message"] + assert senv.get_record(plugin_id, 1)["import_status"] == \ + "pending_selection" + + def test_conflicts_when_no_selection_is_pending( + self, senv, admin_setup): + usecase_id, admin = admin_setup + _, result, record = senv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": "https://example.com/gst-myfilter.git", + "architectures": ["x86_64"], + }, files={"meson.build": MESON_PLUGIN}) + assert record["import_status"] == "imported" + plugin_id = record["plugin_id"] + + status, body = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["gst-myfilter"]}) + + assert status == 409 + assert body["error"]["code"] == "SELECTION_NOT_PENDING" + + def test_missing_record_returns_not_found(self, senv, admin_setup): + _, admin = admin_setup + status, body = senv.select_plugins(admin, "no-such-plugin", 1, { + "selected_plugins": ["rtp"]}) + assert status == 404 + + def test_selection_denied_without_import_permission( + self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + scientist = senv.make_user(role="Viewer") + senv.assign_role(scientist, usecase_id, "DataScientist") + + status, body = senv.select_plugins(scientist, plugin_id, 1, { + "selected_plugins": ["rtp"]}) + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + assert senv.get_record(plugin_id, 1)["import_status"] == \ + "pending_selection" + + +# ===================================================================== +# Import-time selection (POST /plugins/import selected_plugins) +# ===================================================================== +# +# The import view loads the module's plugin list from GET +# /plugin-modules?module=... and sends the chosen subset as +# selected_plugins on POST /plugins/import: the selection is recorded +# on the Plugin_Record (and provenance.selectedPlugins) and builds +# submit immediately — the pending-selection step is skipped. Absent or +# empty selected_plugins keeps today's behavior exactly. + +class TestValidateImportSelectedPlugins: + def _validate(self, aws_stack, selected): + return aws_stack.plugin_importer.validate_import_selected_plugins( + selected) + + def test_absent_and_empty_mean_whole_module(self, aws_stack): + assert self._validate(aws_stack, None) is None + assert self._validate(aws_stack, []) is None + + def test_accepts_a_list_of_non_empty_strings(self, aws_stack): + assert self._validate(aws_stack, ["rtp"]) is None + assert self._validate(aws_stack, ["rtp", "udp", "jpeg"]) is None + + @pytest.mark.parametrize("selected", ["rtp", {"rtp": True}, 42]) + def test_rejects_non_list_values(self, aws_stack, selected): + assert self._validate(aws_stack, selected) is not None + + @pytest.mark.parametrize("selected", [[""], [None], ["rtp", 3]]) + def test_rejects_non_string_or_empty_entries(self, aws_stack, selected): + assert self._validate(aws_stack, selected) is not None + + def test_dedupe_preserves_order(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.dedupe_selected_plugins( + ["udp", "rtp", "udp", "jpeg", "rtp"]) == ["udp", "rtp", "jpeg"] + assert mod.dedupe_selected_plugins(None) == [] + assert mod.dedupe_selected_plugins([]) == [] + + +class TestImportTimeSelection: + def test_selection_is_recorded_and_builds_queue_immediately( + self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set( + senv, usecase_id, admin, + extra={"selected_plugins": ["udp", "rtp", "udp"]}) # dupe collapses + + # No pending-selection step: the fetch result completes the + # import at once, recorded on the Plugin_Record and its + # provenance so plugin_builds.py passes SELECTED_PLUGINS + # through to CodeBuild. + assert record["import_status"] == "imported" + assert record["selected_plugins"] == ["udp", "rtp"] + assert record["provenance"]["selectedPlugins"] == ["udp", "rtp"] + for arch in ("x86_64", "arm64_jp5"): + entry = record["artifacts"][arch] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + # The transient pending field is gone once the fetch settles. + assert "pending_selected_plugins" not in record + + def test_absent_selection_keeps_pending_selection_behavior( + self, senv, admin_setup): + """No selected_plugins: identical to today — a plugin set still + pends selection and submits no builds.""" + usecase_id, admin = admin_setup + record = import_plugin_set(senv, usecase_id, admin) + assert record["import_status"] == "pending_selection" + assert "selected_plugins" not in record + assert "selectedPlugins" not in record["provenance"] + + def test_empty_selection_means_whole_module( + self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set(senv, usecase_id, admin, + architectures=("x86_64",), + extra={"selected_plugins": []}) + + assert record["import_status"] == "pending_selection" + assert "selected_plugins" not in record + assert "selectedPlugins" not in record["provenance"] + + @pytest.mark.parametrize("selected", ["rtp", [""], ["rtp", 3]]) + def test_invalid_selection_is_rejected_before_any_fetch( + self, senv, admin_setup, monkeypatch, selected): + usecase_id, admin = admin_setup + + def exploding_fetch(*args, **kwargs): + raise AssertionError( + "fetch must not start for an invalid selection") + + monkeypatch.setattr(senv.module, "start_fetch", exploding_fetch) + + status, body = senv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + "selected_plugins": selected, + }) + + assert status == 400 + assert body["error"]["code"] == "INVALID_PLUGIN_SELECTION" + assert senv.records_for(usecase_id) == [] + + def test_selection_on_unbuildable_source_is_not_recorded( + self, senv, admin_setup): + """An unbuildable tree still lands the record failed with the + finding (4.5); the selection is meaningless there and is not + recorded.""" + usecase_id, admin = admin_setup + record = import_plugin_set(senv, usecase_id, admin, + architectures=("x86_64",), + files={"README.md": "docs only"}, + extra={"selected_plugins": ["rtp"]}) + + assert record["import_status"] == "failed" + assert "selected_plugins" not in record + assert "selectedPlugins" not in record["provenance"] + assert "pending_selected_plugins" not in record + + +# ===================================================================== +# Per-plugin descriptions (gst_plugins_cache.json join) +# ===================================================================== +# +# enumerate_plugins joins per-plugin descriptions from any +# docs/gst_plugins_cache.json whose content is present in the source +# tree. The cache parse (plugin_descriptions_from_cache) and the join +# are pure functions; every malformed input degrades to entries without +# descriptions — descriptions never fail an enumeration. + +PLUGINS_CACHE_JSON = json.dumps({ + "rtp": {"description": "Real-time Transport Protocol plugin", + "elements": {"rtpbin": {}}}, + "jpeg": {"description": "JPeg plugin library"}, + "udp": {"description": " "}, # blank: no description + "v4l2": {"elements": {}}, # missing description field +}) + + +class TestPluginDescriptionsFromCache: + def _parse(self, aws_stack, content): + return aws_stack.plugin_importer.plugin_descriptions_from_cache( + content) + + def test_parses_descriptions_keyed_by_plugin_name(self, aws_stack): + assert self._parse(aws_stack, PLUGINS_CACHE_JSON) == { + "rtp": "Real-time Transport Protocol plugin", + "jpeg": "JPeg plugin library", + } + + def test_accepts_an_already_parsed_mapping_and_bytes(self, aws_stack): + assert self._parse( + aws_stack, {"rtp": {"description": "RTP"}}) == {"rtp": "RTP"} + assert self._parse( + aws_stack, PLUGINS_CACHE_JSON.encode("utf-8")) == { + "rtp": "Real-time Transport Protocol plugin", + "jpeg": "JPeg plugin library", + } + + @pytest.mark.parametrize("content", [ + "", "{not json", "[1, 2]", '"a string"', "null", None, 42, + json.dumps({"rtp": "not-a-dict", "udp": ["x"]}), + json.dumps({"rtp": {"description": 42}}), + ]) + def test_malformed_or_unexpected_content_parses_to_empty( + self, aws_stack, content): + assert self._parse(aws_stack, content) == {} + + def test_truncated_oversized_content_parses_to_empty(self, aws_stack): + # A size-capped (Range) read of an oversized cache yields + # truncated JSON: it must parse to {} rather than raise. + truncated = PLUGINS_CACHE_JSON[:len(PLUGINS_CACHE_JSON) // 2] + assert self._parse(aws_stack, truncated) == {} + + +class TestJoinPluginDescriptions: + def test_joins_known_names_and_leaves_others_untouched(self, aws_stack): + mod = aws_stack.plugin_importer + entries = [{"name": "rtp", "path": "gst/rtp"}, + {"name": "udp", "path": "gst/udp"}] + joined = mod.join_plugin_descriptions(entries, {"rtp": "RTP"}) + assert joined == [ + {"name": "rtp", "path": "gst/rtp", "description": "RTP"}, + {"name": "udp", "path": "gst/udp"}, + ] + # The input entries are not mutated. + assert entries == [{"name": "rtp", "path": "gst/rtp"}, + {"name": "udp", "path": "gst/udp"}] + + def test_empty_descriptions_change_nothing(self, aws_stack): + mod = aws_stack.plugin_importer + entries = [{"name": "rtp", "path": "gst/rtp"}] + assert mod.join_plugin_descriptions(entries, {}) == entries + + +class TestEnumeratePluginsDescriptions: + def test_descriptions_join_from_the_docs_cache_file(self, aws_stack): + files = dict(MONOREPO_FILES) + files["docs/gst_plugins_cache.json"] = PLUGINS_CACHE_JSON + entries = aws_stack.plugin_importer.enumerate_plugins(files) + assert entries == [ + {"name": "jpeg", "path": "ext/jpeg", + "description": "JPeg plugin library"}, + {"name": "rtp", "path": "gst/rtp", + "description": "Real-time Transport Protocol plugin"}, + {"name": "udp", "path": "gst/udp"}, + {"name": "v4l2", "path": "sys/v4l2"}, + ] + + def test_malformed_cache_file_never_fails_the_enumeration( + self, aws_stack): + files = dict(MONOREPO_FILES) + files["docs/gst_plugins_cache.json"] = "{truncated" + entries = aws_stack.plugin_importer.enumerate_plugins(files) + assert [e["name"] for e in entries] == ["jpeg", "rtp", "udp", "v4l2"] + assert all("description" not in e for e in entries) + + def test_content_less_cache_file_is_ignored(self, aws_stack): + files = dict(MONOREPO_FILES) + files["docs/gst_plugins_cache.json"] = None # fetch failed + entries = aws_stack.plugin_importer.enumerate_plugins(files) + assert all("description" not in e for e in entries) + + def test_single_plugin_repo_can_carry_a_description(self, aws_stack): + entries = aws_stack.plugin_importer.enumerate_plugins( + {"meson.build": MESON_PLUGIN, + "docs/gst_plugins_cache.json": json.dumps( + {"gst-myfilter": {"description": "My filter plugin"}})}, + single_plugin_name="gst-myfilter") + assert entries == [{"name": "gst-myfilter", "path": "", + "description": "My filter plugin"}] + + +class TestPluginSetImportDescriptions: + def test_import_surfaces_descriptions_in_plugins_found( + self, senv, admin_setup): + """The post-fetch enumeration carries descriptions from the + repository's docs/gst_plugins_cache.json into plugins_found so + the selection dialog can show them.""" + usecase_id, admin = admin_setup + files = dict(MONOREPO_FILES) + files["docs/gst_plugins_cache.json"] = PLUGINS_CACHE_JSON + record = import_plugin_set(senv, usecase_id, admin, + architectures=("x86_64",), files=files) + + # Persisted on the Plugin_Record for the selection step. + assert record["import_status"] == "pending_selection" + by_name = {p["name"]: p for p in record["plugins_found"]} + assert by_name["rtp"]["description"] == \ + "Real-time Transport Protocol plugin" + assert by_name["jpeg"]["description"] == "JPeg plugin library" + assert "description" not in by_name["udp"] + + +# ===================================================================== +# Single-plugin selection naming (presentation) +# ===================================================================== +# +# A record importing one plugin out of a plugin set used to keep the +# set's base name ("gst-plugins-good") with nothing showing what was +# actually imported. Both selection paths now surface a single-plugin +# selection in the record name ("{base}-{plugin}"): derive_import_name +# at import time and selection_rename on the post-fetch select-plugins +# endpoint. An explicitly provided name always wins; multi-plugin and +# absent selections keep the base name. + +class TestDeriveImportName: + def _derive(self, aws_stack, explicit, selected): + return aws_stack.plugin_importer.derive_import_name( + explicit, REPO_URL, selected) + + def test_single_selection_appends_the_plugin(self, aws_stack): + assert self._derive(aws_stack, None, ["rtp"]) == \ + "gst-plugins-good-rtp" + + def test_multi_and_absent_selections_keep_the_base_name( + self, aws_stack): + assert self._derive(aws_stack, None, ["rtp", "udp"]) == \ + "gst-plugins-good" + assert self._derive(aws_stack, None, []) == "gst-plugins-good" + + def test_explicit_name_always_wins(self, aws_stack): + assert self._derive(aws_stack, "My Import", ["rtp"]) == "My Import" + assert self._derive(aws_stack, "My Import", []) == "My Import" + + +class TestSelectionRename: + def _rename(self, aws_stack, current, selected, repo_url=REPO_URL): + return aws_stack.plugin_importer.selection_rename( + current, repo_url, selected) + + def test_single_selection_renames_the_derived_default(self, aws_stack): + assert self._rename(aws_stack, "gst-plugins-good", ["rtp"]) == \ + "gst-plugins-good-rtp" + + def test_multi_selection_never_renames(self, aws_stack): + assert self._rename( + aws_stack, "gst-plugins-good", ["rtp", "udp"]) is None + + def test_explicitly_named_record_keeps_its_name(self, aws_stack): + assert self._rename(aws_stack, "My Import", ["rtp"]) is None + + def test_missing_repo_url_renames_unconditionally(self, aws_stack): + # Without a provenance repoUrl the derived default cannot be + # recomputed: the rename applies on any single selection. + assert self._rename(aws_stack, "whatever", ["rtp"], + repo_url=None) == "whatever-rtp" + + +class TestImportTimeSelectionNaming: + def test_single_selection_derives_the_record_name( + self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set(senv, usecase_id, admin, + extra={"selected_plugins": ["rtp"]}) + + assert record["import_status"] == "imported" + assert record["name"] == "gst-plugins-good-rtp" + assert record["selected_plugins"] == ["rtp"] + + def test_explicit_name_wins_over_the_derived_name( + self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set( + senv, usecase_id, admin, + extra={"name": "My RTP Import", "selected_plugins": ["rtp"]}) + + assert record["name"] == "My RTP Import" + + def test_multi_selection_keeps_the_base_name(self, senv, admin_setup): + usecase_id, admin = admin_setup + record = import_plugin_set( + senv, usecase_id, admin, + extra={"selected_plugins": ["rtp", "udp"]}) + + assert record["name"] == "gst-plugins-good" + + +class TestSelectPluginsNaming: + def test_single_selection_renames_the_record(self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + status, body = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["rtp"]}) + + assert status == 200 + record = senv.get_record(plugin_id, 1) + assert record["name"] == "gst-plugins-good-rtp" + assert record["selected_plugins"] == ["rtp"] + assert body["plugin"]["name"] == "gst-plugins-good-rtp" + + def test_multi_selection_keeps_the_name(self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + + status, _ = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["rtp", "udp"]}) + + assert status == 200 + assert senv.get_record(plugin_id, 1)["name"] == "gst-plugins-good" + + def test_explicitly_named_import_keeps_its_name(self, senv, admin_setup): + usecase_id, admin = admin_setup + plugin_id = import_plugin_set( + senv, usecase_id, admin, + extra={"name": "My Plugin Set"})["plugin_id"] + + status, _ = senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["rtp"]}) + + assert status == 200 + assert senv.get_record(plugin_id, 1)["name"] == "My Plugin Set" + + +class TestRecordSummarySelection: + def test_summary_carries_selection_and_enumeration_count( + self, senv, admin_setup): + """The library list (record_summary) additively carries + selected_plugins and plugins_found_count so the UI can show + which plugins an import covers.""" + usecase_id, admin = admin_setup + plugin_id = import_plugin_set(senv, usecase_id, admin)["plugin_id"] + senv.select_plugins(admin, plugin_id, 1, { + "selected_plugins": ["rtp"]}) + + summary = senv.stack.plugin_records.record_summary( + senv.get_record(plugin_id, 1)) + assert summary["selected_plugins"] == ["rtp"] + assert summary["plugins_found_count"] == 4 + assert summary["name"] == "gst-plugins-good-rtp" + + def test_summary_of_non_imports_carries_no_selection_fields( + self, senv, admin_setup): + usecase_id, _ = admin_setup + item = senv.stack.plugin_records.new_version_item( + plugin_id="p-1", version=1, usecase_id=usecase_id, + name="scaffolded", kind="scaffold", user_id="u-1", + timestamp=1) + summary = senv.stack.plugin_records.record_summary(item) + assert "selected_plugins" not in summary + assert "plugins_found_count" not in summary diff --git a/edge-cv-portal/backend/tests/test_plugin_importer.py b/edge-cv-portal/backend/tests/test_plugin_importer.py new file mode 100644 index 00000000..97922a9d --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_importer.py @@ -0,0 +1,3157 @@ +""" +Unit tests for plugin_importer.py repository import (custom-node-designer +task 4.1). + +Covers: +- URL validation and plugin-name derivation (pure, 4.1) +- The buildability scan as a pure function over a file listing (4.5) +- Import provenance {repoUrl, revision, importedBy, importedAt, + classification} with classification via classify_plugin_set (4.2, + 15.4, 15.5) +- POST /plugins/import against the moto-backed stack, asynchronous + flow: the import answers 202 immediately with the Plugin_Record + created in import_status 'fetching' (lifecycle dev, review pending, + provenance recorded, no polling in the request path — API Gateway + caps REST integrations at 29 s), and the EventBridge-delivered fetch + result (plugin_builds.py delegating to + plugin_importer.handle_fetch_result) advances the record: builds + queued and auto-started for the selected Target_Architectures on + success (4.2, 4.3, plugin_builds.start_queued_builds), + failed with the REPO_FETCH_FAILED finding when the repository is + unreachable or the revision missing (4.4 — the record now exists, a + deliberate change from the old synchronous no-record behavior), or + failed with the buildability finding for unbuildable trees (4.5) +- handle_fetch_result idempotency on the fetch build id (mocked + EventBridge details), mirroring handle_build_result's guards +- start_fetch StartBuild (no polling) with the PLUGIN_ID / + PLUGIN_VERSION / USECASE_ID attribution env overrides +- GET /plugin-modules Module_Listing (task 4.4): the pure page parse + into {name, description, repoUrl, classification} entries (6.1, 6.2), + ModuleIndexCache hit/miss/expiry (6.4), and the + MODULE_LISTING_UNAVAILABLE failure path (6.3) + +Task 4.6 (import error paths and cache behavior) is covered here too: +unreachable-repository and missing-revision fetch failures marking the +record failed (4.4), unbuildable source marking the record failed with +the finding persisted (4.5), MODULE_LISTING_UNAVAILABLE on fetch/parse +failure (6.3), and the cache TTL boundary exactly at 24 hours (6.4). +The property coverage belongs to tasks 4.2, 4.3, and 4.5. +""" +import json +import uuid + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from conftest import TEST_ENV + +MESON_PLUGIN = """\ +project('gst-myfilter', 'c') +gst_dep = dependency('gstreamer-1.0') +shared_library('gstmyfilter', 'gstmyfilter.c', dependencies: [gst_dep]) +""" + +CONFIGURE_AC_PLUGIN = """\ +AC_INIT([gst-myfilter], [1.0]) +PKG_CHECK_MODULES(GST, gstreamer-1.0 >= 1.20) +AG_GST_CHECK_PLUGIN(myfilter) +""" + + +# ===================================================================== +# Pure functions (no AWS) +# ===================================================================== + +class TestValidateRepoUrl: + def _module(self): + import plugin_importer + return plugin_importer + + @pytest.mark.parametrize("url", [ + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git", + "http://example.com/repo", + "git://anongit.freedesktop.org/gstreamer/gst-plugins-good", + ]) + def test_accepts_public_repo_urls(self, aws_stack, url): + assert aws_stack.plugin_importer.validate_repo_url(url) is None + + @pytest.mark.parametrize("url", [ + None, + "", + "/local/path/repo", + "ssh://host/repo", + "git@github.com:user/repo.git", + "https://", + "https://example.com/repo with space", + "file:///etc/passwd", + ]) + def test_rejects_invalid_urls(self, aws_stack, url): + assert aws_stack.plugin_importer.validate_repo_url(url) is not None + + def test_default_plugin_name_from_last_segment(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.default_plugin_name( + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git" + ) == "gst-plugins-good" + assert mod.default_plugin_name("https://example.com") == "example.com" + + +class TestScanBuildability: + def test_prebuilt_so_is_buildable(self, aws_stack): + scan = aws_stack.plugin_importer.scan_buildability( + {"README.md": None, "prebuilt/libgstmyfilter.so": None}) + assert scan["buildable"] is True + assert scan["kind"] == "prebuilt" + assert scan["evidence"] == ["prebuilt/libgstmyfilter.so"] + + def test_meson_with_gst_plugin_target_is_buildable(self, aws_stack): + scan = aws_stack.plugin_importer.scan_buildability( + {"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + assert scan["buildable"] is True + assert scan["kind"] == "meson" + assert scan["evidence"] == ["meson.build"] + + def test_configure_ac_with_gst_references_is_buildable(self, aws_stack): + scan = aws_stack.plugin_importer.scan_buildability( + {"configure.ac": CONFIGURE_AC_PLUGIN, "src/filter.c": None}) + assert scan["buildable"] is True + assert scan["kind"] == "autotools" + + def test_meson_without_gstreamer_is_not_buildable(self, aws_stack): + scan = aws_stack.plugin_importer.scan_buildability( + {"meson.build": "project('hello', 'c')\nexecutable('hello', 'hello.c')\n"}) + assert scan["buildable"] is False + assert "meson.build" in scan["finding"] + + def test_tree_without_build_definition_is_not_buildable(self, aws_stack): + scan = aws_stack.plugin_importer.scan_buildability( + {"README.md": None, "src/filter.c": None}) + assert scan["buildable"] is False + assert scan["kind"] is None + assert scan["finding"] # the finding is reported (4.5) + + +class TestImportProvenance: + def test_records_all_fields_and_classification(self, aws_stack): + mod = aws_stack.plugin_importer + url = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git" + prov = mod.import_provenance(url, "1.24.2", None, "user-1", 1234) + assert prov == { + "repoUrl": url, + "revision": "1.24.2", + "importedBy": "user-1", + "importedAt": 1234, + "classification": "good", + } + + def test_default_revision_and_unclassified(self, aws_stack): + mod = aws_stack.plugin_importer + prov = mod.import_provenance( + "https://github.com/someone/gst-thing.git", None, None, "u", 1) + assert prov["revision"] == mod.DEFAULT_REVISION + assert prov["classification"] == "unclassified" + + +# ===================================================================== +# Handler tests (moto-backed) +# ===================================================================== + +class ImporterEnv: + """Facade for invoking the Plugin_Importer API in tests.""" + + def __init__(self, stack): + self.stack = stack + self.module = stack.plugin_importer + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + def create_usecase(self, name="Importer Test Use Case"): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": name, + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + # --------------------------------------------- async fetch helpers + + FETCH_BUILD_ARN_PREFIX = "arn:aws:codebuild:us-east-1:123456789012:build/" + + def sync_source(self, plugin, files): + """Sync `files` under the record's plugin-sources prefix exactly + like the CodeBuild fetch step would.""" + prefix = plugin["source_s3_prefix"] + for path, content in files.items(): + self.s3.put_object( + Bucket=self.bucket, + Key=f"{prefix}{path}", + Body=(content or "").encode("utf-8"), + ) + + def fetch_result_detail(self, plugin, build_id, status="SUCCEEDED"): + """Synthetic EventBridge CodeBuild Build State Change detail for + the fetch project, carrying the attribution env overrides.""" + return { + "build-status": status, + "project-name": TEST_ENV["FETCH_PROJECT_NAME"], + "build-id": self.FETCH_BUILD_ARN_PREFIX + build_id, + "additional-information": { + "environment": { + "environment-variables": [ + {"name": "USECASE_ID", "value": plugin["usecase_id"]}, + {"name": "PLUGIN_ID", "value": plugin["plugin_id"]}, + {"name": "PLUGIN_VERSION", + "value": str(plugin["version"])}, + ], + }, + }, + } + + def deliver_fetch_result(self, detail): + """Deliver via plugin_builds.py's handler (the EventBridge rule + target), which delegates fetch results to handle_fetch_result.""" + return self.stack.plugin_builds.handler({ + "source": "aws.codebuild", + "detail-type": "CodeBuild Build State Change", + "detail": detail, + }, None) + + def get_record(self, plugin_id, version=1): + return self.stack.plugin_records.get_version_item(plugin_id, version) + + def complete_import(self, user, body, files=None, + fetch_status="SUCCEEDED"): + """POST /plugins/import (202 'fetching'), sync `files` like the + fetch step, deliver the fetch result; returns + (import_response_body, fetch_result, record).""" + status, response = self.import_plugin(user, body) + assert status == 202, response + plugin = response["plugin"] + if fetch_status == "SUCCEEDED": + self.sync_source(plugin, files or {}) + result = self.deliver_fetch_result(self.fetch_result_detail( + plugin, response["import"]["buildId"], status=fetch_status)) + record = self.get_record(plugin["plugin_id"], plugin["version"]) + return response, result, record + + def records_for(self, usecase_id): + from boto3.dynamodb.conditions import Key + response = self.stack.tables.plugin_records.query( + IndexName="usecase-plugins-index", + KeyConditionExpression=Key("usecase_id").eq(usecase_id), + ) + return response["Items"] + + def import_plugin(self, user, body): + event = { + "httpMethod": "POST", + "resource": "/plugins/import", + "path": "/plugins/import", + "pathParameters": None, + "queryStringParameters": None, + "body": json.dumps(body), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def ienv(aws_stack): + return ImporterEnv(aws_stack) + + +@pytest.fixture +def admin_setup(ienv): + usecase_id = ienv.create_usecase() + admin = ienv.make_user(role="Viewer") + ienv.assign_role(admin, usecase_id, "UseCaseAdmin") + return usecase_id, admin + + +REPO_URL = "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git" + + +class RecordingCodeBuild: + """Wraps the module's codebuild client, recording StartBuild calls.""" + + def __init__(self, inner): + self._inner = inner + self.calls = [] + + def start_build(self, **kwargs): + self.calls.append(kwargs) + return self._inner.start_build(**kwargs) + + def __getattr__(self, name): + return getattr(self._inner, name) + + +class TestImportRepository: + def test_import_answers_202_fetching_with_the_record_created( + self, ienv, admin_setup): + usecase_id, admin = admin_setup + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "1.24.2", + "architectures": ["x86_64", "arm64_jp5"], + }) + + # 202 immediately: the fetch runs asynchronously — API Gateway + # caps REST integrations at 29 s, so the request path never polls. + assert status == 202 + plugin = body["plugin"] + assert plugin["import_status"] == "fetching" + assert body["import"]["status"] == "fetching" + assert body["import"]["buildId"].startswith( + TEST_ENV["FETCH_PROJECT_NAME"] + ":") + # Provenance {repoUrl, revision, importedBy, importedAt, + # classification} recorded at import time (4.2, 15.4, 15.5) + assert plugin["provenance"]["repoUrl"] == REPO_URL + assert plugin["provenance"]["revision"] == "1.24.2" + assert plugin["provenance"]["importedBy"] == admin["user_id"] + assert plugin["provenance"]["importedAt"] > 0 + assert plugin["provenance"]["classification"] == "good" + # New record: lifecycle dev, review pending (9.1, 10.1) + assert plugin["lifecycle_state"] == "dev" + assert plugin["review"]["decision"] == "pending" + assert plugin["kind"] == "imported" + # No plugins_found and no builds until the fetch settles + assert plugin["artifacts"] == {} + assert "plugins_found" not in plugin + # The record is persisted in 'fetching' with the fetch build id + record = ienv.get_record(plugin["plugin_id"]) + assert record["import_status"] == "fetching" + assert record["fetch_build_id"] == body["import"]["buildId"] + + def test_fetch_start_carries_attribution_env_overrides( + self, ienv, admin_setup, monkeypatch): + """PLUGIN_ID / PLUGIN_VERSION / USECASE_ID ride the StartBuild + env overrides so handle_fetch_result can attribute the result.""" + usecase_id, admin = admin_setup + recorder = RecordingCodeBuild(ienv.module.codebuild) + monkeypatch.setattr(ienv.module, "codebuild", recorder) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "1.24.2", + "architectures": ["x86_64"], + }) + + assert status == 202 + (call,) = recorder.calls + assert call["projectName"] == TEST_ENV["FETCH_PROJECT_NAME"] + env = {v["name"]: v["value"] + for v in call["environmentVariablesOverride"]} + plugin = body["plugin"] + assert env["REPO_URL"] == REPO_URL + assert env["REVISION"] == "1.24.2" + assert env["DEST_PREFIX"] == plugin["source_s3_prefix"].rstrip("/") + assert env["USECASE_ID"] == usecase_id + assert env["PLUGIN_ID"] == plugin["plugin_id"] + assert env["PLUGIN_VERSION"] == "1" + + def test_successful_fetch_advances_to_imported_and_starts_builds( + self, ienv, admin_setup): + usecase_id, admin = admin_setup + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "1.24.2", + "architectures": ["x86_64", "arm64_jp5"], + }, files={"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + + assert result == {"recorded": True, "import_status": "imported"} + assert record["import_status"] == "imported" + # Builds submitted for the selected Target_Architectures (4.3): + # the queued entries auto-start once the fetch settles. + assert sorted(record["artifacts"]) == ["arm64_jp5", "x86_64"] + for arch, entry in record["artifacts"].items(): + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + # Single-plugin repository: one enumerated entry, no selection + assert [p["path"] for p in record["plugins_found"]] == [""] + + def test_default_branch_when_revision_omitted( + self, ienv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + recorder = RecordingCodeBuild(ienv.module.codebuild) + monkeypatch.setattr(ienv.module, "codebuild", recorder) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }) + + assert status == 202 + # The fetch clones the default branch (4.1)... + env = {v["name"]: v["value"] + for v in recorder.calls[0]["environmentVariablesOverride"]} + assert env["REVISION"] == "" + # ...and provenance records the default-revision marker + assert body["plugin"]["provenance"]["revision"] == \ + ienv.module.DEFAULT_REVISION + + def test_fetch_failure_marks_the_record_failed( + self, ienv, admin_setup): + """An unreachable repository or missing revision fails the + asynchronous fetch: the record — which now exists, a deliberate + change from the old synchronous no-record behavior — is marked + failed with the REPO_FETCH_FAILED finding (4.4).""" + usecase_id, admin = admin_setup + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": "https://github.com/someone/gone.git", + "revision": "no-such-tag", + "architectures": ["x86_64"], + }, fetch_status="FAILED") + + assert result == {"recorded": True, "import_status": "failed"} + assert record["import_status"] == "failed" + assert record["import_error_code"] == "REPO_FETCH_FAILED" + assert "unreachable" in record["import_finding"] + assert record["artifacts"] == {} # no builds submitted + + def test_unbuildable_source_marks_record_failed_with_finding( + self, ienv, admin_setup): + usecase_id, admin = admin_setup + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": "https://github.com/someone/not-a-plugin.git", + "architectures": ["x86_64"], + }, files={"README.md": "docs only", "src/main.c": None}) + + # Record marked failed with the finding persisted (4.5) + assert result == {"recorded": True, "import_status": "failed"} + assert record["import_status"] == "failed" + assert record["import_finding"] + assert record["artifacts"] == {} # no builds submitted + + def test_get_version_detail_exposes_the_import_status( + self, ienv, admin_setup): + """GET /plugins/{id}/versions/{v} responses include + import_status (and the finding once failed) so the UI can poll + while the fetch runs.""" + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }) + assert status == 202 + + record = ienv.get_record(body["plugin"]["plugin_id"]) + detail = ienv.stack.plugin_records.version_detail(record) + assert detail["import_status"] == "fetching" + + def test_invalid_url_rejected_before_fetch( + self, ienv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + + def exploding_fetch(*args, **kwargs): + raise AssertionError("fetch must not start for an invalid URL") + + monkeypatch.setattr(ienv.module, "start_fetch", exploding_fetch) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": "git@github.com:user/repo.git", + "architectures": ["x86_64"], + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_REPO_URL" + assert ienv.records_for(usecase_id) == [] + + def test_fetch_start_failure_creates_no_record( + self, ienv, admin_setup, monkeypatch): + """A StartBuild failure (e.g. throttling) still answers 502 + REPO_FETCH_FAILED with no record created.""" + usecase_id, admin = admin_setup + + def failing_fetch(*args, **kwargs): + raise RuntimeError("StartBuild throttled") + + monkeypatch.setattr(ienv.module, "start_fetch", failing_fetch) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }) + assert status == 502 + assert body["error"]["code"] == "REPO_FETCH_FAILED" + assert ienv.records_for(usecase_id) == [] + + def test_unknown_architecture_rejected(self, ienv, admin_setup): + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["sparc64"], + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCHITECTURES" + + def test_deepstream_restricted_to_jetson_architectures( + self, ienv, admin_setup): + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + "deepstream": True, + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCHITECTURES" + + def test_import_denied_without_import_permission(self, ienv, admin_setup): + usecase_id, _ = admin_setup + scientist = ienv.make_user(role="Viewer") + ienv.assign_role(scientist, usecase_id, "DataScientist") + + status, body = ienv.import_plugin(scientist, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }) + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + assert ienv.records_for(usecase_id) == [] + + +class TestHandleFetchResult: + """Fetch-result attribution and idempotency guards, mirroring + handle_build_result (mocked EventBridge details delivered through + plugin_builds.py's handler, the actual rule target).""" + + def _start_import(self, ienv, admin_setup): + usecase_id, admin = admin_setup + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }) + assert status == 202, body + return body + + def test_duplicate_delivery_is_idempotent_on_the_build_id( + self, ienv, admin_setup): + body = self._start_import(ienv, admin_setup) + plugin = body["plugin"] + ienv.sync_source(plugin, {"meson.build": MESON_PLUGIN}) + detail = ienv.fetch_result_detail(plugin, body["import"]["buildId"]) + + first = ienv.deliver_fetch_result(detail) + record_after_first = ienv.get_record(plugin["plugin_id"]) + duplicate = ienv.deliver_fetch_result(detail) + + assert first["recorded"] is True + assert duplicate == {"recorded": False, "reason": "already recorded"} + # The duplicate changed nothing. + assert ienv.get_record(plugin["plugin_id"]) == record_after_first + + def test_superseded_build_ids_are_skipped(self, ienv, admin_setup): + body = self._start_import(ienv, admin_setup) + plugin = body["plugin"] + + result = ienv.deliver_fetch_result(ienv.fetch_result_detail( + plugin, "dda-plugin-fetch:someone-else", status="FAILED")) + + assert result == {"recorded": False, "reason": "superseded build"} + assert ienv.get_record(plugin["plugin_id"])["import_status"] == \ + "fetching" + + def test_missing_attribution_is_skipped(self, ienv): + result = ienv.deliver_fetch_result({ + "build-status": "SUCCEEDED", + "project-name": TEST_ENV["FETCH_PROJECT_NAME"], + "build-id": (ienv.FETCH_BUILD_ARN_PREFIX + + "dda-plugin-fetch:no-env"), + }) + assert result == {"recorded": False, + "reason": "missing fetch metadata"} + + def test_unknown_record_is_skipped(self, ienv): + detail = ienv.fetch_result_detail( + {"plugin_id": "no-such-plugin", "version": 1, + "usecase_id": "uc-x"}, + "dda-plugin-fetch:orphan") + result = ienv.deliver_fetch_result(detail) + assert result == {"recorded": False, + "reason": "plugin record not found"} + + +class TestImportAutoStartBuilds: + """The fetch-result path auto-starts the builds it queued + (plugin_builds.start_queued_builds, called by handle_fetch_result + once the record settles to 'imported'): imported plugins build + without a manual POST /plugins/{id}/versions/{v}/build.""" + + ARCHS = ["x86_64", "arm64_jp5"] + + def _build_calls(self, recorder): + return [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + + def test_settled_import_starts_one_build_per_architecture( + self, ienv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + builds_module = ienv.stack.plugin_builds + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": self.ARCHS, + }, files={"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + + assert result == {"recorded": True, "import_status": "imported"} + # One StartBuild per requested Target_Architecture, sourcing the + # synced tree with the attribution env overrides. + calls = self._build_calls(recorder) + assert sorted(c["projectName"] for c in calls) == \ + ["dda-plugin-build-arm64_jp5", "dda-plugin-build-x86_64"] + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + for call in calls: + assert call["sourceLocationOverride"] == \ + f"{bucket}/{record['source_s3_prefix']}" + env = {v["name"]: v["value"] + for v in call["environmentVariablesOverride"]} + assert env["USECASE_ID"] == usecase_id + assert env["PLUGIN_ID"] == record["plugin_id"] + assert env["PLUGIN_VERSION"] == "1" + # The queued artifact entries flipped to building with the + # started build ids recorded. + for arch in self.ARCHS: + entry = record["artifacts"][arch] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith(f"dda-plugin-build-{arch}:") + # The started CodeBuild builds actually exist. + builds = ienv.stack.codebuild.batch_get_builds( + ids=[record["artifacts"][a]["buildId"] + for a in self.ARCHS])["builds"] + assert len(builds) == len(self.ARCHS) + + def test_fetch_failure_starts_no_builds( + self, ienv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + builds_module = ienv.stack.plugin_builds + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": self.ARCHS, + }, fetch_status="FAILED") + + assert result == {"recorded": True, "import_status": "failed"} + assert self._build_calls(recorder) == [] + assert record["artifacts"] == {} + + class _ExplodingCodeBuild: + def start_build(self, **kwargs): + raise RuntimeError("StartBuild throttled") + + def test_auto_start_failure_never_fails_the_fetch_result( + self, ienv, admin_setup, monkeypatch): + """A StartBuild failure is recorded on the arch entry instead + of failing the fetch-result handler (the import still settles + to 'imported').""" + usecase_id, admin = admin_setup + monkeypatch.setattr(ienv.stack.plugin_builds, "codebuild", + self._ExplodingCodeBuild()) + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64"], + }, files={"meson.build": MESON_PLUGIN}) + + assert result == {"recorded": True, "import_status": "imported"} + assert record["import_status"] == "imported" + entry = record["artifacts"]["x86_64"] + assert entry["buildStatus"] == "failed" + assert "StartBuild throttled" in entry["logTail"] + + def test_unconfigured_architecture_is_left_queued( + self, aws_stack, ienv, admin_setup, monkeypatch): + """Architectures without a configured CodeBuild project are + skipped (left queued) while the rest start.""" + usecase_id, admin = admin_setup + builds_module = ienv.stack.plugin_builds + monkeypatch.setattr( + builds_module, "BUILD_PROJECTS", + {"x86_64": builds_module.BUILD_PROJECTS["x86_64"]}) + + _, result, record = ienv.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "architectures": ["x86_64", "arm64_jp5"], + }, files={"meson.build": MESON_PLUGIN}) + + assert result == {"recorded": True, "import_status": "imported"} + assert record["artifacts"]["x86_64"]["buildStatus"] == "building" + assert record["artifacts"]["arm64_jp5"] == {"buildStatus": "queued"} + + +class TestStartFetch: + def test_start_fetch_starts_the_fetch_project_without_polling( + self, aws_stack): + """start_fetch StartBuilds FETCH_PROJECT_NAME and returns the + build id immediately (no batch_get_builds polling loop).""" + build_id = aws_stack.plugin_importer.start_fetch( + "https://example.com/repo.git", "main", + "plugin-sources/uc/p/1", + usecase_id="uc", plugin_id="p", version=1) + + assert build_id.startswith(TEST_ENV["FETCH_PROJECT_NAME"] + ":") + + +# ===================================================================== +# Module_Listing (GET /plugin-modules, task 4.4) +# ===================================================================== + +#: Synthetic Module_Listing page mirroring the real page structure: +#: layout/navigation tables without a "module" header row, then the +#: module index table (header row: module | description | ...). +MODULE_LISTING_PAGE = """\ + +
+
Home
Features
+

Modules

+ + + + + + + + + + + + + + + + + + + + + + +
moduledescriptionstable versiondevel versionstatus
gstreamercore library and elements1.28.51.29.2active
gst-plugins-gooda set of good-quality plug-ins under our preferred license, LGPL1.28.51.29.2active
gst-plugins-uglya set of good-quality plug-ins that might pose distribution problems1.28.51.29.2active
gst-plugins-bada set of plug-ins that need more quality, testing or documentation1.28.51.29.2active
qt-gstreamerQtGStreamer + N/A + + N/A + abandoned
+ +""" + + +class TestParseModuleListing: + """The parse is a pure function over the page content (6.1).""" + + def test_parses_every_module_row(self, aws_stack): + modules = aws_stack.plugin_importer.parse_module_listing( + MODULE_LISTING_PAGE) + assert [m["name"] for m in modules] == [ + "gstreamer", "gst-plugins-good", "gst-plugins-ugly", + "gst-plugins-bad", "qt-gstreamer", + ] + + def test_entries_carry_description_repo_url_and_classification( + self, aws_stack): + modules = aws_stack.plugin_importer.parse_module_listing( + MODULE_LISTING_PAGE) + by_name = {m["name"]: m for m in modules} + + good = by_name["gst-plugins-good"] + assert good["description"].startswith("a set of good-quality") + # Published repository location fed into the import path (6.2) + assert good["repoUrl"] == \ + "https://gitlab.freedesktop.org/gstreamer/gst-plugins-good.git" + # Classification via classify_plugin_set (15.1) + assert good["classification"] == "good" + assert by_name["gst-plugins-bad"]["classification"] == "bad" + assert by_name["gst-plugins-ugly"]["classification"] == "ugly" + assert by_name["gstreamer"]["classification"] == "unclassified" + + def test_layout_tables_are_ignored(self, aws_stack): + modules = aws_stack.plugin_importer.parse_module_listing( + MODULE_LISTING_PAGE) + names = [m["name"] for m in modules] + assert "Home" not in names + assert "Features" not in names + + @pytest.mark.parametrize("page", [ + "", + "

maintenance

", + "
foo
bar
", + ]) + def test_unparseable_content_raises(self, aws_stack, page): + with pytest.raises(aws_stack.plugin_importer.ModuleListingParseError): + aws_stack.plugin_importer.parse_module_listing(page) + + +class ModuleListingEnv(ImporterEnv): + """ImporterEnv plus GET /plugin-modules invocation and cache access.""" + + def clear_cache(self): + self.stack.tables.module_index_cache.delete_item( + Key={"cache_key": self.module.MODULE_INDEX_CACHE_KEY}) + + def cache_item(self): + return self.stack.tables.module_index_cache.get_item( + Key={"cache_key": self.module.MODULE_INDEX_CACHE_KEY} + ).get("Item") + + def list_modules(self, user=None): + user = user or self.make_user() + event = { + "httpMethod": "GET", + "resource": "/plugin-modules", + "path": "/plugin-modules", + "pathParameters": None, + "queryStringParameters": None, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def menv(aws_stack): + env = ModuleListingEnv(aws_stack) + env.clear_cache() + return env + + +class TestListPluginModules: + def test_cache_miss_fetches_parses_and_caches(self, menv, monkeypatch): + monkeypatch.setattr(menv.module, "fetch_module_listing", + lambda: MODULE_LISTING_PAGE) + + status, body = menv.list_modules() + + assert status == 200 + assert body["cached"] is False + assert body["fetchedAt"] > 0 + names = [m["name"] for m in body["modules"]] + assert "gst-plugins-good" in names + # The parsed index is cached with fetchedAt and the 24 h TTL (6.4) + item = menv.cache_item() + assert item is not None + assert int(item["fetchedAt"]) == body["fetchedAt"] + assert int(item["ttl"]) == (int(item["fetchedAt"]) // 1000 + + menv.module.MODULE_INDEX_TTL_SECONDS) + assert len(item["modules"]) == len(body["modules"]) + + def test_fresh_cache_is_reused_without_fetching(self, menv, monkeypatch): + monkeypatch.setattr(menv.module, "fetch_module_listing", + lambda: MODULE_LISTING_PAGE) + status1, body1 = menv.list_modules() + assert status1 == 200 + + def exploding_fetch(): + raise AssertionError("a fresh cache must be reused (6.4)") + + monkeypatch.setattr(menv.module, "fetch_module_listing", + exploding_fetch) + status2, body2 = menv.list_modules() + + assert status2 == 200 + assert body2["cached"] is True + assert body2["fetchedAt"] == body1["fetchedAt"] + assert body2["modules"] == body1["modules"] + + def test_expired_cache_is_refetched(self, menv, monkeypatch): + stale_fetched_at = (menv.module.now_ms() + - menv.module.MODULE_INDEX_TTL_SECONDS * 1000 + - 1) + menv.stack.tables.module_index_cache.put_item(Item={ + "cache_key": menv.module.MODULE_INDEX_CACHE_KEY, + "modules": [{"name": "stale", "description": "", + "repoUrl": "https://example.com/stale.git", + "classification": "unclassified"}], + "fetchedAt": stale_fetched_at, + "ttl": stale_fetched_at // 1000, + }) + monkeypatch.setattr(menv.module, "fetch_module_listing", + lambda: MODULE_LISTING_PAGE) + + status, body = menv.list_modules() + + assert status == 200 + assert body["cached"] is False + assert "stale" not in [m["name"] for m in body["modules"]] + + def _seed_cache(self, menv, fetched_at): + menv.stack.tables.module_index_cache.put_item(Item={ + "cache_key": menv.module.MODULE_INDEX_CACHE_KEY, + "modules": [{"name": "seeded", "description": "", + "repoUrl": "https://example.com/seeded.git", + "classification": "unclassified"}], + "fetchedAt": fetched_at, + "ttl": fetched_at // 1000 + menv.module.MODULE_INDEX_TTL_SECONDS, + }) + + def test_cache_exactly_24h_old_is_stale_and_refetched( + self, menv, monkeypatch): + """At exactly 24 hours the cached index is no longer reused: + the TTL bound is 'at most 24 hours' (6.4).""" + fixed_now = menv.module.now_ms() + monkeypatch.setattr(menv.module, "now_ms", lambda: fixed_now) + self._seed_cache( + menv, fixed_now - menv.module.MODULE_INDEX_TTL_SECONDS * 1000) + monkeypatch.setattr(menv.module, "fetch_module_listing", + lambda: MODULE_LISTING_PAGE) + + status, body = menv.list_modules() + + assert status == 200 + assert body["cached"] is False + assert "seeded" not in [m["name"] for m in body["modules"]] + + def test_cache_one_ms_younger_than_24h_is_fresh_and_reused( + self, menv, monkeypatch): + """One millisecond inside the 24-hour window the cache is still + fresh and reused without fetching (6.4).""" + fixed_now = menv.module.now_ms() + monkeypatch.setattr(menv.module, "now_ms", lambda: fixed_now) + fetched_at = (fixed_now + - menv.module.MODULE_INDEX_TTL_SECONDS * 1000 + 1) + self._seed_cache(menv, fetched_at) + + def exploding_fetch(): + raise AssertionError( + "a cache younger than 24 h must be reused (6.4)") + + monkeypatch.setattr(menv.module, "fetch_module_listing", + exploding_fetch) + + status, body = menv.list_modules() + + assert status == 200 + assert body["cached"] is True + assert body["fetchedAt"] == fetched_at + assert [m["name"] for m in body["modules"]] == ["seeded"] + + def test_fetch_failure_returns_module_listing_unavailable( + self, menv, monkeypatch): + def failing_fetch(): + raise ConnectionError("upstream unreachable") + + monkeypatch.setattr(menv.module, "fetch_module_listing", + failing_fetch) + + status, body = menv.list_modules() + + # The distinct code lets the UI offer manual URL entry (6.3) + assert status == 502 + assert body["error"]["code"] == "MODULE_LISTING_UNAVAILABLE" + + def test_unparseable_response_returns_module_listing_unavailable( + self, menv, monkeypatch): + monkeypatch.setattr(menv.module, "fetch_module_listing", + lambda: "maintenance") + + status, body = menv.list_modules() + + assert status == 502 + assert body["error"]["code"] == "MODULE_LISTING_UNAVAILABLE" + + +# ===================================================================== +# Per-module plugin list (GET /plugin-modules?module=) +# ===================================================================== + +def _tree_entry(name, entry_type="tree"): + return {"id": f"sha-{name}", "name": name, "type": entry_type, + "path": f"subprojects/x/{name}", "mode": "040000"} + + +#: GitLab tree listings for a plugin-set module: one plugin per 'tree' +#: entry under gst/, ext/, sys/; blob entries (meson.build etc.) must +#: not enumerate. +MODULE_PLUGIN_TREES = { + "gst": [_tree_entry("rtp"), _tree_entry("udp"), + _tree_entry("meson.build", "blob")], + "ext": [_tree_entry("jpeg"), _tree_entry("rtp")], # duplicate name + "sys": [_tree_entry("v4l2")], +} + + +class TestModulePluginsFromTrees: + """The parse is a pure function over the GitLab tree listings.""" + + def test_one_entry_per_directory_sorted_and_deduped(self, aws_stack): + plugins = aws_stack.plugin_importer.module_plugins_from_trees( + MODULE_PLUGIN_TREES) + assert plugins == [{"name": "jpeg"}, {"name": "rtp"}, + {"name": "udp"}, {"name": "v4l2"}] + + def test_blob_entries_and_missing_roots_are_ignored(self, aws_stack): + mod = aws_stack.plugin_importer + assert mod.module_plugins_from_trees( + {"gst": [_tree_entry("meson.build", "blob")]}) == [] + # A module without some roots (e.g. no sys/) parses fine. + assert mod.module_plugins_from_trees( + {"gst": [_tree_entry("rtp")]}) == [{"name": "rtp"}] + assert mod.module_plugins_from_trees({}) == [] + + +class ModulePluginsEnv(ModuleListingEnv): + """ModuleListingEnv plus GET /plugin-modules?module=... invocation.""" + + def clear_module_cache(self, module): + self.stack.tables.module_index_cache.delete_item( + Key={"cache_key": self.module.module_plugins_cache_key(module)}) + + def module_cache_item(self, module): + return self.stack.tables.module_index_cache.get_item( + Key={"cache_key": self.module.module_plugins_cache_key(module)} + ).get("Item") + + def list_module_plugins(self, module, user=None): + user = user or self.make_user() + event = { + "httpMethod": "GET", + "resource": "/plugin-modules", + "path": "/plugin-modules", + "pathParameters": None, + "queryStringParameters": {"module": module}, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +@pytest.fixture +def penv(aws_stack, monkeypatch): + env = ModulePluginsEnv(aws_stack) + env.clear_module_cache("gst-plugins-good") + # Per-plugin descriptions are an enhancement fetched separately from + # the monorepo's docs/gst_plugins_cache.json; default to none so + # these tests never touch the network. Description-join tests + # re-patch this with concrete values. + monkeypatch.setattr(env.module, "fetch_module_plugin_descriptions", + lambda module: {}) + return env + + +class TestListModulePlugins: + def test_fetches_parses_and_caches_the_module_plugin_list( + self, penv, monkeypatch): + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: MODULE_PLUGIN_TREES) + + status, body = penv.list_module_plugins("gst-plugins-good") + + assert status == 200 + assert body["module"] == "gst-plugins-good" + assert body["plugins"] == [{"name": "jpeg"}, {"name": "rtp"}, + {"name": "udp"}, {"name": "v4l2"}] + assert body["cached"] is False + assert body["fetchedAt"] > 0 + # Cached per module with fetchedAt and the 24 h TTL pattern. + item = penv.module_cache_item("gst-plugins-good") + assert item is not None + assert int(item["fetchedAt"]) == body["fetchedAt"] + assert int(item["ttl"]) == (int(item["fetchedAt"]) // 1000 + + penv.module.MODULE_INDEX_TTL_SECONDS) + assert len(item["plugins"]) == 4 + + def test_fresh_cache_is_reused_without_fetching(self, penv, monkeypatch): + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: MODULE_PLUGIN_TREES) + status1, body1 = penv.list_module_plugins("gst-plugins-good") + assert status1 == 200 + + def exploding_fetch(module): + raise AssertionError("a fresh per-module cache must be reused") + + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + exploding_fetch) + status2, body2 = penv.list_module_plugins("gst-plugins-good") + + assert status2 == 200 + assert body2["cached"] is True + assert body2["fetchedAt"] == body1["fetchedAt"] + assert body2["plugins"] == body1["plugins"] + + def test_per_module_cache_keys_are_distinct(self, penv, monkeypatch): + penv.clear_module_cache("gst-plugins-bad") + monkeypatch.setattr( + penv.module, "fetch_module_plugin_trees", + lambda module: {"gst": [_tree_entry(f"{module}-plugin")]}) + + _, good = penv.list_module_plugins("gst-plugins-good") + _, bad = penv.list_module_plugins("gst-plugins-bad") + + assert good["plugins"] == [{"name": "gst-plugins-good-plugin"}] + assert bad["plugins"] == [{"name": "gst-plugins-bad-plugin"}] + # ...and the module index item itself is untouched. + assert penv.module_cache_item("gst-plugins-good")["cache_key"] != \ + penv.module_cache_item("gst-plugins-bad")["cache_key"] + + def test_fetch_failure_returns_module_listing_unavailable( + self, penv, monkeypatch): + def failing_fetch(module): + raise ConnectionError("gitlab unreachable") + + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + failing_fetch) + + status, body = penv.list_module_plugins("gst-plugins-good") + + # The existing distinct code lets the UI fall back to importing + # the full plugin set. + assert status == 502 + assert body["error"]["code"] == "MODULE_LISTING_UNAVAILABLE" + + def test_module_without_plugins_is_unavailable(self, penv, monkeypatch): + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: {"gst": [], "ext": [], "sys": []}) + + status, body = penv.list_module_plugins("gst-plugins-good") + + assert status == 502 + assert body["error"]["code"] == "MODULE_LISTING_UNAVAILABLE" + + @pytest.mark.parametrize("module", ["", " ", "../etc", "a b", + "mod/../../x"]) + def test_invalid_module_names_are_rejected(self, penv, module): + status, body = penv.list_module_plugins(module) + assert status == 400 + assert body["error"]["code"] == "INVALID_MODULE" + + def test_plain_listing_still_answers_without_the_module_param( + self, penv, monkeypatch): + """GET /plugin-modules without ?module= keeps returning the + module index (backward compatible, no new route).""" + penv.clear_cache() + monkeypatch.setattr(penv.module, "fetch_module_listing", + lambda: MODULE_LISTING_PAGE) + status, body = penv.list_modules() + assert status == 200 + assert "modules" in body + + +# ===================================================================== +# Per-plugin descriptions in the module plugin list +# ===================================================================== +# +# GET /plugin-modules?module= joins per-plugin descriptions from +# the monorepo's docs/gst_plugins_cache.json onto the [{name}] entries. +# Descriptions are an enhancement: every failure path (fetch error, +# oversized file, malformed JSON) degrades to entries without +# descriptions and never fails the listing. + +MODULE_PLUGIN_DESCRIPTIONS = { + "rtp": "Real-time Transport Protocol plugin", + "jpeg": "JPeg plugin library", + # udp / v4l2 intentionally absent: their entries stay without a + # description. +} + + +class TestListModulePluginsDescriptions: + def test_descriptions_join_onto_the_listed_plugins( + self, penv, monkeypatch): + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: MODULE_PLUGIN_TREES) + monkeypatch.setattr(penv.module, "fetch_module_plugin_descriptions", + lambda module: dict(MODULE_PLUGIN_DESCRIPTIONS)) + + status, body = penv.list_module_plugins("gst-plugins-good") + + assert status == 200 + assert body["plugins"] == [ + {"name": "jpeg", "description": "JPeg plugin library"}, + {"name": "rtp", + "description": "Real-time Transport Protocol plugin"}, + {"name": "udp"}, + {"name": "v4l2"}, + ] + + def test_descriptions_are_included_in_the_cached_item( + self, penv, monkeypatch): + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: MODULE_PLUGIN_TREES) + monkeypatch.setattr(penv.module, "fetch_module_plugin_descriptions", + lambda module: dict(MODULE_PLUGIN_DESCRIPTIONS)) + status1, body1 = penv.list_module_plugins("gst-plugins-good") + assert status1 == 200 + + item = penv.module_cache_item("gst-plugins-good") + cached_by_name = {p["name"]: p for p in item["plugins"]} + assert cached_by_name["rtp"]["description"] == \ + "Real-time Transport Protocol plugin" + + # A fresh cache is reused without re-fetching anything — + # descriptions included. + def exploding(*args): + raise AssertionError("a fresh per-module cache must be reused") + + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + exploding) + monkeypatch.setattr(penv.module, "fetch_module_plugin_descriptions", + exploding) + status2, body2 = penv.list_module_plugins("gst-plugins-good") + assert status2 == 200 + assert body2["cached"] is True + assert body2["plugins"] == body1["plugins"] + + def test_description_fetch_failure_never_fails_the_listing( + self, penv, monkeypatch): + """Descriptions degrade to nothing: even a raising description + source must not fail the listing (the fetch helper itself never + raises, but the join must tolerate an empty result).""" + monkeypatch.setattr(penv.module, "fetch_module_plugin_trees", + lambda module: MODULE_PLUGIN_TREES) + monkeypatch.setattr(penv.module, "fetch_module_plugin_descriptions", + lambda module: {}) + + status, body = penv.list_module_plugins("gst-plugins-good") + + assert status == 200 + assert body["plugins"] == [{"name": "jpeg"}, {"name": "rtp"}, + {"name": "udp"}, {"name": "v4l2"}] + + +class _FakeDescriptionsResponse: + """Stand-in for requests.get(..., stream=True) responses.""" + + def __init__(self, payload: bytes, status_code=200): + self.payload = payload + self.status_code = status_code + + def raise_for_status(self): + if self.status_code >= 400: + raise ConnectionError(f"HTTP {self.status_code}") + + def iter_content(self, chunk_size): + for i in range(0, len(self.payload), chunk_size): + yield self.payload[i:i + chunk_size] + + +class TestFetchModulePluginDescriptions: + """fetch_module_plugin_descriptions never raises: every failure + returns {} so descriptions never block a listing.""" + + def _patch(self, aws_stack, monkeypatch, response=None, exc=None): + def fake_get(url, **kwargs): + if exc is not None: + raise exc + assert "gst-plugins-good/docs/gst_plugins_cache.json" in url + return response + + monkeypatch.setattr(aws_stack.plugin_importer.requests, "get", + fake_get) + return aws_stack.plugin_importer.fetch_module_plugin_descriptions( + "gst-plugins-good") + + def test_parses_the_cache_into_descriptions(self, aws_stack, + monkeypatch): + payload = json.dumps({ + "rtp": {"description": "Real-time Transport Protocol plugin", + "elements": {}}, + "udp": {"description": "", "elements": {}}, + }).encode("utf-8") + result = self._patch(aws_stack, monkeypatch, + response=_FakeDescriptionsResponse(payload)) + assert result == { + "rtp": "Real-time Transport Protocol plugin"} + + def test_http_failure_returns_empty(self, aws_stack, monkeypatch): + result = self._patch( + aws_stack, monkeypatch, + response=_FakeDescriptionsResponse(b"", status_code=404)) + assert result == {} + + def test_network_error_returns_empty(self, aws_stack, monkeypatch): + result = self._patch(aws_stack, monkeypatch, + exc=ConnectionError("unreachable")) + assert result == {} + + def test_oversized_cache_returns_empty(self, aws_stack, monkeypatch): + mod = aws_stack.plugin_importer + monkeypatch.setattr(mod, "MAX_PLUGIN_DESCRIPTION_CACHE_BYTES", 64) + payload = json.dumps( + {"rtp": {"description": "x" * 256}}).encode("utf-8") + result = self._patch(aws_stack, monkeypatch, + response=_FakeDescriptionsResponse(payload)) + assert result == {} + + def test_malformed_json_returns_empty(self, aws_stack, monkeypatch): + result = self._patch( + aws_stack, monkeypatch, + response=_FakeDescriptionsResponse(b"{not json")) + assert result == {} + + +# ===================================================================== +# Platform requirements/compatibility check (advisory, pure) +# ===================================================================== + +# gst-plugins-good main-branch style: the requirement is the project's +# own major.minor series via the '.format(...)' form (requires +# GStreamer >= 1.24 -> fails on arm64_jp4/1.14 and arm64_jp5/1.16). +GST_GOOD_MAIN_MESON = """\ +project('gst-plugins-good', 'c', + version : '1.24.2', + meson_version : '>= 1.1', + default_options : [ 'warning_level=1', 'buildtype=debugoptimized' ]) + +gst_version = meson.project_version() +version_arr = gst_version.split('.') +gst_version_major = version_arr[0].to_int() +gst_version_minor = version_arr[1].to_int() +gst_version_micro = version_arr[2].to_int() + +gst_req = '>= @0@.@1@.0'.format(gst_version_major, gst_version_minor) + +gst_dep = dependency('gstreamer-1.0', version : gst_req, + fallback : ['gstreamer', 'gst_dep']) +""" + +# gst-plugins-good 1.16 release-branch style (same '.format(...)' form, +# older meson syntax and project version). +GST_GOOD_116_MESON = """\ +project('gst-plugins-good', 'c', + version : '1.16.3', + meson_version : '>= 0.47', + default_options : [ 'warning_level=1', 'buildtype=debugoptimized' ]) + +gst_version = meson.project_version() +version_arr = gst_version.split('.') +gst_version_major = version_arr[0].to_int() +gst_version_minor = version_arr[1].to_int() + +gst_req = '>= @0@.@1@.0'.format(gst_version_major, gst_version_minor) + +gst_dep = dependency('gstreamer-1.0', version : gst_req, + fallback : ['gstreamer', 'gst_dep']) +""" + +CONFIGURE_AC_GST_REQUIRED = """\ +AC_INIT([gst-myfilter], [1.0]) +dnl minimum GStreamer the plugin needs +GST_REQUIRED=1.16.2 +PKG_CHECK_MODULES(GST, gstreamer-1.0 >= $GST_REQUIRED) +AC_SUBST(GST_REQUIRED) +""" + + +class TestGstreamerRequirement: + """gstreamer_requirement parsing over real-ish build definitions.""" + + def test_gst_plugins_good_main_format_pattern(self, aws_stack): + files = {"meson.build": GST_GOOD_MAIN_MESON, "gst/rtsp/meson.build": None} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.24.0" + + def test_gst_plugins_good_116_format_pattern(self, aws_stack): + files = {"meson.build": GST_GOOD_116_MESON} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.16.0" + + @pytest.mark.parametrize("line,expected", [ + ("gst_req = '>= 1.24'", "1.24"), + ('gst_req = ">= 1.18.0"', "1.18.0"), + ("gst_req = '>=1.20'", "1.20"), + ]) + def test_gst_req_literal(self, aws_stack, line, expected): + files = {"meson.build": f"project('p', 'c')\n{line}\n"} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == expected + + def test_dependency_inline_version(self, aws_stack): + files = {"meson.build": + "project('p', 'c')\n" + "gst_dep = dependency('gstreamer-1.0', version : '>= 1.20',\n" + " required : true)\n"} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.20" + + def test_autotools_gst_required_assignment(self, aws_stack): + files = {"configure.ac": CONFIGURE_AC_GST_REQUIRED} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.16.2" + + def test_autotools_ac_subst_pattern(self, aws_stack): + files = {"configure.in": + "AC_INIT([p], [1.0])\nAC_SUBST(GST_REQUIRED, 1.14)\n"} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.14" + + def test_autotools_pkg_check_inline_version(self, aws_stack): + files = {"configure.ac": + "AC_INIT([p], [1.0])\n" + "PKG_CHECK_MODULES(GST, gstreamer-1.0 >= 1.18)\n"} + assert aws_stack.plugin_importer.gstreamer_requirement(files) == "1.18" + + def test_no_requirement_returns_none(self, aws_stack): + # A dependency without a version constraint carries no + # requirement: assume compatible everywhere. + assert aws_stack.plugin_importer.gstreamer_requirement( + {"meson.build": MESON_PLUGIN}) is None + + def test_missing_root_build_definition_returns_none(self, aws_stack): + # Only non-root meson.build files: the root requirement is + # undeterminable (parsing degrades, never blocks). + assert aws_stack.plugin_importer.gstreamer_requirement( + {"gst/rtsp/meson.build": GST_GOOD_MAIN_MESON, + "README.md": None}) is None + assert aws_stack.plugin_importer.gstreamer_requirement({}) is None + + def test_format_pattern_without_project_version_returns_none(self, aws_stack): + files = {"meson.build": + "gst_req = '>= @0@.@1@.0'.format(gst_version_major, " + "gst_version_minor)\n"} + assert aws_stack.plugin_importer.gstreamer_requirement(files) is None + + +class TestPlatformCompatibility: + """platform_compatibility over the requested Target_Architectures.""" + + ALL_ARCHES = ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", + "arm64_jp6"] + + def test_1_24_requirement_matches_production_incident(self, aws_stack): + # gst-plugins-good main (>= 1.24): fails on arm64_jp4 (1.14) + # and arm64_jp5 (1.16), succeeds on x86_64 / x86_64_nvidia / + # arm64_jp6 (1.20). + result = aws_stack.plugin_importer.platform_compatibility( + "1.24.0", self.ALL_ARCHES, "gst-plugins-good") + assert {a: e["compatible"] for a, e in result.items()} == { + "x86_64": True, "x86_64_nvidia": True, + "arm64_jp4": False, "arm64_jp5": False, "arm64_jp6": True, + } + assert result["arm64_jp5"]["reason"] == ( + "The source requires GStreamer >= 1.24.0; " + "arm64 JetPack 5 provides 1.16") + # Official module: the release branch matching the platform's + # GStreamer minor is suggested (verified working in production). + assert result["arm64_jp4"]["suggestedRevision"] == "1.14" + assert result["arm64_jp5"]["suggestedRevision"] == "1.16" + # Compatible platforms carry no reason and no suggestion. + assert result["arm64_jp6"]["reason"] is None + assert result["arm64_jp6"]["suggestedRevision"] is None + assert result["arm64_jp5"]["platformVersion"] == "1.16" + assert result["arm64_jp5"]["requiredVersion"] == "1.24.0" + + def test_classification_also_marks_official(self, aws_stack): + # A repo classified good/bad/ugly (no moduleName) still gets + # branch suggestions. + result = aws_stack.plugin_importer.platform_compatibility( + "1.24.0", ["arm64_jp5"], "ugly") + assert result["arm64_jp5"]["suggestedRevision"] == "1.16" + + @pytest.mark.parametrize("classification_or_module", [None, "", + "unclassified"]) + def test_non_official_repo_gets_no_suggestion(self, aws_stack, + classification_or_module): + result = aws_stack.plugin_importer.platform_compatibility( + "1.24.0", ["arm64_jp4", "arm64_jp5"], classification_or_module) + for entry in result.values(): + assert entry["compatible"] is False + assert entry["reason"] # the why is still explained + assert entry["suggestedRevision"] is None + + def test_no_requirement_is_compatible_everywhere(self, aws_stack): + result = aws_stack.plugin_importer.platform_compatibility( + None, self.ALL_ARCHES, "gst-plugins-good") + assert all(e["compatible"] for e in result.values()) + assert all(e["reason"] is None for e in result.values()) + assert all(e["suggestedRevision"] is None for e in result.values()) + + def test_1_16_requirement_splits_jp4_from_jp5(self, aws_stack): + result = aws_stack.plugin_importer.platform_compatibility( + "1.16.0", ["arm64_jp4", "arm64_jp5"], "gst-plugins-good") + assert result["arm64_jp4"]["compatible"] is False + assert result["arm64_jp4"]["suggestedRevision"] == "1.14" + assert result["arm64_jp5"]["compatible"] is True + + def test_unknown_architecture_counts_compatible(self, aws_stack): + result = aws_stack.plugin_importer.platform_compatibility( + "1.24.0", ["riscv64"], "gst-plugins-good") + assert result["riscv64"]["compatible"] is True + assert result["riscv64"]["platformVersion"] is None + + def test_unparseable_requirement_counts_compatible(self, aws_stack): + result = aws_stack.plugin_importer.platform_compatibility( + "banana", ["arm64_jp4"], "gst-plugins-good") + assert result["arm64_jp4"]["compatible"] is True + + +class TestEvaluateFetchedTreeCompatibility: + """evaluate_fetched_tree carries the advisory map in every outcome + and never blocks builds on it.""" + + BUILDABLE_1_24 = { + "meson.build": + "project('gst-myfilter', 'c', version : '1.0')\n" + "gst_req = '>= 1.24'\n" + "gst_dep = dependency('gstreamer-1.0', version : gst_req)\n" + "shared_library('gstmyfilter', 'gstmyfilter.c', " + "dependencies: [gst_dep])\n", + "gstmyfilter.c": None, + } + + def test_updates_carry_map_and_builds_still_queue(self, aws_stack): + mod = aws_stack.plugin_importer + scan, updates = mod.evaluate_fetched_tree( + self.BUILDABLE_1_24, "gst-myfilter", [], + ["x86_64", "arm64_jp5"], + classification_or_module="gst-plugins-good") + assert scan["buildable"] is True + compat = updates["platform_compatibility"] + assert compat["arm64_jp5"]["compatible"] is False + assert compat["x86_64"]["compatible"] is True + # Advisory only: the incompatible architecture still queues. + assert updates["import_status"] == mod.IMPORT_STATUS_IMPORTED + assert updates["artifacts"] == { + "x86_64": {"buildStatus": "queued"}, + "arm64_jp5": {"buildStatus": "queued"}, + } + + def test_unbuildable_tree_still_carries_map(self, aws_stack): + mod = aws_stack.plugin_importer + scan, updates = mod.evaluate_fetched_tree( + {"README.md": None}, "p", [], ["arm64_jp4"]) + assert scan["buildable"] is False + assert updates["import_status"] == mod.IMPORT_STATUS_FAILED + # No requirement determinable: compatible (advisory degrade). + assert updates["platform_compatibility"]["arm64_jp4"]["compatible"] is True + + def test_no_requirement_yields_all_compatible_map(self, aws_stack): + mod = aws_stack.plugin_importer + _, updates = mod.evaluate_fetched_tree( + {"meson.build": MESON_PLUGIN}, "p", [], ["arm64_jp4", "x86_64"]) + compat = updates["platform_compatibility"] + assert all(entry["compatible"] for entry in compat.values()) + + +class TestVersionDetailPlatformCompatibility: + """version_detail / import_detail expose the recorded map additively.""" + + def _item(self, **extra): + return { + "plugin_id": "p-1", "version": 1, "usecase_id": "uc-1", + "name": "gst-plugins-good", "kind": "imported", + "provenance": {}, "lifecycle_state": "dev", "review": {}, + "artifacts": {}, "component": {}, + "source_s3_prefix": "plugin-sources/uc-1/p-1/1/", + "created_by": "u-1", "created_at": 1, "updated_at": 1, + **extra, + } + + def test_version_detail_includes_recorded_map(self, aws_stack): + compat = {"arm64_jp5": { + "compatible": False, "platformVersion": "1.16", + "requiredVersion": "1.24.0", + "reason": "The source requires GStreamer >= 1.24.0; " + "arm64 JetPack 5 provides 1.16", + "suggestedRevision": "1.16"}} + detail = aws_stack.plugin_records.version_detail( + self._item(platform_compatibility=compat)) + assert detail["platform_compatibility"] == compat + + def test_version_detail_omits_absent_map(self, aws_stack): + detail = aws_stack.plugin_records.version_detail(self._item()) + assert "platform_compatibility" not in detail + + def test_import_detail_includes_recorded_map(self, aws_stack): + compat = {"x86_64": { + "compatible": True, "platformVersion": "1.20", + "requiredVersion": None, "reason": None, + "suggestedRevision": None}} + detail = aws_stack.plugin_importer.import_detail( + self._item(import_status="imported", + platform_compatibility=compat)) + assert detail["platform_compatibility"] == compat + + +# ===================================================================== +# Bug condition exploration: post-import per-platform revision +# adjustment (imported-plugin-revision-adjustment-fix, Property 1) +# ===================================================================== +# +# **Validates: Requirements 1.1, 1.2, 1.3, 1.4** +# +# These tests encode the EXPECTED behavior of the post-import +# adjust-revision path (Property 1: applying a per-platform revision +# override fetches/reuses the adjusted tree, maps arch_revisions, and +# re-runs the affected build). On the UNFIXED code they MUST FAIL — +# the failure is the point: it surfaces the counterexamples proving +# the dead end exists (missing route, fetch-result guard on +# import_status == 'fetching', retry re-using the identical flat +# prefix). Once tasks 3.1-3.5 land, these same tests validate the fix. + +#: Buildable single-plugin tree requiring GStreamer >= 1.24: with the +#: gst-plugins-good classification this settles 'imported' carrying an +#: incompatible platform_compatibility entry for arm64_jp4 (platform +#: 1.14) with suggestedRevision '1.14' — the bug condition anchor. +BUILDABLE_1_24_FILES = { + "meson.build": + "project('gst-myfilter', 'c', version : '1.0')\n" + "gst_req = '>= 1.24'\n" + "gst_dep = dependency('gstreamer-1.0', version : gst_req)\n" + "shared_library('gstmyfilter', 'gstmyfilter.c', " + "dependencies: [gst_dep])\n", + "gstmyfilter.c": None, +} + + +def adjustable_revisions(): + """Requested revisions satisfying the bug condition: non-empty, + different from the record's effective revision ('main'). Constrained + to git-ref-shaped strings (release branches like the recorded + suggestion, plus arbitrary branch/tag names).""" + release_branches = st.sampled_from(["1.14", "1.16", "1.18", + "1.20", "1.22"]) + branch_names = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789._-", + min_size=1, max_size=16, + ).map(lambda s: s.strip("-.")).filter(lambda s: s and s != "main") + return st.one_of(release_branches, branch_names) + + +class AdjustRevisionEnv(ImporterEnv): + """ImporterEnv plus the (expected) adjust-revision invocation and + adjustment fetch-result delivery.""" + + def adjust_revision(self, user, plugin_id, version, architecture, + revision): + event = { + "httpMethod": "POST", + "resource": "/plugins/{id}/versions/{v}/adjust-revision", + "path": f"/plugins/{plugin_id}/versions/{version}" + "/adjust-revision", + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": json.dumps({"architecture": architecture, + "revision": revision}), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def settled_incompatible_import(self, admin, usecase_id): + """Settled ('imported') flat single-revision record whose + arm64_jp4 platform_compatibility entry is incompatible with + suggestedRevision '1.14' (isBugCondition holds for any + requested revision != 'main').""" + _, result, record = self.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "main", + "architectures": ["x86_64", "arm64_jp4"], + }, files=BUILDABLE_1_24_FILES) + assert result == {"recorded": True, "import_status": "imported"} + compat = record["platform_compatibility"]["arm64_jp4"] + assert compat["compatible"] is False + assert compat["suggestedRevision"] == "1.14" + return record + + def fetch_result_detail_with_slug(self, plugin, build_id, slug, + status="SUCCEEDED"): + detail = self.fetch_result_detail(plugin, build_id, status=status) + (detail["additional-information"]["environment"] + ["environment-variables"]).append( + {"name": "REVISION_SLUG", "value": slug}) + return detail + + +@pytest.fixture +def adj_env(aws_stack): + return AdjustRevisionEnv(aws_stack) + + +@pytest.fixture +def settled_incompatible(adj_env, admin_setup): + """(admin, settled record) with the arm64_jp4 bug condition.""" + usecase_id, admin = admin_setup + record = adj_env.settled_incompatible_import(admin, usecase_id) + return admin, record + + +class TestRevisionAdjustmentBugExploration: + """Bug condition exploration (Property 1, exploratory bugfix + workflow). EXPECTED TO FAIL on unfixed code. + + **Validates: Requirements 1.1, 1.2, 1.3, 1.4** + """ + + @given(revision=adjustable_revisions()) + @settings(max_examples=15, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_adjust_revision_route_answers_202_with_a_fetch_slot( + self, adj_env, settled_incompatible, revision): + """For any requested revision satisfying the bug condition, + POST /plugins/{id}/versions/{v}/adjust-revision answers 202 and + the record carries a `fetches` slot recording that revision. + + Unfixed code: plugin_importer.handler routes only + /plugins/import, /plugin-modules, and .../select-plugins — the + adjust call returns 404 NOT_FOUND (no API path exists to change + a platform's revision after import, bug 1.3).""" + admin, record = settled_incompatible + + status, body = adj_env.adjust_revision( + admin, record["plugin_id"], 1, "arm64_jp4", revision) + + assert status == 202, ( + f"adjust-revision answered {status} ({body}): no post-import " + "adjustment path exists") + updated = adj_env.get_record(record["plugin_id"]) + fetches = updated.get("fetches") or {} + assert any((entry or {}).get("revision") == revision + for entry in fetches.values()), ( + f"no fetches slot records the requested revision {revision!r}") + + def test_adjustment_changes_the_effective_source_prefix( + self, adj_env, settled_incompatible): + """Applying the suggested revision to arm64_jp4 changes + arch_source_prefix(item, 'arm64_jp4') to the adjusted entry's + rev-{slug}/ prefix. + + Unfixed code: every reachable operation (including a plain + retry) re-uses the identical flat source_s3_prefix — nothing + changes the platform's effective revision (bug 1.2, 1.4).""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + builds_mod = adj_env.stack.plugin_builds + flat_prefix = builds_mod.arch_source_prefix(record, "arm64_jp4") + assert flat_prefix == record["source_s3_prefix"] # flat layout + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + assert status == 202, ( + f"adjust-revision answered {status} ({body}): no operation " + "exists that changes the effective revision") + + updated = adj_env.get_record(plugin_id) + fetches = updated.get("fetches") or {} + slugs = [s for s, e in fetches.items() + if (e or {}).get("revision") == "1.14"] + assert slugs, "the adjustment must record a fetches slot for 1.14" + slug = slugs[0] + + # Settle a still-running adjustment fetch (SUCCEEDED) so the + # arch_revisions mapping flips. + if fetches[slug].get("status") == "fetching": + detail = adj_env.fetch_result_detail_with_slug( + {"plugin_id": plugin_id, "version": 1, + "usecase_id": record["usecase_id"]}, + fetches[slug].get("fetch_build_id"), slug) + adj_env.deliver_fetch_result(detail) + updated = adj_env.get_record(plugin_id) + + adjusted_prefix = builds_mod.arch_source_prefix(updated, "arm64_jp4") + assert adjusted_prefix == f"{record['source_s3_prefix']}rev-{slug}/" + assert adjusted_prefix != flat_prefix + + def test_settled_record_adjustment_fetch_result_is_processed( + self, adj_env, settled_incompatible): + """A fetch result for a settled record (import_status == + 'imported') carrying an adjustment marker (a fetches entry with + pending_archs) is processed: the slot settles 'succeeded' and + the pending arch maps through arch_revisions. + + Unfixed code: handle_fetch_result guards on import_status == + 'fetching' and skips the delivery as 'already recorded' (root + cause 2 — adjustment fetches for settled records are dropped, + bug 1.3).""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + slug = adj_env.module.revision_slug("1.14") + fetch_build_id = "dda-plugin-fetch:adjustment-1" + # Seed the adjustment marker exactly as the (expected) endpoint + # writes it: a 'fetching' fetches entry with pending_archs, the + # affected arch queued. + adj_env.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression="SET fetches = :f, artifacts.arm64_jp4 = :q", + ExpressionAttributeValues={ + ":f": {slug: { + "revision": "1.14", + "source_prefix": + f"{record['source_s3_prefix']}rev-{slug}/", + "status": "fetching", + "fetch_build_id": fetch_build_id, + "pending_archs": ["arm64_jp4"], + }}, + ":q": {"buildStatus": "queued"}, + }) + + result = adj_env.deliver_fetch_result( + adj_env.fetch_result_detail_with_slug( + {"plugin_id": plugin_id, "version": 1, + "usecase_id": record["usecase_id"]}, + fetch_build_id, slug)) + + assert result.get("recorded") is True, ( + f"the adjustment fetch result was dropped: {result}") + updated = adj_env.get_record(plugin_id) + assert updated["fetches"][slug]["status"] == "succeeded" + assert (updated.get("arch_revisions") or {}).get("arm64_jp4") == slug + + +# ===================================================================== +# Preservation property tests: non-adjusted flows are unchanged +# (imported-plugin-revision-adjustment-fix, Property 2) +# ===================================================================== +# +# **Validates: Requirements 3.1, 3.3, 3.4, 3.5, 3.6** +# +# Observation-first: these properties capture the behavior OBSERVED on +# the UNFIXED code for inputs where the bug condition does NOT hold — +# import-time multi-revision plans and persistence (3.1), plain build +# retries (3.3), the flat single-revision source layout (3.4), other +# architectures' artifact entries and builds_view rows (3.5), and the +# once-per-round component auto-packaging trigger (3.6). They MUST +# PASS on the unfixed code (that run is the baseline the fix must +# preserve) and MUST STILL PASS after tasks 3.1-3.5 land (task 3.7 +# re-runs them unchanged). The compatible-platform display baseline +# (3.2) lives in the frontend suite (importFlow.test.ts). + +import copy + +PRESERVATION_ARCHS = ["x86_64", "x86_64_nvidia", "arm64_jp4", + "arm64_jp5", "arm64_jp6"] + +PRESERVATION_BASE_PREFIX = "plugin-sources/uc-pres/p-pres/1/" + + +def preservation_revisions(): + """Revision strings as the import accepts them (non-empty once + trimmed), biased toward release branches and slug-collision-prone + shapes ('a/b' and 'a b' both slug to 'a-b').""" + return st.one_of( + st.sampled_from(["main", "1.14", "1.16", "1.24.2", + "a/b", "a b", "a-b"]), + st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789._-/ ", + min_size=1, max_size=12).filter(lambda s: s.strip()), + ) + + +def preservation_slugs(): + """S3-key-safe revision slugs as revision_slug produces them.""" + return st.text(alphabet="abcdefghijklmnopqrstuvwxyz0123456789._-", + min_size=1, max_size=8).map( + lambda s: s.strip("-.")).filter(bool) + + +def artifact_entries(): + """One per-arch artifact entry exactly as plugin_builds records + them: succeeded (s3Key + checksum + signature), failed (logTail), + or still in flight (queued/building).""" + succeeded = st.fixed_dictionaries({ + "buildStatus": st.just("succeeded"), + "s3Key": preservation_slugs().map( + lambda s: f"workflow-plugins/custom/uc-pres/{s}.so"), + "checksum": st.text("0123456789abcdef", min_size=8, max_size=8), + "signature": st.text("ABCDEFab0123", min_size=8, max_size=8), + "logTail": st.just(""), + }) + failed = st.fixed_dictionaries({ + "buildStatus": st.just("failed"), + "logTail": st.text(max_size=30), + }) + in_flight = st.fixed_dictionaries({ + "buildStatus": st.sampled_from(["queued", "building"]), + "logTail": st.just(""), + }) + return st.one_of(succeeded, failed, in_flight) + + +@st.composite +def preservation_records(draw): + """(Plugin_Record version item, adjusted_arch): a settled imported + record — flat single-revision (3.4) or multi-revision with a + fetches map and a partial arch_revisions mapping (3.1) — plus the + one architecture an adjustment would target. Every OTHER + architecture is a non-adjusted input whose behavior these + properties pin down (3.5).""" + archs = draw(st.lists(st.sampled_from(PRESERVATION_ARCHS), + min_size=2, max_size=5, unique=True)) + item = { + "plugin_id": "p-pres", "version": 1, "usecase_id": "uc-pres", + "kind": "imported", "import_status": "imported", + "name": "gst-plugins-good", + "requested_architectures": list(archs), + "provenance": {"repoUrl": REPO_URL, "revision": "main"}, + "artifacts": {arch: draw(artifact_entries()) for arch in archs}, + } + if draw(st.booleans()): + item["components_triggered"] = 1234 + if draw(st.booleans()): + # Multi-revision record: fetches map + partial arch mapping. + slugs = draw(st.lists(preservation_slugs(), + min_size=1, max_size=3, unique=True)) + item["fetches"] = { + slug: {"revision": draw(preservation_revisions()), + "source_prefix": f"{PRESERVATION_BASE_PREFIX}rev-{slug}/", + "status": "succeeded"} + for slug in slugs + } + mapped_archs = draw(st.lists(st.sampled_from(archs), + unique=True, max_size=len(archs))) + if mapped_archs: + item["arch_revisions"] = { + arch: draw(st.sampled_from(slugs)) for arch in mapped_archs} + item["source_s3_prefix"] = f"{PRESERVATION_BASE_PREFIX}rev-{slugs[0]}/" + item["default_fetch_slug"] = slugs[0] + else: + # Flat single-revision record (3.4). + item["source_s3_prefix"] = PRESERVATION_BASE_PREFIX + adjusted = draw(st.sampled_from(archs)) + return item, adjusted + + +def apply_adjustment_record_shape(importer, item, arch, revision): + """The record mutation the adjust-revision fix is designed to + persist for `arch` (design 'Persist + act', settled on fetch + success): a fetches slot for the requested revision under a fresh + slug (numeric-suffix collision disambiguation like + revision_fetch_plan), arch_revisions[arch] mapped to it, the arch + re-queued, components_triggered REMOVEd (new build round). Applied + to a deep copy — source_s3_prefix, default_fetch_slug, and every + other architecture's artifact entry are NOT written, which is + exactly the preservation contract (3.4, 3.5) these properties + check against the UNFIXED pure functions.""" + updated = copy.deepcopy(item) + fetches = dict(updated.get("fetches") or {}) + base = slug = importer.revision_slug(revision) + suffix = 2 + while slug in fetches: + slug = f"{base}-{suffix}" + suffix += 1 + fetches[slug] = { + "revision": revision, + "source_prefix": f"{PRESERVATION_BASE_PREFIX}rev-{slug}/", + "status": "succeeded", + } + updated["fetches"] = fetches + arch_revisions = dict(updated.get("arch_revisions") or {}) + arch_revisions[arch] = slug + updated["arch_revisions"] = arch_revisions + updated["artifacts"] = dict(updated["artifacts"]) + updated["artifacts"][arch] = {"buildStatus": "queued"} + updated.pop("components_triggered", None) + return updated + + +class TestPreservationArchSourcePrefix: + """arch_source_prefix preservation (Property 2). + + **Validates: Requirements 3.1, 3.4, 3.5** + """ + + @given(record=preservation_records(), revision=preservation_revisions()) + @settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_non_adjusted_archs_resolve_the_same_prefix( + self, aws_stack, record, revision): + """For any generated record (with/without a fetches map and + arch_revisions) and any adjustment-shaped mutation of one + architecture, every NON-adjusted architecture resolves to the + identical source prefix, and the record's flat + source_s3_prefix is never rewritten (3.1, 3.4, 3.5).""" + item, adjusted = record + builds = aws_stack.plugin_builds + before = {arch: builds.arch_source_prefix(item, arch) + for arch in item["requested_architectures"]} + + updated = apply_adjustment_record_shape( + aws_stack.plugin_importer, item, adjusted, revision) + + for arch in item["requested_architectures"]: + if arch != adjusted: + assert builds.arch_source_prefix(updated, arch) == \ + before[arch] + assert updated["source_s3_prefix"] == item["source_s3_prefix"] + assert updated.get("default_fetch_slug") == \ + item.get("default_fetch_slug") + + @given(record=preservation_records()) + @settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_flat_records_resolve_the_flat_prefix_for_every_arch( + self, aws_stack, record): + """Flat single-revision records (no fetches / arch_revisions) + keep the flat source_s3_prefix layout for every architecture + (3.4).""" + item, _ = record + flat = {k: v for k, v in item.items() + if k not in ("fetches", "arch_revisions", + "default_fetch_slug")} + flat["source_s3_prefix"] = PRESERVATION_BASE_PREFIX + for arch in flat["requested_architectures"]: + assert aws_stack.plugin_builds.arch_source_prefix(flat, arch) \ + == PRESERVATION_BASE_PREFIX + + @given(record=preservation_records()) + @settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_unmapped_archs_fall_back_to_the_records_flat_tree( + self, aws_stack, record): + """Architectures without an arch_revisions mapping resolve to + the record's source_s3_prefix — the fallback multi-revision + records rely on (3.1, 3.4).""" + item, _ = record + mapped = set(item.get("arch_revisions") or {}) + for arch in item["requested_architectures"]: + if arch not in mapped: + assert aws_stack.plugin_builds.arch_source_prefix( + item, arch) == item["source_s3_prefix"] + + +class TestPreservationUntouchedEntries: + """Untouched-entry preservation (Property 2). + + **Validates: Requirements 3.5** + """ + + @given(record=preservation_records(), revision=preservation_revisions()) + @settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_other_archs_entries_and_builds_view_rows_are_identical( + self, aws_stack, record, revision): + """For any generated record, an adjustment-shaped mutation of + one architecture leaves every other architecture's artifact + entry (status, s3Key, checksum, signature, logTail) and its + builds_view row byte-identical (3.5).""" + item, adjusted = record + builds = aws_stack.plugin_builds + snapshot = copy.deepcopy(item) + view_before = builds.builds_view(item) + + updated = apply_adjustment_record_shape( + aws_stack.plugin_importer, item, adjusted, revision) + + assert item == snapshot # the mutation is pure (copy only) + view_after = builds.builds_view(updated) + for arch in item["requested_architectures"]: + if arch == adjusted: + continue + assert updated["artifacts"][arch] == item["artifacts"][arch] + assert view_after["builds"][arch] == view_before["builds"][arch] + assert view_after["requested_architectures"] == \ + view_before["requested_architectures"] + + +class TestPreservationRevisionFetchPlan: + """Import-flow preservation: revision_fetch_plan output (Property 2). + + **Validates: Requirements 3.1, 3.4** + """ + + BASE = PRESERVATION_BASE_PREFIX + + @given(revision=st.one_of(st.none(), preservation_revisions()), + data=st.data()) + @settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_plan_fetches_each_distinct_revision_once_and_maps_archs( + self, aws_stack, revision, data): + """For any top-level revision and any arch_revisions overrides: + a single distinct effective revision collapses to today's flat + single-fetch layout (3.4); multiple distinct revisions plan one + fetch per DISTINCT revision into rev-{slug}/ prefixes with + every architecture mapped to the slug recording its effective + revision, slug collisions disambiguated without clobbering + (3.1).""" + mod = aws_stack.plugin_importer + archs = data.draw(st.lists(st.sampled_from(PRESERVATION_ARCHS), + min_size=1, max_size=5, unique=True), + label="architectures") + overrides = data.draw(st.dictionaries( + st.sampled_from(archs), preservation_revisions(), + max_size=len(archs)), label="arch_revisions") + + plan = mod.revision_fetch_plan(revision, overrides, archs, self.BASE) + + effective = {arch: (overrides.get(arch) or "").strip() + or (revision or "") for arch in archs} + distinct = set(effective.values()) + if len(distinct) <= 1: + single = next(iter(distinct)) if distinct else (revision or "") + assert plan == {"mode": "single", "revision": single or None} + return + + assert plan["mode"] == "multi" + fetches = plan["fetches"] + # One fetch per DISTINCT effective revision; unique slugs. + assert len(fetches) == len(distinct) + assert sorted(e["revision"] for e in fetches.values()) == \ + sorted((rev or mod.DEFAULT_REVISION) for rev in distinct) + for slug, entry in fetches.items(): + assert entry["source_prefix"] == f"{self.BASE}rev-{slug}/" + assert entry["status"] == "fetching" + # Every arch maps to the slug recording its effective revision. + for arch in archs: + slug = plan["arch_revisions"][arch] + assert fetches[slug]["revision"] == \ + (effective[arch] or mod.DEFAULT_REVISION) + # The default tree is the top-level revision's when fetched, + # deterministic otherwise. + assert plan["default_slug"] in fetches + if (revision or "") in distinct: + assert fetches[plan["default_slug"]]["revision"] == \ + ((revision or "") or mod.DEFAULT_REVISION) + + +class PreservationEnv(ImporterEnv): + """ImporterEnv plus POST .../build and per-arch build result + delivery, for the plain-retry (3.3) and auto-packaging (3.6) + baselines.""" + + def post_build(self, user, plugin_id, version, body=None): + event = { + "httpMethod": "POST", + "resource": "/plugins/{id}/versions/{v}/build", + "path": f"/plugins/{plugin_id}/versions/{version}/build", + "pathParameters": {"id": plugin_id, "v": str(version)}, + "queryStringParameters": None, + "body": json.dumps(body or {}), + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.stack.plugin_builds.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def deliver_build_result(self, plugin, arch, build_id, status, + plugin_name): + detail = { + "build-status": status, + "project-name": f"dda-plugin-build-{arch}", + "build-id": ("arn:aws:codebuild:us-east-1:123456789012:build/" + + build_id), + "additional-information": { + "environment": { + "environment-variables": [ + {"name": "USECASE_ID", "value": plugin["usecase_id"]}, + {"name": "PLUGIN_ID", "value": plugin["plugin_id"]}, + {"name": "PLUGIN_VERSION", + "value": str(plugin["version"])}, + {"name": "PLUGIN_NAME", "value": plugin_name}, + {"name": "TARGET_ARCH", "value": arch}, + ], + }, + "logs": {}, + }, + } + return self.stack.plugin_builds.handler({ + "source": "aws.codebuild", + "detail-type": "CodeBuild Build State Change", + "detail": detail, + }, None) + + +@pytest.fixture +def pres_env(aws_stack): + return PreservationEnv(aws_stack) + + +@pytest.fixture +def settled_flat_import(pres_env, admin_setup): + """(admin, settled flat single-revision record): builds building + for three architectures, no fetches map, no arch_revisions — a + non-bug-condition record for the retry baseline.""" + usecase_id, admin = admin_setup + _, result, record = pres_env.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "main", + "architectures": ["x86_64", "arm64_jp4", "arm64_jp5"], + }, files={"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + assert result == {"recorded": True, "import_status": "imported"} + return admin, record + + +class TestPreservationPlainRetry: + """Plain retry preservation (Property 2). + + **Validates: Requirements 3.3, 3.4, 3.5** + """ + + ARCHS = ["x86_64", "arm64_jp4", "arm64_jp5"] + + @given(data=st.data()) + @settings(max_examples=10, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_retry_re_submits_the_same_tree_touching_only_retried_archs( + self, pres_env, settled_flat_import, monkeypatch, data): + """For any subset of architectures, POST .../build WITHOUT an + adjustment StartBuilds each retried architecture from the + IDENTICAL flat source tree (3.3, 3.4) and rewrites only the + retried entries — non-retried architectures' artifact entries + are byte-identical, and no fetches map or arch_revisions + appears on the record (3.5).""" + admin, record = settled_flat_import + builds_module = pres_env.stack.plugin_builds + if not isinstance(builds_module.codebuild, RecordingCodeBuild): + monkeypatch.setattr(builds_module, "codebuild", + RecordingCodeBuild(builds_module.codebuild)) + recorder = builds_module.codebuild + plugin_id = record["plugin_id"] + flat_prefix = record["source_s3_prefix"] + retried = data.draw(st.lists(st.sampled_from(self.ARCHS), + min_size=1, max_size=3, unique=True), + label="retried architectures") + before = pres_env.get_record(plugin_id) + calls_before = len(recorder.calls) + + status, body = pres_env.post_build(admin, plugin_id, 1, + {"architectures": retried}) + + assert status == 202, body + calls = recorder.calls[calls_before:] + # One StartBuild per retried arch, sourcing the identical flat + # tree — the retry never changes the effective revision (3.3). + assert sorted(c["projectName"] for c in calls) == \ + sorted(f"dda-plugin-build-{a}" for a in retried) + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + for call in calls: + assert call["sourceLocationOverride"] == f"{bucket}/{flat_prefix}" + + after = pres_env.get_record(plugin_id) + # The record writes touch only the retried arch entries; the + # flat layout stays flat (3.4) and no adjustment state appears. + assert after["source_s3_prefix"] == flat_prefix + assert "fetches" not in after + assert "arch_revisions" not in after + assert "components_triggered" not in after + for arch in self.ARCHS: + if arch in retried: + entry = after["artifacts"][arch] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith( + f"dda-plugin-build-{arch}:") + else: + assert after["artifacts"][arch] == before["artifacts"][arch] + # Today's write records the retried round's architectures. + assert after["requested_architectures"] == sorted(retried) + + +class TestPreservationImportFlow: + """Import-time multi-revision persistence baseline (Property 2). + + **Validates: Requirements 3.1, 3.4** + """ + + @given(data=st.data()) + @settings(max_examples=8, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_import_fetches_each_distinct_revision_once_and_maps_archs( + self, ienv, admin_setup, monkeypatch, data): + """For any generated arch_revisions overrides on POST + /plugins/import: distinct effective revisions fetch once each + (REVISION env override per fetch) and the record maps every + architecture to the fetches slot recording its revision (3.1); + a single distinct revision keeps today's flat single-fetch + layout with no fetches map at all (3.4).""" + usecase_id, admin = admin_setup + module = ienv.module + if not isinstance(module.codebuild, RecordingCodeBuild): + monkeypatch.setattr(module, "codebuild", + RecordingCodeBuild(module.codebuild)) + recorder = module.codebuild + archs = data.draw(st.lists(st.sampled_from(PRESERVATION_ARCHS), + min_size=2, max_size=4, unique=True), + label="architectures") + overrides = data.draw(st.dictionaries( + st.sampled_from(archs), + st.sampled_from(["main", "1.14", "1.16", "1.22"]), + min_size=1, max_size=len(archs)), label="arch_revisions") + calls_before = len(recorder.calls) + + status, body = ienv.import_plugin(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "main", + "architectures": archs, + "arch_revisions": overrides, + }) + + assert status == 202, body + effective = {arch: overrides.get(arch) or "main" for arch in archs} + distinct = sorted(set(effective.values())) + calls = recorder.calls[calls_before:] + record = ienv.get_record(body["plugin"]["plugin_id"]) + if len(distinct) == 1: + # Collapses to today's flat single-fetch layout (3.4). + assert len(calls) == 1 + assert "fetches" not in record + assert "arch_revisions" not in record + assert "/rev-" not in record["source_s3_prefix"] + return + # One fetch per DISTINCT effective revision (3.1). + assert len(calls) == len(distinct) + envs = [{v["name"]: v["value"] + for v in c["environmentVariablesOverride"]} for c in calls] + assert sorted(e["REVISION"] for e in envs) == distinct + # The record maps every arch to its revision's fetches slot. + for arch in archs: + slug = record["arch_revisions"][arch] + assert record["fetches"][slug]["revision"] == effective[arch] + assert record["fetches"][slug]["status"] == "fetching" + + +class TestPreservationComponentPackaging: + """Component auto-packaging preservation (Property 2). + + **Validates: Requirements 3.6** + """ + + ARCHS = ["x86_64", "arm64_jp5"] + + def _settled_import(self, pres_env, admin_setup): + usecase_id, admin = admin_setup + _, result, record = pres_env.complete_import(admin, { + "usecase_id": usecase_id, + "repo_url": REPO_URL, + "revision": "main", + "architectures": self.ARCHS, + }, files={"meson.build": MESON_PLUGIN, "gstmyfilter.c": None}) + assert result == {"recorded": True, "import_status": "imported"} + return usecase_id, admin, record + + def _stub_packaging(self, pres_env, monkeypatch): + invocations = [] + monkeypatch.setattr( + pres_env.stack.plugin_builds, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + return invocations + + def test_import_build_round_triggers_packaging_exactly_once( + self, pres_env, admin_setup, monkeypatch): + """The build round an import opens triggers auto-packaging + exactly once when all requested builds settle with >= 1 + success, and a duplicate result delivery never re-triggers + (3.6) — the once-per-round baseline the adjustment's round + reopening must not disturb.""" + usecase_id, admin, record = self._settled_import( + pres_env, admin_setup) + invocations = self._stub_packaging(pres_env, monkeypatch) + builds_module = pres_env.stack.plugin_builds + plugin_id = record["plugin_id"] + plugin_name = builds_module.sanitize_plugin_name( + record.get("name"), plugin_id) + plugin = {"plugin_id": plugin_id, "version": 1, + "usecase_id": usecase_id} + + # First arch fails: the round is not settled — no trigger. + r1 = pres_env.deliver_build_result( + plugin, "x86_64", record["artifacts"]["x86_64"]["buildId"], + "FAILED", plugin_name) + assert r1["component_packaging_triggered"] is False + assert invocations == [] + + # Second arch succeeds: settled with one success -> exactly one + # trigger for the round. + pres_env.s3.put_object( + Bucket=pres_env.bucket, + Key=f"workflow-plugins/custom/{usecase_id}/arm64_jp5/" + f"{plugin_name}.so", + Body=b"\x7fELF-shared-object") + r2 = pres_env.deliver_build_result( + plugin, "arm64_jp5", record["artifacts"]["arm64_jp5"]["buildId"], + "SUCCEEDED", plugin_name) + assert r2["component_packaging_triggered"] is True + assert len(invocations) == 1 + assert pres_env.get_record(plugin_id).get("components_triggered") + + # Duplicate delivery of the settled result never re-triggers. + duplicate = pres_env.deliver_build_result( + plugin, "arm64_jp5", record["artifacts"]["arm64_jp5"]["buildId"], + "SUCCEEDED", plugin_name) + assert duplicate.get("recorded") is False + assert len(invocations) == 1 + + def test_plain_retry_reopens_the_round_and_triggers_once_more( + self, pres_env, admin_setup, monkeypatch): + """A plain retry REMOVEs components_triggered (new build + round); settling the retried round triggers auto-packaging + exactly once more (3.6).""" + usecase_id, admin, record = self._settled_import( + pres_env, admin_setup) + invocations = self._stub_packaging(pres_env, monkeypatch) + builds_module = pres_env.stack.plugin_builds + plugin_id = record["plugin_id"] + plugin_name = builds_module.sanitize_plugin_name( + record.get("name"), plugin_id) + plugin = {"plugin_id": plugin_id, "version": 1, + "usecase_id": usecase_id} + + # Settle the first round (both succeed): one trigger. + for arch in self.ARCHS: + pres_env.s3.put_object( + Bucket=pres_env.bucket, + Key=f"workflow-plugins/custom/{usecase_id}/{arch}/" + f"{plugin_name}.so", + Body=b"\x7fELF-shared-object") + pres_env.deliver_build_result( + plugin, arch, record["artifacts"][arch]["buildId"], + "SUCCEEDED", plugin_name) + assert len(invocations) == 1 + + # A plain retry reopens the round: the marker is REMOVEd. + status, _ = pres_env.post_build(admin, plugin_id, 1, + {"architectures": ["arm64_jp5"]}) + assert status == 202 + retried = pres_env.get_record(plugin_id) + assert "components_triggered" not in retried + + # Settling the retried round triggers exactly once more. + pres_env.deliver_build_result( + plugin, "arm64_jp5", retried["artifacts"]["arm64_jp5"]["buildId"], + "SUCCEEDED", plugin_name) + assert len(invocations) == 2 + + +# ===================================================================== +# Unit tests for the adjust-revision fix specifics +# (imported-plugin-revision-adjustment-fix, task 4) +# ===================================================================== +# +# The exploration tests above (TestRevisionAdjustmentBugExploration) +# already cover the 202-with-a-fetch-slot property, the effective- +# prefix flip, and the settled-record fetch-result success mapping. +# These tests pin down the remaining fix specifics: the handler's four +# paths (fetch / reuse / failed-slot re-fetch / concurrent join), the +# rejection matrix, the adjustment fetch-result failure and idempotency +# branches, and adjustment_fetch_slot's slug resolution. + + +class TestAdjustmentFetchSlot: + """adjustment_fetch_slot slug resolution (pure). + + **Validates: Requirements 2.2** + """ + + def _slot(self, aws_stack, fetches, revision): + return aws_stack.plugin_importer.adjustment_fetch_slot( + {"fetches": fetches}, revision) + + def test_reuses_a_succeeded_slot_recording_the_same_revision( + self, aws_stack): + mod = aws_stack.plugin_importer + fetches = {"1.16": {"revision": "1.16", "status": "succeeded"}} + assert self._slot(aws_stack, fetches, "1.16") == \ + ("1.16", mod.ADJUST_REUSE) + + def test_joins_a_concurrent_fetch_of_the_same_revision(self, aws_stack): + mod = aws_stack.plugin_importer + fetches = {"1.16": {"revision": "1.16", "status": "fetching", + "pending_archs": ["x86_64"]}} + assert self._slot(aws_stack, fetches, "1.16") == \ + ("1.16", mod.ADJUST_JOIN) + + def test_failed_slot_for_the_same_revision_is_reset_in_place( + self, aws_stack): + mod = aws_stack.plugin_importer + fetches = {"1.16": {"revision": "1.16", "status": "failed"}} + assert self._slot(aws_stack, fetches, "1.16") == \ + ("1.16", mod.ADJUST_FETCH) + + def test_fresh_slug_on_a_flat_record(self, aws_stack): + mod = aws_stack.plugin_importer + # No fetches map at all (flat single-revision record) and an + # empty one both allocate the plain revision_slug. + assert mod.adjustment_fetch_slot({}, "1.14") == \ + ("1.14", mod.ADJUST_FETCH) + assert self._slot(aws_stack, {}, "feature/x") == \ + ("feature-x", mod.ADJUST_FETCH) + + def test_numeric_suffix_collision_never_clobbers_another_revision( + self, aws_stack): + mod = aws_stack.plugin_importer + # The slug '1.16' already records a DIFFERENT revision: the + # adjustment disambiguates with a numeric suffix exactly like + # revision_fetch_plan instead of clobbering the entry. + fetches = {"1.16": {"revision": "release/1.16", + "status": "succeeded"}} + assert self._slot(aws_stack, fetches, "1.16") == \ + ("1.16-2", mod.ADJUST_FETCH) + fetches["1.16-2"] = {"revision": "another", "status": "failed"} + assert self._slot(aws_stack, fetches, "1.16") == \ + ("1.16-3", mod.ADJUST_FETCH) + + @given( + fetches=st.dictionaries( + preservation_slugs(), + st.fixed_dictionaries({ + "revision": preservation_revisions(), + "status": st.sampled_from(["succeeded", "fetching", + "failed"]), + }), + max_size=5), + revision=preservation_revisions(), + ) + @settings(max_examples=50, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) + def test_never_clobbers_an_entry_recording_a_different_revision( + self, aws_stack, fetches, revision): + """Property: for any set of existing fetches slugs and any + revision, adjustment_fetch_slot never resolves to a slug whose + entry records a different revision — an in-map slug always + records the requested revision; otherwise the slug is fresh + and the action is a fetch. + + **Validates: Requirements 2.2** + """ + mod = aws_stack.plugin_importer + slug, action = mod.adjustment_fetch_slot({"fetches": fetches}, + revision) + if slug in fetches: + assert fetches[slug]["revision"] == revision + expected = {"succeeded": mod.ADJUST_REUSE, + "fetching": mod.ADJUST_JOIN, + "failed": mod.ADJUST_FETCH} + assert action == expected[fetches[slug]["status"]] + else: + assert action == mod.ADJUST_FETCH + + +class TestAdjustRevisionHandler: + """adjust_revision handler paths (task 4 unit tests). + + **Validates: Requirements 2.1, 2.2, 2.5** + """ + + def _seed_record(self, adj_env, plugin_id, expression, values): + adj_env.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression=expression, + ExpressionAttributeValues=values) + + def _fetch_calls(self, recorder): + return [c for c in recorder.calls + if c["projectName"] == TEST_ENV["FETCH_PROJECT_NAME"]] + + def _build_calls(self, recorder): + return [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + + def test_new_revision_writes_the_fetching_slot_and_starts_the_fetch( + self, adj_env, settled_incompatible, monkeypatch): + """Happy path with a NEW revision: the fetches entry is written + with pending_archs, the arch re-queued, the fetch started with + the REVISION_SLUG attribution, and components_triggered REMOVEd + (a new build round). arch_revisions flips only on fetch + success.""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + # A settled build round left its packaging marker: the + # adjustment must open a new round. + self._seed_record(adj_env, plugin_id, + "SET components_triggered = :m", {":m": 1234}) + recorder = RecordingCodeBuild(adj_env.module.codebuild) + monkeypatch.setattr(adj_env.module, "codebuild", recorder) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 202, body + assert set(body) == {"plugin", "builds"} + updated = adj_env.get_record(plugin_id) + entry = updated["fetches"]["1.14"] + assert entry["revision"] == "1.14" + assert entry["status"] == "fetching" + assert entry["pending_archs"] == ["arm64_jp4"] + assert entry["source_prefix"] == \ + f"{record['source_s3_prefix']}rev-1.14/" + assert entry["fetch_build_id"].startswith( + TEST_ENV["FETCH_PROJECT_NAME"] + ":") + # The adjusted arch re-queued; the packaging marker REMOVEd. + assert updated["artifacts"]["arm64_jp4"] == {"buildStatus": "queued"} + assert "components_triggered" not in updated + # arch_revisions is NOT written on the fetch path (it flips on + # fetch success only, 2.4). + assert "arch_revisions" not in updated + # The fetch StartBuild carries the REVISION_SLUG attribution. + fetch_calls = self._fetch_calls(recorder) + assert len(fetch_calls) == 1 + env = {v["name"]: v["value"] + for v in fetch_calls[0]["environmentVariablesOverride"]} + assert env["REVISION_SLUG"] == "1.14" + assert env["REVISION"] == "1.14" + assert env["DEST_PREFIX"].endswith("rev-1.14") + assert env["PLUGIN_ID"] == plugin_id + # Nothing else on the record was touched. + assert updated["artifacts"]["x86_64"] == record["artifacts"]["x86_64"] + assert updated["source_s3_prefix"] == record["source_s3_prefix"] + + def test_reuse_of_a_succeeded_slot_maps_the_arch_and_starts_the_build( + self, adj_env, settled_incompatible, monkeypatch): + """Reuse path: an existing succeeded slot recording the same + revision — no fetch, arch_revisions mapped immediately, and + the build started from the adjusted tree.""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + prefix = f"{record['source_s3_prefix']}rev-1.16/" + self._seed_record(adj_env, plugin_id, "SET fetches = :f", { + ":f": {"1.16": {"revision": "1.16", "source_prefix": prefix, + "status": "succeeded"}}}) + importer_recorder = RecordingCodeBuild(adj_env.module.codebuild) + monkeypatch.setattr(adj_env.module, "codebuild", importer_recorder) + builds_module = adj_env.stack.plugin_builds + builds_recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", builds_recorder) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.16") + + assert status == 202, body + # No fetch was started: the synced tree is reused (2.2). + assert self._fetch_calls(importer_recorder) == [] + updated = adj_env.get_record(plugin_id) + assert updated["arch_revisions"] == {"arm64_jp4": "1.16"} + assert updated["fetches"]["1.16"]["status"] == "succeeded" + # The adjusted arch's build started from the adjusted tree. + entry = updated["artifacts"]["arm64_jp4"] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith("dda-plugin-build-arm64_jp4:") + build_calls = self._build_calls(builds_recorder) + assert len(build_calls) == 1 + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + assert build_calls[0]["sourceLocationOverride"] == \ + f"{bucket}/{prefix}" + # The other architecture's entry is untouched (3.5). + assert updated["artifacts"]["x86_64"] == record["artifacts"]["x86_64"] + + def test_failed_slot_is_re_fetched_in_place( + self, adj_env, settled_incompatible, monkeypatch): + """A previously failed entry for the same revision is reset in + place: same slug, status back to fetching, a new fetch build id + recorded.""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + self._seed_record(adj_env, plugin_id, "SET fetches = :f", { + ":f": {"1.14": { + "revision": "1.14", + "source_prefix": f"{record['source_s3_prefix']}rev-1.14/", + "status": "failed", + "fetch_build_id": "dda-plugin-fetch:old-attempt"}}}) + recorder = RecordingCodeBuild(adj_env.module.codebuild) + monkeypatch.setattr(adj_env.module, "codebuild", recorder) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 202, body + updated = adj_env.get_record(plugin_id) + assert sorted(updated["fetches"]) == ["1.14"] # reset, not added + entry = updated["fetches"]["1.14"] + assert entry["status"] == "fetching" + assert entry["pending_archs"] == ["arm64_jp4"] + assert entry["fetch_build_id"] != "dda-plugin-fetch:old-attempt" + assert entry["fetch_build_id"].startswith( + TEST_ENV["FETCH_PROJECT_NAME"] + ":") + assert len(self._fetch_calls(recorder)) == 1 + + def test_concurrent_fetch_join_appends_the_arch( + self, adj_env, settled_incompatible, monkeypatch): + """A concurrent adjustment already fetching the revision: the + arch joins its pending_archs — no second fetch, no build start, + no arch_revisions write.""" + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + self._seed_record(adj_env, plugin_id, "SET fetches = :f", { + ":f": {"1.14": { + "revision": "1.14", + "source_prefix": f"{record['source_s3_prefix']}rev-1.14/", + "status": "fetching", + "fetch_build_id": "dda-plugin-fetch:concurrent", + "pending_archs": ["x86_64"]}}}) + importer_recorder = RecordingCodeBuild(adj_env.module.codebuild) + monkeypatch.setattr(adj_env.module, "codebuild", importer_recorder) + builds_module = adj_env.stack.plugin_builds + builds_recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", builds_recorder) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 202, body + updated = adj_env.get_record(plugin_id) + entry = updated["fetches"]["1.14"] + assert entry["pending_archs"] == ["x86_64", "arm64_jp4"] + assert entry["status"] == "fetching" + assert entry["fetch_build_id"] == "dda-plugin-fetch:concurrent" + assert updated["artifacts"]["arm64_jp4"] == {"buildStatus": "queued"} + assert "arch_revisions" not in updated + assert self._fetch_calls(importer_recorder) == [] + assert self._build_calls(builds_recorder) == [] + + +class TestAdjustRevisionRejections: + """adjust_revision rejection matrix. + + **Validates: Requirements 2.5** + """ + + def _seed_record(self, adj_env, plugin_id, expression, values=None): + kwargs = {} + if values: + kwargs["ExpressionAttributeValues"] = values + adj_env.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression=expression, **kwargs) + + def test_denied_without_node_designer_manage( + self, adj_env, settled_incompatible): + _, record = settled_incompatible + plugin_id = record["plugin_id"] + scientist = adj_env.make_user(role="Viewer") + adj_env.assign_role(scientist, record["usecase_id"], "DataScientist") + before = adj_env.get_record(plugin_id) + + status, body = adj_env.adjust_revision( + scientist, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + assert adj_env.get_record(plugin_id) == before + + @pytest.mark.parametrize("kind", ["scaffold", "generated"]) + def test_rejected_for_non_imported_records( + self, adj_env, settled_incompatible, kind): + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + self._seed_record(adj_env, plugin_id, "SET kind = :k", {":k": kind}) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 409 + assert body["error"]["code"] == "REVISION_ADJUSTMENT_NOT_AVAILABLE" + + def test_rejected_without_a_recorded_repo_url( + self, adj_env, settled_incompatible): + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + self._seed_record(adj_env, plugin_id, "REMOVE provenance.repoUrl") + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 409 + assert body["error"]["code"] == "REVISION_ADJUSTMENT_NOT_AVAILABLE" + + @pytest.mark.parametrize("import_status", + ["fetching", "pending_selection", "failed"]) + def test_rejected_for_unsettled_imports( + self, adj_env, settled_incompatible, import_status): + admin, record = settled_incompatible + plugin_id = record["plugin_id"] + self._seed_record(adj_env, plugin_id, "SET import_status = :s", + {":s": import_status}) + before = adj_env.get_record(plugin_id) + + status, body = adj_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + + assert status == 409 + assert body["error"]["code"] == "REVISION_ADJUSTMENT_NOT_AVAILABLE" + assert body["error"]["details"]["import_status"] == import_status + assert adj_env.get_record(plugin_id) == before + + def test_unknown_architecture_rejected( + self, adj_env, settled_incompatible): + admin, record = settled_incompatible + + status, body = adj_env.adjust_revision( + admin, record["plugin_id"], 1, "riscv", "1.14") + + assert status == 400 + assert body["error"]["code"] == "INVALID_ARCHITECTURE" + + @pytest.mark.parametrize("revision", ["", " ", None]) + def test_empty_revision_rejected( + self, adj_env, settled_incompatible, revision): + admin, record = settled_incompatible + + status, body = adj_env.adjust_revision( + admin, record["plugin_id"], 1, "arm64_jp4", revision) + + assert status == 400 + assert body["error"]["code"] == "INVALID_REVISION" + + +class TestAdjustmentFetchResult: + """_handle_adjustment_fetch_result specifics beyond the exploration + coverage: the build start on success, the failure branch, and the + duplicate/superseded idempotency guards. + + **Validates: Requirements 2.3, 2.4** + """ + + def _adjusted(self, adj_env, settled_incompatible): + """Apply an adjustment on the fetch path; returns + (record, slug, fetch_build_id).""" + admin, record = settled_incompatible + status, body = adj_env.adjust_revision( + admin, record["plugin_id"], 1, "arm64_jp4", "1.14") + assert status == 202, body + updated = adj_env.get_record(record["plugin_id"]) + return record, "1.14", updated["fetches"]["1.14"]["fetch_build_id"] + + def _plugin_ref(self, record): + return {"plugin_id": record["plugin_id"], "version": 1, + "usecase_id": record["usecase_id"]} + + def test_success_maps_the_arch_and_starts_the_queued_build( + self, adj_env, settled_incompatible, monkeypatch): + record, slug, build_id = self._adjusted(adj_env, + settled_incompatible) + builds_module = adj_env.stack.plugin_builds + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + + result = adj_env.deliver_fetch_result( + adj_env.fetch_result_detail_with_slug( + self._plugin_ref(record), build_id, slug)) + + assert result["recorded"] is True + updated = adj_env.get_record(record["plugin_id"]) + assert updated["fetches"][slug]["status"] == "succeeded" + assert "pending_archs" not in updated["fetches"][slug] + assert updated["arch_revisions"] == {"arm64_jp4": slug} + # The queued build started, sourcing the adjusted tree (2.3). + entry = updated["artifacts"]["arm64_jp4"] + assert entry["buildStatus"] == "building" + assert entry["buildId"].startswith("dda-plugin-build-arm64_jp4:") + calls = [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + assert len(calls) == 1 + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + assert calls[0]["sourceLocationOverride"] == \ + f"{bucket}/{record['source_s3_prefix']}rev-{slug}/" + + def test_failure_records_the_log_tail_on_the_affected_arch_only( + self, adj_env, settled_incompatible): + record, slug, build_id = self._adjusted(adj_env, + settled_incompatible) + before = adj_env.get_record(record["plugin_id"]) + + result = adj_env.deliver_fetch_result( + adj_env.fetch_result_detail_with_slug( + self._plugin_ref(record), build_id, slug, status="FAILED")) + + assert result["recorded"] is True + updated = adj_env.get_record(record["plugin_id"]) + assert updated["fetches"][slug]["status"] == "failed" + assert "pending_archs" not in updated["fetches"][slug] + # The fetch failure surfaces on the affected arch only (2.4). + assert updated["artifacts"]["arm64_jp4"] == { + "buildStatus": "failed", + "logTail": + adj_env.module.adjustment_fetch_failure_log_tail("1.14"), + } + # The prior mapping (none: flat record) and everything else are + # untouched (3.5). + assert "arch_revisions" not in updated + assert updated["artifacts"]["x86_64"] == before["artifacts"]["x86_64"] + assert updated["import_status"] == "imported" + assert updated["source_s3_prefix"] == record["source_s3_prefix"] + + def test_duplicate_and_superseded_deliveries_change_nothing( + self, adj_env, settled_incompatible): + record, slug, build_id = self._adjusted(adj_env, + settled_incompatible) + plugin = self._plugin_ref(record) + + # A result for a superseded build id is skipped. + superseded = adj_env.deliver_fetch_result( + adj_env.fetch_result_detail_with_slug( + plugin, "dda-plugin-fetch:someone-else", slug, + status="FAILED")) + assert superseded == {"recorded": False, + "reason": "superseded build"} + assert adj_env.get_record( + record["plugin_id"])["fetches"][slug]["status"] == "fetching" + + # The real result settles the slot... + detail = adj_env.fetch_result_detail_with_slug(plugin, build_id, + slug) + first = adj_env.deliver_fetch_result(detail) + assert first["recorded"] is True + settled_record = adj_env.get_record(record["plugin_id"]) + + # ...and a duplicate delivery of the same result changes nothing. + duplicate = adj_env.deliver_fetch_result(detail) + assert duplicate == {"recorded": False, + "reason": "already recorded"} + assert adj_env.get_record(record["plugin_id"]) == settled_record + + +# ===================================================================== +# Integration tests: end-to-end revision-adjustment flows +# (imported-plugin-revision-adjustment-fix, task 5) +# ===================================================================== +# +# Full sequences through the real handlers (moto-backed), beyond the +# per-step coverage above: import -> incompatible platform -> adjust -> +# fetch result -> rebuild from the adjusted tree -> once-per-round +# auto-packaging (2.2, 2.3, 3.6), and adjust -> fetch FAILED -> only +# the affected arch touched -> plain retry from the prior tree +# (2.4, 3.3, 3.4, 3.5). + + +class AdjustmentE2EEnv(AdjustRevisionEnv, PreservationEnv): + """AdjustRevisionEnv (adjust-revision + slugged fetch-result + delivery) plus PreservationEnv (POST .../build + per-arch build + result delivery) for the end-to-end flows.""" + + +@pytest.fixture +def e2e_env(aws_stack): + return AdjustmentE2EEnv(aws_stack) + + +class TestAdjustmentEndToEnd: + """End-to-end integration flows for the revision adjustment. + + **Validates: Requirements 2.2, 2.3, 2.4, 3.3, 3.4, 3.5, 3.6** + """ + + def _stub_packaging(self, env, monkeypatch): + invocations = [] + monkeypatch.setattr( + env.stack.plugin_builds, "lambda_client", + type("Stub", (), {"invoke": staticmethod( + lambda **kw: invocations.append(kw))})()) + return invocations + + def _promote_artifact(self, env, usecase_id, arch, plugin_name): + """Place the built .so where the build's promotion step leaves + it, so a SUCCEEDED result records the artifact fields.""" + env.s3.put_object( + Bucket=env.bucket, + Key=f"workflow-plugins/custom/{usecase_id}/{arch}/" + f"{plugin_name}.so", + Body=b"\x7fELF-shared-object") + + def _settle_first_round(self, env, record, plugin_name): + """Settle the import's build round like CodeBuild would: x86_64 + succeeds, arm64_jp4 (the incompatible platform) fails. Returns + the plugin attribution ref for result deliveries.""" + plugin = {"plugin_id": record["plugin_id"], "version": 1, + "usecase_id": record["usecase_id"]} + self._promote_artifact(env, record["usecase_id"], "x86_64", + plugin_name) + env.deliver_build_result( + plugin, "x86_64", record["artifacts"]["x86_64"]["buildId"], + "SUCCEEDED", plugin_name) + env.deliver_build_result( + plugin, "arm64_jp4", record["artifacts"]["arm64_jp4"]["buildId"], + "FAILED", plugin_name) + return plugin + + def test_full_flow_rebuilds_from_the_adjusted_tree_and_packages_once( + self, e2e_env, admin_setup, monkeypatch): + """Full flow: import (flat, single revision) -> the platform + scan records the incompatible arm64_jp4 entry with + suggestedRevision '1.14' -> adjust via the endpoint -> fetch + SUCCEEDED via handle_fetch_result -> the arch's StartBuild + sources the rev-{slug}/ prefix (2.2, 2.3) -> build SUCCEEDED -> + auto-packaging triggers exactly once for the round (3.6).""" + usecase_id, admin = admin_setup + invocations = self._stub_packaging(e2e_env, monkeypatch) + builds_module = e2e_env.stack.plugin_builds + + # Import settles 'imported' with the incompatible arm64_jp4 + # entry (asserted inside settled_incompatible_import). + record = e2e_env.settled_incompatible_import(admin, usecase_id) + plugin_id = record["plugin_id"] + plugin_name = builds_module.sanitize_plugin_name( + record.get("name"), plugin_id) + plugin = self._settle_first_round(e2e_env, record, plugin_name) + # The import's round settled with one success: packaged once. + assert len(invocations) == 1 + assert e2e_env.get_record(plugin_id).get("components_triggered") + + # Adjust arm64_jp4 to the suggested revision via the endpoint. + status, body = e2e_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + assert status == 202, body + assert body["builds"]["settled"] is False + adjusted = e2e_env.get_record(plugin_id) + (slug,) = [s for s, e in adjusted["fetches"].items() + if e["revision"] == "1.14"] + assert adjusted["fetches"][slug]["status"] == "fetching" + assert adjusted["artifacts"]["arm64_jp4"] == {"buildStatus": "queued"} + # The adjustment opened a new build round (3.6). + assert "components_triggered" not in adjusted + + # The adjustment fetch SUCCEEDED: the arch maps to the slot and + # its build re-runs from the adjusted tree (2.2, 2.3). + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + result = e2e_env.deliver_fetch_result( + e2e_env.fetch_result_detail_with_slug( + plugin, adjusted["fetches"][slug]["fetch_build_id"], slug)) + assert result["recorded"] is True + mapped = e2e_env.get_record(plugin_id) + assert mapped["arch_revisions"] == {"arm64_jp4": slug} + calls = [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + assert [c["projectName"] for c in calls] == \ + ["dda-plugin-build-arm64_jp4"] + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + assert calls[0]["sourceLocationOverride"] == \ + f"{bucket}/{record['source_s3_prefix']}rev-{slug}/" + assert mapped["artifacts"]["arm64_jp4"]["buildStatus"] == "building" + + # The adjusted build SUCCEEDED: the round settles and + # auto-packaging triggers exactly once for it (3.6). + self._promote_artifact(e2e_env, usecase_id, "arm64_jp4", + plugin_name) + build_id = mapped["artifacts"]["arm64_jp4"]["buildId"] + settled = e2e_env.deliver_build_result( + plugin, "arm64_jp4", build_id, "SUCCEEDED", plugin_name) + assert settled["component_packaging_triggered"] is True + assert len(invocations) == 2 # import round + adjusted round + final = e2e_env.get_record(plugin_id) + assert final["artifacts"]["arm64_jp4"]["buildStatus"] == "succeeded" + assert final["artifacts"]["x86_64"]["buildStatus"] == "succeeded" + # A duplicate delivery of the settled result never re-triggers. + duplicate = e2e_env.deliver_build_result( + plugin, "arm64_jp4", build_id, "SUCCEEDED", plugin_name) + assert duplicate.get("recorded") is False + assert len(invocations) == 2 + + def test_fetch_failure_flow_touches_only_the_affected_arch( + self, e2e_env, admin_setup, monkeypatch): + """Fetch-failure flow: adjust -> fetch FAILED -> the failure + surfaces as the affected arch's logTail only (2.4); the other + arch's succeeded entry and the record's flat source_s3_prefix + are untouched (3.4, 3.5); a plain retry afterwards still + StartBuilds from the prior (flat) tree (3.3).""" + usecase_id, admin = admin_setup + self._stub_packaging(e2e_env, monkeypatch) + builds_module = e2e_env.stack.plugin_builds + + record = e2e_env.settled_incompatible_import(admin, usecase_id) + plugin_id = record["plugin_id"] + plugin_name = builds_module.sanitize_plugin_name( + record.get("name"), plugin_id) + plugin = self._settle_first_round(e2e_env, record, plugin_name) + before = e2e_env.get_record(plugin_id) + flat_prefix = record["source_s3_prefix"] + + # Adjust arm64_jp4; the adjustment fetch then FAILS. + status, body = e2e_env.adjust_revision( + admin, plugin_id, 1, "arm64_jp4", "1.14") + assert status == 202, body + fetch_build_id = e2e_env.get_record( + plugin_id)["fetches"]["1.14"]["fetch_build_id"] + result = e2e_env.deliver_fetch_result( + e2e_env.fetch_result_detail_with_slug( + plugin, fetch_build_id, "1.14", status="FAILED")) + assert result["recorded"] is True + + after = e2e_env.get_record(plugin_id) + # The fetch failure surfaces on the affected arch only (2.4). + assert after["artifacts"]["arm64_jp4"] == { + "buildStatus": "failed", + "logTail": + e2e_env.module.adjustment_fetch_failure_log_tail("1.14"), + } + # The other arch's succeeded entry is byte-identical (3.5) and + # the record's flat tree was never rewritten (3.4). + assert after["artifacts"]["x86_64"] == before["artifacts"]["x86_64"] + assert after["source_s3_prefix"] == flat_prefix + assert "arch_revisions" not in after + assert after["import_status"] == "imported" + assert after["fetches"]["1.14"]["status"] == "failed" + + # A plain retry still builds from the prior (flat) tree (3.3). + recorder = RecordingCodeBuild(builds_module.codebuild) + monkeypatch.setattr(builds_module, "codebuild", recorder) + status, _ = e2e_env.post_build(admin, plugin_id, 1, + {"architectures": ["arm64_jp4"]}) + assert status == 202 + calls = [c for c in recorder.calls + if c["projectName"].startswith("dda-plugin-build-")] + assert [c["projectName"] for c in calls] == \ + ["dda-plugin-build-arm64_jp4"] + bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + assert calls[0]["sourceLocationOverride"] == f"{bucket}/{flat_prefix}" + retried = e2e_env.get_record(plugin_id) + assert retried["artifacts"]["x86_64"] == before["artifacts"]["x86_64"] diff --git a/edge-cv-portal/backend/tests/test_plugin_records.py b/edge-cv-portal/backend/tests/test_plugin_records.py new file mode 100644 index 00000000..9bde5903 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_records.py @@ -0,0 +1,774 @@ +""" +Unit tests for plugin_records.py (custom-node-designer task 3.1). + +Covers Plugin_Record creation and versioning (9.1, 9.13, 10.1, 10.5), +lifecycle transitions with guards (9.4, 9.5, 9.9, 9.10, 9.12), and the +security review endpoints with provenance display, source inspection, +and AuditLog recording (9.3, 10.2, 10.3, 15.6). + +Runs against the moto-backed stack from conftest.py, exercising the +real RBAC / audit / persistence code paths. +""" +import json +import uuid + +import pytest +from boto3.dynamodb.conditions import Key + +from conftest import TEST_ENV + + +class PluginRecordsEnv: + """Facade for invoking the Plugin_Record API in tests.""" + + def __init__(self, stack): + self.stack = stack + self.module = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + # ------------------------------------------------------------- setup + def create_usecase(self, name="Plugin Test Use Case"): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": name, + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + def seed_artifact(self, plugin_id, version, arch="x86_64", + build_status="succeeded", checksum="ab" * 32, + signature="sig-bytes"): + """Record a per-arch Plugin_Artifact entry directly on the item.""" + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": version}, + UpdateExpression="SET artifacts.#a = :entry", + ExpressionAttributeNames={"#a": arch}, + ExpressionAttributeValues={":entry": { + "s3Key": f"workflow-plugins/custom/uc/{arch}/p.so", + "checksum": checksum, + "signature": signature, + "buildStatus": build_status, + "logTail": "" if build_status == "succeeded" else "error: boom", + }}, + ) + + def audit_entries(self, action): + response = self.stack.tables.audit_log.scan() + return [i for i in response["Items"] if i["action"] == action] + + # ----------------------------------------------------------- invoke + def invoke(self, method, resource, user, plugin_id=None, version=None, + body=None, query=None): + path_params = {} + if plugin_id is not None: + path_params["id"] = plugin_id + if version is not None: + path_params["v"] = str(version) + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", plugin_id or "").replace("{v}", str(version or "")), + "pathParameters": path_params or None, + "queryStringParameters": query, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ------------------------------------------------------ conveniences + def create_plugin(self, user, usecase_id, name="blur-regions", + kind="scaffold", **extra): + body = {"usecase_id": usecase_id, "name": name, "kind": kind} + body.update(extra) + return self.invoke("POST", "/plugins", user, body=body) + + def promote(self, user, plugin_id, version): + return self.invoke("POST", "/plugins/{id}/versions/{v}/promote", + user, plugin_id, version) + + def demote(self, user, plugin_id, version): + return self.invoke("POST", "/plugins/{id}/versions/{v}/demote", + user, plugin_id, version) + + def review(self, user, plugin_id, version, decision, **extra): + body = {"decision": decision} + body.update(extra) + return self.invoke("POST", "/plugins/{id}/versions/{v}/review", + user, plugin_id, version, body=body) + + +@pytest.fixture +def penv(aws_stack): + return PluginRecordsEnv(aws_stack) + + +@pytest.fixture +def admin_setup(penv): + """A Use_Case with a UseCaseAdmin assigned to it.""" + usecase_id = penv.create_usecase() + admin = penv.make_user(role="Viewer") + penv.assign_role(admin, usecase_id, "UseCaseAdmin") + return usecase_id, admin + + +# ----------------------------------------------------- creation (9.1, 10.1) + +class TestCreation: + def test_create_starts_in_dev_with_pending_review(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin(admin, usecase_id) + assert status == 201 + plugin = body["plugin"] + assert plugin["lifecycle_state"] == "dev" + assert plugin["review"]["decision"] == "pending" + assert plugin["version"] == 1 + assert plugin["source_s3_prefix"] == ( + f"plugin-sources/{usecase_id}/{plugin['plugin_id']}/1/" + ) + + def test_create_records_provenance(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin( + admin, usecase_id, kind="imported", + provenance={ + "repoUrl": "https://example.com/repo.git", + "revision": "abc123", + "classification": "unclassified", + }) + assert status == 201 + prov = body["plugin"]["provenance"] + assert prov["repoUrl"] == "https://example.com/repo.git" + assert prov["revision"] == "abc123" + assert prov["classification"] == "unclassified" + assert prov["createdBy"] == admin["user_id"] + assert prov["createdAt"] > 0 + + def test_create_rejects_invalid_kind(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin(admin, usecase_id, kind="bogus") + assert status == 400 + assert body["error"]["code"] == "INVALID_KIND" + + def test_create_denied_for_non_admin(self, penv): + usecase_id = penv.create_usecase() + operator = penv.make_user(role="Viewer") + penv.assign_role(operator, usecase_id, "Operator") + status, body = penv.create_plugin(operator, usecase_id) + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + +# --------------------------------------------- new versions (9.13, 10.5) + +class TestVersioning: + def test_new_version_resets_state_and_review_independently(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + + # Drive v1 to prod with an approved review + penv.seed_artifact(plugin_id, 1) + assert penv.promote(admin, plugin_id, 1)[0] == 200 + portal_admin = penv.make_user(role="PortalAdmin") + assert penv.review(portal_admin, plugin_id, 1, "approved")[0] == 200 + assert penv.promote(admin, plugin_id, 1)[0] == 200 + + # New version from changed source + status, body = penv.invoke( + "PUT", "/plugins/{id}", admin, plugin_id, + body={"new_version": True}) + assert status == 201 + v2 = body["plugin"] + assert v2["version"] == 2 + assert v2["lifecycle_state"] == "dev" + assert v2["review"]["decision"] == "pending" + assert v2["artifacts"] == {} + + # Prior version untouched + status, body = penv.invoke( + "GET", "/plugins/{id}/versions/{v}", admin, plugin_id, 1) + assert status == 200 + assert body["plugin"]["lifecycle_state"] == "prod" + assert body["plugin"]["review"]["decision"] == "approved" + + +# ------------------------------------- lifecycle guards (9.4, 9.5, 9.9, 9.10) + +class TestLifecycleTransitions: + def test_promote_dev_to_test_without_build_is_409(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 409 + assert body["error"]["code"] == "PLUGIN_BUILD_REQUIRED" + # Rejection identifies the missing build (9.5) + assert body["error"]["details"]["missing"] == "successfully built Plugin_Artifact" + + def test_failed_build_does_not_satisfy_promotion_guard(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1, build_status="failed") + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 409 + assert body["error"]["code"] == "PLUGIN_BUILD_REQUIRED" + + def test_promote_dev_to_test_with_build_succeeds(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 200 + assert body["plugin"]["lifecycle_state"] == "test" + + def test_promote_test_to_prod_without_approval_is_409(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + penv.promote(admin, plugin_id, 1) + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 409 + assert body["error"]["code"] == "SECURITY_REVIEW_REQUIRED" + # Rejection identifies the missing approval (9.10) + assert body["error"]["details"]["missing"] == "approved security review" + assert body["error"]["details"]["review_decision"] == "pending" + + def test_promote_test_to_prod_after_approval_succeeds(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + penv.promote(admin, plugin_id, 1) + portal_admin = penv.make_user(role="PortalAdmin") + penv.review(portal_admin, plugin_id, 1, "approved") + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 200 + assert body["plugin"]["lifecycle_state"] == "prod" + + def test_promote_from_prod_is_invalid(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + penv.promote(admin, plugin_id, 1) + portal_admin = penv.make_user(role="PortalAdmin") + penv.review(portal_admin, plugin_id, 1, "approved") + penv.promote(admin, plugin_id, 1) + + status, body = penv.promote(admin, plugin_id, 1) + assert status == 409 + assert body["error"]["code"] == "INVALID_LIFECYCLE_TRANSITION" + + def test_demotion_always_succeeds(self, penv, admin_setup): + """prod->test and test->dev demote without guards (9.12).""" + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + penv.promote(admin, plugin_id, 1) + portal_admin = penv.make_user(role="PortalAdmin") + penv.review(portal_admin, plugin_id, 1, "approved") + penv.promote(admin, plugin_id, 1) + + status, body = penv.demote(admin, plugin_id, 1) + assert status == 200 + assert body["plugin"]["lifecycle_state"] == "test" + + status, body = penv.demote(admin, plugin_id, 1) + assert status == 200 + assert body["plugin"]["lifecycle_state"] == "dev" + + # Demotion only changed lifecycle_state; the approval stands + assert body["plugin"]["review"]["decision"] == "approved" + + def test_demote_from_dev_is_invalid(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + status, body = penv.demote(admin, plugin_id, 1) + assert status == 409 + assert body["error"]["code"] == "INVALID_LIFECYCLE_TRANSITION" + + +# ------------------------------------ security review (10.2, 10.3, 15.6) + +class TestSecurityReview: + def _pending_record(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin( + admin, usecase_id, kind="imported", + provenance={ + "repoUrl": "https://gitlab.freedesktop.org/gstreamer/gst-plugins-bad.git", + "revision": "1.20", + "importedBy": admin["user_id"], + "importedAt": 1700000000000, + "classification": "bad", + }) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1, arch="x86_64", checksum="aa" * 32, + signature="sig-x86") + penv.seed_artifact(plugin_id, 1, arch="arm64_jp5", checksum="bb" * 32, + signature="sig-jp5") + return usecase_id, admin, plugin_id + + def test_pending_display_shows_provenance_checksums_signatures(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + portal_admin = penv.make_user(role="PortalAdmin") + + status, body = penv.invoke( + "GET", "/plugins/{id}/versions/{v}", portal_admin, plugin_id, 1) + assert status == 200 + plugin = body["plugin"] + # Full provenance incl. classification (10.2, 15.6) + assert plugin["provenance"]["repoUrl"].endswith("gst-plugins-bad.git") + assert plugin["provenance"]["revision"] == "1.20" + assert plugin["provenance"]["importedBy"] + assert plugin["provenance"]["importedAt"] + assert plugin["provenance"]["classification"] == "bad" + # Per-arch checksums and signatures (10.2) + assert plugin["artifacts"]["x86_64"]["checksum"] == "aa" * 32 + assert plugin["artifacts"]["x86_64"]["signature"] == "sig-x86" + assert plugin["artifacts"]["arm64_jp5"]["checksum"] == "bb" * 32 + assert plugin["artifacts"]["arm64_jp5"]["signature"] == "sig-jp5" + assert plugin["review"]["decision"] == "pending" + + def test_review_queue_lists_pending_records(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + portal_admin = penv.make_user(role="PortalAdmin") + penv.assign_role(portal_admin, "global", "PortalAdmin") + + status, body = penv.invoke( + "GET", "/plugins", portal_admin, + query={"usecase_id": usecase_id, "review": "pending"}) + assert status == 200 + ids = [p["plugin_id"] for p in body["plugins"]] + assert plugin_id in ids + + def test_source_inspection(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + prefix = f"plugin-sources/{usecase_id}/{plugin_id}/1/" + penv.s3.put_object(Bucket=penv.bucket, Key=prefix + "meson.build", + Body=b"project('blur')") + penv.s3.put_object(Bucket=penv.bucket, Key=prefix + "src/hook.py", + Body=b"def process_frame(frame, params):\n return frame\n") + portal_admin = penv.make_user(role="PortalAdmin") + + status, body = penv.invoke( + "GET", "/plugins/{id}/versions/{v}/source", portal_admin, plugin_id, 1) + assert status == 200 + files = {f["file"] for f in body["files"]} + assert files == {"meson.build", "src/hook.py"} + + status, body = penv.invoke( + "GET", "/plugins/{id}/versions/{v}/source", portal_admin, plugin_id, 1, + query={"file": "src/hook.py"}) + assert status == 200 + assert "process_frame" in body["content"] + + # Path traversal is rejected + status, body = penv.invoke( + "GET", "/plugins/{id}/versions/{v}/source", portal_admin, plugin_id, 1, + query={"file": "../../../etc/passwd"}) + assert status == 400 + + def test_approve_records_decision_reviewer_timestamp_in_audit_log(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + portal_admin = penv.make_user(role="PortalAdmin") + + status, body = penv.review(portal_admin, plugin_id, 1, "approved") + assert status == 200 + review = body["plugin"]["review"] + assert review["decision"] == "approved" + assert review["reviewer"] == portal_admin["user_id"] + assert review["reviewedAt"] > 0 + + # Requirement 10.3: decision + acting PortalAdmin + timestamp in + # the existing AuditLog table + entries = [e for e in penv.audit_entries("security_review_approved") + if e["resource_id"] == plugin_id] + assert len(entries) == 1 + entry = entries[0] + assert entry["user_id"] == portal_admin["user_id"] + assert entry["timestamp"] > 0 + assert entry["details"]["decision"] == "approved" + + def test_reject_recorded(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + portal_admin = penv.make_user(role="PortalAdmin") + + status, body = penv.review(portal_admin, plugin_id, 1, "rejected", + notes="unvetted dependency") + assert status == 200 + assert body["plugin"]["review"]["decision"] == "rejected" + entries = [e for e in penv.audit_entries("security_review_rejected") + if e["resource_id"] == plugin_id] + assert len(entries) == 1 + + def test_review_denied_for_usecase_admin(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + status, body = penv.review(admin, plugin_id, 1, "approved") + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + def test_invalid_decision_rejected(self, penv, admin_setup): + usecase_id, admin, plugin_id = self._pending_record(penv, admin_setup) + portal_admin = penv.make_user(role="PortalAdmin") + status, body = penv.review(portal_admin, plugin_id, 1, "maybe") + assert status == 400 + assert body["error"]["code"] == "INVALID_REVIEW_DECISION" + + +# ----------------------------------------------------------- read views + +class TestReadViews: + def test_get_plugin_returns_latest_and_history(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.invoke("PUT", "/plugins/{id}", admin, plugin_id, + body={"new_version": True}) + + status, body = penv.invoke("GET", "/plugins/{id}", admin, plugin_id) + assert status == 200 + assert body["plugin"]["version"] == 2 + assert [v["version"] for v in body["versions"]] == [2, 1] + + def test_list_scoped_by_usecase(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + + status, body = penv.invoke("GET", "/plugins", admin, + query={"usecase_id": usecase_id}) + assert status == 200 + assert [p["plugin_id"] for p in body["plugins"]] == [plugin_id] + + def test_unknown_plugin_is_404(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.invoke("GET", "/plugins/{id}", admin, "nonexistent") + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + + +# ------------------------------------------- scaffold create path (task 12.1) + +def make_scaffold_declaration(**overrides): + """A valid Custom_Node_Type declaration for the create-wizard path.""" + declaration = { + "typeId": "custom.blur_regions", + "displayName": "Blur Regions", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [{ + "name": "radius", + "paramType": "int", + "required": True, + "default": 5, + "description": "Blur radius in pixels", + "examples": [8], + }], + "mappings": [], + "architectures": ["x86_64", "arm64_jp5"], + } + declaration.update(overrides) + return declaration + + +class TestScaffoldCreate: + """POST /plugins with a declaration renders and stores the scaffold + (Requirements 1.2, 1.5, 1.7).""" + + def _source_keys(self, penv, usecase_id, plugin_id, version=1): + prefix = f"plugin-sources/{usecase_id}/{plugin_id}/{version}/" + response = penv.s3.list_objects_v2(Bucket=penv.bucket, Prefix=prefix) + return {obj["Key"][len(prefix):] for obj in response.get("Contents", [])} + + def test_create_with_declaration_renders_and_stores_scaffold(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin( + admin, usecase_id, declaration=make_scaffold_declaration()) + + assert status == 201 + files = body["files"] + # Hook + C skeleton + per-arch build configs + README (1.2) + assert "plugin/frame_processing_hook.py" in files + assert "builds/x86_64/meson.build" in files + assert "builds/arm64_jp5/meson.build" in files + assert "README.md" in files + # The rendered scaffold lands under plugin-sources for the + # Plugin_Build_Service (1.5, 1.6). + plugin_id = body["plugin"]["plugin_id"] + assert self._source_keys(penv, usecase_id, plugin_id) == set(files) + # The declaration is recorded as provenance. + provenance = body["plugin"]["provenance"] + assert "blur_regions" in provenance["scaffoldDeclaration"] + + def test_invalid_declaration_identifies_field_and_creates_no_record( + self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin( + admin, usecase_id, + declaration=make_scaffold_declaration(category="nonsense")) + + # Requirement 1.7: error identifies the failing input, no record. + assert status == 400 + assert body["error"]["code"] == "INVALID_DECLARATION" + assert body["error"]["details"]["field"] == "category" + + status, body = penv.invoke("GET", "/plugins", admin, + query={"usecase_id": usecase_id}) + assert body["plugins"] == [] + + def test_declaration_rejected_for_non_scaffold_kind(self, penv, admin_setup): + usecase_id, admin = admin_setup + status, body = penv.create_plugin( + admin, usecase_id, kind="imported", + declaration=make_scaffold_declaration()) + assert status == 400 + assert body["error"]["code"] == "INVALID_DECLARATION" + + +class TestPutVersionSource: + """PUT /plugins/{id}/versions/{v}/source persists submitted source + (original or edited) ahead of a build (Requirement 1.6).""" + + def _scaffold_record(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin( + admin, usecase_id, declaration=make_scaffold_declaration()) + return usecase_id, admin, body["plugin"]["plugin_id"], body["files"] + + def test_edited_source_is_persisted(self, penv, admin_setup): + usecase_id, admin, plugin_id, files = self._scaffold_record(penv, admin_setup) + files["plugin/frame_processing_hook.py"] = ( + "def process_frame(frame, params):\n return frame[::-1]\n") + + status, body = penv.invoke( + "PUT", "/plugins/{id}/versions/{v}/source", admin, plugin_id, 1, + body={"files": files}) + assert status == 200 + assert body["count"] == len(files) + + key = f"plugin-sources/{usecase_id}/{plugin_id}/1/plugin/frame_processing_hook.py" + stored = penv.s3.get_object(Bucket=penv.bucket, Key=key)["Body"].read() + assert b"frame[::-1]" in stored + + def test_non_buildable_scaffold_source_is_rejected(self, penv, admin_setup): + usecase_id, admin, plugin_id, files = self._scaffold_record(penv, admin_setup) + files.pop("plugin/frame_processing_hook.py") + + status, body = penv.invoke( + "PUT", "/plugins/{id}/versions/{v}/source", admin, plugin_id, 1, + body={"files": files}) + assert status == 422 + assert body["error"]["code"] == "SCAFFOLD_INVALID" + assert any("frame_processing_hook" in d + for d in body["error"]["details"]["defects"]) + + def test_path_traversal_rejected(self, penv, admin_setup): + usecase_id, admin, plugin_id, files = self._scaffold_record(penv, admin_setup) + files["../../escape.py"] = "print('nope')" + status, body = penv.invoke( + "PUT", "/plugins/{id}/versions/{v}/source", admin, plugin_id, 1, + body={"files": files}) + assert status == 400 + assert body["error"]["code"] == "INVALID_FILE_PATH" + + def test_requires_manage_permission(self, penv, admin_setup): + usecase_id, admin, plugin_id, files = self._scaffold_record(penv, admin_setup) + operator = penv.make_user(role="Viewer") + penv.assign_role(operator, usecase_id, "Operator") + status, body = penv.invoke( + "PUT", "/plugins/{id}/versions/{v}/source", operator, plugin_id, 1, + body={"files": files}) + assert status == 403 + + +# --------------------------------------------- deletion (DELETE /plugins/{id}) + +class TestDeletePlugin: + """DELETE /plugins/{id}: removes every version of the record (bad or + duplicate imports) with best-effort S3 cleanup, refusing records + promoted beyond dev with 409 RECORD_IN_USE.""" + + def _delete(self, penv, user, plugin_id): + return penv.invoke("DELETE", "/plugins/{id}", user, plugin_id) + + def _versions_in_table(self, penv, plugin_id): + response = penv.stack.tables.plugin_records.query( + KeyConditionExpression=Key("plugin_id").eq(plugin_id)) + return response["Items"] + + def test_delete_removes_every_version_and_audits(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + status, _ = penv.invoke("PUT", "/plugins/{id}", admin, plugin_id, + body={"new_version": True}) + assert status == 201 + + status, body = self._delete(penv, admin, plugin_id) + + assert status == 200 + assert body == {"deleted": True, "plugin_id": plugin_id, + "versions": [1, 2]} + assert self._versions_in_table(penv, plugin_id) == [] + # The deletion is audit-logged. + entries = penv.audit_entries("delete_plugin_record") + assert any(e["resource_id"] == plugin_id for e in entries) + + def test_delete_cleans_up_sources_and_promoted_artifacts( + self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin = body["plugin"] + plugin_id = plugin["plugin_id"] + # Source snapshot under the version's plugin-sources prefix, + # plus a multi-revision fetch tree. + prefix = plugin["source_s3_prefix"] + penv.s3.put_object(Bucket=penv.bucket, Key=f"{prefix}meson.build", + Body=b"project('p', 'c')") + penv.s3.put_object(Bucket=penv.bucket, + Key=f"{prefix}rev-1.16/meson.build", + Body=b"project('p', 'c')") + penv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression="SET fetches = :f", + ExpressionAttributeValues={":f": { + "1.16": {"revision": "1.16", "status": "succeeded", + "source_prefix": f"{prefix}rev-1.16/"}, + }}, + ) + # A promoted Plugin_Library artifact with its detached signature. + so_key = f"workflow-plugins/custom/{usecase_id}/x86_64/p.so" + penv.s3.put_object(Bucket=penv.bucket, Key=so_key, Body=b"so") + penv.s3.put_object(Bucket=penv.bucket, Key=so_key + ".sig", Body=b"s") + penv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression="SET artifacts.#a = :e", + ExpressionAttributeNames={"#a": "x86_64"}, + ExpressionAttributeValues={":e": { + "s3Key": so_key, "buildStatus": "succeeded", + "checksum": "ab" * 32, "signature": "sig", "logTail": "", + }}, + ) + + status, _ = self._delete(penv, admin, plugin_id) + + assert status == 200 + listed = penv.s3.list_objects_v2(Bucket=penv.bucket, Prefix=prefix) + assert listed.get("KeyCount", 0) == 0 + for key in (so_key, so_key + ".sig"): + with pytest.raises(penv.s3.exceptions.ClientError): + penv.s3.head_object(Bucket=penv.bucket, Key=key) + + def test_s3_cleanup_failure_never_fails_the_delete( + self, penv, admin_setup, monkeypatch): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + + def exploding(*args, **kwargs): + raise RuntimeError("S3 unavailable") + + monkeypatch.setattr(penv.module, "_delete_prefix_objects", exploding) + + status, body = self._delete(penv, admin, plugin_id) + + assert status == 200 + assert body["deleted"] is True + assert self._versions_in_table(penv, plugin_id) == [] + + def test_promoted_version_refuses_deletion(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + penv.seed_artifact(plugin_id, 1) + assert penv.promote(admin, plugin_id, 1)[0] == 200 # dev -> test + + status, body = self._delete(penv, admin, plugin_id) + + assert status == 409 + assert body["error"]["code"] == "RECORD_IN_USE" + assert body["error"]["details"]["versions"] == [1] + assert len(self._versions_in_table(penv, plugin_id)) == 1 + + # Demoting back to dev unblocks the delete. + assert penv.demote(admin, plugin_id, 1)[0] == 200 + status, _ = self._delete(penv, admin, plugin_id) + assert status == 200 + + @pytest.mark.parametrize("import_status", + ["failed", "fetching", "pending_selection"]) + def test_unsettled_or_failed_imports_are_always_deletable( + self, penv, admin_setup, import_status): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id, kind="imported") + plugin_id = body["plugin"]["plugin_id"] + penv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": 1}, + UpdateExpression="SET import_status = :s", + ExpressionAttributeValues={":s": import_status}, + ) + + status, body = self._delete(penv, admin, plugin_id) + + assert status == 200 + assert body["deleted"] is True + assert self._versions_in_table(penv, plugin_id) == [] + + def test_missing_record_returns_not_found(self, penv, admin_setup): + _, admin = admin_setup + status, body = self._delete(penv, admin, "no-such-plugin") + assert status == 404 + assert body["error"]["code"] == "PLUGIN_NOT_FOUND" + + def test_delete_requires_manage_permission(self, penv, admin_setup): + usecase_id, admin = admin_setup + _, body = penv.create_plugin(admin, usecase_id) + plugin_id = body["plugin"]["plugin_id"] + operator = penv.make_user(role="Viewer") + penv.assign_role(operator, usecase_id, "Operator") + + status, body = self._delete(penv, operator, plugin_id) + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + assert len(self._versions_in_table(penv, plugin_id)) == 1 diff --git a/edge-cv-portal/backend/tests/test_plugin_simulator.py b/edge-cv-portal/backend/tests/test_plugin_simulator.py new file mode 100644 index 00000000..6c54c960 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_plugin_simulator.py @@ -0,0 +1,589 @@ +""" +Unit tests for plugin_simulator.py (custom-node-designer task 8.2). + +Covers the simulator start guard (409 describing the missing x86_64 +build, 7.5), run starts with Test_Dataset or uploaded sample frame +input (7.1), re-runs with changed parameter values (7.4), the +SimulationRuns record shape, GET /simulations/{runId} status/results +(7.3), the state machine steps (guard / prepare staging under the +run's prefix / collect), and the timeout/failure recorders retaining +flushed partial results (7.6, 7.7). + +Runs against the moto-backed stack from conftest.py (real Step +Functions state machine, DynamoDB, S3). +""" +import base64 +import json +import uuid + +import pytest + +from conftest import TEST_ENV + + +class SimulatorEnv: + """Facade for invoking the Plugin_Simulator API in tests.""" + + def __init__(self, stack): + self.stack = stack + self.module = stack.plugin_simulator + self.records = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + # ------------------------------------------------------------- setup + def create_usecase(self): + usecase_id = f"uc-{uuid.uuid4()}" + self.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Simulator Test Use Case", + "account_id": "123456789012", + }) + return usecase_id + + def make_user(self, role="Viewer"): + user_id = f"user-{uuid.uuid4()}" + return { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": role, + } + + def assign_role(self, user, usecase_id, role): + self.stack.tables.user_roles.put_item(Item={ + "user_id": user["user_id"], + "usecase_id": usecase_id, + "role": role, + }) + + def make_admin(self, usecase_id): + admin = self.make_user() + self.assign_role(admin, usecase_id, "UseCaseAdmin") + return admin + + def create_plugin(self, user, usecase_id, name="blur-regions"): + event = self._event("POST", "/plugins", user, body={ + "usecase_id": usecase_id, "name": name, "kind": "scaffold"}) + response = self.records.handler(event, None) + body = json.loads(response["body"]) + assert response["statusCode"] == 201, body + return body["plugin"] + + def record_x86_64_artifact(self, plugin, data=b"\x7fELF-shared-object", + plugin_name="blur-regions"): + """Store a successful x86_64 Plugin_Artifact on the record + in S3.""" + so_key = (f"workflow-plugins/custom/{plugin['usecase_id']}" + f"/x86_64/{plugin_name}.so") + self.s3.put_object(Bucket=self.bucket, Key=so_key, Body=data) + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET artifacts.x86_64 = :entry", + ExpressionAttributeValues={":entry": { + "buildStatus": "succeeded", + "s3Key": so_key, + "checksum": "aa" * 32, + "signature": "sig", + }}, + ) + return so_key + + def create_dataset(self, usecase_id, frames=("frame1.jpg", "frame2.jpg")): + dataset_id = f"ds-{uuid.uuid4()}" + prefix = f"workflows/{usecase_id}/test-datasets/{dataset_id}/" + for name in frames: + self.s3.put_object(Bucket=self.bucket, Key=prefix + name, + Body=b"\xff\xd8\xff fake-jpeg") + self.stack.tables.test_datasets.put_item(Item={ + "dataset_id": dataset_id, + "usecase_id": usecase_id, + "s3_prefix": prefix, + "created_at": 1, + }) + return dataset_id, prefix + + def get_run_item(self, run_id): + return self.module.get_run_item(run_id) + + # ----------------------------------------------------------- invoke + def _event(self, method, resource, user, plugin_id=None, version=None, + run_id=None, body=None): + path_params = {} + if plugin_id is not None: + path_params = {"id": plugin_id, "v": str(version)} + if run_id is not None: + path_params = {"runId": run_id} + return { + "httpMethod": method, + "resource": resource, + "path": resource, + "pathParameters": path_params or None, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + }, + } + + def post_simulate(self, user, plugin_id, version, body=None): + event = self._event("POST", "/plugins/{id}/versions/{v}/simulate", + user, plugin_id=plugin_id, version=version, + body=body or {}) + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def get_simulation(self, user, run_id): + event = self._event("GET", "/simulations/{runId}", user, run_id=run_id) + response = self.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def run_step(self, step, step_input): + return self.module.handler({"step": step, "input": step_input}, None) + + # ------------------------------------------------------ conveniences + def started_run(self, body_extra=None, frames=("a.jpg", "b.jpg")): + """Create usecase/admin/plugin with x86_64 artifact and start a run.""" + usecase_id = self.create_usecase() + admin = self.make_admin(usecase_id) + plugin = self.create_plugin(admin, usecase_id) + self.record_x86_64_artifact(plugin) + dataset_id, dataset_prefix = self.create_dataset(usecase_id, frames) + body = {"dataset_id": dataset_id, "parameters": {"radius": 5}} + body.update(body_extra or {}) + status, response = self.post_simulate( + admin, plugin["plugin_id"], plugin["version"], body) + assert status == 202, response + run = response["simulation_run"] + return {"usecase_id": usecase_id, "admin": admin, "plugin": plugin, + "dataset_id": dataset_id, "dataset_prefix": dataset_prefix, + "run": run} + + +@pytest.fixture +def senv(aws_stack): + return SimulatorEnv(aws_stack) + + +class TestSimulationStartGuard: + """The x86_64-artifact guard (7.5).""" + + def test_refuses_without_x86_64_artifact(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + dataset_id, _ = senv.create_dataset(usecase_id) + + status, body = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], + {"dataset_id": dataset_id}) + + assert status == 409 + assert body["error"]["code"] == "SIMULATION_REQUIRES_X86_64_BUILD" + # The refusal describes that simulation requires a successful + # x86_64 build (7.5). + assert "x86_64" in body["error"]["message"] + assert body["error"]["details"]["missing"] == ( + "successful x86_64 Plugin_Artifact") + + def test_refuses_when_x86_64_build_failed(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET artifacts.x86_64 = :entry", + ExpressionAttributeValues={":entry": { + "buildStatus": "failed", "logTail": "compile error"}}, + ) + dataset_id, _ = senv.create_dataset(usecase_id) + + status, body = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], + {"dataset_id": dataset_id}) + + assert status == 409 + assert body["error"]["details"]["x86_64_build_status"] == "failed" + + def test_succeeded_build_on_other_arch_does_not_satisfy_guard(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET artifacts.arm64_jp5 = :entry", + ExpressionAttributeValues={":entry": { + "buildStatus": "succeeded", "s3Key": "some/key.so"}}, + ) + dataset_id, _ = senv.create_dataset(usecase_id) + + status, body = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], + {"dataset_id": dataset_id}) + + assert status == 409 + assert body["error"]["code"] == "SIMULATION_REQUIRES_X86_64_BUILD" + + +class TestSimulationStart: + """POST /plugins/{id}/versions/{v}/simulate (7.1, 7.4).""" + + def test_starts_run_with_dataset_and_parameters(self, senv): + ctx = senv.started_run() + run = ctx["run"] + + assert run["status"] == "running" + assert run["parameters"] == {"radius": 5} + assert run["dataset"] == {"kind": "dataset", + "dataset_id": ctx["dataset_id"]} + assert run["results_s3_key"].startswith( + f"plugin-simulations/{ctx['usecase_id']}/{run['run_id']}/") + + item = senv.get_run_item(run["run_id"]) + assert item["execution_arn"] + assert item["created_by"] == ctx["admin"]["user_id"] + + def test_rerun_with_changed_parameters_is_a_new_run(self, senv): + """A re-run passes changed parameter values (7.4).""" + ctx = senv.started_run() + status, body = senv.post_simulate( + ctx["admin"], ctx["plugin"]["plugin_id"], + ctx["plugin"]["version"], + {"dataset_id": ctx["dataset_id"], "parameters": {"radius": 42}}) + + assert status == 202 + rerun = body["simulation_run"] + assert rerun["run_id"] != ctx["run"]["run_id"] + assert rerun["parameters"] == {"radius": 42} + # The first run's recorded parameters are untouched. + assert senv.get_run_item(ctx["run"]["run_id"])["parameters"] == { + "radius": 5} + + def test_starts_run_with_uploaded_sample_frames(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.record_x86_64_artifact(plugin) + + content = base64.b64encode(b"\xff\xd8\xff fake-jpeg").decode() + status, body = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], + {"sample_frames": [{"name": "up1.jpg", "content_base64": content}, + {"name": "up2.jpg", "content_base64": content}]}) + + assert status == 202, body + run = body["simulation_run"] + assert run["dataset"] == {"kind": "uploaded", "frame_count": 2} + uploads_prefix = (f"plugin-simulations/{usecase_id}" + f"/{run['run_id']}/uploads/") + listed = senv.s3.list_objects_v2(Bucket=senv.bucket, + Prefix=uploads_prefix) + names = sorted(o["Key"][len(uploads_prefix):] + for o in listed.get("Contents", [])) + assert names == ["up1.jpg", "up2.jpg"] + + def test_requires_exactly_one_input_source(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.record_x86_64_artifact(plugin) + dataset_id, _ = senv.create_dataset(usecase_id) + content = base64.b64encode(b"x").decode() + + for body in ( + {}, + {"dataset_id": dataset_id, + "sample_frames": [{"name": "a.jpg", "content_base64": content}]}, + ): + status, response = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], body) + assert status == 400 + assert response["error"]["code"] == "MISSING_INPUT" + + def test_dataset_of_another_usecase_is_not_found(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.record_x86_64_artifact(plugin) + other_usecase = senv.create_usecase() + foreign_dataset, _ = senv.create_dataset(other_usecase) + + status, body = senv.post_simulate( + admin, plugin["plugin_id"], plugin["version"], + {"dataset_id": foreign_dataset}) + + assert status == 404 + assert body["error"]["code"] == "TEST_DATASET_NOT_FOUND" + + def test_read_only_role_cannot_simulate(self, senv): + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.record_x86_64_artifact(plugin) + dataset_id, _ = senv.create_dataset(usecase_id) + viewer = senv.make_user() + senv.assign_role(viewer, usecase_id, "DataScientist") + + status, body = senv.post_simulate( + viewer, plugin["plugin_id"], plugin["version"], + {"dataset_id": dataset_id}) + + assert status == 403 + assert body["error"]["code"] == "FORBIDDEN" + + def test_cross_tenant_user_cannot_simulate(self, senv): + """A UseCaseAdmin of a different Use_Case cannot start runs here + (13.1: own Use_Case only). The shared RBAC layer resolves users + without a role record to read-only, so the denial is a 403 on the + simulate permission (same shape the node-designer RBAC suite + asserts).""" + usecase_id = senv.create_usecase() + admin = senv.make_admin(usecase_id) + plugin = senv.create_plugin(admin, usecase_id) + senv.record_x86_64_artifact(plugin) + other_usecase = senv.create_usecase() + outsider = senv.make_admin(other_usecase) + + status, body = senv.post_simulate( + outsider, plugin["plugin_id"], plugin["version"], + {"dataset_id": "irrelevant"}) + + assert status in (403, 404) + + +class TestSimulationStatusAndResults: + """GET /simulations/{runId} (7.3, 7.7).""" + + def test_returns_status_and_results_document(self, senv): + ctx = senv.started_run() + run = ctx["run"] + results_doc = { + "status": "completed", + "frames": [ + {"frameIndex": 0, "inputRef": "frames/input_00000.jpg", + "outputRef": "frames/output_00000.jpg", + "metadata": {"score": 0.4}}, + ], + } + senv.s3.put_object(Bucket=senv.bucket, Key=run["results_s3_key"], + Body=json.dumps(results_doc).encode()) + + status, body = senv.get_simulation(ctx["admin"], run["run_id"]) + + assert status == 200 + assert body["simulation_run"]["run_id"] == run["run_id"] + assert body["results"] == results_doc + + def test_missing_results_document_returns_none(self, senv): + ctx = senv.started_run() + status, body = senv.get_simulation(ctx["admin"], ctx["run"]["run_id"]) + assert status == 200 + assert body["results"] is None + + def test_unknown_run_is_not_found(self, senv): + ctx = senv.started_run() + status, body = senv.get_simulation(ctx["admin"], "no-such-run") + assert status == 404 + assert body["error"]["code"] == "SIMULATION_RUN_NOT_FOUND" + + +class TestStateMachineSteps: + """Guard / Prepare / Collect / recorders (7.2, 7.5, 7.6, 7.7).""" + + def _execution_input(self, ctx): + run = ctx["run"] + item = ctx["plugin"] + return { + "run_id": run["run_id"], + "plugin_id": item["plugin_id"], + "version": item["version"], + "usecase_id": ctx["usecase_id"], + "results_s3_key": run["results_s3_key"], + "input_kind": "dataset", + "source_dataset_s3_prefix": ctx["dataset_prefix"], + "plugin_source_s3_key": ( + f"workflow-plugins/custom/{ctx['usecase_id']}" + "/x86_64/blur-regions.so"), + } + + def test_guard_step_passes_with_artifact(self, senv): + ctx = senv.started_run() + result = senv.run_step("guard", self._execution_input(ctx)) + assert result == {"ok": True} + + def test_guard_step_marks_run_failed_without_artifact(self, senv): + ctx = senv.started_run() + senv.stack.tables.plugin_records.update_item( + Key={"plugin_id": ctx["plugin"]["plugin_id"], + "version": ctx["plugin"]["version"]}, + UpdateExpression="REMOVE artifacts.x86_64", + ) + + result = senv.run_step("guard", self._execution_input(ctx)) + + assert result["ok"] is False + assert result["error"]["code"] == "SIMULATION_REQUIRES_X86_64_BUILD" + item = senv.get_run_item(ctx["run"]["run_id"]) + assert item["status"] == "failed" + assert "x86_64" in item["failure"]["message"] + + def test_prepare_stages_dataset_and_plugin_under_run_prefix(self, senv): + """Prepare copies everything into plugin-simulations/... so the + sandbox task role never needs Plugin_Library or dataset access + (7.2).""" + ctx = senv.started_run(frames=("f1.jpg", "f2.jpg")) + run_prefix = (f"plugin-simulations/{ctx['usecase_id']}" + f"/{ctx['run']['run_id']}/") + + result = senv.run_step("prepare", self._execution_input(ctx)) + + assert result["dataset_s3_prefix"] == run_prefix + "inputs/" + assert result["plugin_s3_key"] == run_prefix + "plugin/blur-regions.so" + # Every staged object sits under the run's prefix. + listed = senv.s3.list_objects_v2(Bucket=senv.bucket, Prefix=run_prefix) + staged = sorted(o["Key"][len(run_prefix):] + for o in listed.get("Contents", [])) + assert staged == ["inputs/f1.jpg", "inputs/f2.jpg", + "plugin/blur-regions.so"] + + def test_prepare_uses_uploads_prefix_for_uploaded_frames(self, senv): + ctx = senv.started_run() + step_input = self._execution_input(ctx) + uploads_prefix = (f"plugin-simulations/{ctx['usecase_id']}" + f"/{ctx['run']['run_id']}/uploads/") + step_input.update({"input_kind": "uploaded", + "source_dataset_s3_prefix": uploads_prefix}) + + result = senv.run_step("prepare", step_input) + + assert result["dataset_s3_prefix"] == uploads_prefix + + def test_collect_marks_run_completed(self, senv): + ctx = senv.started_run() + senv.s3.put_object( + Bucket=senv.bucket, Key=ctx["run"]["results_s3_key"], + Body=json.dumps({"status": "completed", "frames": []}).encode()) + + senv.run_step("collect", self._execution_input(ctx)) + + item = senv.get_run_item(ctx["run"]["run_id"]) + assert item["status"] == "completed" + assert item["finished_at"] + + def test_collect_marks_run_failed_from_harness_error(self, senv): + ctx = senv.started_run() + senv.s3.put_object( + Bucket=senv.bucket, Key=ctx["run"]["results_s3_key"], + Body=json.dumps({ + "status": "failed", + "error": {"code": "PIPELINE_EXECUTION_ERROR", + "message": "plugin crashed: segfault in blur"}, + "frames": [{"frameIndex": 0}], + }).encode()) + + senv.run_step("collect", self._execution_input(ctx)) + + item = senv.get_run_item(ctx["run"]["run_id"]) + assert item["status"] == "failed" + assert "segfault" in item["failure"]["message"] + + def test_record_timeout_marks_failed_and_retains_partial_results(self, senv): + """7.7: failed-with-timeout, flushed partial results untouched.""" + ctx = senv.started_run() + partial = {"status": "running", + "frames": [{"frameIndex": 0, "metadata": {}}]} + senv.s3.put_object(Bucket=senv.bucket, + Key=ctx["run"]["results_s3_key"], + Body=json.dumps(partial).encode()) + + senv.run_step("record_timeout", self._execution_input(ctx)) + + item = senv.get_run_item(ctx["run"]["run_id"]) + assert item["status"] == "failed" + assert item["failure"]["timeout"] is True + assert "5 minute" in item["failure"]["message"] + # The partial results document survives for display (7.7). + status, body = senv.get_simulation(ctx["admin"], ctx["run"]["run_id"]) + assert status == 200 + assert body["results"] == partial + + def test_record_failure_carries_harness_error_output(self, senv): + """7.6: the failure report includes the plugin's error output.""" + ctx = senv.started_run() + senv.s3.put_object( + Bucket=senv.bucket, Key=ctx["run"]["results_s3_key"], + Body=json.dumps({ + "status": "failed", + "error": {"message": "gst-launch error: assertion failed " + "in blurregions"}, + }).encode()) + + senv.run_step("record_failure", self._execution_input(ctx)) + + item = senv.get_run_item(ctx["run"]["run_id"]) + assert item["status"] == "failed" + assert item["failure"]["timeout"] is False + assert "blurregions" in item["failure"]["message"] + + def test_failure_error_output_propagates_through_get_simulation(self, senv): + """7.6 end to end across the backend: the plugin's error output in + the flushed failure document (the shape the simulate harness + writes on abnormal plugin termination — see the containerized + test-sandbox suite) reaches the caller of + GET /simulations/{runId}, together with the retained partial + frame results (custom-node-designer task 8.5).""" + ctx = senv.started_run() + flushed = { + "element": "blurregions", + "parameters": {"radius": 5}, + "status": "failed", + "frameCount": 4, + "frames": [ + {"frameIndex": 0, + "inputRef": "frames/input_00000.jpg", + "outputRef": "frames/output_00000.jpg", + "metadata": {"bytes": 2048}}, + {"frameIndex": 1, + "inputRef": "frames/input_00001.jpg", + "outputRef": "frames/output_00001.jpg", + "metadata": {"bytes": 2011}}, + ], + "error": { + "code": "PIPELINE_EXECUTION_ERROR", + "message": "Pipeline failed with: Internal data stream " + "error. (element blurregions0)", + "errorOutput": "blurregions0: assertion 'frame != NULL' " + "failed\nsegmentation fault (core dumped)", + }, + } + senv.s3.put_object(Bucket=senv.bucket, + Key=ctx["run"]["results_s3_key"], + Body=json.dumps(flushed).encode()) + + # The state machine's failure recorder finalizes the run item. + senv.run_step("record_failure", self._execution_input(ctx)) + + status, body = senv.get_simulation(ctx["admin"], ctx["run"]["run_id"]) + + assert status == 200 + run = body["simulation_run"] + assert run["status"] == "failed" + assert run["failure"]["timeout"] is False + # The run failure carries the harness error message... + assert "blurregions0" in run["failure"]["message"] + # ...and the full flushed document — the plugin's error output + # included — is returned for display (7.6). + assert body["results"]["error"]["errorOutput"] == ( + flushed["error"]["errorOutput"]) + assert body["results"]["error"]["code"] == "PIPELINE_EXECUTION_ERROR" + # The partial frame results produced before the failure survive. + assert [f["frameIndex"] for f in body["results"]["frames"]] == [0, 1] diff --git a/edge-cv-portal/backend/tests/test_property_aravis_binding_points.py b/edge-cv-portal/backend/tests/test_property_aravis_binding_points.py new file mode 100644 index 00000000..458e00d9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_aravis_binding_points.py @@ -0,0 +1,230 @@ +""" +Property-based test for Aravis binding point emission (task 7.2). + +**Feature: aravis-camera-input, Property 9: Packaging emits Aravis binding points** + +*For any* workflow definition containing `aravis_camera_source` nodes, +packaging SHALL emit exactly one `bindingPoints` entry per Aravis node +per architecture carrying `aravisBinding: true`, empty slots, and the +node's rendered `camera_id`/`gain`/`exposure` parameter values, and +SHALL record each node in the version item's `camera_input_nodes` with +`has_binding_points: true`. + +**Validates: Requirements 4.1, 4.2** + +The layer under test is the packager's pure binding-point pipeline — +``gather_camera_input_nodes`` -> ``binding_hints_from_definition`` -> +``build_binding_points`` -> ``camera_input_nodes_record`` — exercised +over compiler output exactly as the handler drives it (same call +sequence, same built-in catalog), with no AWS. The module is imported +through the shared moto-backed session fixture only so its module-level +boto3 clients bind to the mock (same re-import pattern as +test_property_packaging_gates.py). ``has_binding_points`` is asserted +through the value the handler stores: ``bool(camera_nodes)``. + +Generators: definitions with 1..3 source->capture chains, each headed +by an aravis_camera_source or (mixed) a camera_source, with generated +camera_id/device values, optional gain/exposure overrides, and optional +cameraBindingHint node data. +""" +import json +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.serializer import parse +from workflow_core.compiler import compile as compile_workflow, CompileContext +from workflow_core.catalog import DEVICE_ARCHITECTURES +from workflow_core.catalog.custom import resolve_catalog + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Reference model: the rendered parameter values Requirement 4.2 demands, +# restated from the catalog contract (declared defaults overlaid with the +# node's explicit values) rather than imported from the implementation. +# --------------------------------------------------------------------------- + +ARAVIS_TYPE = "aravis_camera_source" +CAMERA_TYPE = "camera_source" + +#: aravis_camera_source declared parameter defaults (design section 1). +DEFAULT_GAIN = 4 +DEFAULT_EXPOSURE = 5000000 + + +def expected_rendered_parameters(node_parameters): + """camera_id (required, no default) plus gain/exposure defaults + overlaid with the node's explicit values.""" + rendered = {"camera_id": node_parameters["camera_id"], + "gain": DEFAULT_GAIN, "exposure": DEFAULT_EXPOSURE} + rendered.update(node_parameters) + return rendered + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_camera_ids = st.text( + alphabet="abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.", + min_size=1, max_size=32) + +_device_paths = st.integers(min_value=0, max_value=63).map( + lambda n: "/dev/video{}".format(n)) + +_hint_text = st.text(min_size=1, max_size=24) + +_hints = st.fixed_dictionaries( + {"cameraSourceId": _hint_text}, + optional={"cameraName": _hint_text, "sourceDeviceId": _hint_text}, +) + + +@st.composite +def _aravis_definitions(draw): + """A valid definition of 1..3 source->capture chains, at least one + headed by an aravis_camera_source, the rest optionally mixed with + camera_source chains. Returns (definition, aravis_specs, camera_ids) + where aravis_specs maps node id -> (parameters, hint-or-None) and + camera_ids is the full ordered list of Camera_Input_Node ids.""" + chain_count = draw(st.integers(min_value=1, max_value=3)) + aravis_indices = draw(st.sets( + st.integers(min_value=0, max_value=chain_count - 1), min_size=1)) + + nodes, connections = [], [] + aravis_specs, camera_node_ids = {}, [] + for i in range(chain_count): + if i in aravis_indices: + source_id = "arv{}".format(i) + parameters = {"camera_id": draw(_camera_ids)} + if draw(st.booleans()): + parameters["gain"] = draw(st.integers(min_value=0, max_value=100)) + if draw(st.booleans()): + parameters["exposure"] = draw( + st.integers(min_value=0, max_value=10_000_000)) + source = {"id": source_id, "type": ARAVIS_TYPE, + "position": {"x": 100.0 * i, "y": 0.0}, + "parameters": parameters} + hint = draw(st.one_of(st.none(), _hints)) + if hint is not None: + source["data"] = {"cameraBindingHint": hint} + aravis_specs[source_id] = (parameters, hint) + else: + source_id = "cam{}".format(i) + source = {"id": source_id, "type": CAMERA_TYPE, + "position": {"x": 100.0 * i, "y": 0.0}, + "parameters": {"device": draw(_device_paths)}} + camera_node_ids.append(source_id) + nodes.append(source) + nodes.append({"id": "cap{}".format(i), "type": "capture", + "position": {"x": 100.0 * i, "y": 200.0}, + "parameters": {"output_path": "/out/{}".format(i)}}) + connections.append({ + "id": "c{}".format(i), + "from": {"node": source_id, "port": "out"}, + "to": {"node": "cap{}".format(i), "port": "in"}, + }) + + definition = {"schemaVersion": 1, "nodes": nodes, + "connections": connections} + return definition, aravis_specs, camera_node_ids + + +# --------------------------------------------------------------------------- +# Property 9 +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(case=_aravis_definitions()) +def test_packaging_emits_aravis_binding_points(packaging, case): + """**Feature: aravis-camera-input, Property 9: Packaging emits Aravis binding points** + + Exactly one bindingPoints entry per Aravis node per device + architecture, carrying ``aravisBinding: true``, empty slots, and the + rendered camera_id/gain/exposure values; each node recorded in + camera_input_nodes with has_binding_points: true. + + **Validates: Requirements 4.1, 4.2** + """ + definition, aravis_specs, camera_node_ids = case + + parse_result = parse(json.dumps(definition)) + assert parse_result.ok, parse_result.error + graph = parse_result.graph + + # 4.1: every aravis_camera_source is gathered as a Camera_Input_Node + # (alongside any camera_source), in graph node order. + camera_nodes = packaging.gather_camera_input_nodes(graph, set()) + assert [node.id for node in camera_nodes] == camera_node_ids + + hints = packaging.binding_hints_from_definition(definition) + catalog = resolve_catalog([]) + descriptors_by_id = {d.type_id: d for d in catalog} + context = CompileContext(workflow_id="wf-p9", workflow_version="1") + + arch_binding_points, arch_compiled_dicts = {}, {} + for arch in DEVICE_ARCHITECTURES: + compiled = compile_workflow(graph, arch, context, simulation=False, + catalog=catalog) + assert not isinstance(compiled, list), ( + "compilation failed on {}: {}".format(arch, compiled)) + compiled_dict = compiled.to_dict() + points = packaging.build_binding_points( + camera_nodes, compiled_dict, arch, hints, descriptors_by_id) + arch_compiled_dicts[arch] = compiled_dict + arch_binding_points[arch] = points + + # One entry per Camera_Input_Node, hence exactly one per Aravis + # node per architecture (4.1). + assert [p["nodeId"] for p in points] == camera_node_ids + + for point in points: + if point["nodeId"] in aravis_specs: + parameters, hint = aravis_specs[point["nodeId"]] + # 4.2: the aravisBinding marker with empty slots on every + # physical device architecture. + assert point["nodeType"] == ARAVIS_TYPE + assert point["aravisBinding"] is True + assert point["slots"] == [] + # 4.2: rendered (defaults-overlaid) parameter values. + assert point["parameters"] == expected_rendered_parameters( + parameters) + # The hint rides along exactly when the definition + # carries one; the other camera markers never appear. + assert point.get("bindingHint") == hint or ( + hint is None and "bindingHint" not in point) + assert "adapterBinding" not in point + assert "csiSensorBinding" not in point + else: + # Mixed camera_source entries never gain the Aravis marker. + assert "aravisBinding" not in point + + # 4.1: the version item records each Aravis node in camera_input_nodes + # through the existing recording path... + records = packaging.camera_input_nodes_record( + camera_nodes, hints, arch_binding_points, arch_compiled_dicts) + assert [r["node_id"] for r in records] == camera_node_ids + for record in records: + if record["node_id"] in aravis_specs: + _, hint = aravis_specs[record["node_id"]] + assert record["node_type"] == ARAVIS_TYPE + assert record.get("binding_hint") == hint or ( + hint is None and "binding_hint" not in record) + # No Aravis parameter ever lands in an element argument. + assert record["compiled_device_paths"] == {} + + # ...with has_binding_points: true (the handler stores + # bool(camera_nodes) — an Aravis workflow always has camera nodes). + assert bool(camera_nodes) is True diff --git a/edge-cv-portal/backend/tests/test_property_aravis_free_packaging_identity.py b/edge-cv-portal/backend/tests/test_property_aravis_free_packaging_identity.py new file mode 100644 index 00000000..17548822 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_aravis_free_packaging_identity.py @@ -0,0 +1,204 @@ +""" +Property-based test for Aravis-free packaging identity (task 7.3). + +**Feature: aravis-camera-input, Property 10: Aravis-free packaging identity** + +*For any* workflow definition containing no `aravis_camera_source` +node, the packaged compiled document SHALL be byte-identical to the +pre-feature packaging output for the same definition. + +**Validates: Requirements 4.3** + +Pre-feature oracle: the only packaging behavior the feature changed is +gated on the new `aravis_camera_source` type id (the gather predicate +and the ``aravisBinding`` branch of ``build_binding_points``), so the +pre-feature output over an Aravis-free definition is reconstructed by +running the same pure pipeline with the pre-feature gather rule — +Camera_Input_Nodes are ``camera_source`` (or camera-backed custom) +nodes only — and asserting the current output equals it byte-for-byte. +The structure is additionally pinned the way the camera-registry-sync +snapshot tests (test_workflow_packaging_binding_points.py) pinned +theirs: a cameraless document IS the compiler's own serialization +(``compiled.to_json()``), a camera document is that serialization plus +only the pre-existing bindingPoints section, and no ``aravisBinding`` +key appears anywhere in any produced document. + +The module is imported through the shared moto-backed session fixture +only so its module-level boto3 clients bind to the mock (same re-import +pattern as test_property_packaging_gates.py). + +Generators: 1..3 source->capture chains headed by folder_source or +camera_source (any mix, including zero camera nodes), with generated +device paths, optional gain/exposure overrides, and optional +cameraBindingHint node data. +""" +import json +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.serializer import parse +from workflow_core.compiler import compile as compile_workflow, CompileContext +from workflow_core.catalog import DEVICE_ARCHITECTURES +from workflow_core.catalog.custom import resolve_catalog + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Generators: Aravis-free definitions +# --------------------------------------------------------------------------- + +_device_paths = st.integers(min_value=0, max_value=63).map( + lambda n: "/dev/video{}".format(n)) + +_hint_text = st.text(min_size=1, max_size=24) + +_hints = st.fixed_dictionaries( + {"cameraSourceId": _hint_text}, + optional={"cameraName": _hint_text, "sourceDeviceId": _hint_text}, +) + + +@st.composite +def _aravis_free_definitions(draw): + """A valid definition of 1..3 source->capture chains headed by + folder_source or camera_source — never aravis_camera_source.""" + chain_count = draw(st.integers(min_value=1, max_value=3)) + nodes, connections = [], [] + for i in range(chain_count): + if draw(st.booleans()): + parameters = {"device": draw(_device_paths)} + if draw(st.booleans()): + parameters["gain"] = draw(st.integers(min_value=0, max_value=100)) + if draw(st.booleans()): + parameters["exposure"] = draw( + st.integers(min_value=0, max_value=10_000_000)) + source = {"id": "cam{}".format(i), "type": "camera_source", + "position": {"x": 100.0 * i, "y": 0.0}, + "parameters": parameters} + if draw(st.booleans()): + source["data"] = {"cameraBindingHint": draw(_hints)} + else: + source = {"id": "src{}".format(i), "type": "folder_source", + "position": {"x": 100.0 * i, "y": 0.0}, + "parameters": {"location": "/data/images/{}".format(i)}} + nodes.append(source) + nodes.append({"id": "cap{}".format(i), "type": "capture", + "position": {"x": 100.0 * i, "y": 200.0}, + "parameters": {"output_path": "/out/{}".format(i)}}) + connections.append({ + "id": "c{}".format(i), + "from": {"node": source["id"], "port": "out"}, + "to": {"node": "cap{}".format(i), "port": "in"}, + }) + return {"schemaVersion": 1, "nodes": nodes, "connections": connections} + + +# --------------------------------------------------------------------------- +# Pre-feature oracle +# --------------------------------------------------------------------------- + +#: Binding-point entry keys the pre-feature packager could produce +#: (camera-registry-sync): never aravisBinding. +PRE_FEATURE_POINT_KEYS = {"nodeId", "nodeType", "parameters", "slots", + "bindingHint", "adapterBinding", "csiSensorBinding"} + + +def pre_feature_camera_nodes(graph): + """The pre-feature gather rule: camera_source nodes only (no custom + camera-backed types are in play — resolved_items is empty here).""" + return [node for node in graph.nodes if node.type == "camera_source"] + + +def assert_no_aravis_key(value): + """No aravisBinding key anywhere in the document tree.""" + if isinstance(value, dict): + assert "aravisBinding" not in value + for child in value.values(): + assert_no_aravis_key(child) + elif isinstance(value, list): + for child in value: + assert_no_aravis_key(child) + + +# --------------------------------------------------------------------------- +# Property 10 +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(definition=_aravis_free_definitions()) +def test_aravis_free_packaging_is_byte_identical_to_pre_feature_output( + packaging, definition): + """**Feature: aravis-camera-input, Property 10: Aravis-free packaging identity** + + For any Aravis-free definition, ``compiled_document_json`` over the + current pipeline is byte-equal to the pre-feature serialization of + the same definition on every device architecture. + + **Validates: Requirements 4.3** + """ + parse_result = parse(json.dumps(definition)) + assert parse_result.ok, parse_result.error + graph = parse_result.graph + + # The feature's gather gate adds nothing on an Aravis-free graph: + # the gathered Camera_Input_Nodes ARE the pre-feature set. + camera_nodes = packaging.gather_camera_input_nodes(graph, set()) + expected_nodes = pre_feature_camera_nodes(graph) + assert [n.id for n in camera_nodes] == [n.id for n in expected_nodes] + + hints = packaging.binding_hints_from_definition(definition) + catalog = resolve_catalog([]) + descriptors_by_id = {d.type_id: d for d in catalog} + context = CompileContext(workflow_id="wf-p10", workflow_version="1") + + for arch in DEVICE_ARCHITECTURES: + compiled = compile_workflow(graph, arch, context, simulation=False, + catalog=catalog) + assert not isinstance(compiled, list), ( + "compilation failed on {}: {}".format(arch, compiled)) + compiled_dict = compiled.to_dict() + + binding_points = packaging.build_binding_points( + camera_nodes, compiled_dict, arch, hints, descriptors_by_id) + packaged_text = packaging.compiled_document_json( + compiled, binding_points) + + # Pre-feature reconstruction: same pure pipeline driven by the + # pre-feature gather rule (the type-id gate is the only change). + pre_feature_points = packaging.build_binding_points( + expected_nodes, compiled_dict, arch, hints, descriptors_by_id) + pre_feature_text = packaging.compiled_document_json( + compiled, pre_feature_points) + + # 4.3: byte-identical output. + assert packaged_text == pre_feature_text + + # Structure pinning, as the camera-registry-sync snapshots did: + document = json.loads(packaged_text) + assert_no_aravis_key(document) + if not camera_nodes: + # Cameraless: byte-identical to the compiler's own + # serialization — exactly the pre-feature packager output. + assert packaged_text == compiled.to_json() + assert "bindingPoints" not in document + else: + # Camera workflow: the compiler serialization plus only the + # pre-existing bindingPoints section, canonically serialized. + expected_doc = compiled.to_dict() + expected_doc["bindingPoints"] = binding_points + assert packaged_text == json.dumps( + expected_doc, sort_keys=True, indent=2, ensure_ascii=True) + for point in document["bindingPoints"]: + assert set(point) <= PRE_FEATURE_POINT_KEYS diff --git a/edge-cv-portal/backend/tests/test_property_aravis_override_constraints.py b/edge-cv-portal/backend/tests/test_property_aravis_override_constraints.py new file mode 100644 index 00000000..ef56858f --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_aravis_override_constraints.py @@ -0,0 +1,176 @@ +""" +Property-based test for manual-override constraint validation of +aravis_camera_source nodes — validate_camera_bindings +(functions/deployments.py). + +**Feature: aravis-camera-input, Property 12: Aravis override constraint validation** + +*For any* manual override submitted for an `aravis_camera_source` node, +validation SHALL accept the override exactly when every value satisfies +the descriptor's declared constraints (non-empty string `camera_id`, +`gain` within 0-100, `exposure` non-negative, no undeclared parameter +names). + +**Validates: Requirements 5.4** + +Task 8.3 (spec: aravis-camera-input). The example-based override unit +tests live in test_camera_binding_validation.py (task 8.1); the generic +override property over camera_source (camera-registry-sync Property 16) +lives in test_camera_binding_type_override_properties.py. +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Generators: per declared parameter, a valid pool and a violating pool, +# so the expected verdict is decidable by the requirement's own words +# (never by the implementation's validator). +# --------------------------------------------------------------------------- + +#: camera_id: string, min_length 1 — valid iff a non-empty string. +_CAMERA_ID_VALID = st.one_of( + st.sampled_from(["Aravis-Fake-GV01", "Basler-12345678", "caméra ☂"]), + st.text(min_size=1, max_size=30), +) +#: Violations: emptiness (the emptiness case named by the task) and +#: non-string values. +_CAMERA_ID_INVALID = st.one_of( + st.just(""), + st.integers(min_value=-10, max_value=10), + st.booleans(), +) + +#: gain: int, 0-100 — valid iff an int (bools are not ints to the +#: validator) within bounds. +_GAIN_VALID = st.integers(min_value=0, max_value=100) +_GAIN_INVALID = st.one_of( + st.integers(min_value=-1000, max_value=-1), + st.integers(min_value=101, max_value=1_000_000), + st.sampled_from(["4", "high"]), + st.booleans(), + st.floats(allow_nan=False, allow_infinity=False), +) + +#: exposure: int, min 0 (no max) — valid iff a non-negative int. +_EXPOSURE_VALID = st.integers(min_value=0, max_value=10**12) +_EXPOSURE_INVALID = st.one_of( + st.integers(min_value=-10**9, max_value=-1), + st.sampled_from(["5000000"]), + st.booleans(), + st.floats(allow_nan=False, allow_infinity=False), +) + +_POOLS = { + "camera_id": (_CAMERA_ID_VALID, _CAMERA_ID_INVALID), + "gain": (_GAIN_VALID, _GAIN_INVALID), + "exposure": (_EXPOSURE_VALID, _EXPOSURE_INVALID), +} + +#: Undeclared parameter names: violations whatever the value. +_UNDECLARED_NAMES = ["device", "bogus", "camera-id"] + + +@st.composite +def _override_cases(draw): + """One aravis_camera_source node per target with a manual-override + binding drawing, per declared parameter, from the valid or the + violating pool (recording which), plus optionally an undeclared key. + Returns the expected per-value verdicts alongside the inputs.""" + targets = draw(st.lists(st.sampled_from(["line-a", "line-b"]), + unique=True, min_size=1, max_size=2)) + + bindings = {} + # (device, name) -> expected_valid + verdicts = {} + for device in targets: + override = {} + names = draw(st.lists(st.sampled_from(sorted(_POOLS)), + unique=True, max_size=3)) + for name in names: + valid_pool, invalid_pool = _POOLS[name] + valid = draw(st.booleans()) + override[name] = draw(valid_pool if valid else invalid_pool) + verdicts[(device, name)] = valid + if draw(st.booleans()): + name = draw(st.sampled_from(_UNDECLARED_NAMES)) + override[name] = draw(st.one_of( + st.text(max_size=10), st.integers(-5, 5))) + verdicts[(device, name)] = False + bindings[device] = {"n1": {"override": override}} + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": "n1", "node_type": "aravis_camera_source"} + ], + } + registry_snapshot = {device: {"never_synced": False, "cameras": {}} + for device in targets} + return version, targets, registry_snapshot, bindings, verdicts + + +# --------------------------------------------------------------------------- +# Property 12 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_override_cases()) +def test_aravis_override_accepted_exactly_when_constraints_hold( + deployments, case): + """**Feature: aravis-camera-input, Property 12: Aravis override + constraint validation** + + **Validates: Requirements 5.4** + + An aravis_camera_source override produces one + CAMERA_OVERRIDE_INVALID error per value violating the descriptor's + declared constraints (empty or non-string camera_id, gain outside + 0-100 or not an int, negative or non-int exposure) and per + undeclared parameter name — and is accepted (no error at all) + exactly when every supplied value satisfies its declared constraint. + """ + version, targets, registry_snapshot, bindings, verdicts = case + + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + override_errors = [ + e for e in errors + if e["code"] == deployments.CAMERA_ERROR_OVERRIDE_INVALID] + + expected_rejections = {(device, name) + for (device, name), valid in verdicts.items() + if not valid} + + # One error per violating value / undeclared name, none for + # satisfying values: acceptance exactly when every value holds. + assert {(e["device"], e["parameter"]) for e in override_errors} \ + == expected_rejections + assert len(override_errors) == len(expected_rejections) + + for error in override_errors: + assert error["nodeId"] == "n1" + # The message names the offending parameter. + assert error["parameter"] in error["message"] + + # Override constraint checking is the only error signal here, and a + # bound (overridden) node on a synced target raises no warnings. + assert len(errors) == len(override_errors) + assert warnings == [] diff --git a/edge-cv-portal/backend/tests/test_property_aravis_type_compatibility.py b/edge-cv-portal/backend/tests/test_property_aravis_type_compatibility.py new file mode 100644 index 00000000..2968ecc2 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_aravis_type_compatibility.py @@ -0,0 +1,172 @@ +""" +Property-based test for the Aravis type-compatibility rule of +deploy-time Camera_Binding validation — validate_camera_bindings +(functions/deployments.py). + +**Feature: aravis-camera-input, Property 11: Aravis type-compatibility validation** + +*For any* workflow version with Camera_Input_Nodes, target registry +snapshot, and binding set, `validate_camera_bindings` SHALL produce a +type-incompatibility error for a binding exactly when the bound +Camera_Source's type is outside the node type's declared compatible set +— `{Camera, AravisDiscovered}` for `aravis_camera_source`, the existing +set plus `AravisDiscovered` for `camera_source`. + +**Validates: Requirements 5.2, 5.3** + +Task 8.2 (spec: aravis-camera-input). The example-based unit tests over +the same rule live in test_camera_binding_validation.py (task 8.1); the +pre-feature type/override property (camera-registry-sync Property 16) +lives in test_camera_binding_type_override_properties.py. +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Compatibility-map oracle (restated from the requirements, independent +# of the implementation's _CAMERA_COMPATIBLE_SOURCE_TYPES) +# --------------------------------------------------------------------------- + +#: camera_source keeps its pre-feature camera-backed set plus +#: AravisDiscovered (Requirement 5.3); aravis_camera_source must bind to +#: an Aravis-backed source (Requirement 5.2). +_COMPATIBLE = { + "camera_source": frozenset({"Camera", "ICam", "NvidiaCSI", + "V4L2Discovered", "AravisDiscovered"}), + "aravis_camera_source": frozenset({"Camera", "AravisDiscovered"}), +} + +_NODE_TYPES = sorted(_COMPATIBLE) + +#: Arbitrary registry source types: every type in either compatible set, +#: plus types outside both (Folder is Requirement 9.4's categorical +#: mismatch; RTSP/HTTPPull are network-stream types) and an unknown one. +_SOURCE_TYPES = ["Camera", "ICam", "NvidiaCSI", "V4L2Discovered", + "AravisDiscovered", "Folder", "RTSP", "HTTPPull", + "SomethingElse"] + +_NODE_IDS = ["n1", "n2", "n3", "n4"] +_DEVICES = ["line-a", "line-b", "line-c"] + + +@st.composite +def _type_cases(draw): + """A version item mixing aravis_camera_source and camera_source + nodes, per-device registries whose entries carry arbitrary source + types, and a binding set referencing them. Every referenced source + exists and is healthy (synced, present, fresh), so type + compatibility is the only possible error signal.""" + node_ids = draw(st.lists(st.sampled_from(_NODE_IDS), + unique=True, min_size=1, max_size=4)) + node_types = {node_id: draw(st.sampled_from(_NODE_TYPES)) + for node_id in node_ids} + # At least one Aravis node so every case exercises the new rule. + node_types[node_ids[0]] = "aravis_camera_source" + targets = draw(st.lists(st.sampled_from(_DEVICES), + unique=True, min_size=1, max_size=3)) + + registry_snapshot = {} + bindings = {} + # (device, node_id) -> (camera_source_id, source_type) + source_refs = {} + + for device in targets: + cameras = {} + device_bindings = {} + for node_id in node_ids: + source_type = draw(st.sampled_from(_SOURCE_TYPES)) + camera_source_id = f"src-{device}-{node_id}" + cameras[camera_source_id] = { + "type": source_type, + "params": {"cameraId": "Aravis-Fake-GV01"}, + "sync_status": "synced", + "absent": False, + "stale": False, + } + device_bindings[node_id] = {"cameraSourceId": camera_source_id} + source_refs[(device, node_id)] = (camera_source_id, source_type) + registry_snapshot[device] = {"never_synced": False, + "cameras": cameras} + bindings[device] = device_bindings + + version = { + "has_binding_points": True, + "camera_input_nodes": [ + {"node_id": node_id, "node_type": node_types[node_id]} + for node_id in node_ids + ], + } + return version, targets, registry_snapshot, bindings, node_types, \ + source_refs + + +# --------------------------------------------------------------------------- +# Property 11 +# --------------------------------------------------------------------------- + +# Example count comes from the conftest hypothesis profile: 25 for fast +# local runs (portal-fast), 100 (the spec minimum) with HYPOTHESIS_PROFILE=ci. +@settings(deadline=None) +@given(_type_cases()) +def test_aravis_type_incompatibility_is_flagged_exactly(deployments, case): + """**Feature: aravis-camera-input, Property 11: Aravis + type-compatibility validation** + + **Validates: Requirements 5.2, 5.3** + + A CAMERA_TYPE_INCOMPATIBLE error is produced exactly for the + bindings whose Camera_Source type is outside the node type's + compatible set per the compatibility-map oracle: an + aravis_camera_source node accepts only {Camera, AravisDiscovered} + (5.2), a camera_source node accepts its pre-feature set plus + AravisDiscovered (5.3) — and no other error is produced for these + healthy, fully bound cases. + """ + (version, targets, registry_snapshot, bindings, node_types, + source_refs) = case + + errors, warnings = deployments.validate_camera_bindings( + version, targets, registry_snapshot, bindings, []) + + expected_mismatches = { + (device, node_id, camera_source_id, source_type) + for (device, node_id), (camera_source_id, source_type) + in source_refs.items() + if source_type not in _COMPATIBLE[node_types[node_id]] + } + + type_errors = [ + e for e in errors + if e["code"] == deployments.CAMERA_ERROR_TYPE_INCOMPATIBLE] + + # Exactly one type error per incompatible binding; every compatible + # binding passes the type check. + assert {(e["device"], e["nodeId"], e["cameraSourceId"], e["sourceType"]) + for e in type_errors} == expected_mismatches + assert len(type_errors) == len(expected_mismatches) + + # Each error identifies both sides of the mismatch. + for error in type_errors: + assert error["nodeType"] == node_types[error["nodeId"]] + assert error["sourceType"] in error["message"] + assert error["nodeType"] in error["message"] + + # Healthy, present, fully bound sources: type compatibility is the + # only signal — no other errors, no warnings. + assert len(errors) == len(type_errors) + assert warnings == [] diff --git a/edge-cv-portal/backend/tests/test_property_bedrock_config_resolution.py b/edge-cv-portal/backend/tests/test_property_bedrock_config_resolution.py new file mode 100644 index 00000000..8fff3679 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_bedrock_config_resolution.py @@ -0,0 +1,262 @@ +"""Property test for Bedrock configuration resolution +(custom-node-code-assist, task 1.3). + +**Feature: custom-node-code-assist, Property 10: Bedrock configuration +resolution** + +_For any_ stored `bedrock_configuration` settings item - any subset of the +known keys, arbitrary extra keys, Decimal-typed numbers (how DynamoDB stores +every number), explicit nulls for the sampling parameters, junk +`timeout_seconds` values, nested (`{setting_key, value: {...}}`) or flat item +shape, or no stored item at all - `bedrock_common.get_bedrock_configuration()` +SHALL return the defaults overridden by the present non-null stored values, +with an explicitly-null `temperature`/`top_p` left unset, and a resolved +`timeout_seconds` that is an integer in [1, 60], equal to 60 whenever the +stored value is missing or uninterpretable. + +**Validates: Requirements 4.1, 4.4, 4.6, 4.7** + +Runs against the shared moto stack from conftest.py: the stored item goes +through a real (moto) DynamoDB put/get round trip, so numbers arrive as +Decimal exactly as in production. +""" +import os +import sys +from decimal import Decimal +from types import SimpleNamespace + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION + +SETTINGS_TABLE_NAME = "test-settings-bedrock-config-resolution" + +KNOWN_KEYS = ("model_id", "region", "max_tokens", + "temperature", "top_p", "timeout_seconds") +SAMPLING_KEYS = ("temperature", "top_p") + + +@pytest.fixture(scope="module") +def config_env(aws_stack): + """Settings table + freshly imported bedrock_common bound to it inside + moto (conftest re-import pattern).""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + boto3.client("dynamodb", region_name=REGION).create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds SETTINGS_TABLE and a moto-intercepted + # boto3 resource. + sys.modules.pop("bedrock_common", None) + import bedrock_common + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + bedrock_common=bedrock_common, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + ) + + +# --------------------------------------------------------------------------- +# DynamoDB round-trip helpers +# --------------------------------------------------------------------------- + +def to_ddb(value): + """Native Python -> DynamoDB-storable (floats become Decimal, exactly + like boto3 requires for numbers).""" + if isinstance(value, bool): + return value + if isinstance(value, float): + return Decimal(str(value)) + if isinstance(value, list): + return [to_ddb(v) for v in value] + if isinstance(value, dict): + return {k: to_ddb(v) for k, v in value.items()} + return value + + +def expected_native(value): + """What a stored native value looks like after the DynamoDB Decimal + round trip and the module's Decimal-to-native conversion: whole numbers + come back as int, fractional ones as float, everything else unchanged.""" + if isinstance(value, bool): + return value + if isinstance(value, float): + d = Decimal(str(value)) + return float(d) if d % 1 else int(d) + if isinstance(value, list): + return [expected_native(v) for v in value] + return value + + +def apply_store_state(env, shape, stored): + """Materialize a store state: no item, the production nested shape + ({setting_key, value: {...}}), or the also-accepted flat shape.""" + env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + if shape == "no_item": + return + if shape == "nested": + env.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": to_ddb(stored), + }) + else: # flat: attributes directly on the item + item = {"setting_key": "bedrock_configuration"} + item.update(to_ddb(stored)) + env.settings_table.put_item(Item=item) + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# Floats destined for DynamoDB are rounded: raw hypothesis floats include +# magnitudes below DynamoDB's representable range (< ~1e-130), which fail +# PutItem with a number underflow before the code under test runs. +storable_unit_floats = st.floats( + min_value=0, max_value=1, allow_nan=False, allow_infinity=False, +).map(lambda x: round(x, 6)) + +# Non-empty printable-ASCII strings for the string-typed keys. +storable_strings = st.text( + alphabet=st.characters(min_codepoint=33, max_codepoint=126), + min_size=1, max_size=40, +) + +# Values per known key; None appears everywhere so both "explicit null +# sampling parameter" and "null non-sampling value keeps the default" are +# exercised. +KNOWN_KEY_VALUES = { + "model_id": st.one_of(st.none(), storable_strings), + "region": st.one_of(st.none(), storable_strings), + "max_tokens": st.one_of( + st.none(), + st.integers(min_value=1, max_value=100000), + st.floats(min_value=1, max_value=100000, + allow_nan=False, allow_infinity=False) + .map(lambda x: round(x, 3)), + ), + "temperature": st.one_of(st.none(), st.integers(min_value=0, max_value=1), + storable_unit_floats), + "top_p": st.one_of(st.none(), st.integers(min_value=0, max_value=1), + storable_unit_floats), + # Interpretable (in- and out-of-range ints, Decimal floats, booleans) + # and uninterpretable (null, letter strings, lists) timeout values. + "timeout_seconds": st.one_of( + st.none(), + st.integers(min_value=-1000, max_value=1000), + st.floats(min_value=-100, max_value=200, + allow_nan=False, allow_infinity=False) + .map(lambda x: round(x, 3)), + st.booleans(), + st.text(alphabet="abcdefghijxyz ", min_size=1, max_size=8), + st.lists(st.integers(min_value=0, max_value=9), + min_size=0, max_size=3), + ), +} + +extra_keys = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz_", min_size=1, max_size=12, +).filter(lambda k: k not in KNOWN_KEYS and k not in ("value", "setting_key")) + +extra_values = st.one_of( + st.none(), + st.integers(min_value=-100, max_value=100), + storable_strings, +) + + +@st.composite +def stored_bedrock_items(draw): + """(shape, stored): a store state - nothing stored, or a nested/flat + item over any subset of known keys plus arbitrary extra keys.""" + shape = draw(st.sampled_from(["no_item", "nested", "flat"])) + if shape == "no_item": + return shape, None + stored = {} + for key in draw(st.lists(st.sampled_from(KNOWN_KEYS), unique=True)): + stored[key] = draw(KNOWN_KEY_VALUES[key]) + stored.update(draw(st.dictionaries(extra_keys, extra_values, max_size=3))) + return shape, stored + + +# --------------------------------------------------------------------------- +# Property 10: Bedrock configuration resolution +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(case=stored_bedrock_items()) +def test_bedrock_configuration_resolution(config_env, case): + """For any stored configuration item, the resolved configuration equals + the defaults overridden by the present non-null stored values, with + explicit-null sampling parameters unset, and a timeout that is an + integer in [1, 60], equal to 60 whenever the stored value is missing or + uninterpretable (Requirements 4.1, 4.4, 4.6, 4.7).""" + shape, stored = case + apply_store_state(config_env, shape, stored) + + resolved = config_env.bedrock_common.get_bedrock_configuration() + defaults = dict(config_env.bedrock_common.DEFAULT_BEDROCK_CONFIG) + + # Exactly the known keys - extra stored keys never leak through. + assert set(resolved) == set(defaults) == set(KNOWN_KEYS) + + native = ({key: expected_native(value) + for key, value in stored.items()} if stored else {}) + + # Non-sampling keys: a present non-null stored value overrides the + # default; a missing key or explicit null keeps the default (Req 4.1). + for key in ("model_id", "region", "max_tokens"): + if native.get(key) is not None: + assert resolved[key] == native[key], ( + f"{key}: stored {native[key]!r} must override the default, " + f"got {resolved[key]!r}") + else: + assert resolved[key] == defaults[key], ( + f"{key}: missing/null stored value must resolve to the " + f"default {defaults[key]!r}, got {resolved[key]!r}") + + # Sampling parameters: a present key overrides - including an explicit + # null, which leaves the parameter unset; a missing key resolves to the + # (unset) default (Requirements 4.6, 4.7). + for key in SAMPLING_KEYS: + if stored is not None and key in stored: + assert resolved[key] == native[key] and ( + (resolved[key] is None) == (native[key] is None)), ( + f"{key}: explicitly stored {native[key]!r} (null = unset) " + f"must be honored, got {resolved[key]!r}") + else: + assert resolved[key] is None, ( + f"{key}: unstored sampling parameter must stay unset, " + f"got {resolved[key]!r}") + + # Timeout: always an integer in [1, 60]; 60 whenever the stored value + # is missing or uninterpretable; otherwise the interpreted integer + # clamped to [1, 60] (Requirement 4.4). + timeout = resolved["timeout_seconds"] + assert isinstance(timeout, int) and not isinstance(timeout, bool), ( + f"timeout must resolve to an int, got {timeout!r}") + assert 1 <= timeout <= 60, f"timeout must be in [1, 60], got {timeout!r}" + + raw = native.get("timeout_seconds") + try: + interpreted = int(raw) + except (TypeError, ValueError): + interpreted = None + if raw is None or interpreted is None: + assert timeout == 60, ( + f"missing/uninterpretable stored timeout {raw!r} must resolve " + f"to 60, got {timeout}") + else: + assert timeout == max(1, min(interpreted, 60)), ( + f"stored timeout {raw!r} must resolve to its clamped integer " + f"value, got {timeout}") diff --git a/edge-cv-portal/backend/tests/test_property_bedrock_sampling_exclusivity.py b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_exclusivity.py new file mode 100644 index 00000000..2c102ea3 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_exclusivity.py @@ -0,0 +1,101 @@ +"""Property test for sampling parameter exclusivity +(custom-node-code-assist, task 1.4). + +**Feature: custom-node-code-assist, Property 11: Sampling parameter +exclusivity** + +_For any_ combination of `temperature` and `top_p` values (each set or +unset, where unset means the key is absent from the resolved configuration +or holds an explicit None), `bedrock_common.build_inference_config` SHALL +emit at most one sampling parameter - `temperature` when it is set, else +`topP` when it is set - and SHALL omit any parameter that is unset. The +result always carries `maxTokens` as an int and nothing else. + +**Validates: Requirements 4.2, 4.3** + +`build_inference_config` is a pure function over an already-resolved +Bedrock_Configuration dict (Decimal values have been converted to native +int/float by `get_bedrock_configuration`), so this test needs no moto +stack - it drives the function directly with native values. +""" +import pytest +from hypothesis import given +from hypothesis import strategies as st + +import bedrock_common + +# Sentinel for "key absent from the resolved configuration" - distinct +# from an explicit None, though both mean the parameter is unset +# (Requirement 4.2). +UNSET = object() + +# A set sampling parameter: any int or float in [0, 1] (the values the +# settings API accepts; 0 is set, not falsy-unset). +set_sampling_values = st.one_of( + st.integers(min_value=0, max_value=1), + st.floats(min_value=0, max_value=1, + allow_nan=False, allow_infinity=False), +) + +# Each sampling parameter is independently: absent, explicit None, or set. +sampling_states = st.one_of( + st.just(UNSET), st.none(), set_sampling_values) + +max_tokens_values = st.one_of( + st.integers(min_value=1, max_value=100000), + st.floats(min_value=1, max_value=100000, + allow_nan=False, allow_infinity=False), +) + + +@st.composite +def resolved_configs(draw): + """A resolved Bedrock_Configuration dict with every set/unset + combination of the two sampling parameters.""" + config = { + 'model_id': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'region': 'us-east-1', + 'max_tokens': draw(max_tokens_values), + 'timeout_seconds': draw(st.integers(min_value=1, max_value=60)), + } + for key in ('temperature', 'top_p'): + state = draw(sampling_states) + if state is not UNSET: + config[key] = state + return config + + +@given(config=resolved_configs()) +def test_sampling_parameter_exclusivity(config): + """For any set/unset combination of temperature and top_p, the + inferenceConfig contains at most one sampling parameter - temperature + when set, else topP when set - and omits unset parameters + (Requirements 4.2, 4.3).""" + result = bedrock_common.build_inference_config(config) + + temperature = config.get('temperature') + top_p = config.get('top_p') + + # At most one sampling parameter is ever emitted (Requirement 4.3). + emitted = {'temperature', 'topP'} & set(result) + assert len(emitted) <= 1, ( + f"at most one sampling parameter may be emitted, got {result!r}") + + if temperature is not None: + # Temperature wins whenever set - even when top_p is also set. + assert set(result) == {'maxTokens', 'temperature'}, ( + f"set temperature must be emitted alone, got {result!r}") + assert result['temperature'] == pytest.approx(float(temperature)) + elif top_p is not None: + # topP is sent only when temperature is unset (absent or None). + assert set(result) == {'maxTokens', 'topP'}, ( + f"set top_p with unset temperature must emit topP alone, " + f"got {result!r}") + assert result['topP'] == pytest.approx(float(top_p)) + else: + # Both unset: no sampling parameter at all (Requirement 4.2). + assert set(result) == {'maxTokens'}, ( + f"unset sampling parameters must be omitted, got {result!r}") + + assert isinstance(result['maxTokens'], int) + assert result['maxTokens'] == int(config['max_tokens']) diff --git a/edge-cv-portal/backend/tests/test_property_bedrock_sampling_preservation.py b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_preservation.py new file mode 100644 index 00000000..58051be9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_preservation.py @@ -0,0 +1,594 @@ +"""Bug 1 preservation property tests (workflow-designer-bugfixes, task 2). + +**Feature: workflow-designer-bugfixes, Property 2: Preservation - Explicit +temperature behavior unchanged** + +_For any_ input where the bug condition does NOT hold - a temperature +explicitly stored as a number in [0, 1], or a per-request override supplied - +the pipeline SHALL produce exactly the same result as the original: the +applicable temperature sent as `inferenceConfig.temperature` with `topP` +suppressed; invalid overrides (out of range, non-numeric, boolean) rejected +with 400 `INVALID_TEMPERATURE` before any Bedrock call; `temperature` and +`topP` never sent together; a request without a temperature key (what a blank +GenerateChatPanel field produces) using the configured value; and all +non-temperature settings validation rules unchanged. + +**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** + +PRESERVATION TESTS (observation-first): these encode the observed behavior of +the UNFIXED code for non-buggy inputs and MUST PASS both before and after the +Bug 1 fix - they pin down the baseline the fix must not disturb. Assertions +deliberately avoid anything that differs between the unfixed and fixed code +(e.g. the effective temperature when nothing was stored). + +Runs against the shared moto stack from conftest.py with the Bedrock Converse +API mocked exactly as in test_property_bedrock_sampling_unset.py / +test_workflow_generation.py. +""" +import json +import os +import sys +import uuid +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION + +SETTINGS_TABLE_NAME = "test-settings-sampling-preservation" +CHAT_SESSIONS_TABLE_NAME = "test-workflow-chat-sessions-sampling-preservation" +NODE_GEN_SESSIONS_TABLE_NAME = "test-node-gen-sessions-sampling-preservation" + +BEDROCK_CONFIG_RESOURCE_ID = "bedrock-configuration" + +# Minimal catalog-conformant Workflow_Definition (as in +# test_workflow_generation.py) so the generated definition validates cleanly. +VALID_DEFINITION = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], +} + +NODE_DECLARATION = { + "typeId": "custom_blur", + "displayName": "Custom Blur", + "description": "Blurs each frame.", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [], + "architectures": ["x86_64"], +} + + +def tool_response(tool_name, tool_input): + """A Converse API response whose assistant message calls the tool.""" + return { + "output": {"message": {"role": "assistant", "content": [ + {"toolUse": {"toolUseId": "tool-1", "name": tool_name, + "input": tool_input}}, + ]}}, + "stopReason": "tool_use", + } + + +@pytest.fixture(scope="module") +def sampling_env(aws_stack): + """Settings + chat-session tables and freshly imported + workflow_generator / node_generator / data_accounts modules bound to + them inside moto, with the Converse clients mocked.""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + os.environ["WORKFLOW_CHAT_SESSIONS_TABLE"] = CHAT_SESSIONS_TABLE_NAME + os.environ["NODE_GEN_SESSIONS_TABLE"] = NODE_GEN_SESSIONS_TABLE_NAME + os.environ["PLUGIN_SOURCES_PREFIX"] = "plugin-sources" + + client = boto3.client("dynamodb", region_name=REGION) + for table_name in (SETTINGS_TABLE_NAME, CHAT_SESSIONS_TABLE_NAME, + NODE_GEN_SESSIONS_TABLE_NAME): + key = ("setting_key" if table_name == SETTINGS_TABLE_NAME + else "session_id") + client.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": key, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": key, + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the modules bind the table names above and + # moto-intercepted boto3 clients (conftest pattern). node_generator + # takes get_bedrock_configuration from workflow_generator, and + # data_accounts serves the settings API. + for module_name in ("data_accounts", "node_generator", + "workflow_generator", "workflow_validation"): + sys.modules.pop(module_name, None) + import workflow_generator + import node_generator + import data_accounts + + # Mocked Converse clients, one per pipeline, injected permanently + # through get_bedrock_client (reset per example in the tests). + wf_client = MagicMock(name="bedrock-runtime-workflow") + node_client = MagicMock(name="bedrock-runtime-node") + workflow_generator.get_bedrock_client = lambda region, timeout: wf_client + node_generator.get_bedrock_client = lambda region, timeout: node_client + + # Run the node-generation worker synchronously in-process so a + # dispatched turn is settled by the time the start route returns. + def dispatch(session_id, prompt, user): + node_generator.handler({ + "node_gen_worker": True, + "session_id": session_id, + "prompt": prompt, + "user": user, + }, None) + node_generator.dispatch_generation_worker = dispatch + + from workflow_core.scaffold import render_scaffold + reference_files = render_scaffold(NODE_DECLARATION) + + # One Use_Case with a DataScientist (workflow generation) and a + # UseCaseAdmin (node generation), reused across examples. + usecase_id = f"uc-{uuid.uuid4()}" + aws_stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, "name": "UC", "account_id": "123456789012", + }) + + def make_user(role, usecase_role=None): + user_id = f"user-{uuid.uuid4()}" + user = {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": role} + if usecase_role: + aws_stack.tables.user_roles.put_item(Item={ + "user_id": user_id, "usecase_id": usecase_id, + "role": usecase_role, + }) + return user + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + workflow_generator=workflow_generator, + node_generator=node_generator, + data_accounts=data_accounts, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + wf_client=wf_client, + node_client=node_client, + reference_files=reference_files, + usecase_id=usecase_id, + wf_user=make_user("DataScientist", "DataScientist"), + node_user=make_user("UseCaseAdmin", "UseCaseAdmin"), + admin=make_user("PortalAdmin"), + ) + + +# --------------------------------------------------------------------------- +# Store-state setup and invocation helpers +# --------------------------------------------------------------------------- + +def _to_ddb(value): + return Decimal(str(value)) if isinstance(value, float) else value + + +def store_sampling_config(env, temperature, top_p=None): + """Store a Bedrock_Configuration item with an explicitly configured + temperature (a number - the non-bug-condition store states) and an + optionally stored top_p.""" + env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + value = {"temperature": _to_ddb(temperature)} + if top_p is not None: + value["top_p"] = _to_ddb(top_p) + env.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": value, + }) + + +def clear_stored_config(env): + env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + + +def auth_context(user): + return { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + } + + +_NO_OVERRIDE = object() + + +def generate_workflow(env, temperature_override=_NO_OVERRIDE): + """POST /workflows/generate, optionally carrying a temperature override. + + When no override is given the request body has no temperature key at + all - exactly the payload a blank GenerateChatPanel temperature field + produces (Requirement 3.5). + """ + env.wf_client.converse.reset_mock(return_value=True, side_effect=True) + env.wf_client.converse.return_value = tool_response( + "create_workflow", VALID_DEFINITION) + body = {"usecase_id": env.usecase_id, "prompt": "Camera to capture"} + if temperature_override is not _NO_OVERRIDE: + body["temperature"] = temperature_override + event = { + "httpMethod": "POST", + "resource": "/workflows/generate", + "path": "/workflows/generate", + "body": json.dumps(body), + "requestContext": auth_context(env.wf_user), + } + response = env.workflow_generator.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def generate_node(env): + """POST /plugins/generate (start) polled to its settled outcome.""" + env.node_client.converse.reset_mock(return_value=True, side_effect=True) + env.node_client.converse.return_value = tool_response( + "create_plugin_scaffold", {"files": dict(env.reference_files)}) + + def invoke(resource, method, body=None, session_id=None): + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{session}", session_id or ""), + "pathParameters": ({"session": session_id} if session_id + else None), + "body": json.dumps(body) if body is not None else None, + "requestContext": auth_context(env.node_user), + } + response = env.node_generator.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + status, body = invoke("/plugins/generate", "POST", { + "usecase_id": env.usecase_id, + "prompt": "Blur frames", + "declaration": NODE_DECLARATION, + }) + if status != 202: + return status, body + poll_status, turn = invoke("/plugins/generate/{session}", "GET", + session_id=body["session_id"]) + assert poll_status == 200 + if turn["turn_status"] == "completed": + return 200, turn + error = turn["turn_error"] + return error.get("http_status", 502), {"error": error} + + +def invoke_settings(env, method, body=None): + """GET/PUT /data-accounts/bedrock-configuration as PortalAdmin.""" + event = { + "httpMethod": method, + "resource": "/data-accounts/{id}", + "path": f"/data-accounts/{BEDROCK_CONFIG_RESOURCE_ID}", + "pathParameters": {"id": BEDROCK_CONFIG_RESOURCE_ID}, + "body": json.dumps(body) if body is not None else None, + "requestContext": auth_context(env.admin), + } + response = env.data_accounts.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def captured_inference_config(client): + assert client.converse.call_count == 1 + return client.converse.call_args.kwargs["inferenceConfig"] + + +def assert_temperature_sent(inference_config, expected): + """The applicable temperature is sent, topP suppressed, and the + never-both invariant holds (Requirements 3.1, 3.2, 3.4).""" + assert inference_config.get("temperature") == pytest.approx( + float(expected)), ( + f"expected temperature {expected!r} in {inference_config!r}") + assert "topP" not in inference_config, ( + f"topP must be suppressed when a temperature applies, got " + f"{inference_config!r}") + assert_never_both(inference_config) + + +def assert_never_both(inference_config): + """Invariant across all generated cases: temperature and topP are + never both present (Requirement 3.4).""" + assert not ("temperature" in inference_config + and "topP" in inference_config), ( + f"temperature and topP must never be sent together, got " + f"{inference_config!r}") + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# Floats destined for DynamoDB storage are rounded to 6 decimal places: +# raw hypothesis floats include subnormals (e.g. 2.2e-311) below DynamoDB's +# representable number range (magnitude >= 1e-130), which fail PutItem with +# a number underflow before the code under test even runs. Six decimals +# comfortably covers the realistic temperature/top_p input space. +storable_floats = st.floats( + min_value=0, max_value=1, allow_nan=False, allow_infinity=False, +).map(lambda x: round(x, 6)) + +# Explicitly stored temperatures: numeric ints (0, 1) and floats in [0, 1] +# (floats are Decimal-encoded on storage, as DynamoDB requires). +stored_temperatures = st.one_of( + st.integers(min_value=0, max_value=1), + storable_floats, +) + +optional_stored_top_ps = st.one_of(st.none(), storable_floats) + +# Valid per-request overrides: numbers in [0, 1] (booleans are NOT valid). +valid_overrides = st.one_of( + st.integers(min_value=0, max_value=1), + st.floats(min_value=0, max_value=1, + allow_nan=False, allow_infinity=False), +) + +# Invalid per-request overrides: out-of-range numbers, booleans, +# non-numeric JSON values. None is excluded - it means "no override". +invalid_overrides = st.one_of( + st.floats(min_value=1, max_value=1e6, exclude_min=True, + allow_nan=False, allow_infinity=False), + st.floats(min_value=-1e6, max_value=0, exclude_max=True, + allow_nan=False, allow_infinity=False), + st.integers(min_value=2, max_value=1000), + st.integers(min_value=-1000, max_value=-1), + st.booleans(), + st.text(max_size=10), + st.lists(st.integers(), max_size=3), + st.dictionaries(st.text(max_size=5), st.integers(), max_size=2), +) + +# Invalid stored sampling values for the settings PUT: out-of-range numbers, +# booleans, non-numbers. Explicit null is excluded - its acceptance is the +# Bug 1 fix itself (exploration test), not preserved behavior. +invalid_sampling_values = st.one_of( + st.floats(min_value=1, max_value=100, exclude_min=True, + allow_nan=False, allow_infinity=False), + st.floats(min_value=-100, max_value=0, exclude_max=True, + allow_nan=False, allow_infinity=False), + st.integers(min_value=2, max_value=100), + st.integers(min_value=-100, max_value=-1), + st.booleans(), + st.text(max_size=10), + st.lists(st.integers(), max_size=2), +) + + +# --------------------------------------------------------------------------- +# Property 2a: an explicitly stored temperature keeps being sent +# (workflow generation), topP suppressed (Req 3.1, 3.4, 3.5) +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(temperature=stored_temperatures, top_p=optional_stored_top_ps) +def test_workflow_generation_sends_stored_temperature( + sampling_env, temperature, top_p): + """For any explicitly stored temperature in [0, 1] (with or without a + stored top_p) and a generation request without a temperature key (the + blank-GenerateChatPanel payload), the stored temperature is sent as + inferenceConfig.temperature and topP is suppressed + (Requirements 3.1, 3.4, 3.5).""" + store_sampling_config(sampling_env, temperature, top_p) + + status, _ = generate_workflow(sampling_env) + assert status == 200 + + inference_config = captured_inference_config(sampling_env.wf_client) + assert_temperature_sent(inference_config, temperature) + + +# --------------------------------------------------------------------------- +# Property 2b: node scaffold generation keeps sending the stored +# temperature (Req 3.1, 3.4) +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(temperature=stored_temperatures, top_p=optional_stored_top_ps) +def test_node_generation_sends_stored_temperature( + sampling_env, temperature, top_p): + """For any explicitly stored temperature in [0, 1], node scaffold + generation (node_generator reuses get_bedrock_configuration()) keeps + sending it as inferenceConfig.temperature with topP suppressed + (Requirements 3.1, 3.4).""" + store_sampling_config(sampling_env, temperature, top_p) + + status, _ = generate_node(sampling_env) + assert status == 200 + + inference_config = captured_inference_config(sampling_env.node_client) + assert_temperature_sent(inference_config, temperature) + + +# --------------------------------------------------------------------------- +# Property 2c: a valid override replaces the configured temperature for +# that invocation only (Req 3.2, 3.4) +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(stored=stored_temperatures, top_p=optional_stored_top_ps, + override=valid_overrides) +def test_valid_override_replaces_stored_temperature_for_one_invocation( + sampling_env, stored, top_p, override): + """For any explicitly stored temperature and any valid override + (a number in [0, 1]), the override is sent for that invocation + (topP suppressed), and a subsequent invocation without an override + reverts to the stored temperature (Requirements 3.2, 3.4).""" + store_sampling_config(sampling_env, stored, top_p) + + # Overridden invocation: the override wins. + status, _ = generate_workflow(sampling_env, + temperature_override=override) + assert status == 200 + inference_config = captured_inference_config(sampling_env.wf_client) + assert_temperature_sent(inference_config, override) + + # Next invocation without an override: back to the stored value - + # the override applied to that invocation only. + status, _ = generate_workflow(sampling_env) + assert status == 200 + inference_config = captured_inference_config(sampling_env.wf_client) + assert_temperature_sent(inference_config, stored) + + +# --------------------------------------------------------------------------- +# Property 2d: invalid overrides keep rejecting with 400 INVALID_TEMPERATURE +# before any Bedrock call (Req 3.3) +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(stored=stored_temperatures, override=invalid_overrides) +def test_invalid_override_rejected_before_bedrock_call( + sampling_env, stored, override): + """For any invalid per-request temperature (out of range, non-numeric, + or boolean), the request is rejected with 400 INVALID_TEMPERATURE and + Bedrock is never invoked (Requirement 3.3).""" + store_sampling_config(sampling_env, stored) + + status, body = generate_workflow(sampling_env, + temperature_override=override) + assert status == 400, ( + f"override {override!r} must be rejected, got {status}: {body!r}") + assert body["error"]["code"] == "INVALID_TEMPERATURE" + assert sampling_env.wf_client.converse.call_count == 0, ( + "Bedrock must not be invoked when the override is invalid") + + +# --------------------------------------------------------------------------- +# Property 2e: settings validation keeps rejecting invalid sampling values +# and accepting numeric ones (Req 3.1) +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(key=st.sampled_from(["temperature", "top_p"]), + value=invalid_sampling_values) +def test_settings_rejects_invalid_sampling_values(sampling_env, key, value): + """PUT bedrock-configuration with an out-of-range, boolean, or + non-numeric temperature/top_p keeps rejecting with 400 and the + established validation message (unchanged validation rules).""" + clear_stored_config(sampling_env) + + status, body = invoke_settings(sampling_env, "PUT", {key: value}) + assert status == 400, ( + f"{key}={value!r} must be rejected, got {status}: {body!r}") + assert f"{key} must be a number between 0 and 1" \ + in body["validation_errors"] + + +@settings(deadline=None) +@given(temperature=stored_temperatures, top_p=storable_floats) +def test_settings_accepts_numeric_sampling_values_and_round_trips( + sampling_env, temperature, top_p): + """PUT bedrock-configuration with numeric temperature/top_p in [0, 1] + keeps being accepted with 200 and round-trips through GET + (Requirement 3.1).""" + clear_stored_config(sampling_env) + + status, body = invoke_settings(sampling_env, "PUT", { + "temperature": temperature, "top_p": top_p, + }) + assert status == 200, f"got {status}: {body!r}" + assert body["bedrock_configuration"]["temperature"] == pytest.approx( + temperature) + assert body["bedrock_configuration"]["top_p"] == pytest.approx(top_p) + + status, body = invoke_settings(sampling_env, "GET") + assert status == 200 + assert body["bedrock_configuration"]["temperature"] == pytest.approx( + temperature) + assert body["bedrock_configuration"]["top_p"] == pytest.approx(top_p) + + +# --------------------------------------------------------------------------- +# Property 2f: non-temperature settings validation rules unchanged +# --------------------------------------------------------------------------- + +@st.composite +def non_sampling_field_cases(draw): + """(field, value, valid, error_snippet) over the non-sampling + Bedrock_Configuration fields' validation rules as they stand today.""" + field = draw(st.sampled_from( + ["model_id", "region", "max_tokens", "timeout_seconds"])) + valid = draw(st.booleans()) + + if field in ("model_id", "region"): + if valid: + value = draw(st.text(min_size=1, max_size=40).filter( + lambda s: s.strip())) + else: + value = draw(st.one_of( + st.just(""), st.just(" "), + st.integers(), st.booleans(), st.none(), + st.lists(st.text(max_size=3), max_size=2), + )) + snippet = f"{field} must be a non-empty string" + elif field == "max_tokens": + if valid: + value = draw(st.integers(min_value=1, max_value=100000)) + else: + value = draw(st.one_of( + st.integers(min_value=-1000, max_value=0), + st.booleans(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=10), st.none(), + )) + snippet = "max_tokens must be a positive integer" + else: # timeout_seconds: integer in [1, 60] + if valid: + value = draw(st.integers(min_value=1, max_value=60)) + else: + value = draw(st.one_of( + st.integers(min_value=-1000, max_value=0), + st.integers(min_value=61, max_value=1000), + st.booleans(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=10), st.none(), + )) + snippet = "timeout_seconds must be an integer between 1 and 60" + return field, value, valid, snippet + + +@settings(deadline=None) +@given(case=non_sampling_field_cases()) +def test_settings_non_sampling_rules_unchanged(sampling_env, case): + """model_id / region / max_tokens / timeout_seconds keep accepting and + rejecting exactly as today (unchanged validation rules, timeout clamp + domain 1..60).""" + field, value, valid, snippet = case + clear_stored_config(sampling_env) + + status, body = invoke_settings(sampling_env, "PUT", {field: value}) + if valid: + assert status == 200, ( + f"valid {field}={value!r} must be accepted, got {status}: " + f"{body!r}") + else: + assert status == 400, ( + f"invalid {field}={value!r} must be rejected, got {status}: " + f"{body!r}") + assert snippet in body["validation_errors"] diff --git a/edge-cv-portal/backend/tests/test_property_bedrock_sampling_unset.py b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_unset.py new file mode 100644 index 00000000..3cc4b8f7 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_bedrock_sampling_unset.py @@ -0,0 +1,422 @@ +"""Bug 1 bug condition exploration property test (workflow-designer-bugfixes, +task 1). + +**Feature: workflow-designer-bugfixes, Property 1: Bug Condition - Temperature +omitted when unset** + +_For any_ Bedrock configuration store state in which no temperature value was +explicitly stored (nothing stored at all, a stored item without the +`temperature` key, or a stored explicit null) and any generation request +without a temperature override, the `get_bedrock_configuration()` + +`invoke_generation()` pipeline (workflow and node generators alike) SHALL +produce an `inferenceConfig` containing no `temperature` key - and no `topP` +key unless a top_p was explicitly stored; and _for any_ settings save with a +blank/null temperature (or top_p), validation SHALL accept the save and store +the parameter as unset, round-tripping as unset through GET +(`read_stored_bedrock_configuration`). + +**Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2, 2.3** + +EXPLORATION TEST: this encodes the EXPECTED (fixed) behavior and is EXPECTED +TO FAIL on the unfixed code - the failure (with its counterexamples) confirms +the bug exists (isBugCondition1 in design.md). After the Bug 1 fix it must +pass unchanged. + +Runs against the shared moto stack from conftest.py with the Bedrock Converse +API mocked exactly as in test_workflow_generation.py / +test_node_generator_integration.py. +""" +import json +import os +import sys +import uuid +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import REGION, TEST_ENV + +SETTINGS_TABLE_NAME = "test-settings-sampling-unset" +CHAT_SESSIONS_TABLE_NAME = "test-workflow-chat-sessions-sampling-unset" +NODE_GEN_SESSIONS_TABLE_NAME = "test-node-gen-sessions-sampling-unset" + +BEDROCK_CONFIG_RESOURCE_ID = "bedrock-configuration" + +# Minimal catalog-conformant Workflow_Definition (as in +# test_workflow_generation.py) so the generated definition validates cleanly. +VALID_DEFINITION = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], +} + +NODE_DECLARATION = { + "typeId": "custom_blur", + "displayName": "Custom Blur", + "description": "Blurs each frame.", + "category": "preprocessing", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [], + "architectures": ["x86_64"], +} + + +def tool_response(tool_name, tool_input): + """A Converse API response whose assistant message calls the tool.""" + return { + "output": {"message": {"role": "assistant", "content": [ + {"toolUse": {"toolUseId": "tool-1", "name": tool_name, + "input": tool_input}}, + ]}}, + "stopReason": "tool_use", + } + + +@pytest.fixture(scope="module") +def sampling_env(aws_stack): + """Settings + chat-session tables and freshly imported + workflow_generator / node_generator / data_accounts modules bound to + them inside moto, with the Converse clients mocked.""" + import boto3 + + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + os.environ["WORKFLOW_CHAT_SESSIONS_TABLE"] = CHAT_SESSIONS_TABLE_NAME + os.environ["NODE_GEN_SESSIONS_TABLE"] = NODE_GEN_SESSIONS_TABLE_NAME + os.environ["PLUGIN_SOURCES_PREFIX"] = "plugin-sources" + + client = boto3.client("dynamodb", region_name=REGION) + for table_name in (SETTINGS_TABLE_NAME, CHAT_SESSIONS_TABLE_NAME, + NODE_GEN_SESSIONS_TABLE_NAME): + key = ("setting_key" if table_name == SETTINGS_TABLE_NAME + else "session_id") + client.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": key, "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": key, + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the modules bind the table names above and + # moto-intercepted boto3 clients (conftest pattern). node_generator + # takes get_bedrock_configuration from workflow_generator, and + # data_accounts serves the settings API. + for module_name in ("data_accounts", "node_generator", + "workflow_generator", "workflow_validation"): + sys.modules.pop(module_name, None) + import workflow_generator + import node_generator + import data_accounts + + # Mocked Converse clients, one per pipeline, injected permanently + # through get_bedrock_client (reset per example in the tests). + wf_client = MagicMock(name="bedrock-runtime-workflow") + node_client = MagicMock(name="bedrock-runtime-node") + workflow_generator.get_bedrock_client = lambda region, timeout: wf_client + node_generator.get_bedrock_client = lambda region, timeout: node_client + + # Run the node-generation worker synchronously in-process so a + # dispatched turn is settled by the time the start route returns. + def dispatch(session_id, prompt, user): + node_generator.handler({ + "node_gen_worker": True, + "session_id": session_id, + "prompt": prompt, + "user": user, + }, None) + node_generator.dispatch_generation_worker = dispatch + + from workflow_core.scaffold import render_scaffold + reference_files = render_scaffold(NODE_DECLARATION) + + # One Use_Case with a DataScientist (workflow generation) and a + # UseCaseAdmin (node generation), reused across examples. + usecase_id = f"uc-{uuid.uuid4()}" + aws_stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, "name": "UC", "account_id": "123456789012", + }) + + def make_user(role, usecase_role=None): + user_id = f"user-{uuid.uuid4()}" + user = {"user_id": user_id, "email": f"{user_id}@example.com", + "username": user_id, "role": role} + if usecase_role: + aws_stack.tables.user_roles.put_item(Item={ + "user_id": user_id, "usecase_id": usecase_id, + "role": usecase_role, + }) + return user + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + workflow_generator=workflow_generator, + node_generator=node_generator, + data_accounts=data_accounts, + settings_table=resource.Table(SETTINGS_TABLE_NAME), + wf_client=wf_client, + node_client=node_client, + reference_files=reference_files, + usecase_id=usecase_id, + wf_user=make_user("DataScientist", "DataScientist"), + node_user=make_user("UseCaseAdmin", "UseCaseAdmin"), + admin=make_user("PortalAdmin"), + ) + + +# --------------------------------------------------------------------------- +# Store-state setup and invocation helpers +# --------------------------------------------------------------------------- + +def _to_ddb(value): + return Decimal(str(value)) if isinstance(value, float) else value + + +def apply_store_state(env, state, top_p): + """Materialize an unset-temperature Bedrock_Configuration store state. + + state: + 'no_item' - nothing stored at all + 'no_temperature_key' - stored item whose value has no temperature key + 'explicit_null' - stored item with temperature explicitly null + top_p: an explicitly stored top_p value, or None for not stored + (necessarily None when state == 'no_item'). + """ + env.settings_table.delete_item(Key={"setting_key": "bedrock_configuration"}) + if state == "no_item": + return + value = {} + if state == "explicit_null": + value["temperature"] = None + if top_p is not None: + value["top_p"] = _to_ddb(top_p) + env.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": value, + }) + + +def auth_context(user): + return { + "authorizer": { + "claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + } + } + } + + +def generate_workflow(env): + """POST /workflows/generate without a temperature override.""" + env.wf_client.converse.reset_mock(return_value=True, side_effect=True) + env.wf_client.converse.return_value = tool_response( + "create_workflow", VALID_DEFINITION) + event = { + "httpMethod": "POST", + "resource": "/workflows/generate", + "path": "/workflows/generate", + "body": json.dumps({"usecase_id": env.usecase_id, + "prompt": "Camera to capture"}), + "requestContext": auth_context(env.wf_user), + } + response = env.workflow_generator.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def generate_node(env): + """POST /plugins/generate (start) polled to its settled outcome.""" + env.node_client.converse.reset_mock(return_value=True, side_effect=True) + env.node_client.converse.return_value = tool_response( + "create_plugin_scaffold", {"files": dict(env.reference_files)}) + + def invoke(resource, method, body=None, session_id=None): + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{session}", session_id or ""), + "pathParameters": ({"session": session_id} if session_id + else None), + "body": json.dumps(body) if body is not None else None, + "requestContext": auth_context(env.node_user), + } + response = env.node_generator.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + status, body = invoke("/plugins/generate", "POST", { + "usecase_id": env.usecase_id, + "prompt": "Blur frames", + "declaration": NODE_DECLARATION, + }) + if status != 202: + return status, body + poll_status, turn = invoke("/plugins/generate/{session}", "GET", + session_id=body["session_id"]) + assert poll_status == 200 + if turn["turn_status"] == "completed": + return 200, turn + error = turn["turn_error"] + return error.get("http_status", 502), {"error": error} + + +def invoke_settings(env, method, body=None): + """GET/PUT /data-accounts/bedrock-configuration as PortalAdmin.""" + event = { + "httpMethod": method, + "resource": "/data-accounts/{id}", + "path": f"/data-accounts/{BEDROCK_CONFIG_RESOURCE_ID}", + "pathParameters": {"id": BEDROCK_CONFIG_RESOURCE_ID}, + "body": json.dumps(body) if body is not None else None, + "requestContext": auth_context(env.admin), + } + response = env.data_accounts.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def assert_no_temperature(inference_config, top_p): + """Property 1 core assertion over a captured inferenceConfig.""" + assert "temperature" not in inference_config, ( + f"temperature must be omitted when unset, got " + f"{inference_config!r}") + if top_p is None: + assert "topP" not in inference_config, ( + f"topP must be omitted unless a top_p was explicitly stored, " + f"got {inference_config!r}") + else: + assert inference_config.get("topP") == pytest.approx(top_p) + + +# --------------------------------------------------------------------------- +# Generators: unset-temperature store states from isBugCondition1 +# --------------------------------------------------------------------------- + +@st.composite +def unset_temperature_stores(draw): + """(state, top_p): a store state where no temperature was explicitly + stored, optionally with an explicitly stored top_p (only possible when + an item exists at all).""" + state = draw(st.sampled_from( + ["no_item", "no_temperature_key", "explicit_null"])) + top_p = None + if state != "no_item": + # Rounded to 6 decimal places: DynamoDB numbers only support + # exponents down to ~1e-130, so unconstrained floats (e.g. + # 6.5e-177) are unrealizable store states - boto3's Decimal + # serializer raises Underflow before the property is evaluated. + top_p = draw(st.one_of( + st.none(), + st.floats(min_value=0, max_value=1, + allow_nan=False, allow_infinity=False) + .map(lambda x: round(x, 6)), + )) + return state, top_p + + +# --------------------------------------------------------------------------- +# Property 1a: workflow generation omits temperature when unset +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(store=unset_temperature_stores()) +def test_workflow_generation_omits_temperature_when_unset(sampling_env, store): + """For any unset-temperature store state and a generation request with + no temperature override, the workflow-generation inferenceConfig + contains no temperature key, and no topP key unless a top_p was + explicitly stored (Requirements 1.1, 2.1).""" + state, top_p = store + apply_store_state(sampling_env, state, top_p) + + status, _ = generate_workflow(sampling_env) + assert status == 200 + + assert sampling_env.wf_client.converse.call_count == 1 + inference_config = sampling_env.wf_client.converse.call_args.kwargs[ + "inferenceConfig"] + assert_no_temperature(inference_config, top_p) + + +# --------------------------------------------------------------------------- +# Property 1b: node scaffold generation omits temperature when unset +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(store=unset_temperature_stores()) +def test_node_generation_omits_temperature_when_unset(sampling_env, store): + """For any unset-temperature store state and a node scaffold generation + (node_generator reuses get_bedrock_configuration()), the inferenceConfig + contains no temperature key, and no topP key unless a top_p was + explicitly stored (Requirements 1.2, 2.2).""" + state, top_p = store + apply_store_state(sampling_env, state, top_p) + + status, _ = generate_node(sampling_env) + assert status == 200 + + assert sampling_env.node_client.converse.call_count == 1 + inference_config = sampling_env.node_client.converse.call_args.kwargs[ + "inferenceConfig"] + assert_no_temperature(inference_config, top_p) + + +# --------------------------------------------------------------------------- +# Property 1c: settings save accepts null sampling parameters and +# round-trips them as unset +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given( + nulled=st.lists(st.sampled_from(["temperature", "top_p"]), + min_size=1, max_size=2, unique=True), + prior_numeric_config=st.booleans(), +) +def test_settings_save_accepts_null_and_round_trips_unset( + sampling_env, nulled, prior_numeric_config): + """PUT bedrock-configuration with temperature (and/or top_p) explicitly + null is accepted with 200 and the value round-trips as unset through GET + / read_stored_bedrock_configuration (Requirements 1.3, 2.3).""" + sampling_env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + + if prior_numeric_config: + # An admin previously configured numeric sampling values and now + # clears the field(s). + status, _ = invoke_settings(sampling_env, "PUT", { + "temperature": 0.5, "top_p": 0.8, + }) + assert status == 200 + + body = {key: None for key in nulled} + status, payload = invoke_settings(sampling_env, "PUT", body) + assert status == 200, ( + f"save with {body!r} must be accepted, got {status}: {payload!r}") + + for key in nulled: + assert payload["bedrock_configuration"][key] is None + + # Round-trip: GET reports the parameter as unset, not the default. + status, payload = invoke_settings(sampling_env, "GET") + assert status == 200 + for key in nulled: + assert payload["bedrock_configuration"][key] is None, ( + f"{key} must round-trip as unset, got " + f"{payload['bedrock_configuration'][key]!r}") + + config = sampling_env.data_accounts.read_stored_bedrock_configuration() + for key in nulled: + assert config[key] is None diff --git a/edge-cv-portal/backend/tests/test_property_binding_hint_transparency.py b/edge-cv-portal/backend/tests/test_property_binding_hint_transparency.py new file mode 100644 index 00000000..f2b8c0fd --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_binding_hint_transparency.py @@ -0,0 +1,153 @@ +""" +Property-based test for binding-hint transparency (workflow_core). + +**Feature: camera-registry-sync, Property 12: Binding hints are transparent to validation and compilation** + +*For any* valid workflow definition and any binding hints attached to +its Camera_Input_Nodes, validating and compiling the hinted definition +produces results equivalent to validating and compiling the same +definition with the hints stripped. + +**Validates: Requirements 7.5, 11.5** + +The hint is advisory metadata inside the workflow definition JSON +(``nodes[].data.cameraBindingHint``, recorded by the Workflow_Builder +camera picker). The design requires the validator and compiler to +ignore unknown node data keys so the workflow stays device-portable +(7.5) and existing definitions are untouched (11.5). The pipeline under +test is the definition-level one every portal consumer uses: +``workflow_core.serializer.parse`` -> ``workflow_core.validator.validate`` +-> ``workflow_core.compiler.compile`` (per device architecture). + +Generators: 1-3 camera_source -> capture chains with generated device +paths and optional gain/exposure values; hints with unicode/whitespace +names attached to a non-empty subset of the camera nodes. +""" +import copy +import json + +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.serializer import parse +from workflow_core.validator import validate +from workflow_core.compiler import compile as compile_workflow, CompileContext +from workflow_core.catalog import DEVICE_ARCHITECTURES + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +_device_paths = st.integers(min_value=0, max_value=63).map( + lambda n: "/dev/video{}".format(n)) + +# Whitespace/unicode-heavy hint text: transparency must hold for any +# advisory content the Workflow_Builder records. +_hint_text = st.text( + alphabet=st.characters(codec="utf-8", categories=("L", "N", "P", "Zs", "S")), + min_size=1, + max_size=24, +) + +_hints = st.fixed_dictionaries( + {"cameraSourceId": _hint_text}, + optional={ + "cameraName": _hint_text, + "sourceDeviceId": _hint_text, + }, +) + + +@st.composite +def _definitions_with_hints(draw): + """A valid definition (camera_source -> capture chains) plus hints + for a non-empty subset of its Camera_Input_Nodes.""" + chain_count = draw(st.integers(min_value=1, max_value=3)) + nodes, connections = [], [] + for i in range(chain_count): + parameters = {"device": draw(_device_paths)} + if draw(st.booleans()): + parameters["gain"] = draw(st.integers(min_value=0, max_value=100)) + if draw(st.booleans()): + parameters["exposure"] = draw(st.integers(min_value=0, + max_value=10_000_000)) + nodes.append({ + "id": "cam{}".format(i), "type": "camera_source", + "position": {"x": 100.0 * i, "y": 0.0}, + "parameters": parameters, + }) + nodes.append({ + "id": "cap{}".format(i), "type": "capture", + "position": {"x": 100.0 * i, "y": 200.0}, + "parameters": {"output_path": "/out/{}".format(i)}, + }) + connections.append({ + "id": "c{}".format(i), + "from": {"node": "cam{}".format(i), "port": "out"}, + "to": {"node": "cap{}".format(i), "port": "in"}, + }) + + hinted_cameras = draw(st.sets( + st.integers(min_value=0, max_value=chain_count - 1), min_size=1)) + hints = {"cam{}".format(i): draw(_hints) for i in sorted(hinted_cameras)} + + definition = {"schemaVersion": 1, "nodes": nodes, + "connections": connections} + return definition, hints + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _apply_hints(definition, hints): + """The hinted equivalent: ``data.cameraBindingHint`` on hinted nodes.""" + hinted = copy.deepcopy(definition) + for node in hinted["nodes"]: + if node["id"] in hints: + node["data"] = {"cameraBindingHint": copy.deepcopy(hints[node["id"]])} + return hinted + + +def _comparable(compiled): + """Compile output as plain data: a document dict or an error list.""" + if isinstance(compiled, list): + return [error.to_dict() for error in compiled] + return compiled.to_dict() + + +# --------------------------------------------------------------------------- +# Property 12 +# --------------------------------------------------------------------------- + +@settings(deadline=None) +@given(_definitions_with_hints()) +def test_binding_hints_are_transparent_to_validation_and_compilation(case): + definition, hints = case + hinted = _apply_hints(definition, hints) + + stripped_parse = parse(json.dumps(definition)) + hinted_parse = parse(json.dumps(hinted)) + + # The generator only emits valid definitions. + assert stripped_parse.ok, stripped_parse.error + # Transparency starts at parse: the hinted definition must be + # accepted and yield the same graph as its hint-stripped equivalent. + assert hinted_parse.ok, ( + "hinted definition failed to parse: {}".format(hinted_parse.error)) + assert hinted_parse.graph.is_equivalent_to(stripped_parse.graph) + + # Validation findings are identical. + stripped_findings = [f.to_dict() for f in validate(stripped_parse.graph)] + hinted_findings = [f.to_dict() for f in validate(hinted_parse.graph)] + assert hinted_findings == stripped_findings + + # Compilation output is identical on every device architecture. + context = CompileContext(workflow_id="wf-hint-transparency", + workflow_version="1") + for arch in DEVICE_ARCHITECTURES: + stripped_compiled = compile_workflow(stripped_parse.graph, arch, + context) + hinted_compiled = compile_workflow(hinted_parse.graph, arch, context) + assert _comparable(hinted_compiled) == _comparable(stripped_compiled), ( + "compilation diverged on {}".format(arch)) diff --git a/edge-cv-portal/backend/tests/test_property_buildability_scan.py b/edge-cv-portal/backend/tests/test_property_buildability_scan.py new file mode 100644 index 00000000..ea2da511 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_buildability_scan.py @@ -0,0 +1,179 @@ +"""Property test for the Plugin_Importer buildability scan (task 4.2). + +**Feature: custom-node-designer, Property 4: Import buildability scan matches source-tree construction** + +For all synthetic source trees generated with or without a GStreamer +plugin build definition (meson/autotools plugin target, or prebuilt +.so), the Plugin_Importer's buildability scan reports buildable if and +only if the tree was constructed with one, and reports a non-empty +finding when unbuildable. + +**Validates: Requirements 4.5** + +The scan under test (`scan_buildability`) is a pure function over a +{relative_path: content-or-None} source-tree mapping, so it is +exercised directly with no AWS involvement. The module is imported +through the shared moto-backed session fixture only so the real +`shared_utils` layer (not a test fake) backs the import. + +Construction is the reference model: buildable trees are planted with +one known build definition; unbuildable trees are generated from +alphabets and extensions that cannot form a prebuilt `.so` name, a +meson plugin-target declaration, or an autotools GStreamer reference. +""" + +from __future__ import annotations + +import posixpath + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def importer(aws_stack): + """The real plugin_importer module, imported via the session stack.""" + return aws_stack.plugin_importer + + +# --------------------------------------------------------------------------- +# Noise: source-tree content guaranteed free of any build definition. +# +# The requirement (4.5) makes a tree buildable through exactly three +# doors: a prebuilt `.so` binary, a meson.build declaring a GStreamer +# plugin library target, or a configure.ac/.in referencing GStreamer. +# Noise closes all three by construction: +# - file names never end in ".so" (safe extensions, dot-free stems); +# - build-definition file content is drawn from an alphabet with no +# letters besides x/y/z, so the tokens `library(`, `shared_*`, +# `gstreamer-1.0`, `gst_*`, `gst-plugin`, `GST_PLUGIN`, `AG_GST_` +# cannot occur (or the content is None, as for every file whose +# content the scan never fetches). +# --------------------------------------------------------------------------- + +_DEFINITION_NAMES = ("meson.build", "configure.ac", "configure.in") + +_SEGMENT = st.text(alphabet="abcdefghijklmnop", min_size=1, max_size=8) +_SAFE_EXT = st.sampled_from(["", ".c", ".h", ".txt", ".md", ".py", ".sh", ".json"]) + +#: Text that cannot spell any build-definition trigger token. +_INERT_TEXT = st.text(alphabet="xyz012 \n\t#()'=,.-", max_size=120) + +_dirs = st.lists(_SEGMENT, min_size=0, max_size=3) + + +def _join(dirs, basename): + return "/".join(list(dirs) + [basename]) + + +#: An ordinary source file: never a definition name, never *.so. +_plain_file = st.builds( + lambda dirs, stem, ext: (_join(dirs, stem + ext), None), + _dirs, _SEGMENT, _SAFE_EXT, +).filter(lambda kv: posixpath.basename(kv[0]) not in _DEFINITION_NAMES) + +#: A build-definition file whose content declares nothing (inert text +#: or None): present in the tree but never making it buildable. +_inert_definition_file = st.builds( + lambda dirs, name, content: (_join(dirs, name), content), + _dirs, st.sampled_from(_DEFINITION_NAMES), + st.one_of(st.none(), _INERT_TEXT), +) + +_noise_tree = st.lists( + st.one_of(_plain_file, _inert_definition_file), max_size=8, +).map(dict) + + +# --------------------------------------------------------------------------- +# Planted build definitions: each generator returns +# (kind, relative_path, content) for one known-buildable definition. +# --------------------------------------------------------------------------- + +_MESON_TARGETS = ("library", "shared_library", "shared_module") +_MESON_GST_REFS = ( + "dependency('gstreamer-1.0')", + "# needs gst-plugin support", + "gst_dep", +) +_AUTOTOOLS_GST_REFS = ( + "PKG_CHECK_MODULES(GST, gstreamer-1.0 >= 1.20)", + "GST_PLUGIN_LDFLAGS='-module -avoid-version'", + "AG_GST_INIT", +) + +_planted_prebuilt = st.builds( + lambda dirs, stem: ("prebuilt", _join(dirs, "libgst" + stem + ".so"), None), + _dirs, _SEGMENT, +) + +_planted_meson = st.builds( + lambda dirs, target, gap, gst_ref, filler: ( + "meson", + _join(dirs, "meson.build"), + f"{filler}\ngst = {gst_ref}\n" + f"{target}{gap}('myplugin', 'plugin.c', dependencies: [gst])\n", + ), + _dirs, st.sampled_from(_MESON_TARGETS), st.sampled_from(["", " ", " "]), + st.sampled_from(_MESON_GST_REFS), _INERT_TEXT, +) + +_planted_autotools = st.builds( + lambda dirs, name, gst_ref, filler: ( + "autotools", _join(dirs, name), f"{filler}\n{gst_ref}\n", + ), + _dirs, st.sampled_from(("configure.ac", "configure.in")), + st.sampled_from(_AUTOTOOLS_GST_REFS), _INERT_TEXT, +) + +_planted_definition = st.one_of( + _planted_prebuilt, _planted_meson, _planted_autotools) + + +# --------------------------------------------------------------------------- +# Property 4 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(noise=_noise_tree, planted=_planted_definition) +def test_buildable_tree_is_reported_buildable(importer, noise, planted): + """**Feature: custom-node-designer, Property 4: Import buildability scan matches source-tree construction** + + For all source trees planted with a known GStreamer plugin build + definition (prebuilt .so, meson plugin target, or autotools + GStreamer reference) amid inert noise files, the scan reports + buildable, of the planted kind, with the planted file as evidence. + + **Validates: Requirements 4.5** + """ + kind, path, content = planted + tree = dict(noise) + tree[path] = content + + scan = importer.scan_buildability(tree) + + assert scan["buildable"] is True + assert scan["kind"] == kind + assert scan["evidence"] == [path] + assert scan["finding"] == "" + + +@settings(max_examples=25, deadline=None) +@given(noise=_noise_tree) +def test_unbuildable_tree_is_reported_unbuildable_with_finding(importer, noise): + """**Feature: custom-node-designer, Property 4: Import buildability scan matches source-tree construction** + + For all source trees constructed with no GStreamer plugin build + definition (no .so, no declaring meson.build, no GStreamer- + referencing configure.ac/.in), the scan reports unbuildable with + no evidence and a non-empty finding to report to the user (4.5). + + **Validates: Requirements 4.5** + """ + scan = importer.scan_buildability(noise) + + assert scan["buildable"] is False + assert scan["kind"] is None + assert scan["evidence"] == [] + assert isinstance(scan["finding"], str) and scan["finding"] diff --git a/edge-cv-portal/backend/tests/test_property_catalog_membership.py b/edge-cv-portal/backend/tests/test_property_catalog_membership.py new file mode 100644 index 00000000..96976840 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_catalog_membership.py @@ -0,0 +1,228 @@ +"""Property test for merged Node_Type_Catalog membership (task 9.3). + +**Feature: custom-node-designer, Property 11: Resolved catalog membership is exact** + +For all sets of registered Custom_Node_Types with random lifecycle +states, deprecation flags, versions, and plugin pins, the palette +catalog resolved for a Use_Case equals the built-in NODE_CATALOG plus +exactly those non-deprecated custom types whose backing Plugin_Record +version is in test or prod state — test-state entries carrying the +test marker — while resolution for loading/validating/packaging +existing workflows additionally includes deprecated (and dev-state) +types. + +**Validates: Requirements 8.2, 9.2, 9.6, 14.3** + +Exercises the pure merge/marker/exclusion logic in +functions/node_catalog_resolution.py (resolve_palette_catalog, +resolve_resolution_catalog) directly over plain dicts with no AWS +involvement. Declarations reuse the valid wire shape from +test_custom_node_types.make_declaration so descriptor conversion is +the real one used by registration and the catalog endpoint. +""" + +from __future__ import annotations + +import copy + +from hypothesis import given, settings +from hypothesis import strategies as st + +from test_custom_node_types import make_declaration # conftest sets sys.path + +from node_catalog_resolution import ( + BUILTIN_TYPE_IDS, + PALETTE_LIFECYCLE_STATES, + resolve_palette_catalog, + resolve_resolution_catalog, +) +from workflow_core.catalog.custom import descriptor_from_declaration +from workflow_core.catalog.nodes import NODE_CATALOG + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +_TYPE_ID_POOL = st.sampled_from( + ["custom.p11_a", "custom.p11_b", "custom.p11_c", + "custom.p11_d", "custom.p11_e", "custom.p11_f"]) + +_PLUGIN_ID_POOL = st.sampled_from(["plg-1", "plg-2", "plg-3"]) + +#: dev / test / prod backing states, or None meaning the backing +#: Plugin_Record version is unknown (absent from the lifecycle map — +#: the palette must fail closed). +_STATE_OR_UNKNOWN = st.sampled_from(["dev", "test", "prod", None]) + + +@st.composite +def catalog_and_lifecycles(draw): + """A random stored catalog plus a random lifecycle-state map. + + Returns ``(items, lifecycle_states)``: CustomNodeTypes version items + in random order — random versions, deprecated flags, and plugin pins + (occasionally missing, i.e. an unpinned/corrupt item) — including, + sometimes, a registration colliding with a built-in type id; and the + Lifecycle_State of each pinned backing Plugin_Record version, with + some pins deliberately absent from the map (unknown state). + """ + type_ids = draw(st.lists(_TYPE_ID_POOL, unique=True, + min_size=1, max_size=6)) + if draw(st.booleans()): + # A custom registration colliding with a built-in type id: + # built-ins always win the merge. + type_ids.append(draw(st.sampled_from(sorted(BUILTIN_TYPE_IDS)))) + + items = [] + pinned_keys = set() + for type_id in type_ids: + versions = draw(st.sets(st.integers(min_value=1, max_value=9), + min_size=1, max_size=4)) + for version in sorted(versions): + item = { + "node_type_id": type_id, + "version": version, + "usecase_id": "uc-p11", + "usecase_ids": ["uc-p11"], + "declaration": make_declaration(type_id), + "deprecated": draw(st.booleans()), + } + # Mostly pinned; occasionally the plugin pin is missing + # entirely (fails closed like an unknown state). + if draw(st.integers(min_value=0, max_value=9)) >= 1: + plugin_id = draw(_PLUGIN_ID_POOL) + plugin_version = draw(st.integers(min_value=1, max_value=4)) + item["plugin_id"] = plugin_id + item["plugin_version"] = plugin_version + pinned_keys.add((plugin_id, plugin_version)) + items.append(item) + items = list(draw(st.permutations(items))) if len(items) > 1 else items + + lifecycle_states = {} + for key in sorted(pinned_keys): + state = draw(_STATE_OR_UNKNOWN) + if state is not None: + lifecycle_states[key] = state + return items, lifecycle_states + + +# --------------------------------------------------------------------------- +# Reference oracle +# --------------------------------------------------------------------------- + +def _latest_by_type(items): + """Independent latest-version-per-type computation.""" + latest = {} + for item in items: + type_id = item["node_type_id"] + current = latest.get(type_id) + if current is None or item["version"] > current["version"]: + latest[type_id] = item + return latest + + +def _expected_palette_states(items, lifecycle_states): + """{type_id: backing state} of exactly the palette-eligible types: + latest version, not deprecated, backing state test or prod (dev and + unknown excluded — fail closed).""" + eligible = {} + for type_id, item in _latest_by_type(items).items(): + if item.get("deprecated"): + continue + key = (item.get("plugin_id"), item.get("plugin_version")) + if key[0] is None or key[1] is None: + continue + state = lifecycle_states.get(key) + if state in PALETTE_LIFECYCLE_STATES: + eligible[type_id] = state + return eligible + + +# --------------------------------------------------------------------------- +# Property 11a: palette membership and markers are exact +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(data=catalog_and_lifecycles()) +def test_palette_catalog_membership_is_exact(data): + """**Feature: custom-node-designer, Property 11: Resolved catalog membership is exact** + + For all random catalogs and lifecycle-state maps, the resolved + palette catalog is exactly the built-in NODE_CATALOG (unchanged, + first, winning every type-id collision) plus one descriptor per + eligible custom type — the latest version, non-deprecated, backing + Plugin_Record in test or prod (dev and unknown excluded) — with + ``test`` markers on exactly the merged test-state entries and + nothing else in the catalog or the marker map. + + **Validates: Requirements 8.2, 9.2, 9.6, 14.3** + """ + items, lifecycle_states = data + items_before = copy.deepcopy(items) + + merged, markers = resolve_palette_catalog(items, lifecycle_states) + + latest = _latest_by_type(items) + eligible_states = _expected_palette_states(items, lifecycle_states) + # Built-ins win on collision: a colliding custom type never merges. + expected_custom_ids = set(eligible_states) - BUILTIN_TYPE_IDS + + # The built-in catalog comes first, unchanged (8.2; built-ins win). + assert merged[:len(NODE_CATALOG)] == NODE_CATALOG + + # Membership is exact: one entry per eligible custom type, in + # deterministic type-id order, and nothing else (8.2, 9.2, 14.3). + tail = merged[len(NODE_CATALOG):] + assert [d.type_id for d in tail] == sorted(expected_custom_ids) + + # Each merged custom entry is the descriptor of the latest + # version's stored declaration, byte-for-byte (8.2: same + # declaration structure as built-in types). + for descriptor in tail: + expected = descriptor_from_declaration( + latest[descriptor.type_id]["declaration"]) + assert descriptor == expected + + # Markers are exact: 'test' on precisely the merged test-state + # entries; prod entries and built-ins never carry one (9.6). + assert markers == {type_id: "test" + for type_id, state in eligible_states.items() + if state == "test" and type_id not in BUILTIN_TYPE_IDS} + + # Resolution is a read: the stored items are never mutated. + assert items == items_before + + +# --------------------------------------------------------------------------- +# Property 11b: resolution membership additionally keeps deprecated types +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(data=catalog_and_lifecycles()) +def test_resolution_catalog_additionally_includes_deprecated_types(data): + """**Feature: custom-node-designer, Property 11: Resolved catalog membership is exact** + + For all random catalogs, the resolution catalog existing workflows + load/validate/package against contains the built-in NODE_CATALOG + plus exactly one descriptor per registered custom type regardless + of deprecation or lifecycle state (deprecated and dev-state types + stay resolvable), so its membership is always a superset of the + palette's. + + **Validates: Requirements 8.2, 9.2, 9.6, 14.3** + """ + items, lifecycle_states = data + + resolution = resolve_resolution_catalog(items) + + expected_ids = set(_latest_by_type(items)) - BUILTIN_TYPE_IDS + + assert resolution[:len(NODE_CATALOG)] == NODE_CATALOG + assert [d.type_id for d in resolution[len(NODE_CATALOG):]] == \ + sorted(expected_ids) + + # The palette never offers something existing workflows could not + # resolve (14.3: exclusion from new placement, not from loading). + palette, _ = resolve_palette_catalog(items, lifecycle_states) + assert {d.type_id for d in palette}.issubset( + {d.type_id for d in resolution}) diff --git a/edge-cv-portal/backend/tests/test_property_component_immutability.py b/edge-cv-portal/backend/tests/test_property_component_immutability.py new file mode 100644 index 00000000..17daf7c7 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_component_immutability.py @@ -0,0 +1,336 @@ +"""Property test for Plugin_Component version immutability (task 6.5). + +**Feature: custom-node-designer, Property 23: Plugin_Component versions are immutable under rebuild** + +For all sequences of source-change and rebuild operations on a plugin +(each round creating a new Plugin_Record version, recording per-arch +build results, and auto-packaging, with registration randomly failing), +every publish produces a Plugin_Component version not previously +registered, and the recipes and artifact references (and the account- +bucket artifact bytes) of all previously published Plugin_Component +versions are unchanged after each publish; component versions map 1:1 +to Plugin_Record versions. Failed registration rounds clean up only +their own version and never touch previously published versions. + +**Validates: Requirements 16.7** + +Runs the real plugin_records + plugin_components handlers against the +moto-backed stack from conftest.py with a stateful fake Use_Case-account +Greengrass registry (moto does not implement greengrassv2). The fake +registry records every created component version so the test can assert +previously registered versions are never modified or deleted. +""" +import copy +import hashlib +import json +import sys +import uuid +from types import SimpleNamespace + +import pytest +from botocore.exceptions import ClientError +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import TEST_ENV + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + + +# --------------------------------------------------------------------------- +# Stateful fake Greengrass registry (moto lacks greengrassv2) +# --------------------------------------------------------------------------- + +class FakeGreengrassRegistry: + """Records created component versions; a component version that + already exists raises ConflictException, exactly like the real + registry. `fail_next_registration` makes the next created version + settle in BROKEN instead of DEPLOYABLE, driving the packaging + failure/cleanup path.""" + + def __init__(self, account_id="123456789012", region="us-east-1"): + self.account_id = account_id + self.meta = SimpleNamespace(region_name=region) + self.versions = {} # arn -> {"recipe": dict, "tags": dict, "state": str} + self.fail_next_registration = False + + def _arn(self, recipe): + return (f"arn:aws:greengrass:{self.meta.region_name}:{self.account_id}:" + f"components:{recipe['ComponentName']}:versions:" + f"{recipe['ComponentVersion']}") + + def create_component_version(self, inlineRecipe, tags=None): + recipe = json.loads(inlineRecipe) + arn = self._arn(recipe) + if arn in self.versions: + raise ClientError( + {"Error": {"Code": "ConflictException", + "Message": "component version already exists"}}, + "CreateComponentVersion") + state = "BROKEN" if self.fail_next_registration else "DEPLOYABLE" + self.fail_next_registration = False + self.versions[arn] = {"recipe": recipe, "tags": dict(tags or {}), + "state": state} + return {"arn": arn} + + def describe_component(self, arn): + version = self.versions[arn] + return {"status": {"componentState": version["state"], + "message": "simulated"}} + + def delete_component(self, arn): + self.versions.pop(arn, None) + + +# --------------------------------------------------------------------------- +# Module-scoped environment (hypothesis-safe: no function-scoped fixtures) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def cenv(aws_stack): + """plugin_components imported inside the moto mock, one Use_Case with + an account bucket, and get_usecase_client patched to a swappable fake + Greengrass registry (holder['gg']) plus the moto S3 client.""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + mp = pytest.MonkeyPatch() + mp.setattr(plugin_components, "COMPONENT_STATUS_POLL_SECONDS", 0) + + holder = {"gg": None} + moto_s3 = aws_stack.s3 + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + return {"s3": moto_s3, "greengrassv2": holder["gg"]}[service_name] + + mp.setattr(plugin_components, "get_usecase_client", + fake_get_usecase_client) + + usecase_id = f"uc-{uuid.uuid4()}" + usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + moto_s3.create_bucket(Bucket=usecase_bucket) + aws_stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Immutability Property Use Case", + "account_id": "123456789012", + "s3_bucket": usecase_bucket, + }) + admin_id = f"user-{uuid.uuid4()}" + aws_stack.tables.user_roles.put_item(Item={ + "user_id": admin_id, "usecase_id": usecase_id, "role": "UseCaseAdmin", + }) + admin = {"user_id": admin_id, "email": f"{admin_id}@example.com", + "username": admin_id, "role": "UseCaseAdmin"} + + yield SimpleNamespace( + stack=aws_stack, + module=plugin_components, + records=aws_stack.plugin_records, + s3=moto_s3, + portal_bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + usecase_id=usecase_id, + usecase_bucket=usecase_bucket, + admin=admin, + holder=holder, + ) + mp.undo() + + +# --------------------------------------------------------------------------- +# Handler invocation helpers (real plugin_records API events) +# --------------------------------------------------------------------------- + +def _claims(user): + return {"authorizer": {"claims": { + "sub": user["user_id"], + "email": user["email"], + "cognito:username": user["username"], + "custom:role": user["role"], + }}} + + +def _create_plugin(cenv, name): + """POST /plugins -> Plugin_Record version 1 (a plugin's first source).""" + response = cenv.records.handler({ + "httpMethod": "POST", "resource": "/plugins", "path": "/plugins", + "pathParameters": None, "queryStringParameters": None, + "body": json.dumps({"usecase_id": cenv.usecase_id, + "name": name, "kind": "scaffold"}), + "requestContext": _claims(cenv.admin), + }, None) + assert response["statusCode"] == 201, response["body"] + return json.loads(response["body"])["plugin"] + + +def _new_version(cenv, plugin_id): + """PUT /plugins/{id} new_version=true -> the rebuild/source-change + path: a fresh Plugin_Record version (design: rebuilds always create + a new Plugin_Record version, which packages as a new component + version).""" + response = cenv.records.handler({ + "httpMethod": "PUT", "resource": "/plugins/{id}", + "path": f"/plugins/{plugin_id}", + "pathParameters": {"id": plugin_id}, "queryStringParameters": None, + "body": json.dumps({"new_version": True}), + "requestContext": _claims(cenv.admin), + }, None) + assert response["statusCode"] == 201, response["body"] + return json.loads(response["body"])["plugin"] + + +def _record_builds(cenv, plugin_id, version, name, built_archs): + """Record successful per-arch builds: the rebuilt .so overwrites the + portal Plugin_Library key (same key every round - the library holds + the latest build), and the artifact entries land on the record.""" + artifacts = {} + for arch in built_archs: + data = f"\x7fELF {name} {arch} v{version} {uuid.uuid4()}".encode() + key = f"workflow-plugins/custom/{cenv.usecase_id}/{arch}/{name}.so" + cenv.s3.put_object(Bucket=cenv.portal_bucket, Key=key, Body=data) + artifacts[arch] = { + "buildStatus": "succeeded", "s3Key": key, + "checksum": hashlib.sha256(data).hexdigest(), + "signature": "c2ln", "logTail": "", + } + cenv.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin_id, "version": version}, + UpdateExpression="SET artifacts = :a, requested_architectures = :r", + ExpressionAttributeValues={":a": artifacts, + ":r": sorted(built_archs)}, + ) + + +def _package(cenv, plugin_id, version): + return cenv.module.handler({ + "action": "package_plugin_component", + "plugin_id": plugin_id, "version": version, + "usecase_id": cenv.usecase_id, + }, None) + + +def _account_objects(cenv, prefix): + """{key: bytes} of every account-bucket object under a prefix.""" + listed = cenv.s3.list_objects_v2(Bucket=cenv.usecase_bucket, + Prefix=prefix) + return { + obj["Key"]: cenv.s3.get_object( + Bucket=cenv.usecase_bucket, Key=obj["Key"])["Body"].read() + for obj in listed.get("Contents", []) + } + + +# --------------------------------------------------------------------------- +# Rounds: each is one source-change/rebuild with random built archs and +# random registration success/failure. +# --------------------------------------------------------------------------- + +rounds = st.lists( + st.tuples( + st.sets(st.sampled_from(ARCHS), min_size=1, max_size=3), + st.booleans(), # registration succeeds? + ), + min_size=1, + max_size=4, +) + + +@settings(max_examples=25, deadline=None) +@given(build_rounds=rounds) +def test_component_versions_immutable_under_rebuild(cenv, build_rounds): + """**Feature: custom-node-designer, Property 23: Plugin_Component versions are immutable under rebuild** + + For all sequences of source-change and rebuild operations on a + plugin, every publish produces a Plugin_Component version not + previously registered, and the recipes and artifact references of + all previously published Plugin_Component versions are unchanged + after each publish. + + **Validates: Requirements 16.7** + """ + registry = FakeGreengrassRegistry() + cenv.holder["gg"] = registry + + name = f"plg-{uuid.uuid4().hex[:12]}" + plugin = _create_plugin(cenv, name) + plugin_id = plugin["plugin_id"] + + # comp_version -> {"registry": deep copy of the registry entry, + # "objects": {account key: bytes}} at publish time + published = {} + # plugin version -> comp version, for the 1:1 mapping check + version_map = {} + + for round_index, (built_archs, registration_ok) in enumerate(build_rounds): + if round_index == 0: + version = plugin["version"] + else: + # Rebuild/source change -> always a new Plugin_Record version. + version = _new_version(cenv, plugin_id)["version"] + + _record_builds(cenv, plugin_id, version, name, built_archs) + registry.fail_next_registration = not registration_ok + + comp_version = cenv.module.component_version_for(version) + arn = cenv.module.component_version_arn( + "us-east-1", "123456789012", plugin_id, version) + + # A rebuild packages as a version not previously registered. + assert comp_version not in published + assert arn not in registry.versions + + result = _package(cenv, plugin_id, version) + + version_prefix = f"plugins/components/{plugin_id}/{version}/" + if registration_ok: + assert result["packaged"] is True, result + assert result["component_version"] == comp_version + # Registered in the registry exactly once, never before. + assert arn in registry.versions + published[comp_version] = { + "registry": copy.deepcopy(registry.versions[arn]), + "objects": _account_objects(cenv, version_prefix), + } + version_map[version] = comp_version + # The publish shipped one artifact prefix per built arch. + assert set(published[comp_version]["objects"]) == { + f"{version_prefix}{arch}/{fname}" + for arch in built_archs + for fname in (f"{name}.so", "plugin-manifest.json") + } + else: + # Failed registration cleans up only this version: nothing + # registered, no artifacts under this version's prefix. + assert result["packaged"] is False, result + assert arn not in registry.versions + assert _account_objects(cenv, version_prefix) == {} + + # Immutability (16.7): every previously published component + # version's registry entry (recipe + tags + state) and account- + # bucket artifacts are byte-identical after this publish. + for prior_comp_version, snapshot in published.items(): + if prior_comp_version == comp_version and registration_ok: + continue # the version published this round + prior_version = int(prior_comp_version.split(".")[0]) + prior_arn = cenv.module.component_version_arn( + "us-east-1", "123456789012", plugin_id, prior_version) + assert registry.versions[prior_arn] == snapshot["registry"] + prior_prefix = f"plugins/components/{plugin_id}/{prior_version}/" + assert _account_objects(cenv, prior_prefix) == snapshot["objects"] + + # No staging leftovers accumulate across rounds. + assert _account_objects(cenv, f"plugins/staging/{plugin_id}/") == {} + + # Component versions map 1:1 to Plugin_Record versions: exactly the + # successfully packaged record versions are registered, each as its + # own distinct {version}.0.0 component version. + expected_arns = { + cenv.module.component_version_arn( + "us-east-1", "123456789012", plugin_id, v) + for v in version_map + } + assert set(registry.versions) == expected_arns + assert len(set(version_map.values())) == len(version_map) + for record_version, comp_version in version_map.items(): + assert comp_version == f"{record_version}.0.0" diff --git a/edge-cv-portal/backend/tests/test_property_custom_python_gathering.py b/edge-cv-portal/backend/tests/test_property_custom_python_gathering.py new file mode 100644 index 00000000..7889628c --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_custom_python_gathering.py @@ -0,0 +1,143 @@ +"""Property test for Custom Python node gathering and manifest membership +(custom-python-frames task 2.2). + +**Feature: custom-python-frames, Property 3: Custom Python node gathering and manifest membership** + +For any workflow graph containing an arbitrary mix of ``custom_python`` +nodes, ``custom_python_preprocess`` nodes, and other node types with +arbitrary node ids, code strings, and requirements strings: + +- ``gather_custom_python_nodes`` returns exactly the Custom Python nodes + of both types (no other node gathered, none missed), with each node's + ``code`` and ``requirements`` parameter values preserved; +- ``build_manifest``'s ``customPythonNodeIds`` equals exactly those + nodes' ids. + +**Validates: Requirements 2.4, 2.5** + +Both functions are pure over the parsed graph / plain values, so they +are exercised directly with no AWS calls. The module is imported through +the shared moto-backed session fixture only so its module-level boto3 +clients are intercepted and the real ``shared_utils`` layer backs the +import, mirroring the other packaging property tests. +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.serializer.models import Node, Position, WorkflowGraph + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Reference expectations, restated from Requirements 2.4 / 2.5 (not imported +# from the implementation, so the test cannot silently agree with a wrong +# type set). +# --------------------------------------------------------------------------- + +CUSTOM_PYTHON_TYPES = ("custom_python", "custom_python_preprocess") + +# Other node types a graph may mix in: realistic catalog type ids plus +# arbitrary strings, excluding the two Custom Python types. +OTHER_TYPE_EXAMPLES = ( + "folder_source", "camera_source", "dewarp", "format_convert", + "model_inference", "capture", "overlay", +) + +_id_alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" +node_ids = st.text(alphabet=_id_alphabet, min_size=1, max_size=24) + +other_types = st.one_of( + st.sampled_from(OTHER_TYPE_EXAMPLES), + st.text(min_size=1, max_size=30).filter( + lambda t: t not in CUSTOM_PYTHON_TYPES), +) + +# Arbitrary code / requirements parameter values as the definition may +# carry them (including empty and absent — gather normalizes to str). +code_values = st.text(max_size=200) +requirements_values = st.text(max_size=100) + + +@st.composite +def graphs(draw): + """A WorkflowGraph mixing both Custom Python node types and other + types, with unique random ids and random code/requirements values. + Returns (graph, expected) where expected is the ordered list of + {node_id, code, requirements} entries for the Custom Python nodes.""" + ids = draw(st.lists(node_ids, min_size=0, max_size=12, unique=True)) + nodes, expected = [], [] + for node_id in ids: + kind = draw(st.sampled_from(("custom_python", + "custom_python_preprocess", + "other"))) + if kind == "other": + nodes.append(Node(id=node_id, type=draw(other_types), + position=Position(0.0, 0.0), + parameters={"code": draw(code_values)})) + continue + parameters = {} + code = draw(code_values) + requirements = draw(requirements_values) + parameters["code"] = code + # requirements is optional; sometimes omit it entirely. + include_requirements = draw(st.booleans()) + if include_requirements: + parameters["requirements"] = requirements + nodes.append(Node(id=node_id, type=kind, + position=Position(0.0, 0.0), + parameters=parameters)) + expected.append({ + "node_id": node_id, + "code": str(code or ""), + "requirements": str(requirements or "") if include_requirements else "", + }) + return WorkflowGraph(nodes=nodes, connections=[]), expected + + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + + +@settings(deadline=None) +@given(scenario=graphs(), arch=st.sampled_from(ARCHS)) +def test_gathering_and_manifest_membership(packaging, scenario, arch): + """**Feature: custom-python-frames, Property 3: Custom Python node gathering and manifest membership** + + For any graph mixing both Custom Python node types and other node + types with arbitrary ids/code/requirements, gather_custom_python_nodes + returns exactly the Custom Python nodes with code and requirements + preserved, and build_manifest's customPythonNodeIds equals exactly + those nodes' ids. + + **Validates: Requirements 2.4, 2.5** + """ + graph, expected = scenario + + gathered = packaging.gather_custom_python_nodes(graph) + + # Exactly the Custom Python nodes of both types, in graph order, + # with code and requirements preserved (2.4). + assert gathered == expected + assert [n["node_id"] for n in gathered] == \ + [node.id for node in graph.nodes if node.type in CUSTOM_PYTHON_TYPES] + + # The manifest lists exactly those nodes' ids, both types together (2.5). + manifest = packaging.build_manifest( + "wf-1", 1, arch, + gst_plugins=["dda-emlpython"], python_packages=[], + custom_python_nodes=gathered, user={"user_id": "user-1"}) + assert manifest["customPythonNodeIds"] == [n["node_id"] for n in expected] diff --git a/edge-cv-portal/backend/tests/test_property_custom_stubbing.py b/edge-cv-portal/backend/tests/test_property_custom_stubbing.py new file mode 100644 index 00000000..8a918353 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_custom_stubbing.py @@ -0,0 +1,246 @@ +"""Property test for custom-node test-run stubbing (task 13.2). + +**Feature: custom-node-designer, Property 17: Test-run stubbing is exactly the unavailable custom nodes** + +For all custom-node type sets with random x86_64 artifact availabilities +(succeeded / failed / building / missing s3Key / malformed / absent +entries), the test-run compile step's stub decision selects exactly the +Custom_Node_Types lacking a successfully built x86_64 Plugin_Artifact, +and apply_custom_stubs replaces exactly those descriptors with the +pass-through recording stub (identity element named via the +custom_stub_ template, mapped for the target and ``sim`` +architectures) while every other descriptor is left untouched. + +**Validates: Requirements 12.2** + +The functions under test (x86_64_artifact_available, +stubbed_custom_type_ids, apply_custom_stubs, stub_descriptor, +custom_stub_mapping) are pure over plain dicts and catalog descriptors, +so they are exercised directly with no AWS calls. The module is imported +through the shared moto-backed session fixture only so its module-level +boto3 clients bind the mock (workflow_test_steps has no shared_utils +dependency), mirroring test_workflow_testing_errors.py. +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog.models import ( + ARCH_SIM, + ARCHITECTURES, + CATEGORIES, + GstMapping, + NodeTypeDescriptor, + PortDescriptor, + PORT_TYPES, +) + + +@pytest.fixture(scope="module") +def steps_module(aws_stack): + """Import workflow_test_steps inside the moto mock so its module-level + boto3 clients (and node_catalog_resolution's) are intercepted.""" + for name in ("workflow_test_steps", "node_catalog_resolution"): + sys.modules.pop(name, None) + import workflow_test_steps + + return workflow_test_steps + + +# --------------------------------------------------------------------------- +# Reference expectations, restated from Requirement 12.2 / the design +# (not derived from the implementation, so the test cannot silently +# agree with a wrong availability rule). +# --------------------------------------------------------------------------- + +def artifact_is_usable(entry): + """A usable x86_64 Plugin_Artifact entry: a successfully built + artifact with a stored library object (buildStatus 'succeeded' and a + nonempty s3Key).""" + return (isinstance(entry, dict) + and entry.get("buildStatus") == "succeeded" + and bool(entry.get("s3Key"))) + + +#: The identity-element name prefix the sandbox harness keys on (12.2: +#: "identity element named custom_stub_"). +EXPECTED_STUB_PREFIX = "custom_stub_" + + +# --------------------------------------------------------------------------- +# Strategies: random custom type sets, artifact entry shapes, and +# descriptors. +# --------------------------------------------------------------------------- + +_name_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789_" +_names = st.text(alphabet=_name_alphabet, min_size=1, max_size=20) + +_s3_keys = _names.map(lambda n: f"plugins/artifacts/{n}.so") + + +@st.composite +def artifact_entries(draw): + """One per-type x86_64 artifact entry shape, usable or not: + succeeded-with-key, succeeded-without-key, empty key, failed, + building, malformed (statusless / empty dict), or None. The ABSENT + sentinel (entry omitted from the dict entirely) is drawn separately. + """ + shape = draw(st.sampled_from([ + "usable", "usable_extra_keys", "missing_s3_key", "empty_s3_key", + "failed", "building", "no_status", "empty_dict", "none", + ])) + if shape == "usable": + return {"buildStatus": "succeeded", "s3Key": draw(_s3_keys)} + if shape == "usable_extra_keys": + return {"buildStatus": "succeeded", "s3Key": draw(_s3_keys), + "builtAt": draw(st.integers(min_value=1)), + "sizeBytes": draw(st.integers(min_value=0))} + if shape == "missing_s3_key": + return {"buildStatus": "succeeded"} + if shape == "empty_s3_key": + return {"buildStatus": "succeeded", "s3Key": ""} + if shape == "failed": + return {"buildStatus": "failed", "s3Key": draw(_s3_keys), + "error": "compile error"} + if shape == "building": + return {"buildStatus": "building"} + if shape == "no_status": + return {"s3Key": draw(_s3_keys)} + if shape == "empty_dict": + return {} + return None + + +def _ports(draw, prefix): + return [ + PortDescriptor(name=f"{prefix}{i}", + port_type=draw(st.sampled_from(PORT_TYPES))) + for i in range(draw(st.integers(min_value=0, max_value=2))) + ] + + +@st.composite +def descriptors(draw, type_id): + """A random Custom_Node_Type descriptor with arbitrary realizations.""" + mappings = [ + GstMapping( + arch=arch, + element_chain=[{"factory": draw(_names), + "args_template": {"name": draw(_names)}}], + plugin_dependencies=draw( + st.lists(_names, max_size=2)), + ) + for arch in draw(st.lists(st.sampled_from(ARCHITECTURES), + unique=True, min_size=1, max_size=3)) + ] + return NodeTypeDescriptor( + type_id=type_id, + category=draw(st.sampled_from(CATEGORIES)), + display_name=draw(_names), + inputs=_ports(draw, "in"), + outputs=_ports(draw, "out"), + parameters=[], + mappings=mappings, + hardware_dependent=draw(st.booleans()), + ) + + +@st.composite +def stubbing_cases(draw): + """A random custom type set with per-type artifact availability. + + Each type's entry is either drawn from artifact_entries() or absent + from the dict altogether; the dict may also carry entries for types + outside the set (unused custom types of the same Use_Case).""" + type_ids = draw(st.lists(_names, unique=True, min_size=0, max_size=6)) + entries = {} + for type_id in type_ids: + if draw(st.booleans()) or not type_ids: + entries[type_id] = draw(artifact_entries()) + # else: absent entirely -> load failed closed, must be stubbed + for extra_id in draw(st.lists(_names, unique=True, max_size=2)): + if extra_id not in type_ids: + entries[extra_id] = draw(artifact_entries()) + custom_descriptors = [draw(descriptors(type_id)) for type_id in type_ids] + target_arch = draw(st.sampled_from(ARCHITECTURES)) + return type_ids, entries, custom_descriptors, target_arch + + +# --------------------------------------------------------------------------- +# Property 17 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(case=stubbing_cases()) +def test_stubbing_is_exactly_the_unavailable_custom_nodes(steps_module, + case): + """**Feature: custom-node-designer, Property 17: Test-run stubbing is exactly the unavailable custom nodes** + + For all custom type sets and artifact entry shapes, the stub set is + exactly the types lacking a usable x86_64 Plugin_Artifact, and + apply_custom_stubs substitutes the pass-through recording stub for + exactly those descriptors (identity element with the + custom_stub_ name template, target + sim architectures) + leaving every other descriptor untouched. + + **Validates: Requirements 12.2** + """ + type_ids, entries, custom_descriptors, target_arch = case + + # -- stub decision: exactly the types without a usable x86_64 build -- + stub_ids = steps_module.stubbed_custom_type_ids(type_ids, entries) + expected_stub_ids = frozenset( + type_id for type_id in type_ids + if not artifact_is_usable(entries.get(type_id))) + assert stub_ids == expected_stub_ids + + # Availability of entries outside the used set never leaks in. + assert stub_ids <= frozenset(type_ids) + + # x86_64_artifact_available agrees with the restated rule per entry. + for type_id in type_ids: + assert steps_module.x86_64_artifact_available( + entries.get(type_id)) == artifact_is_usable(entries.get(type_id)) + + # -- substitution: exactly the stubbed descriptors are replaced -- + result = steps_module.apply_custom_stubs( + custom_descriptors, stub_ids, target_arch) + + assert len(result) == len(custom_descriptors) + expected_archs = ([target_arch] if target_arch == ARCH_SIM + else [target_arch, ARCH_SIM]) + + for original, resolved in zip(custom_descriptors, result): + assert resolved.type_id == original.type_id + if original.type_id not in stub_ids: + # Untouched: the very same descriptor object passes through. + assert resolved is original + continue + + # Declaration is identical; only the realizations change. + assert resolved.category == original.category + assert resolved.display_name == original.display_name + assert resolved.inputs == original.inputs + assert resolved.outputs == original.outputs + assert resolved.parameters == original.parameters + assert resolved.hardware_dependent == original.hardware_dependent + + # Every realization is the pass-through recording stub, mapped + # for the target arch and sim (identically stubbed under the + # hardware-dependent sim-stub rule, 12.2). + assert [m.arch for m in resolved.mappings] == expected_archs + for mapping in resolved.mappings: + assert mapping.executor_binding is None + assert mapping.plugin_dependencies == [] + assert mapping.element_chain == [{ + "factory": "identity", + "args_template": {"name": "{custom_stub_name}"}, + }] + + # The harness contract: stub instances are named custom_stub_. + assert steps_module.CUSTOM_STUB_ELEMENT_PREFIX == EXPECTED_STUB_PREFIX diff --git a/edge-cv-portal/backend/tests/test_property_entry_point_validation.py b/edge-cv-portal/backend/tests/test_property_entry_point_validation.py new file mode 100644 index 00000000..02a344fd --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_entry_point_validation.py @@ -0,0 +1,157 @@ +"""Property test for entry-point validation +(custom-node-code-assist, task 2.5). + +**Feature: custom-node-code-assist, Property 3: Entry-point validation** + +_For any_ synthesized Python module with controlled top-level and nested +function definitions (and for invalid sources), and any Node_Contract, +`code_assist.validate_entry_point(code, contract)` SHALL return no defect +if and only if the source parses and the module's top-level function +definitions satisfy the contract's rule - at least one entry-point match +for `process_frame`/`frame_hook`, exactly one of {`process_frame`, +`handle`} for `process_frame_or_handle`. Nested definitions (inside +functions, conditional blocks, or classes) never satisfy the contract, +and an unparseable source always yields the invalid-Python defect. + +**Validates: Requirements 2.2, 2.3, 5.6** + +`validate_entry_point` is pure (ast.parse + top-level FunctionDef +inspection), but importing `code_assist` pulls in the real `shared_utils` +layer, so the module is imported through the conftest moto stack like the +other code-assist backend tests. +""" +import sys + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +# The two entry-point names any contract can require. +ENTRY_NAMES = ('process_frame', 'handle') + +# Top-level decoys that must never satisfy a contract. +DECOY_NAMES = ('main', 'helper', 'process_frames', 'handler', 'run', + 'frame_hook', 'process') + +# Corruption suffixes that make any module unparseable regardless of what +# precedes them (each is a guaranteed SyntaxError at column 0). +CORRUPTIONS = ( + 'def broken(:', # malformed def + 'x = (', # unterminated parenthesis at EOF + 'for in range(3):', # missing loop target +) + + +@pytest.fixture(scope="module") +def code_assist_module(aws_stack): + """The real code_assist module, imported inside the moto stack so its + module-level `from shared_utils import ...` binds the real layer.""" + sys.modules.pop("code_assist", None) + import code_assist + return code_assist + + +def _function_lines(name, indent, params='frame, metadata'): + pad = ' ' * indent + return [f'{pad}def {name}({params}):', + f'{pad} return None'] + + +@st.composite +def synthesized_modules(draw): + """(code, parses, top_level_names): a module planting a controlled set + of top-level definitions (entry points and decoys) among filler + statements, plus entry-point-named definitions nested inside a + function body, a conditional block, and a class body - none of which + may count as top-level. Optionally corrupted into invalid Python.""" + top_names = draw(st.lists( + st.sampled_from(ENTRY_NAMES + DECOY_NAMES), + unique=True, min_size=0, max_size=5)) + nested_in_function = draw(st.lists( + st.sampled_from(ENTRY_NAMES), unique=True, max_size=2)) + nested_in_conditional = draw(st.lists( + st.sampled_from(ENTRY_NAMES), unique=True, max_size=2)) + nested_in_class = draw(st.lists( + st.sampled_from(ENTRY_NAMES), unique=True, max_size=2)) + + lines = ['import sys', '', 'THRESHOLD = 3', ''] + + for name in top_names: + lines += _function_lines(name, 0) + lines.append('') + + if nested_in_function: + lines.append('def _outer_scope():') + for name in nested_in_function: + lines += _function_lines(name, 4) + lines.append(' return None') + lines.append('') + + if nested_in_conditional: + lines.append('if THRESHOLD > 1:') + for name in nested_in_conditional: + lines += _function_lines(name, 4) + lines.append('') + + if nested_in_class: + lines.append('class Processor:') + for name in nested_in_class: + lines += _function_lines(name, 4, params='self, frame, metadata') + lines.append('') + + corruption = draw(st.one_of(st.none(), st.sampled_from(CORRUPTIONS))) + if corruption is not None: + lines.append(corruption) + + return '\n'.join(lines), corruption is None, frozenset(top_names) + + +@given(case=synthesized_modules(), + contract=st.sampled_from(('process_frame', + 'process_frame_or_handle', + 'frame_hook'))) +def test_entry_point_validation(code_assist_module, case, contract): + """validate_entry_point returns no defect iff the source parses and + the top-level definitions satisfy the contract's rule: at least one + entry-point match for process_frame/frame_hook, exactly one of + {process_frame, handle} for process_frame_or_handle (Requirements + 2.2, 2.3, 5.6).""" + code, parses, top_names = case + spec = code_assist_module.CONTRACTS[contract] + + defect = code_assist_module.validate_entry_point(code, contract) + + if not parses: + # An unparseable source is always the invalid-Python defect, + # regardless of what names appear in the text (5.6 / 2.2). + assert defect is not None and defect.startswith( + code_assist_module.INVALID_PYTHON_PREFIX), ( + f"invalid source must yield the invalid-Python defect, " + f"got {defect!r}") + return + + defined = top_names & spec['entry_points'] + if spec['require_exactly_one']: + expected_valid = len(defined) == 1 # Requirement 2.3 + else: + expected_valid = len(defined) >= 1 # Requirements 2.2, 5.6 + + assert (defect is None) == expected_valid, ( + f"contract {contract!r} with top-level entry points " + f"{sorted(defined)} (of top-level defs {sorted(top_names)}) must " + f"{'be valid' if expected_valid else 'be a defect'}, got {defect!r}") + + if defect is not None: + # The defect kind matches the violated rule, and a parse-ok module + # never yields the invalid-Python defect. + assert not defect.startswith( + code_assist_module.INVALID_PYTHON_PREFIX) + if not defined: + assert defect.startswith('missing entry point'), ( + f"zero matches must be the missing-entry-point defect, " + f"got {defect!r}") + else: + assert 'both entry points' in defect, ( + f"both process_frame and handle defined under " + f"require_exactly_one must be the both-defined defect, " + f"got {defect!r}") diff --git a/edge-cv-portal/backend/tests/test_property_failure_category_totality.py b/edge-cv-portal/backend/tests/test_property_failure_category_totality.py new file mode 100644 index 00000000..bd8b10f8 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_failure_category_totality.py @@ -0,0 +1,83 @@ +"""Property test for Bedrock failure category totality +(custom-node-code-assist, task 2.6). + +**Feature: custom-node-code-assist, Property 12: Failure category totality** + +_For any_ Bedrock error-code string, `code_assist.categorize_bedrock_error` +SHALL return exactly one of `throttling`, `authorization`, `model-access`, +`model-error`, with each code in the design's mapping table landing in its +designated category and every unlisted code falling through to +`model-error`. + +**Validates: Requirements 5.1** + +`categorize_bedrock_error` is a pure lookup, but importing `code_assist` +pulls in the real `shared_utils` layer, so the module is imported through +the conftest moto stack like the other code-assist backend tests. +""" +import sys + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +# The four Requirement 5.1 failure categories - the categorization's +# entire codomain. +CATEGORIES = frozenset( + ('throttling', 'authorization', 'model-access', 'model-error')) + +# The design's botocore error code -> category mapping table, verbatim. +DESIGN_MAPPING = { + 'ThrottlingException': 'throttling', + 'TooManyRequestsException': 'throttling', + 'ServiceQuotaExceededException': 'throttling', + 'AccessDeniedException': 'authorization', + 'UnrecognizedClientException': 'authorization', + 'ExpiredTokenException': 'authorization', + 'ResourceNotFoundException': 'model-access', + 'ModelNotReadyException': 'model-access', + 'ValidationException': 'model-access', + 'ModelErrorException': 'model-error', + 'ModelTimeoutException': 'model-error', + 'ServiceUnavailableException': 'model-error', + 'InternalServerException': 'model-error', +} + + +@pytest.fixture(scope="module") +def code_assist_module(aws_stack): + """The real code_assist module, imported inside the moto stack so its + module-level `from shared_utils import ...` binds the real layer.""" + sys.modules.pop("code_assist", None) + import code_assist + return code_assist + + +# Arbitrary error-code strings seeded with the known Bedrock codes so the +# designated rows of the mapping table are always exercised alongside +# unlisted codes. +error_codes = st.one_of( + st.sampled_from(sorted(DESIGN_MAPPING)), + st.text(), +) + + +@given(error_code=error_codes) +def test_failure_category_totality(code_assist_module, error_code): + """categorize_bedrock_error returns exactly one of the four + Requirement 5.1 categories for any error-code string, with every code + in the design's mapping table landing in its designated category and + every unlisted code categorized as model-error.""" + category = code_assist_module.categorize_bedrock_error(error_code) + + # Totality: the result is always exactly one of the four categories. + assert category in CATEGORIES, ( + f"categorization of {error_code!r} returned {category!r}, " + f"outside the four Requirement 5.1 categories") + + # Designated rows: listed codes land in their mapped category; + # anything else falls through to model-error. + expected = DESIGN_MAPPING.get(error_code, 'model-error') + assert category == expected, ( + f"error code {error_code!r} must categorize as {expected!r}, " + f"got {category!r}") diff --git a/edge-cv-portal/backend/tests/test_property_gst_base_class_filtering.py b/edge-cv-portal/backend/tests/test_property_gst_base_class_filtering.py new file mode 100644 index 00000000..711e93e4 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_base_class_filtering.py @@ -0,0 +1,211 @@ +"""Property test for Base_Class_Property filtering (task 1.10). + +**Feature: gst-parameter-prepopulation, Property 7: Base-class filtering keeps exactly the element's own properties** + +For any generated element with a mix of properties whose `owner` equals or +differs from the element's GType, `suggestions_for_element` includes every +writable mappable property owned by the element's own GType (including +names that shadow base-class names) and none owned by a different GType. + +Base-class properties are excluded entirely: they appear neither in the +suggestions nor in the skipped list (Requirement 4.1). A property whose +owner IS the element's own GType is kept even when a base class declares a +property of the same name — ownership, not name, decides (Requirement 4.2). + +**Validates: Requirements 4.1, 4.2** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real `suggestions_for_element` +directly. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import List + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + GTYPE_BOOL, + GTYPE_FLOAT, + GTYPE_INT, + GTYPE_STRING, + EnumValue, + GstProperty, + ReportElement, + suggestions_for_element, +) + +# --------------------------------------------------------------------------- +# Generators: elements mixing own-owner and base-owner properties, with +# shadowed names (the same property name declared by both the element's own +# GType and a base GType) so Requirement 4.2's ownership-decides rule is +# exercised, plus non-writable and unmapped-GType properties so the kept +# own properties split across suggestions and skipped. +# --------------------------------------------------------------------------- + +# GType names claimed by the scalar mapping rows; generated GEnum/unmapped +# type names must avoid these so the intended mapping row is exercised. +_SCALAR_GTYPES = frozenset(GTYPE_INT) | frozenset(GTYPE_FLOAT) | {GTYPE_BOOL, GTYPE_STRING} + +# GObject-style class/property names. +_gtype_names = st.from_regex(r'Gst[A-Z][a-zA-Z]{0,20}', fullmatch=True) +_property_names = st.from_regex(r'[a-z][a-z0-9-]{0,24}', fullmatch=True) + +_optional_blurbs = st.one_of(st.none(), st.text(max_size=60)) + +# Defaults do not affect filtering; a small boundary-skewed palette keeps +# both required and optional suggestions in play. +_defaults = st.one_of( + st.none(), + st.just(''), + st.booleans(), + st.integers(min_value=-10**9, max_value=10**9), + st.floats(min_value=-1e9, max_value=1e9, allow_nan=False, allow_infinity=False), + st.text(max_size=20), +) + + +@st.composite +def _gst_props(draw, owner: str) -> GstProperty: + """One property owned by `owner`, drawn across every mapping outcome: + the five mapped rows, an unmapped GType, an empty GEnum, and both + writable states.""" + kind = draw(st.sampled_from( + ['int', 'float', 'bool', 'string', 'enum', 'empty-enum', 'unmapped'])) + writable = draw(st.booleans()) + name = draw(_property_names) + blurb = draw(_optional_blurbs) + default = draw(_defaults) + + if kind == 'int': + lo, hi = sorted((draw(st.integers(-10**9, 10**9)), + draw(st.integers(-10**9, 10**9)))) + return GstProperty(name=name, gtype=draw(st.sampled_from(sorted(GTYPE_INT))), + owner=owner, writable=writable, blurb=blurb, default=default, + min=lo if draw(st.booleans()) else None, + max=hi if draw(st.booleans()) else None) + if kind == 'float': + lo, hi = sorted((draw(st.floats(-1e9, 1e9, allow_nan=False)), + draw(st.floats(-1e9, 1e9, allow_nan=False)))) + return GstProperty(name=name, gtype=draw(st.sampled_from(sorted(GTYPE_FLOAT))), + owner=owner, writable=writable, blurb=blurb, default=default, + min=lo if draw(st.booleans()) else None, + max=hi if draw(st.booleans()) else None) + if kind == 'bool': + return GstProperty(name=name, gtype=GTYPE_BOOL, owner=owner, + writable=writable, blurb=blurb, default=default) + if kind == 'string': + return GstProperty(name=name, gtype=GTYPE_STRING, owner=owner, + writable=writable, blurb=blurb, default=default) + if kind == 'enum': + pairs = draw(st.lists( + st.tuples(st.integers(), st.text(min_size=1, max_size=20)), + min_size=1, max_size=4, + unique_by=(lambda p: p[0], lambda p: p[1]), + )) + return GstProperty(name=name, + gtype=draw(_gtype_names.filter(lambda g: g not in _SCALAR_GTYPES)), + owner=owner, writable=writable, blurb=blurb, default=default, + enum_values=[EnumValue(value=v, nick=n) for v, n in pairs]) + if kind == 'empty-enum': + return GstProperty(name=name, + gtype=draw(_gtype_names.filter(lambda g: g not in _SCALAR_GTYPES)), + owner=owner, writable=writable, blurb=blurb, default=default, + enum_values=[]) + # unmapped: a GType outside the mapping table, no enum values. + return GstProperty(name=name, + gtype=draw(_gtype_names.filter(lambda g: g not in _SCALAR_GTYPES)), + owner=owner, writable=writable, blurb=blurb, default=default) + + +@st.composite +def _elements(draw) -> ReportElement: + """An element with own-owner properties, base-owner properties, and + shadowed names in both directions: base copies of own names (the + subclass overrides a base property — the own pspec must be kept) and + the plain base properties a real element always carries.""" + element_gtype = draw(_gtype_names) + base_gtypes = draw(st.lists( + _gtype_names.filter(lambda g: g != element_gtype), + min_size=1, max_size=3, unique=True)) + base_owner = st.sampled_from(base_gtypes) + + own_props = draw(st.lists(_gst_props(owner=element_gtype), max_size=6)) + base_props = draw(st.lists(_gst_props(owner=draw(base_owner)), max_size=6)) + + # Shadowed names: re-declare a subset of own property names on a base + # GType (and vice versa), so name collisions across owners exist. + shadows: List[GstProperty] = [ + replace(prop, owner=draw(base_owner)) + for prop in own_props if draw(st.booleans()) + ] + shadows += [ + replace(prop, owner=element_gtype) + for prop in base_props if draw(st.booleans()) + ] + + properties = draw(st.permutations(own_props + base_props + shadows)) + return ReportElement( + factory=draw(_property_names), + element_gtype=element_gtype, + instantiation_error=None, + properties=list(properties), + ) + + +# --------------------------------------------------------------------------- +# Oracle: mappability restated independently of map_property — a property +# yields a suggestion iff it is writable and its GType is in the mapping +# table (int/float/bool/string rows) or it is a GEnum with at least one +# enum value; every other own property lands in skipped. +# --------------------------------------------------------------------------- + +def _is_mappable(prop: GstProperty) -> bool: + if not prop.writable: + return False + if prop.gtype in GTYPE_INT or prop.gtype in GTYPE_FLOAT: + return True + if prop.gtype in (GTYPE_BOOL, GTYPE_STRING): + return True + return bool(prop.enum_values) + + +# --------------------------------------------------------------------------- +# Property 7 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(element=_elements()) +def test_base_class_filtering_keeps_exactly_the_elements_own_properties(element): + """**Feature: gst-parameter-prepopulation, Property 7: Base-class filtering keeps exactly the element's own properties** + + The derived suggestions include every writable mappable property whose + owner is the element's own GType — including names shadowing base-class + names (4.2) — and no property owned by a different GType appears in + either the suggestions or the skipped list (4.1). Own-property order is + preserved, and unmappable/non-writable own properties land in skipped. + + **Validates: Requirements 4.1, 4.2** + """ + result = suggestions_for_element(element) + + own_props = [prop for prop in element.properties + if prop.owner == element.element_gtype] + + # Every writable mappable own property yields a suggestion, in order — + # this includes own properties whose names shadow base-class names (4.2). + expected_suggestions = [prop.name for prop in own_props if _is_mappable(prop)] + assert [s['name'] for s in result['suggestions']] == expected_suggestions + + # Every remaining own property (non-writable or unmapped GType) lands + # in skipped with its name, in order. + expected_skipped = [prop.name for prop in own_props if not _is_mappable(prop)] + assert [s['name'] for s in result['skipped']] == expected_skipped + + # Nothing owned by a different GType appears anywhere in the result: + # the output is exactly one entry per own property (4.1) — base-class + # properties are excluded entirely, not skipped-with-reason. + assert len(result['suggestions']) + len(result['skipped']) == len(own_props) diff --git a/edge-cv-portal/backend/tests/test_property_gst_report_rejection.py b/edge-cv-portal/backend/tests/test_property_gst_report_rejection.py new file mode 100644 index 00000000..7942deff --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_report_rejection.py @@ -0,0 +1,255 @@ +"""Property test for malformed Introspection_Report rejection (task 1.3). + +**Feature: gst-parameter-prepopulation, Property 2: Malformed report documents are rejected, not crashed on** + +For any JSON-decodable document that is not a valid version-1 +Introspection_Report — whether arbitrary JSON or a targeted mutation of a +valid report — `parse_report` raises the typed `ReportError` and nothing +else, so callers can map it to the "introspection_failed" unavailability +reason instead of surfacing an internal error. + +**Validates: Requirements 8.3, 1.6** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real parse function directly. +""" + +from __future__ import annotations + +import pytest +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from gst_properties import ( + REPORT_VERSION, + STATUS_CAPTURED, + STATUS_FAILED, + VALID_STATUSES, + EnumValue, + GstProperty, + Report, + ReportElement, + ReportError, + parse_report, + serialize_report, +) + +# --------------------------------------------------------------------------- +# Generators: valid Report dataclass instances (mirrors the round-trip test) +# --------------------------------------------------------------------------- + +# Finite floats only: NaN/inf are not interchange-safe JSON and cannot +# appear in a stored report. +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=st.text(max_size=40), + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +_elements = st.builds( + ReportElement, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=6), +) + +_reports = st.builds( + Report, + status=st.sampled_from((STATUS_CAPTURED, STATUS_FAILED)), + message=_optional_texts, + gst_version=_optional_texts, + captured_at=_optional_texts, + elements=st.lists(_elements, max_size=5), +) + + +# --------------------------------------------------------------------------- +# Generators: arbitrary JSON values +# --------------------------------------------------------------------------- + +_json_values = st.recursive( + st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=20), + ), + lambda children: st.one_of( + st.lists(children, max_size=4), + st.dictionaries(st.text(max_size=12), children, max_size=4), + ), + max_leaves=15, +) + +# Bias some documents toward "almost a report" shapes so the structural +# checks below the top level get exercised too, not just the top-level +# type/version gate. +_near_report_documents = st.fixed_dictionaries( + {}, + optional={ + 'reportVersion': _json_values, + 'status': _json_values, + 'message': _json_values, + 'gstVersion': _json_values, + 'capturedAt': _json_values, + 'elements': _json_values, + }, +) + +_arbitrary_documents = st.one_of(_json_values, _near_report_documents) + + +def _is_valid_report(document) -> bool: + """True when `parse_report` accepts the document. + + Any non-ReportError exception propagates: that in itself is a + violation of Property 2 and fails the test. + """ + try: + parse_report(document) + except ReportError: + return False + return True + + +# --------------------------------------------------------------------------- +# Generators: targeted mutations of valid serialized reports +# --------------------------------------------------------------------------- + +# A JSON object is not accepted for any field of the version-1 report +# schema (every field is a string / bool / number / scalar / array), so +# clobbering any present value with an object guarantees a ReportError. +_CLOBBER = {'not': 'a valid field value'} + +# Keys whose absence makes the enclosing structure invalid. +_REQUIRED_TOP_KEYS = ('reportVersion', 'status') +_REQUIRED_ELEMENT_KEYS = ('factory', 'elementGType') +_REQUIRED_PROPERTY_KEYS = ('name', 'gtype', 'owner', 'writable') + +_bad_versions = st.one_of( + st.sampled_from([0, 2, -1, None, True, '1', str(REPORT_VERSION)]), + st.integers().filter(lambda v: v != REPORT_VERSION), +) + +_bad_statuses = st.one_of( + st.text(max_size=20).filter(lambda s: s not in VALID_STATUSES), + st.sampled_from([None, 3, True, [], {}]), +) + + +@st.composite +def _mutated_documents(draw): + """A valid serialized report broken by exactly one targeted mutation.""" + document = serialize_report(draw(_reports)) + + kinds = [ + 'bad_version', + 'bad_status', + 'drop_top_key', + 'clobber_top_value', + ] + element_indices = list(range(len(document['elements']))) + property_locations = [ + (i, j) + for i, element in enumerate(document['elements']) + for j in range(len(element['properties'])) + ] + if element_indices: + kinds += ['drop_element_key', 'clobber_element_value'] + if property_locations: + kinds += ['drop_property_key', 'clobber_property_value'] + + kind = draw(st.sampled_from(kinds)) + + if kind == 'bad_version': + document['reportVersion'] = draw(_bad_versions) + elif kind == 'bad_status': + document['status'] = draw(_bad_statuses) + elif kind == 'drop_top_key': + del document[draw(st.sampled_from(_REQUIRED_TOP_KEYS))] + elif kind == 'clobber_top_value': + document[draw(st.sampled_from(sorted(document)))] = _CLOBBER + elif kind == 'drop_element_key': + element = document['elements'][draw(st.sampled_from(element_indices))] + del element[draw(st.sampled_from(_REQUIRED_ELEMENT_KEYS))] + elif kind == 'clobber_element_value': + element = document['elements'][draw(st.sampled_from(element_indices))] + element[draw(st.sampled_from(sorted(element)))] = _CLOBBER + elif kind == 'drop_property_key': + i, j = draw(st.sampled_from(property_locations)) + prop = document['elements'][i]['properties'][j] + del prop[draw(st.sampled_from(_REQUIRED_PROPERTY_KEYS))] + else: # clobber_property_value + i, j = draw(st.sampled_from(property_locations)) + prop = document['elements'][i]['properties'][j] + prop[draw(st.sampled_from(sorted(prop)))] = _CLOBBER + + return document + + +# --------------------------------------------------------------------------- +# Property 2 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(document=_arbitrary_documents) +def test_arbitrary_json_is_rejected_with_report_error(document): + """**Feature: gst-parameter-prepopulation, Property 2: Malformed report documents are rejected, not crashed on** + + For arbitrary JSON-decodable input that is not a valid report, + `parse_report` raises `ReportError` — never a TypeError, KeyError, + or any other crash (Requirements 8.3, 1.6). + + **Validates: Requirements 8.3, 1.6** + """ + # Discard the (vanishingly rare) accidental valid report. Any + # non-ReportError exception raised here already fails the test. + assume(not _is_valid_report(document)) + + with pytest.raises(ReportError): + parse_report(document) + + +@settings(max_examples=100, deadline=None) +@given(document=_mutated_documents()) +def test_mutated_valid_reports_are_rejected_with_report_error(document): + """**Feature: gst-parameter-prepopulation, Property 2: Malformed report documents are rejected, not crashed on** + + Any valid report broken by a single targeted mutation — a dropped + required key, a wrong-typed field value at any depth, or an invalid + reportVersion/status — is rejected with `ReportError` and nothing + else (Requirements 8.3, 1.6). + + **Validates: Requirements 8.3, 1.6** + """ + with pytest.raises(ReportError): + parse_report(document) diff --git a/edge-cv-portal/backend/tests/test_property_gst_report_roundtrip.py b/edge-cv-portal/backend/tests/test_property_gst_report_roundtrip.py new file mode 100644 index 00000000..6a6e1914 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_report_roundtrip.py @@ -0,0 +1,119 @@ +"""Property test for the Introspection_Report round-trip (task 1.2). + +**Feature: gst-parameter-prepopulation, Property 1: Introspection report round-trip** + +For any valid Introspection_Report structure, +`parse_report(serialize_report(report))` produces an equivalent report, +and the `serialize_report` output survives a real `json.dumps` / +`json.loads` cycle unchanged. + +**Validates: Requirements 8.1, 8.2** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and +parse/serialize functions directly. +""" + +from __future__ import annotations + +import json + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + STATUS_CAPTURED, + STATUS_FAILED, + EnumValue, + GstProperty, + Report, + ReportElement, + parse_report, + serialize_report, +) + +# --------------------------------------------------------------------------- +# Generators: valid Report dataclass instances +# --------------------------------------------------------------------------- + +# Finite floats only: NaN never compares equal to itself and infinities are +# not interchange-safe JSON, so neither can appear in a stored report. +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +# JSON scalar | None for property defaults. +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +# int | float | None for ranged numeric min/max. +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=st.text(max_size=40), + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +_elements = st.builds( + ReportElement, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=6), +) + +_reports = st.builds( + Report, + status=st.sampled_from((STATUS_CAPTURED, STATUS_FAILED)), + message=_optional_texts, + gst_version=_optional_texts, + captured_at=_optional_texts, + elements=st.lists(_elements, max_size=5), +) + + +# --------------------------------------------------------------------------- +# Property 1 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(report=_reports) +def test_report_round_trip_through_json(report): + """**Feature: gst-parameter-prepopulation, Property 1: Introspection report round-trip** + + For any valid report, serialization followed by a real + `json.dumps`/`json.loads` cycle and re-parsing reproduces an equal + Report, and the JSON cycle leaves the serialized document unchanged + (Requirements 8.1, 8.2). + + **Validates: Requirements 8.1, 8.2** + """ + document = serialize_report(report) + + # The serialized form survives a real JSON dump/load cycle unchanged + # (8.2): what the build uploads is byte-for-byte re-interpretable. + recovered_document = json.loads(json.dumps(document)) + assert recovered_document == document + + # parse_report is the inverse of serialize_report through that cycle + # (8.1): the served report equals the captured one. + assert parse_report(recovered_document) == report diff --git a/edge-cv-portal/backend/tests/test_property_gst_required_classification.py b/edge-cv-portal/backend/tests/test_property_gst_required_classification.py new file mode 100644 index 00000000..5a0d930f --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_required_classification.py @@ -0,0 +1,263 @@ +"""Property test for required/optional classification in the Type_Mapping (task 1.7). + +**Feature: gst-parameter-prepopulation, Property 6: Required classification follows the usable-default rule** + +For any generated GStreamer_Property that maps to a Parameter_Suggestion, +the suggestion is required exactly when the property lacks a usable +default for its mapped paramType (Requirement 3.1): + + - int: default is an int (bools rejected) or an integral float, + within the property's declared min/max; + - float: default is an int or float (bools rejected), within min/max; + - bool: default is a bool; + - string: default is a non-NULL string, non-empty after strip + (null / empty / whitespace-only defaults are unusable); + - enum: default is a string matching one of the enum's nicks, or an + int matching one of the enum's values. + +When optional, the suggestion carries the property default (converted to +the mapped paramType) as the declaration default; when required, it +carries no default at all (Requirement 3.2). + +**Validates: Requirements 3.1, 3.2** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real `map_property` directly. +""" + +from __future__ import annotations + +from typing import Optional + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + GTYPE_BOOL, + GTYPE_FLOAT, + GTYPE_INT, + GTYPE_STRING, + EnumValue, + GstProperty, + Skipped, + map_property, +) + +# --------------------------------------------------------------------------- +# Generators: writable properties over the mapped GType set, with defaults +# skewed toward the classification boundaries — absent defaults, null/empty/ +# whitespace-only string defaults, wrong-typed defaults, and out-of-range +# numeric defaults (Requirement 3.1's "no usable default" cases). +# --------------------------------------------------------------------------- + +# GType names claimed by the scalar mapping rows; generated GEnum type names +# must avoid these so the enum row is the one exercised. +_SCALAR_GTYPES = frozenset(GTYPE_INT) | frozenset(GTYPE_FLOAT) | {GTYPE_BOOL, GTYPE_STRING} + +_names = st.text(min_size=1, max_size=30) +_optional_blurbs = st.one_of(st.none(), st.text(max_size=60)) + +# Strings that are unusable as a string/enum default: empty or whitespace-only. +_blankish_strings = st.one_of( + st.just(''), + st.text(alphabet=' \t\n\r', min_size=1, max_size=6), +) + +# The full default palette: null, blank-ish strings, and every JSON scalar +# shape — deliberately including wrong-typed values for each paramType. +_boundary_defaults = st.one_of( + st.none(), + _blankish_strings, + st.booleans(), + st.integers(min_value=-10**13, max_value=10**13), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=20), +) + + +@st.composite +def _numeric_props(draw, gtypes, bound_values): + """A writable int/float-row property with a random one-/two-sided range + and a boundary-skewed default (possibly out of range or wrong-typed).""" + lo, hi = sorted((draw(bound_values), draw(bound_values))) + return GstProperty( + name=draw(_names), + gtype=draw(gtypes), + owner=draw(_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_boundary_defaults), + min=lo if draw(st.booleans()) else None, + max=hi if draw(st.booleans()) else None, + enum_values=None, + ) + + +_int_props = _numeric_props( + gtypes=st.sampled_from(sorted(GTYPE_INT)), + bound_values=st.integers(min_value=-10**12, max_value=10**12), +) + +_float_props = _numeric_props( + gtypes=st.sampled_from(sorted(GTYPE_FLOAT)), + bound_values=st.floats(min_value=-1e12, max_value=1e12, + allow_nan=False, allow_infinity=False), +) + + +@st.composite +def _scalar_props(draw, gtype): + """A writable bool/string-row property with a boundary-skewed default.""" + return GstProperty( + name=draw(_names), + gtype=gtype, + owner=draw(_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_boundary_defaults), + min=None, + max=None, + enum_values=None, + ) + + +@st.composite +def _enum_props(draw): + """A writable GEnum-shaped property. Defaults mix matching nicks, + matching values, and boundary-skewed misses (null, blank strings, + non-matching strings/ints, bools, floats).""" + gtype = draw(_names.filter(lambda g: g not in _SCALAR_GTYPES)) + pairs = draw(st.lists( + st.tuples(st.integers(), st.text(min_size=1, max_size=20)), + min_size=1, max_size=6, + unique_by=(lambda p: p[0], lambda p: p[1]), + )) + enum_values = [EnumValue(value=v, nick=n) for v, n in pairs] + + default = draw(st.one_of( + _boundary_defaults, + st.sampled_from([ev.nick for ev in enum_values]), + st.sampled_from([ev.value for ev in enum_values]), + )) + + return GstProperty(name=draw(_names), gtype=gtype, owner=draw(_names), + writable=True, blurb=draw(_optional_blurbs), + default=default, min=None, max=None, + enum_values=enum_values) + + +_mappable_props = st.one_of( + _int_props, + _float_props, + _scalar_props(GTYPE_BOOL), + _scalar_props(GTYPE_STRING), + _enum_props(), +) + + +# --------------------------------------------------------------------------- +# Oracle: the usable-default rule of Requirement 3.1, restated independently +# of the implementation. Returns the converted declaration default, or None +# when the property has no usable default for its mapped paramType. +# --------------------------------------------------------------------------- + +def _expected_param_type(prop: GstProperty) -> str: + if prop.gtype in GTYPE_INT: + return 'int' + if prop.gtype in GTYPE_FLOAT: + return 'float' + if prop.gtype == GTYPE_BOOL: + return 'bool' + if prop.gtype == GTYPE_STRING: + return 'string' + return 'enum' + + +def _in_range(value, prop: GstProperty) -> bool: + if prop.min is not None and value < prop.min: + return False + if prop.max is not None and value > prop.max: + return False + return True + + +def _usable_default(prop: GstProperty, param_type: str): + default = prop.default + if default is None: + return None + if param_type == 'int': + # An int, or an integral float; bools are not ints; must respect + # the property's own declared range. + if isinstance(default, bool): + return None + if isinstance(default, int): + return default if _in_range(default, prop) else None + if isinstance(default, float) and default.is_integer(): + converted = int(default) + return converted if _in_range(converted, prop) else None + return None + if param_type == 'float': + if isinstance(default, bool) or not isinstance(default, (int, float)): + return None + converted = float(default) + return converted if _in_range(converted, prop) else None + if param_type == 'bool': + return default if isinstance(default, bool) else None + if param_type == 'string': + # NULL, empty, or whitespace-only string defaults are unusable. + if isinstance(default, str) and default.strip(): + return default + return None + # enum: a string default must match a nick; an integer default is + # resolved to the nick of the matching enum value. + if isinstance(default, str): + for entry in prop.enum_values: + if entry.nick == default: + return entry.nick + return None + if isinstance(default, int) and not isinstance(default, bool): + for entry in prop.enum_values: + if entry.value == default: + return entry.nick + return None + + +# --------------------------------------------------------------------------- +# Property 6 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(prop=_mappable_props) +def test_required_classification_follows_the_usable_default_rule(prop): + """**Feature: gst-parameter-prepopulation, Property 6: Required classification follows the usable-default rule** + + For any writable property with a mapped GType, the produced + Parameter_Suggestion is required exactly when the property lacks a + usable default for its mapped paramType (3.1); when optional, it + carries the property default, converted to the paramType, as the + declaration default (3.2); when required, it carries no default. + + **Validates: Requirements 3.1, 3.2** + """ + result = map_property(prop) + + # Mappable input: a suggestion is always produced. + assert not isinstance(result, Skipped) + assert isinstance(result, dict) + + param_type = _expected_param_type(prop) + assert result['paramType'] == param_type + + expected_default = _usable_default(prop, param_type) + + if expected_default is None: + # 3.1: no usable default => required, and no declaration default. + assert result['required'] is True + assert 'default' not in result + else: + # 3.2: usable default => optional, carrying the converted default. + assert result['required'] is False + assert 'default' in result + assert result['default'] == expected_default + # Converted to the mapped paramType, not merely equal in value. + assert type(result['default']) is type(expected_default) diff --git a/edge-cv-portal/backend/tests/test_property_gst_skipped_properties.py b/edge-cv-portal/backend/tests/test_property_gst_skipped_properties.py new file mode 100644 index 00000000..4658b2ed --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_skipped_properties.py @@ -0,0 +1,176 @@ +"""Property test for skipped properties in the Type_Mapping (task 1.6). + +**Feature: gst-parameter-prepopulation, Property 4: Unknown or non-writable properties are always skipped with a reason** + +For any generated GStreamer_Property that has an unmapped GType or is not +writable, `map_property` yields a skipped entry carrying the property name +and a non-empty reason, and yields no Parameter_Suggestion. + +Three skip causes per `map_property` (Requirement 2.5): + - the property is not writable (regardless of GType); + - the GType has no mapping row: not an int/float GType, not gboolean or + gchararray, and no enum values are declared; + - a GEnum-shaped property whose enum value list is empty (nothing to + offer as allowed values). + +**Validates: Requirements 2.5** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real `map_property` directly. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + GTYPE_BOOL, + GTYPE_FLOAT, + GTYPE_INT, + GTYPE_STRING, + EnumValue, + GstProperty, + Skipped, + map_property, +) + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# All GTypes the mapping table can convert via its scalar rows; a writable +# property with a non-empty enum value list also maps (to enum) regardless +# of its GType name, so "unmapped" additionally requires no enum values. +_SCALAR_GTYPES = frozenset(GTYPE_INT) | frozenset(GTYPE_FLOAT) | {GTYPE_BOOL, GTYPE_STRING} + +_names = st.text(min_size=1, max_size=30) +_optional_blurbs = st.one_of(st.none(), st.text(max_size=60)) + +# Arbitrary JSON-scalar-or-null defaults; skipping must not depend on them. +_arbitrary_defaults = st.one_of( + st.none(), + st.booleans(), + st.integers(), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=20), +) + +# Realistic unmapped GType names from Requirement 2.5's examples, mixed with +# arbitrary generated type names that avoid the scalar mapping rows. +_KNOWN_UNMAPPED = ('GstCaps', 'GstStructure', 'GstFraction', 'GstBuffer', + 'GObject', 'GstObject', 'gpointer', 'GValueArray') +_unmapped_gtypes = st.one_of( + st.sampled_from(_KNOWN_UNMAPPED), + _names.filter(lambda g: g not in _SCALAR_GTYPES), +) + +# Any GType at all: mapped scalar rows, known-unmapped, or arbitrary. +_any_gtypes = st.one_of( + st.sampled_from(sorted(_SCALAR_GTYPES)), + st.sampled_from(_KNOWN_UNMAPPED), + _names, +) + +_optional_enum_values = st.one_of( + st.none(), + st.lists( + st.builds(EnumValue, + value=st.integers(), + nick=st.text(min_size=1, max_size=20)), + max_size=4, + ), +) + + +@st.composite +def _optional_range(draw): + """A random one-/two-sided numeric range (or none at all).""" + lo, hi = sorted((draw(st.integers(min_value=-10**9, max_value=10**9)), + draw(st.integers(min_value=-10**9, max_value=10**9)))) + return (lo if draw(st.booleans()) else None, + hi if draw(st.booleans()) else None) + + +@st.composite +def _non_writable_props(draw): + """Not writable: skipped regardless of GType, enum values, or default.""" + lo, hi = draw(_optional_range()) + return GstProperty( + name=draw(_names), + gtype=draw(_any_gtypes), + owner=draw(_names), + writable=False, + blurb=draw(_optional_blurbs), + default=draw(_arbitrary_defaults), + min=lo, + max=hi, + enum_values=draw(_optional_enum_values), + ) + + +@st.composite +def _unmapped_writable_props(draw): + """Writable, but the GType has no mapping row and no enum values.""" + lo, hi = draw(_optional_range()) + return GstProperty( + name=draw(_names), + gtype=draw(_unmapped_gtypes), + owner=draw(_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_arbitrary_defaults), + min=lo, + max=hi, + enum_values=None, + ) + + +@st.composite +def _empty_enum_writable_props(draw): + """Writable GEnum-shaped property declaring an empty enum value list.""" + return GstProperty( + name=draw(_names), + gtype=draw(_unmapped_gtypes), + owner=draw(_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_arbitrary_defaults), + min=None, + max=None, + enum_values=[], + ) + + +_skippable_props = st.one_of( + _non_writable_props(), + _unmapped_writable_props(), + _empty_enum_writable_props(), +) + + +# --------------------------------------------------------------------------- +# Property 4 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(prop=_skippable_props) +def test_unmapped_or_non_writable_properties_are_skipped_with_a_reason(prop): + """**Feature: gst-parameter-prepopulation, Property 4: Unknown or non-writable properties are always skipped with a reason** + + For any property with an unmapped GType or `writable == False`, + `map_property` returns a Skipped entry (never a Parameter_Suggestion) + carrying the property's name and a non-empty reason (Requirement 2.5). + + **Validates: Requirements 2.5** + """ + result = map_property(prop) + + # No Parameter_Suggestion is ever produced for a skippable property. + assert isinstance(result, Skipped) + assert not isinstance(result, dict) + + # The skipped entry carries the property name and a non-empty reason. + assert result.name == prop.name + assert isinstance(result.reason, str) + assert result.reason.strip() diff --git a/edge-cv-portal/backend/tests/test_property_gst_suggestion_validation.py b/edge-cv-portal/backend/tests/test_property_gst_suggestion_validation.py new file mode 100644 index 00000000..0db15622 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_suggestion_validation.py @@ -0,0 +1,237 @@ +"""Property test: every Parameter_Suggestion passes declaration validation (task 1.9). + +**Feature: gst-parameter-prepopulation, Property 5: Every suggestion passes declaration validation** + +For any generated GStreamer_Property that maps to a Parameter_Suggestion, +the suggestion satisfies the declaration validation rules: non-empty name, +non-empty description (blurb or synthesized fallback), at least one example +value valid for the paramType, non-empty `values` for enum, and min <= max +when both are present. + +The cross-check is against `workflow_core.catalog.custom`'s REAL parameter +validation (not a re-implementation): each suggestion is embedded as the +sole parameter of a minimal Custom_Node_Type declaration and fed through +`descriptor_from_declaration`, which enforces exactly the rules above — +non-empty name/description, constraint satisfiability (min <= max, +non-empty enum `values`), at least one example, and defaults/examples +checked against the parameter's own type and constraints via +`check_parameter_value`. Acceptance (no `DeclarationError`) is the +property. + +**Validates: Requirements 2.4, 2.6** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures; `workflow_core` is on sys.path via the layer path conftest adds. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + GTYPE_BOOL, + GTYPE_FLOAT, + GTYPE_INT, + GTYPE_STRING, + EnumValue, + GstProperty, + Skipped, + map_property, +) +from workflow_core.catalog.custom import DeclarationError, descriptor_from_declaration +from workflow_core.catalog.models import CATEGORIES + +# --------------------------------------------------------------------------- +# Generators: writable properties over the mapped GType set. +# +# Names are realistic GStreamer property names (launch-safe identifiers, +# never blank) — GObject property names cannot be empty or whitespace, so +# generating blank names would leave the realistic input space. Defaults +# are boundary-skewed (absent, blank strings, wrong-typed, out-of-range) +# so both branches of Requirement 2.6 are exercised: carried defaults used +# as examples, and synthesized examples for required suggestions. +# --------------------------------------------------------------------------- + +# GType names claimed by the scalar mapping rows; generated GEnum type names +# must avoid these so the enum row is the one exercised. +_SCALAR_GTYPES = frozenset(GTYPE_INT) | frozenset(GTYPE_FLOAT) | {GTYPE_BOOL, GTYPE_STRING} + +# GObject-style property names: non-empty, letter first, dashes allowed. +_property_names = st.from_regex(r'[a-z][a-z0-9-]{0,24}', fullmatch=True) + +# Blurbs include None, blank, and whitespace-only so the synthesized +# description fallback path (Requirement 2.4) is exercised. +_optional_blurbs = st.one_of( + st.none(), + st.just(''), + st.text(alphabet=' \t\n', min_size=1, max_size=4), + st.text(min_size=1, max_size=60), +) + +# The full default palette: null, blank-ish strings, and every JSON scalar +# shape — deliberately including wrong-typed values for each paramType, so +# many generated properties come out required with synthesized examples. +_boundary_defaults = st.one_of( + st.none(), + st.just(''), + st.text(alphabet=' \t\n', min_size=1, max_size=4), + st.booleans(), + st.integers(min_value=-10**13, max_value=10**13), + st.floats(allow_nan=False, allow_infinity=False), + st.text(max_size=20), +) + + +@st.composite +def _numeric_props(draw, gtypes, bound_values): + """A writable int/float-row property with a random one-/two-sided range + (min <= max when both survive) and a boundary-skewed default.""" + lo, hi = sorted((draw(bound_values), draw(bound_values))) + return GstProperty( + name=draw(_property_names), + gtype=draw(gtypes), + owner=draw(_property_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_boundary_defaults), + min=lo if draw(st.booleans()) else None, + max=hi if draw(st.booleans()) else None, + enum_values=None, + ) + + +_int_props = _numeric_props( + gtypes=st.sampled_from(sorted(GTYPE_INT)), + bound_values=st.integers(min_value=-10**12, max_value=10**12), +) + +_float_props = _numeric_props( + gtypes=st.sampled_from(sorted(GTYPE_FLOAT)), + bound_values=st.floats(min_value=-1e12, max_value=1e12, + allow_nan=False, allow_infinity=False), +) + + +@st.composite +def _scalar_props(draw, gtype): + """A writable bool/string-row property with a boundary-skewed default.""" + return GstProperty( + name=draw(_property_names), + gtype=gtype, + owner=draw(_property_names), + writable=True, + blurb=draw(_optional_blurbs), + default=draw(_boundary_defaults), + min=None, + max=None, + enum_values=None, + ) + + +@st.composite +def _enum_props(draw): + """A writable GEnum-shaped property with at least one enum value. + Defaults mix matching nicks, matching values, and boundary misses.""" + gtype = draw(st.from_regex(r'Gst[A-Z][a-zA-Z]{0,20}', fullmatch=True) + .filter(lambda g: g not in _SCALAR_GTYPES)) + pairs = draw(st.lists( + st.tuples(st.integers(), st.text(min_size=1, max_size=20)), + min_size=1, max_size=6, + unique_by=(lambda p: p[0], lambda p: p[1]), + )) + enum_values = [EnumValue(value=v, nick=n) for v, n in pairs] + + default = draw(st.one_of( + _boundary_defaults, + st.sampled_from([ev.nick for ev in enum_values]), + st.sampled_from([ev.value for ev in enum_values]), + )) + + return GstProperty(name=draw(_property_names), gtype=gtype, + owner=draw(_property_names), writable=True, + blurb=draw(_optional_blurbs), + default=default, min=None, max=None, + enum_values=enum_values) + + +_mappable_props = st.one_of( + _int_props, + _float_props, + _scalar_props(GTYPE_BOOL), + _scalar_props(GTYPE_STRING), + _enum_props(), +) + + +# --------------------------------------------------------------------------- +# The real validator: a suggestion is valid iff a minimal Custom_Node_Type +# declaration carrying it as its sole parameter converts without error. +# --------------------------------------------------------------------------- + +def _declaration_wrapping(suggestion): + """Embed one Parameter_Suggestion in a minimal, otherwise-valid + Custom_Node_Type declaration in the node-catalog wire shape.""" + return { + 'typeId': 'custom.gst_suggestion_check', + 'displayName': 'GST suggestion check', + 'category': CATEGORIES[0], + 'inputs': [], + 'outputs': [], + 'parameters': [suggestion], + 'mappings': [], + } + + +# --------------------------------------------------------------------------- +# Property 5 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(prop=_mappable_props) +def test_every_suggestion_passes_declaration_validation(prop): + """**Feature: gst-parameter-prepopulation, Property 5: Every suggestion passes declaration validation** + + For any writable property with a mapped GType, the produced + Parameter_Suggestion satisfies the declaration validation rules — + non-empty name, non-empty description (2.4), at least one example + valid for the paramType (2.6), non-empty `values` for enum, and + min <= max when both present — as judged by + `workflow_core.catalog.custom`'s real parameter validation + (`descriptor_from_declaration`), not a re-implementation. + + **Validates: Requirements 2.4, 2.6** + """ + result = map_property(prop) + + # Mappable, writable input: a suggestion is produced, never Skipped. + assert not isinstance(result, Skipped) + assert isinstance(result, dict) + + # The stated invariants, asserted directly for diagnosability. + assert isinstance(result['name'], str) and result['name'].strip() + assert isinstance(result['description'], str) and result['description'].strip() + assert isinstance(result['examples'], list) and len(result['examples']) >= 1 + assert all(example is not None for example in result['examples']) + constraints = result.get('constraints', {}) + if result['paramType'] == 'enum': + assert constraints.get('values'), 'enum suggestions must carry non-empty values' + if 'min' in constraints and 'max' in constraints: + assert constraints['min'] <= constraints['max'] + + # The cross-check: workflow_core.catalog.custom's real validator + # accepts the suggestion, including check_parameter_value on the + # default and every example. + try: + descriptor = descriptor_from_declaration(_declaration_wrapping(result)) + except DeclarationError as error: + raise AssertionError( + f'suggestion rejected by catalog.custom declaration validation: ' + f'{error} (suggestion={result!r}, property={prop!r})' + ) from error + + # The descriptor round-trips the suggestion's identity fields. + (parameter,) = descriptor.parameters + assert parameter.name == result['name'] + assert parameter.param_type == result['paramType'] + assert parameter.required == result['required'] diff --git a/edge-cv-portal/backend/tests/test_property_gst_type_mapping.py b/edge-cv-portal/backend/tests/test_property_gst_type_mapping.py new file mode 100644 index 00000000..e4e6ff84 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_gst_type_mapping.py @@ -0,0 +1,271 @@ + +"""Property test for the GType -> parameter Type_Mapping (task 1.5). + +**Feature: gst-parameter-prepopulation, Property 3: Type mapping is total and correctly typed over writable known GTypes** + +For any generated GStreamer_Property whose GType is in the known mapping +set and which is writable, `map_property` produces a Parameter_Suggestion +(never a Skipped entry) whose `paramType` matches the GType class per the +mapping table (int/float/bool/string/enum), whose `constraints` carry the +property's min/max when the property is ranged and the enum nicks when it +is a GEnum, and whose `default`, when present, is the property's declared +default converted to the mapped paramType. + +**Validates: Requirements 2.1, 2.2, 2.3** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real `map_property` directly. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + GTYPE_BOOL, + GTYPE_FLOAT, + GTYPE_INT, + GTYPE_STRING, + EnumValue, + GstProperty, + Skipped, + map_property, +) + +# --------------------------------------------------------------------------- +# Generators: writable properties over the mapped GType set +# +# Each strategy yields (prop, expected_param_type, expected_default) where +# expected_default is: +# ('known', value) - the generator constructed a definitely-usable (or +# definitely-absent, value=None) default, so the exact +# carried default is asserted (Requirement 2.3); +# ('unknown',) - the default was drawn arbitrarily (possibly +# unconvertible or out of range), so only the typed +# correctness of whatever default appears is asserted. +# --------------------------------------------------------------------------- + +# All GTypes the scalar rows of the mapping table claim (Requirement 2.1); +# generated GEnum type names must avoid these so the enum row is exercised. +_SCALAR_GTYPES = frozenset(GTYPE_INT) | frozenset(GTYPE_FLOAT) | {GTYPE_BOOL, GTYPE_STRING} + +_names = st.text(min_size=1, max_size=30) +_optional_blurbs = st.one_of(st.none(), st.text(max_size=60)) + +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +# Arbitrary JSON-scalar-or-null defaults (may be unusable for the paramType). +_arbitrary_defaults = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=20), +) + + +@st.composite +def _ranged_numeric_cases(draw, gtypes, bound_values, usable_defaults): + """Shared shape for the int and float rows: random one- or two-sided + ranges (min <= max when both present) and none/usable/arbitrary + defaults.""" + gtype = draw(gtypes) + lo_full, hi_full = sorted((draw(bound_values), draw(bound_values))) + lo = lo_full if draw(st.booleans()) else None + hi = hi_full if draw(st.booleans()) else None + + mode = draw(st.sampled_from(('none', 'usable', 'arbitrary'))) + if mode == 'none': + default, expected = None, ('known', None) + elif mode == 'usable': + # Drawn within [lo_full, hi_full], so it satisfies whichever bounds + # survived above. + default = draw(usable_defaults(lo_full, hi_full)) + expected = ('known', default) + else: + default, expected = draw(_arbitrary_defaults), ('unknown',) + + prop = GstProperty(name=draw(_names), gtype=gtype, owner=draw(_names), + writable=True, blurb=draw(_optional_blurbs), + default=default, min=lo, max=hi, enum_values=None) + return prop, expected + + +_int_cases = _ranged_numeric_cases( + gtypes=st.sampled_from(sorted(GTYPE_INT)), + bound_values=st.integers(min_value=-10**12, max_value=10**12), + usable_defaults=lambda lo, hi: st.integers(min_value=lo, max_value=hi), +) + +_float_cases = _ranged_numeric_cases( + gtypes=st.sampled_from(sorted(GTYPE_FLOAT)), + bound_values=st.floats(min_value=-1e12, max_value=1e12, + allow_nan=False, allow_infinity=False), + usable_defaults=lambda lo, hi: st.floats(min_value=lo, max_value=hi, + allow_nan=False, allow_infinity=False), +) + + +@st.composite +def _bool_cases(draw): + mode = draw(st.sampled_from(('none', 'usable', 'arbitrary'))) + if mode == 'none': + default, expected = None, ('known', None) + elif mode == 'usable': + default = draw(st.booleans()) + expected = ('known', default) + else: + default, expected = draw(_arbitrary_defaults), ('unknown',) + prop = GstProperty(name=draw(_names), gtype=GTYPE_BOOL, owner=draw(_names), + writable=True, blurb=draw(_optional_blurbs), + default=default, min=None, max=None, enum_values=None) + return prop, expected + + +@st.composite +def _string_cases(draw): + mode = draw(st.sampled_from(('none', 'usable', 'arbitrary'))) + if mode == 'none': + default, expected = None, ('known', None) + elif mode == 'usable': + # Usable string default: non-NULL, non-empty after strip (3.1). + default = draw(st.text(min_size=1, max_size=30).filter(lambda s: s.strip())) + expected = ('known', default) + else: + default, expected = draw(_arbitrary_defaults), ('unknown',) + prop = GstProperty(name=draw(_names), gtype=GTYPE_STRING, owner=draw(_names), + writable=True, blurb=draw(_optional_blurbs), + default=default, min=None, max=None, enum_values=None) + return prop, expected + + +@st.composite +def _enum_cases(draw): + # A GEnum type name never collides with the scalar mapping rows. + gtype = draw(_names.filter(lambda g: g not in _SCALAR_GTYPES)) + pairs = draw(st.lists( + st.tuples(st.integers(), st.text(min_size=1, max_size=20)), + min_size=1, max_size=6, + unique_by=(lambda p: p[0], lambda p: p[1]), + )) + enum_values = [EnumValue(value=v, nick=n) for v, n in pairs] + + mode = draw(st.sampled_from(('none', 'usable_nick', 'usable_value', 'arbitrary'))) + if mode == 'none': + default, expected = None, ('known', None) + elif mode == 'usable_nick': + entry = draw(st.sampled_from(enum_values)) + default, expected = entry.nick, ('known', entry.nick) + elif mode == 'usable_value': + entry = draw(st.sampled_from(enum_values)) + default, expected = entry.value, ('known', entry.nick) + else: + default, expected = draw(_arbitrary_defaults), ('unknown',) + + prop = GstProperty(name=draw(_names), gtype=gtype, owner=draw(_names), + writable=True, blurb=draw(_optional_blurbs), + default=default, min=None, max=None, + enum_values=enum_values) + return prop, expected + + +_mapped_cases = st.one_of( + st.tuples(_int_cases, st.just('int')), + st.tuples(_float_cases, st.just('float')), + st.tuples(_bool_cases(), st.just('bool')), + st.tuples(_string_cases(), st.just('string')), + st.tuples(_enum_cases(), st.just('enum')), +) + + +def _assert_default_typed_correctly(suggestion, prop, param_type): + """Whatever default the suggestion carries must be the property's + declared default converted to the mapped paramType (Requirement 2.3).""" + default = suggestion['default'] + if param_type == 'int': + assert isinstance(default, int) and not isinstance(default, bool) + assert default == prop.default # numeric equality (e.g. 5 == 5.0) + if prop.min is not None: + assert default >= prop.min + if prop.max is not None: + assert default <= prop.max + elif param_type == 'float': + assert isinstance(default, float) + assert default == prop.default + if prop.min is not None: + assert default >= prop.min + if prop.max is not None: + assert default <= prop.max + elif param_type == 'bool': + assert isinstance(default, bool) + assert default is prop.default + elif param_type == 'string': + assert isinstance(default, str) and default.strip() + assert default == prop.default + else: # enum: the carried default is a nick resolved from the declared one + nicks = [ev.nick for ev in prop.enum_values] + assert default in nicks + assert any(ev.nick == default and prop.default in (ev.nick, ev.value) + for ev in prop.enum_values) + + +# --------------------------------------------------------------------------- +# Property 3 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(case=_mapped_cases) +def test_mapping_is_total_and_correctly_typed_over_writable_known_gtypes(case): + """**Feature: gst-parameter-prepopulation, Property 3: Type mapping is total and correctly typed over writable known GTypes** + + For any writable property with a mapped GType, `map_property` yields a + Parameter_Suggestion with the paramType of the mapping table row (2.1), + constraints carrying the declared min/max range or the enum nicks + (2.2), and a default that, when present, is the declared default + converted to the mapped paramType (2.3). + + **Validates: Requirements 2.1, 2.2, 2.3** + """ + (prop, expected_default), expected_type = case + + result = map_property(prop) + + # Total over writable known GTypes: always a suggestion, never Skipped. + assert not isinstance(result, Skipped) + assert isinstance(result, dict) + assert result['name'] == prop.name + + # 2.1: paramType per the mapping table row. + assert result['paramType'] == expected_type + + # 2.2: constraints carry the declared range for ranged numerics and the + # nick list for GEnums; the other rows carry no constraints. + if expected_type in ('int', 'float'): + expected_constraints = {} + if prop.min is not None: + expected_constraints['min'] = prop.min + if prop.max is not None: + expected_constraints['max'] = prop.max + if expected_constraints: + assert result['constraints'] == expected_constraints + else: + assert 'constraints' not in result + elif expected_type == 'enum': + assert result['constraints'] == {'values': [ev.nick for ev in prop.enum_values]} + else: + assert 'constraints' not in result + + # 2.3: default conversion. A definitely-usable generated default is + # carried (converted for enum value defaults); an absent default stays + # absent; any carried default is typed for the mapped paramType and is + # used as the example value. + if expected_default[0] == 'known': + if expected_default[1] is None: + assert 'default' not in result + else: + assert result['default'] == expected_default[1] + if 'default' in result: + _assert_default_typed_correctly(result, prop, expected_type) + assert result['examples'] == [result['default']] + assert result['required'] == ('default' not in result) diff --git a/edge-cv-portal/backend/tests/test_property_import_provenance.py b/edge-cv-portal/backend/tests/test_property_import_provenance.py new file mode 100644 index 00000000..4ac9bc90 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_import_provenance.py @@ -0,0 +1,266 @@ +"""Property test for import provenance (task 4.3). + +**Feature: custom-node-designer, Property 7: Import provenance records the classification** + +For all import sources (Module_Listing selections and arbitrary +repository URLs), the created Plugin_Record's provenance contains +repository URL, revision, importing user, retrieval timestamp, and a +classification equal to ``classify_plugin_set`` applied to that source. + +**Validates: Requirements 4.2, 15.5** + +The function under test (`plugin_importer.import_provenance`) is pure, +so the property is exercised directly with no AWS involvement. The +module is imported through the shared moto-backed session fixture only +so the real `shared_utils` layer (not a test fake) backs the import. + +The source generators restate the official plugin-set ground truth from +the requirements (15.1) - mirroring the workflow_core classification +property test (task 1.5) - so the test cannot silently agree with a +wrong classification table. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from workflow_core.catalog.classification import ( + CLASSIFICATION_BAD, + CLASSIFICATION_GOOD, + CLASSIFICATION_UGLY, + CLASSIFICATION_UNCLASSIFIED, + classify_plugin_set, +) + + +@pytest.fixture(scope="session") +def importer(aws_stack): + """The real plugin_importer module, imported via the session stack.""" + return aws_stack.plugin_importer + + +# --------------------------------------------------------------------------- +# Ground truth: the official plugin-set module names and their expected +# classifications, restated here from the requirements (15.1). +# --------------------------------------------------------------------------- + +OFFICIAL_MODULES = { + "gst-plugins-good": CLASSIFICATION_GOOD, + "gst-plugins-bad": CLASSIFICATION_BAD, + "gst-plugins-ugly": CLASSIFICATION_UGLY, +} + +official_module_names = st.sampled_from(sorted(OFFICIAL_MODULES)) + +_segment_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-_." + + +def _strip_git(segment): + return segment[: -len(".git")] if segment.endswith(".git") else segment + + +#: URL path segments guaranteed not to be an official plugin-set name, +#: even after the classifier strips a ``.git`` suffix. +_safe_segment = st.text( + alphabet=_segment_alphabet, min_size=1, max_size=25 +).filter(lambda s: _strip_git(s) not in OFFICIAL_MODULES and s != ".git") + + +# --------------------------------------------- official import sources + +@st.composite +def official_urls(draw): + """(repo_url, expected_classification) for a known official location.""" + module = draw(official_module_names) + expected = OFFICIAL_MODULES[module] + shape = draw(st.sampled_from(["gitlab", "monorepo", "legacy", "release"])) + if shape == "gitlab": + suffix = draw(st.sampled_from(["", ".git", "/"])) + url = "https://gitlab.freedesktop.org/gstreamer/%s%s" % (module, suffix) + elif shape == "monorepo": + branch = draw(st.sampled_from(["main", "1.22", "discontinued-for-monorepo"])) + url = ( + "https://gitlab.freedesktop.org/gstreamer/gstreamer/-/tree/" + "%s/subprojects/%s" % (branch, module) + ) + elif shape == "legacy": + scheme = draw(st.sampled_from(["https", "http", "git"])) + host = draw(st.sampled_from( + ["cgit.freedesktop.org", "anongit.freedesktop.org"])) + suffix = draw(st.sampled_from(["", "/"])) + url = "%s://%s/gstreamer/%s%s" % (scheme, host, module, suffix) + else: # release + url = "https://gstreamer.freedesktop.org/src/%s/" % module + return url, expected + + +@st.composite +def official_sources(draw): + """(module_name, repo_url, expected) where the source is official + either by Module_Listing selection (module name) or by known + repository location.""" + if draw(st.booleans()): + # Module_Listing selection: the official module name identifies + # the set even when the accompanying URL is a non-official one. + module = draw(official_module_names) + url = draw(st.one_of(arbitrary_urls(), official_urls().map(lambda u: u[0]))) + return module, url, OFFICIAL_MODULES[module] + # Arbitrary-URL import of a known official repository location; the + # module name, if any, is a non-official one. + url, expected = draw(official_urls()) + name = draw(st.one_of(st.none(), st.just(""), arbitrary_module_names())) + return name, url, expected + + +# -------------------------------------------- arbitrary import sources + +def arbitrary_module_names(): + """Module names that are not an official plugin-set name.""" + return st.text(max_size=30).filter( + lambda s: s.strip() not in OFFICIAL_MODULES + ) + + +@st.composite +def _other_host_urls(draw): + """Public repositories on non-freedesktop hosts, including ones + deliberately named after official plugin sets (15.4).""" + host = draw(st.sampled_from( + ["github.com", "gitlab.com", "bitbucket.org", "example.com", "git.sr.ht"] + )) + segments = draw(st.lists( + st.one_of(_safe_segment, official_module_names), min_size=1, max_size=4 + )) + return "https://%s/%s" % (host, "/".join(segments)) + + +@st.composite +def _freedesktop_non_official_urls(draw): + """freedesktop.org hosts with paths that are not an official set.""" + shape = draw(st.sampled_from(["gitlab-other-ns", "gitlab-safe", "release"])) + if shape == "gitlab-other-ns": + first = draw(_safe_segment.filter(lambda s: _strip_git(s) != "gstreamer")) + rest = draw(st.lists( + st.one_of(_safe_segment, official_module_names), max_size=3 + )) + return "https://gitlab.freedesktop.org/%s" % "/".join([first] + rest) + if shape == "gitlab-safe": + rest = draw(st.lists(_safe_segment, max_size=3)) + return "https://gitlab.freedesktop.org/%s" % "/".join(["gstreamer"] + rest) + segments = draw(st.lists(_safe_segment, min_size=1, max_size=3)) + return "https://gstreamer.freedesktop.org/%s" % "/".join(segments) + + +#: URL-shaped junk on random hosts; excluding "freedesktop.org" keeps +#: them non-official by construction. +_junk_urls = st.text(min_size=1, max_size=40).filter( + lambda s: "freedesktop.org" not in s +).map(lambda s: "https://example.org/" + s) + + +def arbitrary_urls(): + """Repository URLs that are not a known official location.""" + return st.one_of( + _other_host_urls(), + _freedesktop_non_official_urls(), + _junk_urls, + ) + + +@st.composite +def arbitrary_sources(draw): + """(module_name, repo_url) with no official identity at all.""" + name = draw(st.one_of(st.none(), st.just(""), arbitrary_module_names())) + url = draw(arbitrary_urls()) + return name, url + + +# ----------------------------------------------------- other inputs + +#: Any import source: official or arbitrary, expected value dropped. +any_sources = st.one_of( + official_sources().map(lambda s: (s[0], s[1])), + arbitrary_sources(), +) + +#: User-supplied revisions: omitted (None / empty string, meaning the +#: default branch was cloned) or a git-ish branch / tag / SHA string. +revisions = st.one_of( + st.none(), + st.just(""), + st.text(alphabet="abcdef0123456789", min_size=7, max_size=40), + st.text(alphabet=_segment_alphabet + "/", min_size=1, max_size=30), +) + +user_ids = st.text(min_size=1, max_size=40) + +timestamps = st.integers(min_value=0, max_value=2**53) + + +# --------------------------------------------------------------------------- +# Property 7 +# --------------------------------------------------------------------------- + +@settings(max_examples=25) +@given(source=any_sources, revision=revisions, user_id=user_ids, + timestamp=timestamps) +def test_provenance_carries_all_fields_and_the_classification( + importer, source, revision, user_id, timestamp): + """**Feature: custom-node-designer, Property 7: Import provenance records the classification** + + For all import sources, the provenance contains the repository URL, + the retrieved revision (DEFAULT_REVISION when the default branch + was used), the importing user, the retrieval timestamp, and a + classification equal to `classify_plugin_set` applied to that + source. + + **Validates: Requirements 4.2, 15.5** + """ + module_name, repo_url = source + provenance = importer.import_provenance( + repo_url, revision, module_name, user_id, timestamp) + + assert provenance["repoUrl"] == repo_url + assert provenance["revision"] == (revision or importer.DEFAULT_REVISION) + assert provenance["importedBy"] == user_id + assert provenance["importedAt"] == timestamp + assert provenance["classification"] == classify_plugin_set( + module_name, repo_url) + + +@settings(max_examples=25) +@given(source=official_sources(), revision=revisions, user_id=user_ids, + timestamp=timestamps) +def test_official_sources_record_their_plugin_set( + importer, source, revision, user_id, timestamp): + """**Feature: custom-node-designer, Property 7: Import provenance records the classification** + + Module_Listing selections and known official repository locations + record exactly their official plugin-set classification. + + **Validates: Requirements 4.2, 15.5** + """ + module_name, repo_url, expected = source + provenance = importer.import_provenance( + repo_url, revision, module_name, user_id, timestamp) + assert provenance["classification"] == expected + + +@settings(max_examples=25) +@given(source=arbitrary_sources(), revision=revisions, user_id=user_ids, + timestamp=timestamps) +def test_arbitrary_sources_record_unclassified( + importer, source, revision, user_id, timestamp): + """**Feature: custom-node-designer, Property 7: Import provenance records the classification** + + Arbitrary public repository URLs with no official identity record + the unclassified classification. + + **Validates: Requirements 4.2, 15.5** + """ + module_name, repo_url = source + provenance = importer.import_provenance( + repo_url, revision, module_name, user_id, timestamp) + assert provenance["classification"] == CLASSIFICATION_UNCLASSIFIED diff --git a/edge-cv-portal/backend/tests/test_property_invocation_assembly.py b/edge-cv-portal/backend/tests/test_property_invocation_assembly.py new file mode 100644 index 00000000..ed3adab6 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_invocation_assembly.py @@ -0,0 +1,158 @@ +"""Property test for invocation assembly +(custom-node-code-assist, task 2.4). + +**Feature: custom-node-code-assist, Property 2: Invocation assembly** + +_For any_ prompt, contract, and editor content (including unicode and +whitespace-only strings): the assembled Converse messages contain the +prompt verbatim; the system prompt carries the contract's entry-point +signature and its environment description markers (`dda_frames` and the +pre-bound `cv2`/`np` bindings for the Python_Bridge contracts; +`params` for `frame_hook`); and the user message embeds the editor +content in the modify-this-module block if and only if the editor +content contains at least one non-whitespace character. + +**Validates: Requirements 2.1, 2.6, 2.10** + +`build_system_prompt` and `build_user_message` are pure functions over +already-validated inputs, so this test needs no moto stack - it drives +the functions directly. +""" +import sys + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +CONTRACT_NAMES = ("process_frame", "process_frame_or_handle", "frame_hook") + +# The Workflow_Builder contracts executed by the Python_Bridge runner +# (frame_hook runs in the GStreamer element's embedded interpreter). +PYTHON_BRIDGE_CONTRACTS = frozenset({"process_frame", "process_frame_or_handle"}) + + +@pytest.fixture(scope="module") +def code_assist(): + """Import the real code_assist module against the real shared_utils + layer. + + code_assist imports shared_utils at module scope, and standalone + tests in this directory (e.g. test_captures.py) install a *fake* + shared_utils into sys.modules at collection time that lacks the + names code_assist needs (rbac_manager, Permission, log_audit_event). + conftest.py already put the real layer and functions directories on + sys.path, so popping both modules and re-importing binds the real + ones regardless of collection order. + """ + sys.modules.pop("shared_utils", None) + sys.modules.pop("code_assist", None) + import code_assist as module + return module + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +# Valid prompts (Requirement 1.4's constraint: at least one non-whitespace +# character): arbitrary unicode text, capped well below 4,000 for speed. +prompts = st.text(min_size=1, max_size=300).filter(lambda s: s.strip()) + +# Editor content: absent, empty, whitespace-only (spaces/tabs/newlines and +# unicode whitespace), arbitrary unicode text, or realistic module code. +whitespace_only = st.text( + alphabet=" \t\n\r\x0b\f\u00a0\u2003", max_size=20) + +code_snippets = st.sampled_from([ + "def process_frame(frame, metadata):\n return None\n", + "import cv2\n\ndef handle(frame_bytes, metadata):\n" + " return frame_bytes, metadata\n", + "# just a comment\nx = 1\n", + "def process_frame(frame, params):\n return frame\n", +]) + +editor_contents = st.one_of( + st.none(), + whitespace_only, # includes the empty string + st.text(max_size=300), # arbitrary unicode, may be whitespace-only + code_snippets, +) + +# Optional frame_hook context: absent, empty, or carrying a declared +# element parameter list (well-formed and malformed entries alike - the +# assembly must tolerate both without affecting the markers). +parameter_entries = st.fixed_dictionaries( + {"name": st.text(alphabet="abcdefghijklmnopqrstuvwxyz-_", + min_size=1, max_size=12), + "param_type": st.sampled_from(["int", "float", "string", "boolean"])}, + optional={"description": st.text(max_size=30)}, +) + +contexts = st.one_of( + st.none(), + st.just({}), + st.fixed_dictionaries( + {}, optional={"parameters": st.lists(parameter_entries, max_size=3)}), +) + + +# --------------------------------------------------------------------------- +# Property 2: Invocation assembly +# --------------------------------------------------------------------------- + +@given(prompt=prompts, + contract=st.sampled_from(CONTRACT_NAMES), + current_code=editor_contents, + context=contexts) +def test_invocation_assembly(code_assist, prompt, contract, + current_code, context): + """For any prompt, contract, and editor content: the prompt appears + verbatim in the user message; the system prompt carries the + contract's entry-point signature and environment markers; and the + editor content is embedded in the modify-this-module block iff it + contains a non-whitespace character (Requirements 2.1, 2.6, 2.10).""" + spec = code_assist.CONTRACTS[contract] + + system_prompt = code_assist.build_system_prompt(contract, context) + user_message = code_assist.build_user_message(prompt, current_code) + + # The prompt appears verbatim in the user message (Requirement 2.1). + assert prompt in user_message, ( + f"prompt must appear verbatim in the user message, " + f"got {user_message!r}") + + # The system prompt carries the contract's entry-point signature + # (Requirement 2.1). + assert spec["signature"] in system_prompt, ( + f"system prompt for {contract} must carry the entry-point " + f"signature {spec['signature']!r}") + + # Environment markers per contract (Requirement 2.1): the + # Python_Bridge contracts describe the dda_frames helper and the + # pre-bound cv2/np bindings; frame_hook describes the `params` dict. + if contract in PYTHON_BRIDGE_CONTRACTS: + for marker in ("dda_frames", "cv2", "np", "pre-bound"): + assert marker in system_prompt, ( + f"system prompt for {contract} must carry the " + f"Python_Bridge environment marker {marker!r}") + else: + assert "params" in system_prompt, ( + "system prompt for frame_hook must carry the `params` " + "environment marker") + + # The editor content is embedded in the modify-this-module block iff + # it contains a non-whitespace character (Requirements 2.6, 2.10). + should_embed = bool(current_code) and bool(current_code.strip()) + if should_embed: + assert "CURRENT MODULE CODE:" in user_message, ( + "non-whitespace editor content must be presented in the " + "modify-this-module block") + assert current_code in user_message, ( + "the editor content must appear verbatim inside the " + "modify-this-module block") + else: + # Empty/whitespace-only editors are treated as empty: the prompt + # is sent alone and nothing is presented as code to modify. + assert user_message == prompt, ( + f"empty/whitespace-only editor content must send the prompt " + f"alone, got {user_message!r}") diff --git a/edge-cv-portal/backend/tests/test_property_module_listing.py b/edge-cv-portal/backend/tests/test_property_module_listing.py new file mode 100644 index 00000000..dd9fbbdc --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_module_listing.py @@ -0,0 +1,189 @@ +"""Property test for Module_Listing parsing (task 4.5). + +**Feature: custom-node-designer, Property 5: Module listing parse covers every module** + +For all synthetic Module_Listing documents generated from a random set +of module entries, parsing produces exactly that set of modules, each +with its name and repository location (`module_repo_url`) and the +classification `classify_plugin_set` derives from them; documents that +contain no parseable module table raise ModuleListingParseError. + +**Validates: Requirements 6.1** + +The parse under test (`parse_module_listing`) is a pure function over +the fetched page content, so it is exercised directly with no AWS or +network involvement. The module is imported through the shared +moto-backed session fixture only so the real `shared_utils` layer (not +a test fake) backs the import. + +Generator notes: synthetic pages embed the module rows in an HTML +table whose header row starts with a "module" column, exactly as the +official listing page does, surrounded by random layout/navigation +tables (which carry no such header) and free text noise. Module names +mix random identifiers with the official plugin-set names +(gst-plugins-good/bad/ugly) so classified and unclassified entries +both occur. Cell text is HTML-escaped on render; the parser +whitespace-normalizes cell text, so expected descriptions are compared +whitespace-normalized. +""" + +from __future__ import annotations + +import html + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def importer(aws_stack): + """The real plugin_importer module, imported via the session stack.""" + return aws_stack.plugin_importer + + +# --------------------------------------------------------------------------- +# Generators +# --------------------------------------------------------------------------- + +OFFICIAL_SET_NAMES = ("gst-plugins-good", "gst-plugins-bad", "gst-plugins-ugly") + +#: Random module identifiers (no whitespace, so the parser's cell-text +#: normalization leaves them unchanged), mixed with the official names. +_random_name = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz0123456789-._", min_size=1, max_size=24) +_module_name = st.one_of(_random_name, st.sampled_from(OFFICIAL_SET_NAMES)) + +#: Descriptions: free text including HTML-significant characters (the +#: renderer escapes them; convert_charrefs on the parser unescapes). +_description = st.text( + alphabet=" abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + "0123456789,.&<>'\"()-/", + max_size=60) + +_module_entry = st.tuples(_module_name, _description) + +#: Free-text noise placed between tables (data outside cells is ignored +#: by the parser; kept tag-free so it cannot open a stray element). +_free_text = st.text( + alphabet=" abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,", + max_size=40) + +#: Cell words for layout/navigation tables. Never equal to "module" +#: (case-insensitively), so a noise table can never carry the module +#: header the parse keys on. +_noise_word = st.text( + alphabet="abcdefghijklmnopqrstuvwxyz", min_size=1, max_size=12, +).filter(lambda w: w != "module") + + +@st.composite +def _noise_table(draw) -> str: + """A layout/navigation table: either header-less (td-only rows) or + headed by a non-"module" column, so the parse must ignore it.""" + rows = [] + if draw(st.booleans()): + header = draw(st.lists(_noise_word, min_size=1, max_size=4)) + rows.append("" + "".join(f"{w}" for w in header) + "") + for cells in draw(st.lists( + st.lists(_noise_word, min_size=1, max_size=4), max_size=4)): + rows.append("" + "".join(f"{w}" for w in cells) + "") + return '' + "".join(rows) + "
" + + +def _render_module_table(importer, entries, header_case: str, + extra_cell: bool) -> str: + """The module index table exactly as the listing page shapes it: a + header row starting with a "module" column, then one row per module + whose first cell links the module page and whose second cell + carries the description.""" + header = (f"{header_case}Description" + + ("Version" if extra_cell else "") + "") + rows = [] + for name, description in entries: + cells = ( + f'' + f'{html.escape(name)}' + f'{html.escape(description)}' + ) + if extra_cell: + cells += "1.0" + rows.append(f"{cells}") + return '' + header + "".join(rows) + "
" + + +# --------------------------------------------------------------------------- +# Property: parse covers every module, exactly +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given( + entries=st.lists(_module_entry, min_size=1, max_size=15), + header_case=st.sampled_from(("Module", "module", "MODULE")), + extra_cell=st.booleans(), + before=st.lists(st.one_of(_noise_table(), _free_text), max_size=3), + after=st.lists(st.one_of(_noise_table(), _free_text), max_size=3), +) +def test_parse_yields_exactly_the_generated_modules( + importer, entries, header_case, extra_cell, before, after): + """**Feature: custom-node-designer, Property 5: Module listing parse + covers every module** + + For all synthetic Module_Listing documents generated from a random + set of module entries, parsing produces exactly that set of + modules — one entry per generated row, in order, each with its + name, whitespace-normalized description, published repository + location, and classification. + + **Validates: Requirements 6.1** + """ + page = ( + "" + + "".join(before) + + _render_module_table(importer, entries, header_case, extra_cell) + + "".join(after) + + "" + ) + + parsed = importer.parse_module_listing(page) + + expected = [] + for name, description in entries: + repo_url = importer.module_repo_url(name) + expected.append({ + "name": name, + "description": " ".join(description.split()), + "repoUrl": repo_url, + "classification": importer.classify_plugin_set(name, repo_url), + }) + assert parsed == expected + + +# --------------------------------------------------------------------------- +# Property: pages without a module table raise ModuleListingParseError +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given( + chunks=st.lists(st.one_of(_noise_table(), _free_text), max_size=5), + empty_module_table=st.booleans(), +) +def test_pages_without_module_table_raise( + importer, chunks, empty_module_table): + """**Feature: custom-node-designer, Property 5: Module listing parse + covers every module** + + For all documents containing no parseable module table — layout + tables, free text, and at most a module-headed table with zero + module rows — the parse raises ModuleListingParseError. + + **Validates: Requirements 6.1** + """ + body = "".join(chunks) + if empty_module_table: + body += ('' + "
ModuleDescription
") + page = f"{body}" + + with pytest.raises(importer.ModuleListingParseError): + importer.parse_module_listing(page) diff --git a/edge-cv-portal/backend/tests/test_property_packaging_gates.py b/edge-cv-portal/backend/tests/test_property_packaging_gates.py new file mode 100644 index 00000000..39933e2d --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_packaging_gates.py @@ -0,0 +1,305 @@ +"""Property test for the custom-plugin packaging gates (task 10.3). + +**Feature: custom-node-designer, Property 13: Packaging gates on lifecycle state and artifact presence** + +For all workflows containing Custom_Node_Types and all combinations of +backing Plugin_Record lifecycle states and per-architecture artifact +availability, packaging is permitted (zero gate findings) if and only +if every compiled ``custom:`` dependency resolves to a backing +Plugin_Record that is in test or prod lifecycle state, carries a +complete Plugin_Artifact entry (succeeded build + s3Key + checksum + +signature) for every selected architecture the dependency was compiled +for, and has a registered Plugin_Component version; every +ineligibility produces exactly the finding identifying the +Custom_Node_Type and the missing architecture or the offending +lifecycle state. + +**Validates: Requirements 11.1, 11.2, 11.3** + +The gate logic under test (``custom_plugin_gate_findings``, +``artifact_entry_complete``, ``custom_dependency_index``) is pure over +plain dicts, so the property is exercised directly with no AWS calls. +The module is imported through the shared moto-backed session fixture +only so its module-level boto3 clients bind to the mock (same +re-import pattern as test_workflow_packaging_custom_plugins.py). +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Reference model: eligibility restated from the requirements (11.1, +# 11.2, 11.3) rather than imported, so the test cannot silently agree +# with a wrong implementation. +# --------------------------------------------------------------------------- + +PACKAGEABLE_STATES = ("test", "prod") + +# Gate codes as the API contract documents them (11.2, 11.3, 16.4). +CODE_RECORD_MISSING = "PLUGIN_RECORD_NOT_FOUND" +CODE_LIFECYCLE = "PLUGIN_LIFECYCLE_VIOLATION" +CODE_ARTIFACT_MISSING = "PLUGIN_ARTIFACT_MISSING" +CODE_COMPONENT_MISSING = "PLUGIN_COMPONENT_MISSING" + + +def entry_complete_model(entry): + """A usable per-arch Plugin_Artifact entry: succeeded build with the + recorded key, checksum, and signature (11.2, 10.4).""" + return bool( + isinstance(entry, dict) + and entry.get("buildStatus") == "succeeded" + and entry.get("s3Key") + and entry.get("checksum") + and entry.get("signature") + ) + + +def expected_findings_model(dep_specs): + """The set of findings the requirements demand, keyed for comparison. + + Per dependency, in gate order: an unresolvable backing Plugin_Record + fails closed; a lifecycle state outside test/prod rejects identifying + the Custom_Node_Type and state (11.3, and only then); otherwise every + compiled architecture lacking a complete Plugin_Artifact rejects + identifying the Custom_Node_Type and architecture (11.2), and a + missing registered Plugin_Component version rejects (16.4/11.1). + """ + expected = set() + for spec in dep_specs: + dep = spec["dep"] + if not spec["in_index"] or spec["record"] is None: + expected.add((CODE_RECORD_MISSING, dep, None, None)) + continue + node_type_id = spec["node_type_id"] + record = spec["record"] + state = record.get("lifecycle_state") + if state not in PACKAGEABLE_STATES: + expected.add((CODE_LIFECYCLE, dep, node_type_id, state)) + continue + artifacts = record.get("artifacts") or {} + for arch in spec["archs"]: + if not entry_complete_model(artifacts.get(arch)): + expected.add((CODE_ARTIFACT_MISSING, dep, node_type_id, arch)) + component = record.get("component") or {} + if component.get("status") != "registered": + expected.add((CODE_COMPONENT_MISSING, dep, node_type_id, None)) + return expected + + +def finding_key(finding): + """Project an implementation finding onto the model key space.""" + code = finding["code"] + dep = finding["dependency"] + if code == CODE_RECORD_MISSING: + return (code, dep, None, None) + if code == CODE_LIFECYCLE: + return (code, dep, finding["node_type_id"], finding["lifecycle_state"]) + if code == CODE_ARTIFACT_MISSING: + return (code, dep, finding["node_type_id"], finding["arch"]) + if code == CODE_COMPONENT_MISSING: + return (code, dep, finding["node_type_id"], None) + raise AssertionError(f"unknown gate finding code: {code}") + + +# --------------------------------------------------------------------------- +# Random dep/record worlds +# --------------------------------------------------------------------------- + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + +# Random lifecycle states: the packageable pair, dev, plus unknown / +# absent values that must fail closed (11.3). +LIFECYCLE_STATES = ("dev", "test", "prod", "archived", "", None) + +USECASE_ID = "uc-p13" + +_complete_entry = st.fixed_dictionaries({ + "buildStatus": st.just("succeeded"), + "s3Key": st.just(f"workflow-plugins/custom/{USECASE_ID}/a/p.so"), + "checksum": st.just("ab" * 32), + "signature": st.just("c2lnLWJ5dGVz"), +}) + +# Entries with fields randomly present/missing/empty or a wrong build +# status — complete only by coincidence (then genuinely eligible). +_random_entry = st.one_of( + st.none(), + st.fixed_dictionaries({}, optional={ + "buildStatus": st.sampled_from(["succeeded", "failed", "running", ""]), + "s3Key": st.sampled_from(["", f"workflow-plugins/custom/{USECASE_ID}/a/p.so"]), + "checksum": st.sampled_from(["", "ab" * 32]), + "signature": st.sampled_from(["", "c2lnLWJ5dGVz"]), + }), +) + +_component = st.one_of( + st.none(), + st.just({}), + st.fixed_dictionaries({"status": st.sampled_from( + ["registered", "pending", "failed", ""])}), +) + + +@st.composite +def worlds(draw): + """A random packaging world: selected architectures, custom plugin + dependencies compiled per arch, their Custom_Node_Type declarations, + and backing Plugin_Records in random shapes. + + Each dependency draws an ``eligible`` bias flag; eligible deps are + built fully packageable so the empty-findings branch of the iff is + exercised as often as the rejection branch. + """ + selected_archs = draw(st.lists( + st.sampled_from(ARCHS), min_size=1, max_size=len(ARCHS), unique=True)) + n_deps = draw(st.integers(min_value=1, max_value=4)) + + dep_specs = [] + node_type_items = [] + dep_records = {} + arch_custom_deps = {arch: [] for arch in selected_archs} + + for i in range(n_deps): + dep = f"custom:{USECASE_ID}/plugin-{i}" + node_type_id = f"custom.plugin-{i}" + plugin_id = f"plg-{i}" + # The archs this dependency was compiled for: a nonempty subset + # of the selected architectures. + dep_archs = sorted(draw(st.lists( + st.sampled_from(selected_archs), min_size=1, + max_size=len(selected_archs), unique=True))) + + eligible = draw(st.booleans()) + if eligible: + in_index = True + record = { + "plugin_id": plugin_id, + "version": draw(st.integers(min_value=1, max_value=9)), + "lifecycle_state": draw(st.sampled_from(PACKAGEABLE_STATES)), + "artifacts": {arch: draw(_complete_entry) for arch in dep_archs}, + "component": {"status": "registered"}, + } + else: + in_index = draw(st.booleans()) + if draw(st.booleans()): + record = None # backing Plugin_Record missing entirely + else: + artifact_archs = draw(st.lists( + st.sampled_from(ARCHS), max_size=len(ARCHS), unique=True)) + record = { + "plugin_id": plugin_id, + "version": draw(st.integers(min_value=1, max_value=9)), + "lifecycle_state": draw(st.sampled_from(LIFECYCLE_STATES)), + "artifacts": {arch: draw(_random_entry) + for arch in artifact_archs}, + "component": draw(_component), + } + + if in_index: + # A CustomNodeTypes item whose declaration mappings carry the + # custom: dependency, as registration records it (8.6) — the + # dep_index is derived through custom_dependency_index. + node_type_items.append({ + "node_type_id": node_type_id, + "version": 1, + "plugin_id": plugin_id, + "plugin_version": record["version"] if record else 1, + "declaration": { + "typeId": node_type_id, + "mappings": [{ + "arch": arch, + "elementChain": [{"factory": f"plugin{i}"}], + "pluginDependencies": [dep], + } for arch in dep_archs], + }, + }) + + dep_records[dep] = record + for arch in dep_archs: + arch_custom_deps[arch].append(dep) + dep_specs.append({ + "dep": dep, + "node_type_id": node_type_id, + "in_index": in_index, + "record": record, + "archs": dep_archs, + }) + + return dep_specs, node_type_items, arch_custom_deps, dep_records + + +# --------------------------------------------------------------------------- +# Property 13 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(world=worlds()) +def test_packaging_gates_on_lifecycle_and_artifacts(packaging, world): + """**Feature: custom-node-designer, Property 13: Packaging gates on lifecycle state and artifact presence** + + For all combinations of backing Plugin_Record lifecycle states, + per-architecture artifact availability, component registration, and + record resolvability, ``custom_plugin_gate_findings`` returns zero + findings if and only if every dependency is fully packageable, and + each ineligibility produces exactly the finding identifying the + Custom_Node_Type and the offending architecture or lifecycle state. + + **Validates: Requirements 11.1, 11.2, 11.3** + """ + dep_specs, node_type_items, arch_custom_deps, dep_records = world + + dep_index = packaging.custom_dependency_index(node_type_items) + findings = packaging.custom_plugin_gate_findings( + arch_custom_deps, dep_index, dep_records) + + expected = expected_findings_model(dep_specs) + + # 11.1: packaging may proceed (no findings) iff every dependency is + # eligible — records resolvable, lifecycle test/prod, a complete + # artifact for every compiled arch, component registered. + assert (findings == []) == (expected == set()) + + # Each ineligibility produces exactly the identifying finding: same + # multiset (no duplicates) and same identification (11.2, 11.3). + assert len(findings) == len(expected) + assert {finding_key(f) for f in findings} == expected + + # Findings identify the Custom_Node_Type and arch/state in the + # human-readable message too (11.2, 11.3). + for finding in findings: + if finding["code"] == CODE_LIFECYCLE: + assert finding["node_type_id"] in finding["message"] + assert f"'{finding['lifecycle_state']}'" in finding["message"] + elif finding["code"] == CODE_ARTIFACT_MISSING: + assert finding["node_type_id"] in finding["message"] + assert f"'{finding['arch']}'" in finding["message"] + elif finding["code"] == CODE_COMPONENT_MISSING: + assert finding["node_type_id"] in finding["message"] + elif finding["code"] == CODE_RECORD_MISSING: + assert finding["dependency"] in finding["message"] + + # artifact_entry_complete agrees with the requirements' notion of a + # usable per-arch Plugin_Artifact entry across all generated shapes. + for spec in dep_specs: + record = spec["record"] + if not record: + continue + for entry in (record.get("artifacts") or {}).values(): + assert packaging.artifact_entry_complete(entry) \ + == entry_complete_model(entry) diff --git a/edge-cv-portal/backend/tests/test_property_pad_caps_confidence.py b/edge-cv-portal/backend/tests/test_property_pad_caps_confidence.py new file mode 100644 index 00000000..6b8d1522 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_caps_confidence.py @@ -0,0 +1,148 @@ +"""Property test for caps confidence (task 1.9). + +**Feature: port-guidance-and-pad-prepopulation, Property 7: Caps prefix decides confidence** + +For any derived Port_Suggestion, `portType` is `VideoFrames`, and +`confident` is true iff the pad's caps string begins with the exact +case-sensitive characters `video/x-raw` (truncated caps included); every +non-confident suggestion carries the pad's caps string and a reason +stating that InferenceMeta and EventSignal are DDA semantic concepts the +caps cannot express. + +**Validates: Requirements 5.2, 5.3** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and derivation +functions directly. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + CONFIDENT_CAPS_PREFIX, + MAX_CAPS_LEN, + PAD_PRESENCE_ALWAYS, + PORT_TYPE_VIDEO_FRAMES, + VALID_PAD_DIRECTIONS, + PadTemplate, + ReportElement, + ports_for_element, +) + +# --------------------------------------------------------------------------- +# Generators: caps strings biased around the video/x-raw prefix boundary +# --------------------------------------------------------------------------- + +_MAX_SUFFIX = MAX_CAPS_LEN - len(CONFIDENT_CAPS_PREFIX) + + +@st.composite +def _case_variant_prefix(draw): + """The prefix with each character's case independently flipped. + + Only the all-unflipped draw reproduces the exact case-sensitive + prefix; every other variant must be classified non-confident. + """ + flips = draw(st.lists(st.booleans(), + min_size=len(CONFIDENT_CAPS_PREFIX), + max_size=len(CONFIDENT_CAPS_PREFIX))) + return ''.join( + ch.swapcase() if flip else ch + for ch, flip in zip(CONFIDENT_CAPS_PREFIX, flips) + ) + + +_suffixes = st.text(max_size=min(_MAX_SUFFIX, 60)) + +# Caps strategies, all bounded by MAX_CAPS_LEN so pads stay in the valid +# domain (`parse_report` rejects longer caps): +# - exact prefix + arbitrary suffix -> always confident +# - case-variant prefix + arbitrary suffix -> confident only when the +# variant happens to be the exact prefix +# - truncated (strict) prefixes of the prefix, e.g. 'video/x-ra' -> never +# confident (shorter than the prefix itself) +# - arbitrary text, including the empty string +_caps = st.one_of( + st.tuples(st.just(CONFIDENT_CAPS_PREFIX), _suffixes).map(''.join), + st.tuples(_case_variant_prefix(), _suffixes).map(''.join), + st.integers(min_value=0, max_value=len(CONFIDENT_CAPS_PREFIX) - 1).map( + lambda n: CONFIDENT_CAPS_PREFIX[:n]), + st.text(max_size=80), +) + +# Pads that always derive to a Port_Suggestion: presence 'always' and a +# non-whitespace name template (the two Unmapped_Pad routes are excluded so +# every generated pad exercises the confidence classification). Truncated +# variants are covered by caps_truncated spanning both values. +_suggestion_pads = st.builds( + PadTemplate, + name=st.text(min_size=1, max_size=40).filter(lambda s: s.strip()), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.just(PAD_PRESENCE_ALWAYS), + caps=_caps, + caps_truncated=st.booleans(), +) + +_elements = st.builds( + ReportElement, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=st.one_of(st.none(), st.text(max_size=60)), + properties=st.just([]), + pads=st.lists(_suggestion_pads, min_size=1, max_size=8), + pads_error=st.none(), +) + + +# --------------------------------------------------------------------------- +# Property 7 +# --------------------------------------------------------------------------- + +@settings(max_examples=200, deadline=None) +@given(element=_elements) +def test_caps_prefix_decides_confidence(element): + """**Feature: port-guidance-and-pad-prepopulation, Property 7: Caps prefix decides confidence** + + For any derived Port_Suggestion: `portType` is always `VideoFrames`; + `confident` is true iff the pad's caps begin with the exact + case-sensitive `video/x-raw` prefix (truncated caps included); and + every non-confident suggestion carries the pad's caps string plus the + reason naming InferenceMeta and EventSignal as DDA semantic concepts + the caps cannot express — Requirements 5.2, 5.3. + + **Validates: Requirements 5.2, 5.3** + """ + result = ports_for_element(element) + suggestions = result['portSuggestions'] + + # Every generated pad is presence 'always' with a valid name, so each + # derives to exactly one Port_Suggestion in report order. + assert len(suggestions) == len(element.pads) + assert result['unmappedPads'] == [] + + for pad, suggestion in zip(element.pads, suggestions): + # 5.5 backdrop: the only caps-derivable Port_Type. + assert suggestion['portType'] == PORT_TYPE_VIDEO_FRAMES + + # 5.2: confident iff the exact case-sensitive prefix, regardless + # of capture-time truncation. + expected_confident = pad.caps.startswith(CONFIDENT_CAPS_PREFIX) + assert suggestion['confident'] is expected_confident + + # The caps string and truncation flag are carried through verbatim. + assert suggestion['caps'] == pad.caps + assert suggestion['capsTruncated'] is pad.caps_truncated + + if not expected_confident: + # 5.3: the unconfirmed reason names the DDA semantic concepts + # GStreamer caps cannot express. + reason = suggestion['reason'] + assert 'InferenceMeta' in reason + assert 'EventSignal' in reason + assert 'DDA semantic concepts' in reason + else: + # Confident suggestions state the caps prefix instead. + assert CONFIDENT_CAPS_PREFIX in suggestion['reason'] diff --git a/edge-cv-portal/backend/tests/test_property_pad_caps_truncation.py b/edge-cv-portal/backend/tests/test_property_pad_caps_truncation.py new file mode 100644 index 00000000..5ed99fa6 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_caps_truncation.py @@ -0,0 +1,105 @@ +"""Property test for build-time caps truncation (task 4.2). + +**Feature: port-guidance-and-pad-prepopulation, Property 9: Caps truncation is bounded and marked** + +For any string, `truncate_caps` returns a prefix of the input of at most +4096 characters together with a flag that is true iff the input exceeds +4096 characters; when the flag is true the returned string is exactly +4096 characters long. + +**Validates: Requirements 3.4** + +The function under test lives in the executable build-image script +`plugin-build-images/dda-gst-introspect` (no `.py` extension). Its +module-top-level code is deliberately GI-free (the `gi` imports stay +inside `scan()`), so the script is loaded here via a SourceFileLoader +and `truncate_caps` is exercised directly as a pure function. +""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +from pathlib import Path + +from hypothesis import given, settings +from hypothesis import strategies as st + +# --------------------------------------------------------------------------- +# Load the introspection script as a module (top level is GI-free). +# --------------------------------------------------------------------------- + +_SCRIPT_PATH = ( + Path(__file__).resolve().parents[2] / 'plugin-build-images' / 'dda-gst-introspect' +) + + +def _load_introspect_module(): + loader = importlib.machinery.SourceFileLoader( + 'dda_gst_introspect', str(_SCRIPT_PATH)) + spec = importlib.util.spec_from_loader('dda_gst_introspect', loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +_introspect = _load_introspect_module() +truncate_caps = _introspect.truncate_caps +MAX_CAPS_LEN = _introspect.MAX_CAPS_LEN + + +# --------------------------------------------------------------------------- +# Generators: caps strings concentrated around the truncation boundary. +# --------------------------------------------------------------------------- + +# Short everyday caps strings, exact-boundary lengths (MAX_CAPS_LEN - 1, +# MAX_CAPS_LEN, MAX_CAPS_LEN + 1), and clearly oversized inputs. +_boundary_lengths = st.integers( + min_value=MAX_CAPS_LEN - 2, max_value=MAX_CAPS_LEN + 2) +_oversized_lengths = st.integers( + min_value=MAX_CAPS_LEN + 1, max_value=MAX_CAPS_LEN + 512) + +_caps_strings = st.one_of( + st.text(max_size=80), + _boundary_lengths.flatmap( + lambda n: st.text(min_size=n, max_size=n)), + _oversized_lengths.flatmap( + lambda n: st.text(min_size=n, max_size=n)), +) + + +# --------------------------------------------------------------------------- +# Property 9 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(caps=_caps_strings) +def test_caps_truncation_is_bounded_and_marked(caps): + """**Feature: port-guidance-and-pad-prepopulation, Property 9: Caps truncation is bounded and marked** + + For any string, `truncate_caps` returns a prefix of the input of at + most MAX_CAPS_LEN (4096) characters, the truncation flag is true iff + the input exceeds MAX_CAPS_LEN characters, and when the flag is true + the returned string is exactly MAX_CAPS_LEN characters long + (Requirement 3.4). + + **Validates: Requirements 3.4** + """ + result, truncated = truncate_caps(caps) + + # The result is a prefix of the input of at most MAX_CAPS_LEN chars. + assert isinstance(result, str) + assert len(result) <= MAX_CAPS_LEN + assert caps.startswith(result) + + # The flag is true iff the input exceeds MAX_CAPS_LEN characters. + assert isinstance(truncated, bool) + assert truncated == (len(caps) > MAX_CAPS_LEN) + + # When truncation occurred, exactly MAX_CAPS_LEN characters remain; + # otherwise the input is returned unchanged. + if truncated: + assert len(result) == MAX_CAPS_LEN + assert result == caps[:MAX_CAPS_LEN] + else: + assert result == caps diff --git a/edge-cv-portal/backend/tests/test_property_pad_derivation_partition.py b/edge-cv-portal/backend/tests/test_property_pad_derivation_partition.py new file mode 100644 index 00000000..15ccb592 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_derivation_partition.py @@ -0,0 +1,131 @@ +"""Property test for the derivation partition (task 1.8). + +**Feature: port-guidance-and-pad-prepopulation, Property 6: Derivation partitions the pads** + +For any element with a non-empty pad list, every Pad_Template appears in +exactly one of `portSuggestions` or `unmappedPads`: a pad with presence +`always` and a non-whitespace name template becomes a Port_Suggestion whose +direction is `input` for `sink` and `output` for `src` and whose name is the +name template verbatim; a pad with presence `sometimes` or `request` becomes +an Unmapped_Pad carrying its name, direction, presence, and the runtime-pads +caveat; a pad whose name template is empty or whitespace-only becomes an +Unmapped_Pad with the invalid-name caveat; and both output lists preserve +the pads' report order. + +**Validates: Requirements 5.1, 5.4, 5.6** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and derivation +function directly. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + PAD_DIRECTION_SINK, + PAD_PRESENCE_ALWAYS, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + PadTemplate, + ReportElement, + ports_for_element, +) +from gst_properties import _CAVEAT_INVALID_NAME, _CAVEAT_RUNTIME_PADS + +# --------------------------------------------------------------------------- +# Generators: non-empty pad lists spanning every partition branch +# --------------------------------------------------------------------------- + +# Name templates deliberately include the invalid-name cases (empty and +# whitespace-only strings) alongside ordinary and whitespace-padded names. +_whitespace_only_names = st.text(alphabet=' \t\n\r', max_size=6) +_names = st.one_of( + _whitespace_only_names, # includes the empty string + st.text(max_size=40), # arbitrary names + st.text(min_size=1, max_size=20).map(lambda s: f'{s}_%u'), +) + +_pads = st.builds( + PadTemplate, + name=_names, + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), # all three presences + caps=st.text(max_size=MAX_CAPS_LEN), + caps_truncated=st.booleans(), +) + +_elements = st.builds( + ReportElement, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=st.one_of(st.none(), st.text(max_size=60)), + properties=st.just([]), + pads=st.lists(_pads, min_size=1, max_size=10), + pads_error=st.none(), +) + + +def _expected_partition(pads): + """Classify each pad exactly as the design's derivation table specifies.""" + suggestions = [] + unmapped = [] + for pad in pads: + if pad.presence != PAD_PRESENCE_ALWAYS: + unmapped.append({ + 'name': pad.name, + 'direction': pad.direction, + 'presence': pad.presence, + 'caveat': _CAVEAT_RUNTIME_PADS.format(presence=pad.presence), + }) + elif not pad.name.strip(): + unmapped.append({ + 'name': pad.name, + 'direction': pad.direction, + 'presence': pad.presence, + 'caveat': _CAVEAT_INVALID_NAME, + }) + else: + suggestions.append({ + 'name': pad.name, + 'direction': 'input' if pad.direction == PAD_DIRECTION_SINK else 'output', + }) + return suggestions, unmapped + + +# --------------------------------------------------------------------------- +# Property 6 +# --------------------------------------------------------------------------- + +@settings(max_examples=150, deadline=None) +@given(element=_elements) +def test_derivation_partitions_the_pads(element): + """**Feature: port-guidance-and-pad-prepopulation, Property 6: Derivation partitions the pads** + + Every pad lands in exactly one output list with the correct direction + mapping (sink -> input, src -> output), the name template verbatim, the + correct caveat per unmapped case, and report order preserved in both + lists — Requirements 5.1, 5.4, 5.6. + + **Validates: Requirements 5.1, 5.4, 5.6** + """ + result = ports_for_element(element) + suggestions = result['portSuggestions'] + unmapped = result['unmappedPads'] + + # Partition: every pad appears in exactly one output list. + assert len(suggestions) + len(unmapped) == len(element.pads) + + expected_suggestions, expected_unmapped = _expected_partition(element.pads) + + # Unmapped_Pads: verbatim name, direction, presence, the correct caveat + # per case, in report order. + assert unmapped == expected_unmapped + + # Port_Suggestions: verbatim name and sink->input / src->output mapping, + # in report order (portType/confidence/reason are Properties 7 and 8). + assert [{'name': s['name'], 'direction': s['direction']} for s in suggestions] \ + == expected_suggestions diff --git a/edge-cv-portal/backend/tests/test_property_pad_legacy_compat.py b/edge-cv-portal/backend/tests/test_property_pad_legacy_compat.py new file mode 100644 index 00000000..0a1eca14 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_legacy_compat.py @@ -0,0 +1,186 @@ +"""Property test for legacy report compatibility (task 1.3). + +**Feature: port-guidance-and-pad-prepopulation, Property 2: Legacy reports parse compatibly** + +For any valid Introspection_Report, serializing it and then deleting the +`pads` and `padsError` keys from every element (producing exactly the +pre-feature version-1 document shape) parses successfully to a report in +which every element has `pads=None` and `pads_error=None` while every +other report-level, element-level, and property field equals the +original. + +**Validates: Requirements 4.2** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and +parse/serialize functions directly. +""" + +from __future__ import annotations + +import dataclasses +import json + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + STATUS_CAPTURED, + STATUS_FAILED, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + EnumValue, + GstProperty, + PadTemplate, + Report, + ReportElement, + parse_report, + serialize_report, +) + +# --------------------------------------------------------------------------- +# Generators: valid Report dataclass instances, extended with pad data +# (mirroring test_property_gst_report_roundtrip.py) +# --------------------------------------------------------------------------- + +# Finite floats only: NaN never compares equal to itself and infinities are +# not interchange-safe JSON, so neither can appear in a stored report. +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +# JSON scalar | None for property defaults. +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +# int | float | None for ranged numeric min/max. +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=st.text(max_size=40), + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +# Valid pad templates: directions/presences from the valid vocabularies, +# caps at most MAX_CAPS_LEN characters (including the exact boundary). +_pad_templates = st.builds( + PadTemplate, + name=st.text(max_size=40), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=st.one_of( + st.text(max_size=80), + # Exercise the boundary: caps of exactly MAX_CAPS_LEN characters. + st.just('x' * MAX_CAPS_LEN), + ), + caps_truncated=st.booleans(), +) + +# Every valid (pads, pads_error) element state, respecting the domain +# invariant that pads_error is non-None only when pads == []: +# - pads=None (legacy element, never captured) +# - pads=[] without an error (element declares no templates) +# - pads=[] with a diagnostic (per-element read failure) +# - a populated pad list (error always None) +_pad_states = st.one_of( + st.tuples(st.none(), st.none()), + st.tuples(st.just(()), st.none()), + st.tuples(st.just(()), st.text(min_size=1, max_size=60)), + st.tuples(st.lists(_pad_templates, min_size=1, max_size=6), st.none()), +) + + +def _build_element(factory, element_gtype, instantiation_error, properties, + pad_state): + pads, pads_error = pad_state + return ReportElement( + factory=factory, + element_gtype=element_gtype, + instantiation_error=instantiation_error, + properties=properties, + pads=None if pads is None else list(pads), + pads_error=pads_error, + ) + + +_elements = st.builds( + _build_element, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=6), + pad_state=_pad_states, +) + +_reports = st.builds( + Report, + status=st.sampled_from((STATUS_CAPTURED, STATUS_FAILED)), + message=_optional_texts, + gst_version=_optional_texts, + captured_at=_optional_texts, + elements=st.lists(_elements, max_size=5), +) + + +# --------------------------------------------------------------------------- +# Property 2 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(report=_reports) +def test_legacy_reports_parse_compatibly(report): + """**Feature: port-guidance-and-pad-prepopulation, Property 2: Legacy reports parse compatibly** + + Serializing any valid report and deleting the `pads`/`padsError` keys + from every element yields exactly the pre-feature version-1 document + shape; parsing it succeeds, every element comes back with `pads=None` + and `pads_error=None`, and every other report-level, element-level, + and property field equals the original (Requirement 4.2). + + **Validates: Requirements 4.2** + """ + document = serialize_report(report) + + # Produce exactly the pre-feature stored document shape: no element + # carries a `pads` or `padsError` key. Run it through a real JSON + # cycle, as a stored legacy report would be. + for element_doc in document['elements']: + element_doc.pop('pads', None) + element_doc.pop('padsError', None) + legacy_document = json.loads(json.dumps(document)) + + parsed = parse_report(legacy_document) + + # Every element reports pads as never captured (legacy defaults). + for element in parsed.elements: + assert element.pads is None + assert element.pads_error is None + + # All other report-level, element-level, and property fields are + # field-for-field identical to the original report: the parse result + # equals the original with only the pad fields reset. + expected = dataclasses.replace(report, elements=[ + dataclasses.replace(element, pads=None, pads_error=None) + for element in report.elements + ]) + assert parsed == expected diff --git a/edge-cv-portal/backend/tests/test_property_pad_reason_classification.py b/edge-cv-portal/backend/tests/test_property_pad_reason_classification.py new file mode 100644 index 00000000..68f79773 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_reason_classification.py @@ -0,0 +1,177 @@ +"""Property test for pads-reason classification (task 1.7). + +**Feature: port-guidance-and-pad-prepopulation, Property 5: Pads-reason classification is total and exclusive** + +For any report element, `ports_for_element` returns +`padsReason == 'pads_not_captured'` iff `pads is None`, +`'pads_read_failed'` (with the diagnostic as `padsMessage`) iff +`pads == []` with a `pads_error`, `'no_pad_templates'` iff `pads == []` +without one, and `None` iff `pads` is non-empty — and whenever a reason +is set, both `portSuggestions` and `unmappedPads` are empty. + +**Validates: Requirements 4.7, 4.8** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and derivation +function directly. +""" + +from __future__ import annotations + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + PADS_REASON_NO_TEMPLATES, + PADS_REASON_NOT_CAPTURED, + PADS_REASON_READ_FAILED, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + EnumValue, + GstProperty, + PadTemplate, + ReportElement, + ports_for_element, +) + +# --------------------------------------------------------------------------- +# Generators: valid ReportElement instances across every pad-data state +# (mirroring the element generators of test_property_pad_suggestions_unchanged) +# --------------------------------------------------------------------------- + +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_gtypes = st.one_of( + st.sampled_from(( + 'gint', 'guint', 'gint64', 'guint64', 'glong', 'gulong', 'guchar', + 'gfloat', 'gdouble', 'gboolean', 'gchararray', + 'GstCaps', 'gpointer', 'GstMyEnum', + )), + st.text(max_size=40), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=_gtypes, + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +# Valid pad templates, including whitespace-only names, every presence, and +# caps up to the MAX_CAPS_LEN boundary — so the non-empty state exercises +# suggestions and unmapped pads alike. +_pads = st.builds( + PadTemplate, + name=st.one_of( + st.text(max_size=40), + st.text(alphabet=' \t\n', max_size=4), # empty/whitespace-only names + ), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=st.one_of( + st.text(max_size=MAX_CAPS_LEN), + st.text(max_size=200).map(lambda s: 'video/x-raw' + s), + ), + caps_truncated=st.booleans(), +) + +# Every valid pad-data state, respecting the domain invariant that +# pads_error is non-None only when pads == []: +# - pads=None, pads_error=None (legacy element, pads not captured) +# - pads=[], pads_error=None (element declares no templates) +# - pads=[], pads_error= (per-element pad read failure) +# - pads=[...non-empty...], pads_error=None +_pad_states = st.one_of( + st.tuples(st.none(), st.none()), + st.tuples(st.just([]), st.none()), + st.tuples(st.just([]), st.text(min_size=1, max_size=60)), + st.tuples(st.lists(_pads, min_size=1, max_size=6), st.none()), +) + + +@st.composite +def _elements(draw): + pads, pads_error = draw(_pad_states) + return ReportElement( + factory=draw(st.text(max_size=40)), + element_gtype=draw(st.text(max_size=40)), + instantiation_error=draw(_optional_texts), + properties=draw(st.lists(_properties, max_size=4)), + pads=pads, + pads_error=pads_error, + ) + + +# --------------------------------------------------------------------------- +# Property 5 +# --------------------------------------------------------------------------- + +@settings(max_examples=150, deadline=None) +@given(element=_elements()) +def test_pads_reason_classification_is_total_and_exclusive(element): + """**Feature: port-guidance-and-pad-prepopulation, Property 5: Pads-reason classification is total and exclusive** + + The four element states map iff-style onto `padsReason`/`padsMessage` + (Requirements 4.7, 4.8), and whenever a reason is set both + `portSuggestions` and `unmappedPads` are empty. + + **Validates: Requirements 4.7, 4.8** + """ + result = ports_for_element(element) + reason = result['padsReason'] + message = result['padsMessage'] + + # Iff mapping of the four mutually exclusive element states (4.7, 4.8). + if element.pads is None: + assert reason == PADS_REASON_NOT_CAPTURED + assert message is None + elif element.pads == [] and element.pads_error is not None: + assert reason == PADS_REASON_READ_FAILED + assert message == element.pads_error + elif element.pads == []: + assert reason == PADS_REASON_NO_TEMPLATES + assert message is None + else: + assert reason is None + assert message is None + + # The reverse direction of each iff: a given reason implies its state. + if reason == PADS_REASON_NOT_CAPTURED: + assert element.pads is None + elif reason == PADS_REASON_READ_FAILED: + assert element.pads == [] and element.pads_error is not None + elif reason == PADS_REASON_NO_TEMPLATES: + assert element.pads == [] and element.pads_error is None + else: + assert reason is None + assert element.pads # non-empty list + + # Whenever a reason is set, no derivation output leaks through. + if reason is not None: + assert result['portSuggestions'] == [] + assert result['unmappedPads'] == [] diff --git a/edge-cv-portal/backend/tests/test_property_pad_report_rejection.py b/edge-cv-portal/backend/tests/test_property_pad_report_rejection.py new file mode 100644 index 00000000..96ea195b --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_report_rejection.py @@ -0,0 +1,246 @@ +"""Property test for malformed Pad_Template rejection (task 1.4). + +**Feature: port-guidance-and-pad-prepopulation, Property 3: Malformed pad data is rejected, not crashed on** + +For any valid pad-bearing Introspection_Report document broken by a single +targeted pad mutation — a `pads` value that is not a list, a pad entry that +is not an object, a dropped or mistyped pad field, a `direction` outside +{sink, src}, a `presence` outside {always, sometimes, request}, or a `caps` +string longer than 4096 characters — `parse_report` raises the typed +`ReportError` and nothing else, so the route maps it to the existing +"introspection_failed" unavailability reason instead of an internal error. + +**Validates: Requirements 4.4** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real parse function directly. +Mirrors the targeted-mutation approach of +`test_property_gst_report_rejection.py`. +""" + +from __future__ import annotations + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + STATUS_CAPTURED, + STATUS_FAILED, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + EnumValue, + GstProperty, + PadTemplate, + Report, + ReportElement, + ReportError, + parse_report, + serialize_report, +) + +# --------------------------------------------------------------------------- +# Generators: valid pad-bearing Report dataclass instances +# --------------------------------------------------------------------------- + +# Finite floats only: NaN/inf are not interchange-safe JSON and cannot +# appear in a stored report. +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=st.text(max_size=40), + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +_pad_templates = st.builds( + PadTemplate, + name=st.text(max_size=40), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=st.text(max_size=80), + caps_truncated=st.booleans(), +) + +# Pad states respecting the domain invariant: pads_error is non-None only +# when pads == [] (per-element read failure). +_pad_states = st.one_of( + st.just((None, None)), # legacy: not captured + st.tuples(st.just([]), # empty list ± error + st.one_of(st.none(), st.text(min_size=1, max_size=60))), + st.tuples(st.lists(_pad_templates, min_size=1, max_size=4), + st.none()), # populated +) + + +def _element(factory, element_gtype, instantiation_error, properties, pad_state): + pads, pads_error = pad_state + return ReportElement( + factory=factory, + element_gtype=element_gtype, + instantiation_error=instantiation_error, + properties=properties, + pads=pads, + pads_error=pads_error, + ) + + +_elements = st.builds( + _element, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=4), + pad_state=_pad_states, +) + +# An element guaranteed to carry a non-empty pad list, so every mutation +# kind below always has a target. +_pad_bearing_elements = st.builds( + _element, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=4), + pad_state=st.tuples(st.lists(_pad_templates, min_size=1, max_size=4), + st.none()), +) + + +# --------------------------------------------------------------------------- +# Generators: targeted pad mutations of valid serialized reports +# --------------------------------------------------------------------------- + +# A JSON object is not accepted for any pad field (name str, direction str, +# presence str, caps str, capsTruncated bool), so clobbering any field with +# an object guarantees a mistype. +_CLOBBER = {'not': 'a valid pad field value'} + +_PAD_FIELD_KEYS = ('name', 'direction', 'presence', 'caps', 'capsTruncated') + +# `pads` values that are not a list. +_nonlist_pads = st.sampled_from([ + None, True, 0, 1.5, 'sink', {'name': 'sink'}, +]) + +# Pad entries that are not an object. +_nonobject_entries = st.sampled_from([ + None, True, 7, 2.5, 'sink', ['sink'], +]) + +_bad_directions = st.one_of( + st.text(max_size=20).filter(lambda s: s not in VALID_PAD_DIRECTIONS), + st.sampled_from(['SINK', 'SRC', 'Sink', 'source', 'input', 'output']), +) + +_bad_presences = st.one_of( + st.text(max_size=20).filter(lambda s: s not in VALID_PAD_PRESENCES), + st.sampled_from(['ALWAYS', 'Always', 'never', 'on-request', 'dynamic']), +) + +# Caps strings strictly longer than MAX_CAPS_LEN characters. +_overlong_caps = st.text(min_size=1, max_size=50).map( + lambda suffix: 'x' * MAX_CAPS_LEN + suffix +) + + +@st.composite +def _mutated_pad_documents(draw): + """A valid pad-bearing serialized report broken by exactly one + targeted pad mutation.""" + elements = draw(st.lists(_elements, max_size=3)) + target = draw(_pad_bearing_elements) + insert_at = draw(st.integers(min_value=0, max_value=len(elements))) + elements = elements[:insert_at] + [target] + elements[insert_at:] + + report = Report( + status=draw(st.sampled_from((STATUS_CAPTURED, STATUS_FAILED))), + message=draw(_optional_texts), + gst_version=draw(_optional_texts), + captured_at=draw(_optional_texts), + elements=elements, + ) + document = serialize_report(report) + + element = document['elements'][insert_at] + pad_indices = list(range(len(element['pads']))) + + kind = draw(st.sampled_from([ + 'nonlist_pads', + 'nonobject_entry', + 'drop_pad_field', + 'mistype_pad_field', + 'bad_direction', + 'bad_presence', + 'overlong_caps', + ])) + + if kind == 'nonlist_pads': + element['pads'] = draw(_nonlist_pads) + elif kind == 'nonobject_entry': + element['pads'][draw(st.sampled_from(pad_indices))] = draw(_nonobject_entries) + elif kind == 'drop_pad_field': + pad = element['pads'][draw(st.sampled_from(pad_indices))] + del pad[draw(st.sampled_from(_PAD_FIELD_KEYS))] + elif kind == 'mistype_pad_field': + pad = element['pads'][draw(st.sampled_from(pad_indices))] + pad[draw(st.sampled_from(_PAD_FIELD_KEYS))] = _CLOBBER + elif kind == 'bad_direction': + pad = element['pads'][draw(st.sampled_from(pad_indices))] + pad['direction'] = draw(_bad_directions) + elif kind == 'bad_presence': + pad = element['pads'][draw(st.sampled_from(pad_indices))] + pad['presence'] = draw(_bad_presences) + else: # overlong_caps + pad = element['pads'][draw(st.sampled_from(pad_indices))] + pad['caps'] = draw(_overlong_caps) + + return document + + +# --------------------------------------------------------------------------- +# Property 3 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(document=_mutated_pad_documents()) +def test_malformed_pad_data_is_rejected_with_report_error(document): + """**Feature: port-guidance-and-pad-prepopulation, Property 3: Malformed pad data is rejected, not crashed on** + + Any valid pad-bearing report broken by a single targeted pad + mutation — a non-list `pads`, a non-object pad entry, a dropped or + mistyped pad field, a `direction` outside {sink, src}, a `presence` + outside {always, sometimes, request}, or a `caps` string longer than + 4096 characters — is rejected with `ReportError` and nothing else + (Requirement 4.4). + + **Validates: Requirements 4.4** + """ + with pytest.raises(ReportError): + parse_report(document) diff --git a/edge-cv-portal/backend/tests/test_property_pad_report_roundtrip.py b/edge-cv-portal/backend/tests/test_property_pad_report_roundtrip.py new file mode 100644 index 00000000..695031dc --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_report_roundtrip.py @@ -0,0 +1,169 @@ +"""Property test for the extended (pads-bearing) report round-trip (task 1.2). + +**Feature: port-guidance-and-pad-prepopulation, Property 1: Extended report round-trip** + +For any valid Introspection_Report — with pad data (including truncated +caps and per-element pad read failures), without pad data, or mixed per +element — `parse_report(serialize_report(report))` equals the original +report field-for-field, and the serialized document survives a real +`json.dumps`/`json.loads` cycle unchanged. + +**Validates: Requirements 4.1, 4.3** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and +parse/serialize functions directly. The generators extend the report +generators of `test_property_gst_report_roundtrip.py` with pad +strategies covering every legal element pad state. +""" + +from __future__ import annotations + +import json + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + STATUS_CAPTURED, + STATUS_FAILED, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + EnumValue, + GstProperty, + PadTemplate, + Report, + ReportElement, + parse_report, + serialize_report, +) + +# --------------------------------------------------------------------------- +# Generators: valid Report dataclass instances, extended with pad data +# (mirroring test_property_gst_report_roundtrip.py) +# --------------------------------------------------------------------------- + +# Finite floats only: NaN never compares equal to itself and infinities are +# not interchange-safe JSON, so neither can appear in a stored report. +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +# JSON scalar | None for property defaults. +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +# int | float | None for ranged numeric min/max. +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=st.text(max_size=40), + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +# Caps together with a matching capsTruncated marker (Requirement 3.4 shape): +# - caps shorter than the bound, not truncated (the common case); +# - caps of exactly MAX_CAPS_LEN marked truncated (capture cut it there); +# - caps of exactly MAX_CAPS_LEN not truncated (a caps string that is +# naturally at the boundary) — the largest caps the parser accepts. +_caps_with_truncation = st.one_of( + st.tuples(st.text(max_size=80), st.just(False)), + st.tuples(st.text(min_size=MAX_CAPS_LEN, max_size=MAX_CAPS_LEN), st.just(True)), + st.tuples(st.text(min_size=MAX_CAPS_LEN, max_size=MAX_CAPS_LEN), st.just(False)), +) + +_pads = _caps_with_truncation.flatmap( + lambda caps_pair: st.builds( + PadTemplate, + name=st.text(max_size=40), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=st.just(caps_pair[0]), + caps_truncated=st.just(caps_pair[1]), + ) +) + +# Every legal element pad state (domain invariant: pads_error is non-None +# only when pads == []): +# - pads=None, pads_error=None (legacy element, 4.2) +# - pads=[], pads_error=None (no static pad templates, 3.5) +# - pads=[], pads_error= (per-element read failure, 3.2) +# - pads=[...non-empty...], pads_error=None +_pad_states = st.one_of( + st.tuples(st.none(), st.none()), + st.tuples(st.just([]), st.none()), + st.tuples(st.just([]), st.text(min_size=1, max_size=60)), + st.tuples(st.lists(_pads, min_size=1, max_size=6), st.none()), +) + +_elements = _pad_states.flatmap( + lambda pad_state: st.builds( + ReportElement, + factory=st.text(max_size=40), + element_gtype=st.text(max_size=40), + instantiation_error=_optional_texts, + properties=st.lists(_properties, max_size=6), + pads=st.just(pad_state[0]), + pads_error=st.just(pad_state[1]), + ) +) + +# Elements are drawn independently, so a single report freely mixes +# pads-bearing, pad-free, empty-pad-list, and read-failure elements. +_reports = st.builds( + Report, + status=st.sampled_from((STATUS_CAPTURED, STATUS_FAILED)), + message=_optional_texts, + gst_version=_optional_texts, + captured_at=_optional_texts, + elements=st.lists(_elements, max_size=5), +) + + +# --------------------------------------------------------------------------- +# Property 1 +# --------------------------------------------------------------------------- + +@settings(max_examples=100, deadline=None) +@given(report=_reports) +def test_extended_report_round_trip_through_json(report): + """**Feature: port-guidance-and-pad-prepopulation, Property 1: Extended report round-trip** + + For any valid report — pads-bearing, legacy, or mixed per element — + serialization followed by a real `json.dumps`/`json.loads` cycle and + re-parsing reproduces an equal Report, and the JSON cycle leaves the + serialized document unchanged (Requirements 4.1, 4.3). + + **Validates: Requirements 4.1, 4.3** + """ + document = serialize_report(report) + + # The serialized form survives a real JSON dump/load cycle unchanged: + # what the build uploads is byte-for-byte re-interpretable. + recovered_document = json.loads(json.dumps(document)) + assert recovered_document == document + + # parse_report is the inverse of serialize_report through that cycle + # (4.1, 4.3): every report-level, element-level, pad-level, and + # property field of the re-parsed report equals the original. + assert parse_report(recovered_document) == report diff --git a/edge-cv-portal/backend/tests/test_property_pad_suggestion_validity.py b/edge-cv-portal/backend/tests/test_property_pad_suggestion_validity.py new file mode 100644 index 00000000..46a99c7f --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_suggestion_validity.py @@ -0,0 +1,127 @@ +"""Property test for suggestion validity and determinism (task 1.10). + +**Feature: port-guidance-and-pad-prepopulation, Property 8: Derived suggestions are valid and derivation is deterministic** + +For any report element, every derived Port_Suggestion satisfies the existing +Ports_Step validation rules (non-empty trimmed name, portType in the +Node_Type_Catalog), and calling `ports_for_element` twice on the same element +yields deeply equal results. + +**Validates: Requirements 5.5, 5.7** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and derivation +functions directly. +""" + +from __future__ import annotations + +import copy + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + PadTemplate, + ReportElement, + ports_for_element, +) + +# The Node_Type_Catalog: the declared Port_Types the Ports_Step accepts +# (requirements glossary — VideoFrames, InferenceMeta, EventSignal). +NODE_TYPE_CATALOG = ('VideoFrames', 'InferenceMeta', 'EventSignal') + +# --------------------------------------------------------------------------- +# Generators: valid ReportElement instances across every pad-data state +# --------------------------------------------------------------------------- + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +# Pad names deliberately include empty and whitespace-only templates (which +# must never surface as Port_Suggestions) alongside ordinary names. +_pad_names = st.one_of( + st.just(''), + st.text(alphabet=' \t\n', max_size=4), + st.text(max_size=40), +) + +# Caps with and without the confident `video/x-raw` prefix, including the +# MAX_CAPS_LEN boundary, so both confident and unconfirmed suggestions occur. +_caps = st.one_of( + st.text(max_size=MAX_CAPS_LEN), + st.text(max_size=MAX_CAPS_LEN - 20).map(lambda s: 'video/x-raw' + s), +) + +_pads = st.builds( + PadTemplate, + name=_pad_names, + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=_caps, + caps_truncated=st.booleans(), +) + +# Every valid pad-data state, respecting the domain invariant that +# pads_error is non-None only when pads == []: +# - pads=None, pads_error=None (legacy element, pads not captured) +# - pads=[], pads_error=None (element declares no templates) +# - pads=[], pads_error= (per-element pad read failure) +# - pads=[...non-empty...], pads_error=None +_pad_states = st.one_of( + st.tuples(st.none(), st.none()), + st.tuples(st.just([]), st.none()), + st.tuples(st.just([]), st.text(min_size=1, max_size=60)), + st.tuples(st.lists(_pads, min_size=1, max_size=8), st.none()), +) + + +@st.composite +def _elements(draw): + pads, pads_error = draw(_pad_states) + return ReportElement( + factory=draw(st.text(max_size=40)), + element_gtype=draw(st.text(max_size=40)), + instantiation_error=draw(_optional_texts), + properties=[], + pads=pads, + pads_error=pads_error, + ) + + +# --------------------------------------------------------------------------- +# Property 8 +# --------------------------------------------------------------------------- + +@settings(max_examples=150, deadline=None) +@given(element=_elements()) +def test_suggestions_valid_and_derivation_deterministic(element): + """**Feature: port-guidance-and-pad-prepopulation, Property 8: Derived suggestions are valid and derivation is deterministic** + + For any report element, every derived Port_Suggestion satisfies the + existing Ports_Step validation rules — non-empty trimmed name and a + portType from the Node_Type_Catalog (Requirement 5.5) — and calling + `ports_for_element` twice on the same element yields deeply equal + results (Requirement 5.7). + + **Validates: Requirements 5.5, 5.7** + """ + first = ports_for_element(element) + # Deep-copy the first result before the second call so any (forbidden) + # shared mutable state between calls would surface in the comparison. + first_snapshot = copy.deepcopy(first) + second = ports_for_element(element) + + # Requirement 5.5: every derived Port_Suggestion passes the Ports_Step + # validation rules. + for suggestion in first['portSuggestions']: + assert isinstance(suggestion['name'], str) + assert suggestion['name'].strip() != '' + assert suggestion['portType'] in NODE_TYPE_CATALOG + + # Requirement 5.7: derivation is deterministic — two calls on the same + # element yield deeply equal results. + assert first_snapshot == second + assert first == second diff --git a/edge-cv-portal/backend/tests/test_property_pad_suggestions_unchanged.py b/edge-cv-portal/backend/tests/test_property_pad_suggestions_unchanged.py new file mode 100644 index 00000000..40114397 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_pad_suggestions_unchanged.py @@ -0,0 +1,143 @@ +"""Property test for parameter-suggestion isolation (task 1.5). + +**Feature: port-guidance-and-pad-prepopulation, Property 4: Pad data never changes parameter suggestions** + +For any report element, `suggestions_for_element` produces identical +suggestions and skipped lists whether the element carries pad data or has +it stripped (`pads=None, pads_error=None`) — the pad extension is fully +isolated from the gst-parameter-prepopulation feature. + +**Validates: Requirements 4.6** + +Pure-module test: `gst_properties` imports no boto3 and needs no AWS +fixtures, so this test runs against the real dataclasses and derivation +functions directly. +""" + +from __future__ import annotations + +import dataclasses + +from hypothesis import given, settings +from hypothesis import strategies as st + +from gst_properties import ( + MAX_CAPS_LEN, + VALID_PAD_DIRECTIONS, + VALID_PAD_PRESENCES, + EnumValue, + GstProperty, + PadTemplate, + ReportElement, + suggestions_for_element, +) + +# --------------------------------------------------------------------------- +# Generators: valid ReportElement instances across every pad-data state +# --------------------------------------------------------------------------- + +_finite_floats = st.floats(allow_nan=False, allow_infinity=False) + +_optional_scalars = st.one_of( + st.none(), + st.booleans(), + st.integers(), + _finite_floats, + st.text(max_size=40), +) + +_optional_numbers = st.one_of(st.none(), st.integers(), _finite_floats) + +_optional_texts = st.one_of(st.none(), st.text(max_size=60)) + +_enum_values = st.builds( + EnumValue, + value=st.integers(), + nick=st.text(max_size=30), +) + +# Properties span every map_property branch: mappable GTypes (int, float, +# bool, string, GEnum) and unmappable ones, writable and not — so both the +# suggestions and skipped lists are exercised. +_gtypes = st.one_of( + st.sampled_from(( + 'gint', 'guint', 'gint64', 'guint64', 'glong', 'gulong', 'guchar', + 'gfloat', 'gdouble', 'gboolean', 'gchararray', + 'GstCaps', 'gpointer', 'GstMyEnum', + )), + st.text(max_size=40), +) + +_properties = st.builds( + GstProperty, + name=st.text(max_size=40), + gtype=_gtypes, + owner=st.text(max_size=40), + writable=st.booleans(), + blurb=_optional_texts, + default=_optional_scalars, + min=_optional_numbers, + max=_optional_numbers, + enum_values=st.one_of(st.none(), st.lists(_enum_values, max_size=6)), +) + +# Valid pad templates, including the MAX_CAPS_LEN caps boundary. +_pads = st.builds( + PadTemplate, + name=st.text(max_size=40), + direction=st.sampled_from(VALID_PAD_DIRECTIONS), + presence=st.sampled_from(VALID_PAD_PRESENCES), + caps=st.text(max_size=MAX_CAPS_LEN), + caps_truncated=st.booleans(), +) + +# Every valid pad-data state, respecting the domain invariant that +# pads_error is non-None only when pads == []: +# - pads=None, pads_error=None (legacy element, pads not captured) +# - pads=[], pads_error=None (element declares no templates) +# - pads=[], pads_error= (per-element pad read failure) +# - pads=[...non-empty...], pads_error=None +_pad_states = st.one_of( + st.tuples(st.none(), st.none()), + st.tuples(st.just([]), st.none()), + st.tuples(st.just([]), st.text(min_size=1, max_size=60)), + st.tuples(st.lists(_pads, min_size=1, max_size=6), st.none()), +) + + +@st.composite +def _elements(draw): + pads, pads_error = draw(_pad_states) + return ReportElement( + factory=draw(st.text(max_size=40)), + element_gtype=draw(st.text(max_size=40)), + instantiation_error=draw(_optional_texts), + properties=draw(st.lists(_properties, max_size=6)), + pads=pads, + pads_error=pads_error, + ) + + +# --------------------------------------------------------------------------- +# Property 4 +# --------------------------------------------------------------------------- + +@settings(max_examples=150, deadline=None) +@given(element=_elements()) +def test_pad_data_never_changes_parameter_suggestions(element): + """**Feature: port-guidance-and-pad-prepopulation, Property 4: Pad data never changes parameter suggestions** + + For any generated element, `suggestions_for_element` returns identical + suggestions and skipped lists whether the element carries pad data or + has it stripped (`pads=None, pads_error=None`) — Requirement 4.6. + + **Validates: Requirements 4.6** + """ + stripped = dataclasses.replace(element, pads=None, pads_error=None) + + with_pads = suggestions_for_element(element) + without_pads = suggestions_for_element(stripped) + + assert with_pads['suggestions'] == without_pads['suggestions'] + assert with_pads['skipped'] == without_pads['skipped'] + assert with_pads == without_pads diff --git a/edge-cv-portal/backend/tests/test_property_plugin_component_immutability.py b/edge-cv-portal/backend/tests/test_property_plugin_component_immutability.py new file mode 100644 index 00000000..b4899830 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_plugin_component_immutability.py @@ -0,0 +1,336 @@ +"""Property test for Plugin_Component version immutability (task 6.5). + +**Feature: custom-node-designer, Property 23: Plugin_Component versions are immutable under rebuild** + +For all sequences of source-change and rebuild operations on a plugin, +every publish produces a Plugin_Component version not previously +registered, and the recipes and artifact references of all previously +published Plugin_Component versions are unchanged after each publish. + +**Validates: Requirements 16.7** + +Each generated sequence exercises the real production path against the +moto-backed stack: a rebuild/source change creates a new Plugin_Record +version through PUT /plugins/{id} with new_version=true (plugin_records +handler), the new version's per-arch build artifacts are recorded (with +fresh .so bytes overwriting the shared Plugin_Library keys, exactly as a +rebuild would), and the version is packaged through plugin_components' +package_plugin_component. After every publish (including idempotent +retries of already-published versions) the test asserts: + + (a) each previously published version's `component` pointer (name, + component version, ARN, status) and its promoted artifact bytes + under plugins/components/{pluginId}/{priorVersion}/... are + byte-identical to the snapshot taken when it was first published; + (b) the Use_Case-account Greengrass registry (a MagicMock; moto does + not implement greengrassv2) never receives delete_component for + any version, and never receives a second create_component_version + for an already-registered (ComponentName, ComponentVersion); + (c) every publish registers a component version string and ARN that + were not previously registered. +""" + +import hashlib +import json +import sys +import uuid +from unittest.mock import MagicMock + +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from conftest import TEST_ENV + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + + +@pytest.fixture(scope="module") +def components_module(aws_stack): + """Import plugin_components inside the moto mock so its module-level + boto3 clients (and workflow_packaging's) are intercepted.""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + return plugin_components + + +class ImmutabilityEnv: + """Facade driving rebuild -> new version -> package sequences.""" + + def __init__(self, stack, module, monkeypatch): + self.stack = stack + self.module = module + self.records = stack.plugin_records + self.s3 = stack.s3 + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + monkeypatch.setattr(module, "COMPONENT_STATUS_POLL_SECONDS", 0) + + self.usecase_id = f"uc-{uuid.uuid4()}" + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Immutability Property Use Case", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + + user_id = f"user-{uuid.uuid4()}" + self.admin = { + "user_id": user_id, + "email": f"{user_id}@example.com", + "username": user_id, + "role": "UseCaseAdmin", + } + stack.tables.user_roles.put_item(Item={ + "user_id": user_id, + "usecase_id": self.usecase_id, + "role": "UseCaseAdmin", + }) + + # Rebound per example (fresh registry mock per generated sequence). + self.gg = None + moto_s3 = self.s3 + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + return {"s3": moto_s3, "greengrassv2": self.gg}[service_name] + + monkeypatch.setattr(module, "get_usecase_client", + fake_get_usecase_client) + + # ----------------------------------------------------- registry mock + def fresh_greengrass(self): + """MagicMock greengrassv2 whose registry records every created + (ComponentName, ComponentVersion) and mints per-version ARNs, so + duplicate registration of an existing version is detectable.""" + gg = MagicMock(name="greengrassv2") + gg.meta.region_name = "us-east-1" + gg.registered = {} # (name, version) -> recipe dict + + def create_component_version(inlineRecipe=None, tags=None, **kwargs): + recipe = json.loads(inlineRecipe) + key = (recipe["ComponentName"], recipe["ComponentVersion"]) + # (b) an already-registered version is never re-created: + # rebuilds publish strictly new versions (16.7). + assert key not in gg.registered, ( + f"create_component_version called again for already " + f"registered {key}") + gg.registered[key] = recipe + return {"arn": ("arn:aws:greengrass:us-east-1:123456789012:" + f"components:{key[0]}:versions:{key[1]}")} + + gg.create_component_version.side_effect = create_component_version + gg.describe_component.return_value = { + "status": {"componentState": "DEPLOYABLE", "message": "ok"} + } + self.gg = gg + return gg + + # ------------------------------------------------- record operations + def _event(self, method, resource, path_params, body): + return { + "httpMethod": method, + "resource": resource, + "path": resource, + "pathParameters": path_params, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": {"authorizer": {"claims": { + "sub": self.admin["user_id"], + "email": self.admin["email"], + "cognito:username": self.admin["username"], + "custom:role": self.admin["role"], + }}}, + } + + def create_plugin(self, name): + response = self.records.handler(self._event( + "POST", "/plugins", None, + {"usecase_id": self.usecase_id, "name": name, + "kind": "scaffold"}), None) + assert response["statusCode"] == 201, response["body"] + return json.loads(response["body"])["plugin"] + + def rebuild(self, plugin): + """Source change / rebuild: a new Plugin_Record version via + PUT /plugins/{id} new_version=true (the existing design 16.7 + leans on).""" + response = self.records.handler(self._event( + "PUT", "/plugins/{id}", {"id": plugin["plugin_id"]}, + {"new_version": True}), None) + assert response["statusCode"] == 201, response["body"] + return json.loads(response["body"])["plugin"] + + def record_build_results(self, plugin, built, failed, payload): + """Record per-arch artifact entries as the build result handler + would, promoting fresh .so bytes to the (unversioned, shared) + Plugin_Library keys - overwriting what prior versions put there, + exactly like a real rebuild.""" + name = plugin["name"] + artifacts = {} + for arch in sorted(built): + data = (b"\x7fELF " + payload + + f" {name} v{plugin['version']} {arch}".encode()) + key = (f"workflow-plugins/custom/{self.usecase_id}/{arch}/" + f"{name}.so") + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + artifacts[arch] = { + "buildStatus": "succeeded", "s3Key": key, + "checksum": hashlib.sha256(data).hexdigest(), + "signature": "c2ln", "logTail": "", + } + for arch in sorted(failed): + artifacts[arch] = {"buildStatus": "failed", + "logTail": "compile error"} + self.stack.tables.plugin_records.update_item( + Key={"plugin_id": plugin["plugin_id"], + "version": plugin["version"]}, + UpdateExpression="SET artifacts = :a, requested_architectures = :r", + ExpressionAttributeValues={ + ":a": artifacts, + ":r": sorted(set(built) | set(failed)), + }, + ) + + def package(self, plugin): + return self.module.handler({ + "action": "package_plugin_component", + "plugin_id": plugin["plugin_id"], + "version": plugin["version"], + "usecase_id": self.usecase_id, + }, None) + + # -------------------------------------------------------- snapshots + def read_component_prefix(self, plugin): + """{key: bytes} of one version's promoted component artifacts.""" + prefix = (f"plugins/components/{plugin['plugin_id']}/" + f"{plugin['version']}/") + listed = self.s3.list_objects_v2(Bucket=self.usecase_bucket, + Prefix=prefix) + return { + obj["Key"]: self.s3.get_object( + Bucket=self.usecase_bucket, + Key=obj["Key"])["Body"].read() + for obj in listed.get("Contents", []) + } + + def snapshot(self, plugin): + """Everything 16.7 says must stay frozen once published.""" + item = self.records.get_version_item(plugin["plugin_id"], + plugin["version"]) + objects = self.read_component_prefix(plugin) + assert objects, "published version has no promoted artifacts" + return {"plugin": plugin, "component": item["component"], + "objects": objects} + + def assert_unchanged(self, snap): + """(a) The pointer and every promoted artifact byte of a + previously published version are exactly as first published.""" + plugin = snap["plugin"] + item = self.records.get_version_item(plugin["plugin_id"], + plugin["version"]) + assert item["component"] == snap["component"], ( + f"component pointer of published v{plugin['version']} changed") + assert self.read_component_prefix(plugin) == snap["objects"], ( + f"promoted artifacts of published v{plugin['version']} changed") + + +@pytest.fixture +def ienv(aws_stack, components_module, monkeypatch): + return ImmutabilityEnv(aws_stack, components_module, monkeypatch) + + +# --------------------------------------------------------------------------- +# Operation sequences: each step is one source-change/rebuild followed by a +# publish, with a random per-step architecture outcome, fresh source bytes, +# and an optional idempotent re-package of a previously published version. +# --------------------------------------------------------------------------- + +_step = st.fixed_dictionaries({ + "built": st.sets(st.sampled_from(ARCHS), min_size=1, max_size=3), + "failed": st.sets(st.sampled_from(ARCHS), max_size=2), + "payload": st.binary(min_size=1, max_size=16), + # >= 0 selects a previously published version (mod count) to + # re-package after this step's publish; -1 skips the retry. + "retry": st.integers(min_value=-1, max_value=99), +}) + +_steps = st.lists(_step, min_size=1, max_size=3) + + +@settings(max_examples=25, deadline=None, + suppress_health_check=[HealthCheck.function_scoped_fixture]) +@given(steps=_steps) +def test_plugin_component_versions_immutable_under_rebuild(ienv, steps): + """**Feature: custom-node-designer, Property 23: Plugin_Component versions are immutable under rebuild** + + For all sequences of source-change and rebuild operations on a + plugin, every publish produces a Plugin_Component version not + previously registered, and the recipes and artifact references of + all previously published Plugin_Component versions are unchanged + after each publish. + + **Validates: Requirements 16.7** + """ + env = ienv + gg = env.fresh_greengrass() + plugin = env.create_plugin(f"immutable-{uuid.uuid4().hex[:8]}") + + published = [] # snapshots, in publish order + seen_versions = set() # registered ComponentVersion strings + seen_arns = set() # registered component version ARNs + + for i, step in enumerate(steps): + if i > 0: + # Rebuild / source change -> a new Plugin_Record version. + plugin = env.rebuild(plugin) + env.record_build_results(plugin, step["built"], + step["failed"] - step["built"], + step["payload"]) + + result = env.package(plugin) + assert result["packaged"] is True, result + + # (c) Every publish is a not-previously-registered version. + comp_version = result["component_version"] + assert comp_version == f"{plugin['version']}.0.0" + assert comp_version not in seen_versions + assert result["component_arn"] not in seen_arns + seen_versions.add(comp_version) + seen_arns.add(result["component_arn"]) + + # The registry recipe of this publish references only this + # version's artifact prefix, never a prior version's. + recipe = gg.registered[(result["component_name"], comp_version)] + own_prefix = (f"plugins/components/{plugin['plugin_id']}/" + f"{plugin['version']}/") + for manifest in recipe["Manifests"]: + for artifact in manifest["Artifacts"]: + assert (f"s3://{env.usecase_bucket}/{own_prefix}" + in artifact["Uri"]) + + published.append(env.snapshot(plugin)) + + # Optional idempotent retry of an already published version: + # short-circuits without touching the registry or artifacts. + if step["retry"] >= 0: + prior = published[step["retry"] % len(published)] + retry = env.package(prior["plugin"]) + assert retry["packaged"] is True, retry + assert retry.get("short_circuited") is True + assert retry["component_arn"] == prior["component"]["arn"] + + # (b) No published component version is ever deleted, and + # (a) every previously published version is byte-identical. + gg.delete_component.assert_not_called() + for snap in published: + env.assert_unchanged(snap) + + # Final sweep: after the whole sequence every published version is + # still exactly as first published. + gg.delete_component.assert_not_called() + for snap in published: + env.assert_unchanged(snap) diff --git a/edge-cv-portal/backend/tests/test_property_plugin_component_recipe.py b/edge-cv-portal/backend/tests/test_property_plugin_component_recipe.py new file mode 100644 index 00000000..fb5cca1e --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_plugin_component_recipe.py @@ -0,0 +1,178 @@ +"""Property test for Plugin_Component recipe assembly (task 6.4). + +**Feature: custom-node-designer, Property 20: Plugin_Component manifests are exactly the built architectures** + +For all Plugin_Record versions with random per-architecture build +outcomes (over x86_64, x86_64_nvidia, arm64_jp4/jp5/jp6, at least one +success), the assembled Plugin_Component recipe is named +``dda.plugin.{pluginId}`` at version ``{pluginVersion}.0.0``, is +install-only, and contains exactly one platform manifest per +successfully built Target_Architecture - each with the correct +Greengrass platform attributes (amd64/aarch64, ``variant`` for the +JetPack builds, ``runtime: nvidia`` for x86_64_nvidia) - and no +manifest for any failed or unselected architecture. + +**Validates: Requirements 16.1** + +``build_plugin_recipe`` and its helpers are pure over +(plugin_id, plugin_version, bucket, arch_so_names), so the recipe is +exercised directly with no AWS involvement. The module is imported +through the shared moto-backed session fixture only so the real +``shared_utils`` / ``workflow_packaging`` layers (not test fakes) back +the import, mirroring test_plugin_components.py. +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def components_module(aws_stack): + """Import plugin_components inside the moto mock so its module-level + boto3 clients (and workflow_packaging's) are intercepted.""" + for name in ("plugin_components", "workflow_packaging"): + sys.modules.pop(name, None) + import plugin_components + + return plugin_components + + +# --------------------------------------------------------------------------- +# Reference expectations, restated from Requirement 16.1 / the design +# (not imported from the implementation, so the test cannot silently +# agree with a wrong platform map). +# --------------------------------------------------------------------------- + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + +EXPECTED_PLATFORM = { + "x86_64": {"os": "linux", "architecture": "amd64"}, + "x86_64_nvidia": {"os": "linux", "architecture": "amd64", + "runtime": "nvidia"}, + "arm64_jp4": {"os": "linux", "architecture": "aarch64", + "variant": "arm64_jp4"}, + "arm64_jp5": {"os": "linux", "architecture": "aarch64", + "variant": "arm64_jp5"}, + "arm64_jp6": {"os": "linux", "architecture": "aarch64", + "variant": "arm64_jp6"}, +} + +PLUGIN_MANIFEST_FILENAME = "plugin-manifest.json" + + +def _arch_of(platform): + """Recover the Target_Architecture a manifest platform targets. + + The expected platform blocks are pairwise distinct, so this mapping + is well-defined; an unrecognized platform fails the test. + """ + matches = [arch for arch, expected in EXPECTED_PLATFORM.items() + if platform == expected] + assert len(matches) == 1, f"unrecognized platform block: {platform}" + return matches[0] + + +# --------------------------------------------------------------------------- +# Strategies: random nonempty built-arch subsets with random ids, +# versions, bucket, and per-arch .so names. +# --------------------------------------------------------------------------- + +_name_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-" +_names = st.text(alphabet=_name_alphabet, min_size=1, max_size=24) + +built_arch_sets = st.frozensets(st.sampled_from(ARCHS), min_size=1) + + +@st.composite +def recipe_inputs(draw): + archs = draw(built_arch_sets) + return { + "plugin_id": draw(_names), + "plugin_version": draw(st.integers(min_value=1, max_value=9999)), + "bucket": draw(_names), + "arch_so_names": {arch: draw(_names) + ".so" for arch in archs}, + } + + +# --------------------------------------------------------------------------- +# Property 20 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(inputs=recipe_inputs()) +def test_manifests_are_exactly_the_built_architectures(components_module, + inputs): + """**Feature: custom-node-designer, Property 20: Plugin_Component manifests are exactly the built architectures** + + For all nonempty built-architecture subsets with random plugin ids, + versions, buckets, and .so names, build_plugin_recipe produces a + dda.plugin.{pluginId} v{pluginVersion}.0.0 install-only recipe whose + platform manifests correspond bijectively to the built + architectures, each with the correct platform attributes, artifact + URIs, and install lifecycle, with plain x86_64 ordered after + x86_64_nvidia. + + **Validates: Requirements 16.1** + """ + plugin_id = inputs["plugin_id"] + plugin_version = inputs["plugin_version"] + bucket = inputs["bucket"] + arch_so_names = inputs["arch_so_names"] + built = set(arch_so_names) + + recipe = components_module.build_plugin_recipe( + plugin_id, plugin_version, bucket, arch_so_names) + + # Component identity derived from the Plugin_Record (16.1). + assert recipe["ComponentName"] == f"dda.plugin.{plugin_id}" + assert recipe["ComponentVersion"] == f"{plugin_version}.0.0" + + # Install-only: no top-level Run lifecycle exists. + assert recipe["Lifecycle"] == {} + + # Bijection: exactly one manifest per built architecture, none for + # any failed or unselected architecture. + derived_archs = [_arch_of(m["Platform"]) for m in recipe["Manifests"]] + assert len(derived_archs) == len(built) + assert set(derived_archs) == built + + # Ordering: the plain x86_64 manifest comes after x86_64_nvidia so + # NVIDIA amd64 devices match the more specific manifest first. + if "x86_64" in built and "x86_64_nvidia" in built: + assert (derived_archs.index("x86_64") + > derived_archs.index("x86_64_nvidia")) + + for manifest, arch in zip(recipe["Manifests"], derived_archs): + so_name = arch_so_names[arch] + + # Platform attributes exactly as required (amd64/aarch64, + # variant for JetPack arm64, runtime: nvidia for x86_64_nvidia). + assert manifest["Platform"] == EXPECTED_PLATFORM[arch] + + # Install-only lifecycle per manifest. + assert list(manifest["Lifecycle"].keys()) == ["Install"] + install = manifest["Lifecycle"]["Install"] + assert install["requiresPrivilege"] is True + + # Install script targets the versioned per-arch device dir and + # copies both artifacts there. + install_dir = (f"/aws_dda/plugins/{plugin_id}/{plugin_version}/" + f"{arch}") + script = install["Script"] + assert install_dir in script + assert so_name in script + assert PLUGIN_MANIFEST_FILENAME in script + + # Artifacts: the signed .so plus plugin-manifest.json under the + # account bucket's versioned per-arch component prefix. + final_prefix = (f"plugins/components/{plugin_id}/{plugin_version}/" + f"{arch}") + assert [a["Uri"] for a in manifest["Artifacts"]] == [ + f"s3://{bucket}/{final_prefix}/{so_name}", + f"s3://{bucket}/{final_prefix}/{PLUGIN_MANIFEST_FILENAME}", + ] diff --git a/edge-cv-portal/backend/tests/test_property_plugin_deployment_gates.py b/edge-cv-portal/backend/tests/test_property_plugin_deployment_gates.py new file mode 100644 index 00000000..54151559 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_plugin_deployment_gates.py @@ -0,0 +1,182 @@ +"""Property test for Plugin_Component deployment gates (task 10.7). + +**Feature: custom-node-designer, Property 22: Plugin_Component deployment gates on lifecycle and architecture coverage** + +For all deployment submissions over random backing Plugin_Record +lifecycle states, random per-component published-architecture sets, and +random target devices with recorded architectures (including missing) +and test_device flags, the combined pure gates +(``evaluate_plugin_lifecycle_gate`` + ``evaluate_plugin_arch_gate`` in +functions/deployments.py) permit submission if and only if + +- no component in the dependency closure is backed by a dev-state (or + unknown -- fail closed) record, +- every test-state component targets only Test_Devices (prod deploys + anywhere), and +- every target device's recorded Target_Architecture appears in the + platform manifests of every depended-on Plugin_Component version, + matched by exact name -- x86_64 and x86_64_nvidia are distinct with no + fallback in either direction, and a device with no recorded + architecture fails closed; + +and every rejection identifies exactly the offending tuples: lifecycle +violations as {pluginComponent, lifecycleState, devices} and +architecture misses as {pluginComponent, version, device, deviceArch}. + +**Validates: Requirements 16.3, 16.6** +""" +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") +LIFECYCLE_STATES = ("dev", "test", "prod", None) # None: unknown, fails closed + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Strategies: published-arch-set x device-arch (design: Property 22) +# --------------------------------------------------------------------------- + +components_strategy = st.dictionaries( + keys=st.sampled_from([f"dda.plugin.p{i}" for i in range(5)]), + values=st.fixed_dictionaries({ + "lifecycle_state": st.sampled_from(LIFECYCLE_STATES), + "version": st.integers(min_value=1, max_value=9).map( + lambda n: f"{n}.0.0"), + # Published platform manifests: any subset of the five archs + # (empty = no successful builds recorded on the component). + "architectures": st.lists( + st.sampled_from(ARCHS), unique=True).map(sorted), + }), + min_size=0, max_size=4, +) + +devices_strategy = st.dictionaries( + keys=st.sampled_from([f"device-{i}" for i in range(5)]), + values=st.fixed_dictionaries({ + "test_device": st.booleans(), + # None: no Target_Architecture recorded for the device. + "arch": st.sampled_from(ARCHS + (None,)), + }), + min_size=0, max_size=4, +) + + +# --------------------------------------------------------------------------- +# Independent oracle over (component, device) pairs +# --------------------------------------------------------------------------- + +def lifecycle_permits(state, is_test_device): + """One (component, device) pair: prod anywhere, test only to + Test_Devices, dev/unknown nowhere (16.3).""" + if state == "prod": + return True + if state == "test": + return is_test_device + return False + + +@settings(max_examples=25, deadline=None) +@given(components=components_strategy, devices=devices_strategy) +def test_plugin_deployment_gates_pass_iff_lifecycle_and_arch_covered( + deployments, components, devices): + """**Feature: custom-node-designer, Property 22: Plugin_Component deployment gates on lifecycle and architecture coverage** + + The combined gates pass iff the lifecycle permits every + (component, device) pair and every device's recorded architecture is + covered by every component's published manifests (exact match, no + x86_64 <-> x86_64_nvidia crossover, unrecorded arch fails closed); + the violations list exactly the offending tuples. + + **Validates: Requirements 16.3, 16.6** + """ + closure_states = {name: c["lifecycle_state"] + for name, c in components.items()} + device_flags = {name: d["test_device"] for name, d in devices.items()} + component_manifests = { + name: {"version": c["version"], "architectures": c["architectures"]} + for name, c in components.items() + } + device_archs = {name: d["arch"] for name, d in devices.items()} + + lifecycle_violations = deployments.evaluate_plugin_lifecycle_gate( + closure_states, device_flags) + arch_offending = deployments.evaluate_plugin_arch_gate( + component_manifests, device_archs) + + # ------------------------------------------------ the iff (decision) + # Lifecycle: dev/unknown components fail closed even with no targets; + # test-state components require every target to be a Test_Device. + lifecycle_ok = all( + state == "prod" or ( + state == "test" + and all(device_flags[d] for d in devices)) + for state in closure_states.values() + ) + # Architecture: exact-name membership -- x86_64 never matches + # x86_64_nvidia (and vice versa), None is never a member. + arch_ok = all( + d["arch"] in c["architectures"] + for c in components.values() + for d in devices.values() + ) + + passes = lifecycle_violations == [] and arch_offending == [] + assert passes == (lifecycle_ok and arch_ok) + + # ------------------------------- lifecycle violations: exact tuples + expected_lifecycle = [] + for name in sorted(components): + state = components[name]["lifecycle_state"] + if state == "prod": + continue + if state == "test": + offending = sorted(d for d in devices if not device_flags[d]) + if not offending: + continue + else: # dev or unknown: every target device, fail closed + offending = sorted(devices) + expected_lifecycle.append({ + "pluginComponent": name, + "lifecycleState": state, + "devices": offending, + }) + + assert sorted(lifecycle_violations, + key=lambda v: v["pluginComponent"]) == \ + [dict(v, devices=sorted(v["devices"])) for v in sorted( + expected_lifecycle, key=lambda v: v["pluginComponent"])] + + # Sanity: each violating pair is one the oracle rejects. + for violation in lifecycle_violations: + state = closure_states[violation["pluginComponent"]] + for device in violation["devices"]: + assert not lifecycle_permits(state, device_flags[device]) + + # ------------------------------------ arch misses: exact tuples + expected_arch = [ + {"pluginComponent": name, + "version": components[name]["version"], + "device": device, + "deviceArch": device_archs[device]} + for name in sorted(components) + for device in sorted(devices) + if device_archs[device] not in set(components[name]["architectures"]) + ] + + def arch_key(entry): + return (entry["pluginComponent"], entry["device"]) + + assert sorted(arch_offending, key=arch_key) == \ + sorted(expected_arch, key=arch_key) diff --git a/edge-cv-portal/backend/tests/test_property_plugin_lifecycle.py b/edge-cv-portal/backend/tests/test_property_plugin_lifecycle.py new file mode 100644 index 00000000..9ddd8ae4 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_plugin_lifecycle.py @@ -0,0 +1,238 @@ +"""Property test for the Plugin_Record lifecycle state machine (task 3.2). + +**Feature: custom-node-designer, Property 10: Lifecycle state machine conformance** + +For all random sequences of operations (create record, create new +version, record build success/failure per arch, promote, demote, +approve review, reject review) applied to Plugin_Records, the +implementation agrees with a reference model: every new record and +every new version starts in dev with review pending regardless of +prior versions; dev->test succeeds if and only if at least one +successfully built Plugin_Artifact exists (otherwise rejected +identifying the missing build); test->prod succeeds if and only if +the security review is approved (otherwise rejected identifying the +missing approval); demotion always succeeds and only changes the +state. + +**Validates: Requirements 9.1, 9.4, 9.5, 9.9, 9.10, 9.13, 10.1, 10.5** + +The lifecycle guards under test (`new_version_item`, +`evaluate_promotion`, `evaluate_demotion`, `successful_build_archs`) +are pure over the Plugin_Record version-item dicts, so the state +machine is exercised directly with no AWS involvement. The module is +imported through the shared moto-backed session fixture only so the +real `shared_utils` layer (not a test fake) backs the import. +""" + +from __future__ import annotations + +import copy + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def records(aws_stack): + """The real plugin_records module, imported via the session stack.""" + return aws_stack.plugin_records + + +# --------------------------------------------------------------------------- +# Reference model: the lifecycle state machine restated from the +# requirements (9.1, 9.4, 9.5, 9.9, 9.10, 9.13, 10.1, 10.5) rather than +# imported, so the test cannot silently agree with a wrong implementation. +# --------------------------------------------------------------------------- + +class ModelVersion: + """One Plugin_Record version in the reference model.""" + + def __init__(self): + # Every new record and new version starts in dev with the + # security review pending, independently of prior versions. + self.state = "dev" + self.review = "pending" + self.succeeded_archs = set() + + def build(self, arch, ok): + if ok: + self.succeeded_archs.add(arch) + else: + self.succeeded_archs.discard(arch) + + def promote(self): + """Returns (next_state, None) or (None, expected_error_code).""" + if self.state == "dev": + if self.succeeded_archs: + return "test", None + return None, "PLUGIN_BUILD_REQUIRED" + if self.state == "test": + if self.review == "approved": + return "prod", None + return None, "SECURITY_REVIEW_REQUIRED" + # No transition forward from prod (or any other value). + return None, "INVALID_LIFECYCLE_TRANSITION" + + def demote(self): + """Demotion always succeeds one step back; none exists from dev.""" + if self.state == "prod": + return "test", None + if self.state == "test": + return "dev", None + return None, "INVALID_LIFECYCLE_TRANSITION" + + +# --------------------------------------------------------------------------- +# Operation sequences +# --------------------------------------------------------------------------- + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + +#: Each operation carries a selector integer used to pick the target +#: version (mod the number of versions existing when it executes). +_selector = st.integers(min_value=0, max_value=999) + +operations = st.lists( + st.one_of( + st.tuples(st.just("new_version"), _selector), + st.tuples(st.just("build"), _selector, + st.sampled_from(ARCHS), st.booleans()), + st.tuples(st.just("promote"), _selector), + st.tuples(st.just("demote"), _selector), + st.tuples(st.just("review"), _selector, + st.sampled_from(("approved", "rejected"))), + ), + max_size=40, +) + + +def _artifact_entry(arch, ok): + """Per-arch artifact entry as the build result handler records it.""" + return { + "s3Key": f"workflow-plugins/custom/uc/{arch}/p.so", + "checksum": "ab" * 32, + "signature": "sig-bytes", + "buildStatus": "succeeded" if ok else "failed", + "logTail": "" if ok else "error: boom", + } + + +def _assert_fresh(mod, item): + """New records and new versions start dev + pending (9.1, 9.13, + 10.1, 10.5), with no artifacts carried over.""" + assert item["lifecycle_state"] == mod.STATE_DEV + assert item["review"]["decision"] == mod.REVIEW_PENDING + assert item["artifacts"] == {} + + +def _assert_agrees(mod, item, model): + """The implementation item matches the reference model state.""" + assert item["lifecycle_state"] == model.state + assert item["review"]["decision"] == model.review + assert mod.successful_build_archs(item) == sorted(model.succeeded_archs) + + +# --------------------------------------------------------------------------- +# Property 10 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(ops=operations) +def test_lifecycle_state_machine_conformance(records, ops): + """**Feature: custom-node-designer, Property 10: Lifecycle state machine conformance** + + For all random sequences of lifecycle operations applied to + Plugin_Record version items, the implementation agrees with the + reference state machine, every rejection carries the identifying + error, and guard evaluation never mutates the record. + + **Validates: Requirements 9.1, 9.4, 9.5, 9.9, 9.10, 9.13, 10.1, 10.5** + """ + mod = records + + def new_version(version): + item = mod.new_version_item( + plugin_id="plugin-p10", version=version, usecase_id="uc-p10", + name="p10", kind="scaffold", user_id="user-p10", + timestamp=1_700_000_000_000 + version, + ) + _assert_fresh(mod, item) + return item + + # Create the record (version 1, dev + pending: 9.1, 10.1). + items = [new_version(1)] + model = [ModelVersion()] + + for op in ops: + kind, selector = op[0], op[1] + idx = selector % len(items) + item, mv = items[idx], model[idx] + + if kind == "new_version": + # A new version starts dev + pending regardless of the + # states and approvals prior versions reached (9.13, 10.5). + items.append(new_version(len(items) + 1)) + model.append(ModelVersion()) + + elif kind == "build": + arch, ok = op[2], op[3] + item["artifacts"][arch] = _artifact_entry(arch, ok) + mv.build(arch, ok) + + elif kind == "promote": + before = copy.deepcopy(item) + next_state, error = mod.evaluate_promotion(item) + # Guard evaluation only decides; it never mutates (9.12 + # analogue for promotion: only the state may change, and + # only when the handler applies the returned next state). + assert item == before + + expected_state, expected_code = mv.promote() + if expected_state is not None: + # dev->test with a build present, or test->prod with an + # approved review, succeeds one step forward (9.4, 9.9). + assert error is None + assert next_state == expected_state + item["lifecycle_state"] = next_state + mv.state = expected_state + else: + # Rejected: no state change, identifying the missing + # build (9.5) or the missing approval (9.10). + assert next_state is None + assert error["code"] == expected_code + if expected_code == "PLUGIN_BUILD_REQUIRED": + assert "Plugin_Artifact" in error["details"]["missing"] + elif expected_code == "SECURITY_REVIEW_REQUIRED": + assert "security review" in error["details"]["missing"] + + elif kind == "demote": + before = copy.deepcopy(item) + next_state, error = mod.evaluate_demotion(item) + assert item == before + + expected_state, expected_code = mv.demote() + if expected_state is not None: + # Demotion always succeeds one step back and only + # changes the state: review and artifacts untouched. + assert error is None + assert next_state == expected_state + item["lifecycle_state"] = next_state + mv.state = expected_state + else: + # No transition below dev exists. + assert next_state is None + assert error["code"] == expected_code + + elif kind == "review": + decision = op[2] + item["review"] = {"decision": decision, "reviewer": "admin-p10", + "reviewedAt": 1_700_000_000_000} + mv.review = decision + + _assert_agrees(mod, item, mv) + + # Final sweep: every version still agrees with its model twin, so + # operations on one version never leaked into another. + for item, mv in zip(items, model): + _assert_agrees(mod, item, mv) diff --git a/edge-cv-portal/backend/tests/test_property_reference_removal.py b/edge-cv-portal/backend/tests/test_property_reference_removal.py new file mode 100644 index 00000000..421660cc --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_reference_removal.py @@ -0,0 +1,203 @@ +"""Property test for reference-counted Custom_Node_Type removal (task 9.5). + +**Feature: custom-node-designer, Property 19: Reference-counted removal** + +For all sets of saved WorkflowVersions items — random subsets referencing +the Custom_Node_Type via the ``custom_node_types`` map attribute recorded +at save, via a legacy list attribute, or via the stored definition +document fallback, with the others not referencing it (absent id in the +attribute, definition without the node, unloadable definition, or no +recorded key at all) — the removal decision permits removal if and only +if the referencing subset is empty, and every rejection lists exactly the +referencing workflow versions. + +**Validates: Requirements 14.4, 14.5** + +The logic under test (`item_references_node_type`, +`definition_references_node_type`, `evaluate_removal` in +functions/custom_node_types.py) is pure over plain dicts, so it is +exercised directly with no AWS involvement. The module is imported +through the shared moto-backed session fixture only so the real +`shared_utils` layer (not a test fake) backs the import. +""" + +from __future__ import annotations + +import copy + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def node_types(aws_stack): + """The real custom_node_types module, imported via the session stack.""" + return aws_stack.custom_node_types + + +# --------------------------------------------------------------------------- +# Generators: WorkflowVersions items over random reference shapes +# --------------------------------------------------------------------------- + +#: The Custom_Node_Type whose removal is being decided. +NODE_TYPE_ID = "custom.uc-p19.frame-annotator" + +#: Other type ids that may appear in reference attributes and definition +#: documents without ever counting as a reference to NODE_TYPE_ID. +_other_type_ids = st.sampled_from(( + "custom.uc-p19.other-node", + "custom.uc-other.blur", + "source.rtsp", + "ai.inference", + "", +)) + +_versions = st.integers(min_value=1, max_value=9) + + +def _node(type_id, index): + return {"id": f"node-{index}", "type": type_id, "parameters": {}} + + +@st.composite +def _definition(draw, referencing): + """A stored Workflow_Definition document; places NODE_TYPE_ID on the + canvas at a random position exactly when `referencing`.""" + nodes = [_node(t, i) + for i, t in enumerate(draw(st.lists(_other_type_ids, max_size=3)))] + # Structural noise that must never count as a reference. + if draw(st.booleans()): + nodes.append("not-a-node-dict") + if referencing: + position = draw(st.integers(min_value=0, max_value=len(nodes))) + nodes.insert(position, _node(NODE_TYPE_ID, "target")) + return {"nodes": nodes, "connections": []} + + +#: Reference shapes an item may take. The first three reference +#: NODE_TYPE_ID; the rest must be treated as non-referencing. +KINDS = ( + "map_ref", # custom_node_types map attribute contains the id + "list_ref", # legacy list attribute contains the id + "definition_ref", # no attribute; stored definition places the node + "map_noref", # map attribute without the id (attribute preferred) + "list_noref", # list attribute without the id (attribute preferred) + "definition_noref", # definition without the node + "definition_unloadable", # recorded key, but the document is unreadable + "bare", # no attribute and no recorded definition key +) +REFERENCING_KINDS = frozenset({"map_ref", "list_ref", "definition_ref"}) + + +@st.composite +def _workflow_world(draw): + """A random set of WorkflowVersions items plus the stored definition + documents behind their s3 keys and the expected referencing subset.""" + kinds = draw(st.lists(st.sampled_from(KINDS), max_size=8)) + items = [] + definitions = {} + expected_references = [] + + for index, kind in enumerate(kinds): + item = { + "workflow_id": f"wf-{index}", + "version": draw(_versions), + } + + if kind in ("map_ref", "map_noref", "list_ref", "list_noref"): + other = draw(st.lists(_other_type_ids, unique=True, max_size=3)) + if kind.startswith("map"): + references = {t: draw(_versions) for t in other} + if kind == "map_ref": + references[NODE_TYPE_ID] = draw(_versions) + else: + references = list(other) + if kind == "list_ref": + references.insert( + draw(st.integers(min_value=0, max_value=len(other))), + NODE_TYPE_ID) + item["custom_node_types"] = references + # The save-time attribute is preferred over the stored + # definition document: a definition that contradicts the + # attribute must never change the decision. + if draw(st.booleans()): + key = f"workflows/defs/{index}.json" + item["s3_definition_key"] = key + definitions[key] = draw( + _definition(referencing=draw(st.booleans()))) + + elif kind in ("definition_ref", "definition_noref"): + key = f"workflows/defs/{index}.json" + item["s3_definition_key"] = key + definitions[key] = draw( + _definition(referencing=(kind == "definition_ref"))) + + elif kind == "definition_unloadable": + # The loader yields None for this key (unreadable document); + # treated as non-referencing. + item["s3_definition_key"] = f"workflows/defs/missing-{index}.json" + + # "bare": neither a reference attribute nor a definition key. + + if kind in REFERENCING_KINDS: + expected_references.append({ + "workflow_id": item["workflow_id"], + "version": item["version"], + }) + items.append((item, kind in REFERENCING_KINDS)) + + return items, definitions, expected_references + + +# --------------------------------------------------------------------------- +# Property 19 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(world=_workflow_world()) +def test_reference_counted_removal(node_types, world): + """**Feature: custom-node-designer, Property 19: Reference-counted removal** + + For all sets of WorkflowVersions items with random subsets + referencing the Custom_Node_Type (map attribute, list attribute, or + definition-document fallback) and the others not referencing it, + reference detection classifies every item exactly, the removal + decision permits removal if and only if the referencing subset is + empty, and every rejection lists exactly the referencing workflow + versions. + + **Validates: Requirements 14.4, 14.5** + """ + items, definitions, expected_references = world + loader = definitions.get # missing/unloadable keys yield None + + # Reference detection: each item classified exactly as constructed, + # without mutation. + detected_references = [] + for item, expected_referencing in items: + before = copy.deepcopy(item) + referencing = node_types.item_references_node_type( + item, NODE_TYPE_ID, loader) + assert item == before + assert referencing == expected_referencing + if referencing: + detected_references.append({ + "workflow_id": item["workflow_id"], + "version": item["version"], + }) + + assert detected_references == expected_references + + # Removal decision (14.4, 14.5): permitted iff no references exist; + # rejections list exactly the referencing workflow versions. + decision = node_types.evaluate_removal(NODE_TYPE_ID, detected_references) + + if not expected_references: + assert decision is None + else: + assert decision["code"] == "CUSTOM_NODE_TYPE_IN_USE" + assert str(len(expected_references)) in decision["message"] + assert NODE_TYPE_ID in decision["message"] + assert decision["details"]["node_type_id"] == NODE_TYPE_ID + assert decision["details"]["referencing_workflows"] == expected_references diff --git a/edge-cv-portal/backend/tests/test_property_sampling_exclusivity.py b/edge-cv-portal/backend/tests/test_property_sampling_exclusivity.py new file mode 100644 index 00000000..3ff3d607 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_sampling_exclusivity.py @@ -0,0 +1,89 @@ +"""Property test for the shared Bedrock inference-config builder +(custom-node-code-assist, task 1.4). + +**Feature: custom-node-code-assist, Property 11: Sampling parameter +exclusivity** + +*For any* combination of `temperature` and `top_p` values (each set or +unset, where unset means absent from the resolved configuration or +present as None), `build_inference_config` emits at most one sampling +parameter: `temperature` when it is set, else `topP` when it is set, +and omits any parameter that is unset. + +**Validates: Requirements 4.2, 4.3** + +`build_inference_config` is a pure function over a resolved +Bedrock_Configuration dict (bedrock_common.py, shared by workflow +generation and code assist), so no AWS stack is required; conftest.py +puts backend/functions on sys.path and pins fake AWS credentials for +bedrock_common's module-level boto3 resource. +""" +import math + +from hypothesis import given +from hypothesis import strategies as st + +from bedrock_common import build_inference_config + +# A sampling parameter as it appears in a resolved configuration: +# unset as "absent from the dict", unset as an explicit None (both arise +# from get_bedrock_configuration), or set to a number (int or float -- +# build_inference_config coerces through float()). +_ABSENT = object() + +sampling_values = st.one_of( + st.just(_ABSENT), + st.none(), + st.floats(min_value=0, max_value=2, + allow_nan=False, allow_infinity=False), + st.integers(min_value=0, max_value=2), +) + + +@st.composite +def resolved_configs(draw): + """A resolved Bedrock_Configuration covering every set/unset + combination of the two sampling parameters.""" + config = {"max_tokens": draw(st.integers(min_value=1, max_value=200_000))} + temperature = draw(sampling_values) + top_p = draw(sampling_values) + if temperature is not _ABSENT: + config["temperature"] = temperature + if top_p is not _ABSENT: + config["top_p"] = top_p + return config + + +def _is_set(config, key): + return config.get(key) is not None + + +@given(config=resolved_configs()) +def test_at_most_one_sampling_parameter_temperature_wins(config): + """temperature when set, else topP when set; unset parameters are + omitted; nothing but maxTokens and the winning sampling parameter is + emitted (Requirements 4.2, 4.3).""" + inference_config = build_inference_config(config) + + # maxTokens is always present; no other keys beyond the sampling pair. + assert inference_config["maxTokens"] == int(config["max_tokens"]) + assert set(inference_config) <= {"maxTokens", "temperature", "topP"} + + # At most ONE sampling parameter, never both (Requirement 4.3). + assert not ("temperature" in inference_config + and "topP" in inference_config) + + if _is_set(config, "temperature"): + # temperature wins whenever it is set, regardless of top_p. + assert math.isclose(inference_config["temperature"], + float(config["temperature"])) + assert "topP" not in inference_config + elif _is_set(config, "top_p"): + # topP is sent only when temperature is unset (Requirement 4.2/4.3). + assert math.isclose(inference_config["topP"], + float(config["top_p"])) + assert "temperature" not in inference_config + else: + # Both unset: both omitted (Requirement 4.2). + assert "temperature" not in inference_config + assert "topP" not in inference_config diff --git a/edge-cv-portal/backend/tests/test_property_sign_verify.py b/edge-cv-portal/backend/tests/test_property_sign_verify.py new file mode 100644 index 00000000..17ee3484 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_sign_verify.py @@ -0,0 +1,165 @@ + +"""Property test for Plugin_Artifact sign-then-verify (task 10.2). + +**Feature: custom-node-designer, Property 8: Sign-then-verify round trip with tamper detection** + +For all artifact byte strings: storing the artifact through the +Plugin_Build_Service signing path (SHA-256 checksum + KMS ECDSA_SHA_256 +signature over the digest, plugin_builds.store_signed_artifact) and then +verifying it through the Component_Packager verification path +(workflow_packaging.verify_custom_plugin_artifact: stream the bytes, +recompute the SHA-256, KMS-Verify the recorded signature) succeeds on +the unmodified bytes and returns the recorded checksum. + +And for all tampered variants of the stored bytes (single byte flips, +truncations, appends, and wholesale replacement with different bytes), +verification fails via the existing PackagingError path identifying the +checksum failure; a recorded signature computed over different bytes +likewise fails verification identifying the signature failure. + +**Validates: Requirements 3.3, 3.6, 10.4** + +Runs against the moto-backed session stack from conftest.py: the +signing key is a real ECC_NIST_P256 KMS key (PLUGIN_SIGNING_KEY_ARN), +so signatures and verifications are genuine ECDSA operations. +""" + +from __future__ import annotations + +import hashlib +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from conftest import TEST_ENV + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") +BUCKET = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + + +@pytest.fixture(scope="module") +def builds(aws_stack): + """The real plugin_builds module (signing path), from the session stack.""" + return aws_stack.plugin_builds + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Generators: artifact bytes and a guaranteed-different tampered variant +# --------------------------------------------------------------------------- + +@st.composite +def artifact_cases(draw): + """(data, tampered, arch): artifact bytes plus a tampered variant + produced by a byte flip, truncation, append, or replacement with + different bytes — always with tampered != data.""" + data = draw(st.binary(min_size=1, max_size=2048)) + kind = draw(st.sampled_from(("flip", "truncate", "append", "replace"))) + if kind == "flip": + index = draw(st.integers(min_value=0, max_value=len(data) - 1)) + delta = draw(st.integers(min_value=1, max_value=255)) + tampered = (data[:index] + + bytes([data[index] ^ delta]) + + data[index + 1:]) + elif kind == "truncate": + cut = draw(st.integers(min_value=1, max_value=len(data))) + tampered = data[:len(data) - cut] + elif kind == "append": + tampered = data + draw(st.binary(min_size=1, max_size=64)) + else: # replace + tampered = draw( + st.binary(min_size=0, max_size=2048).filter(lambda b: b != data)) + assert tampered != data + arch = draw(st.sampled_from(ARCHS)) + return data, tampered, arch + + +# --------------------------------------------------------------------------- +# Property 8 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(case=artifact_cases()) +def test_sign_then_verify_round_trip_with_tamper_detection( + aws_stack, builds, packaging, case): + """**Feature: custom-node-designer, Property 8: Sign-then-verify round trip with tamper detection** + + For all artifact byte strings, the signing path (SHA-256 checksum + + KMS signature, Requirements 3.3/3.6) followed by the packaging + verification path succeeds on the unmodified bytes; for all + tampered variants the checksum recompute fails via PackagingError, + and a signature recorded over different bytes fails the KMS + signature verification via PackagingError (Requirement 10.4). + + **Validates: Requirements 3.3, 3.6, 10.4** + """ + data, tampered, arch = case + s3 = aws_stack.s3 + + # Deterministic per-example namespace so examples never collide. + usecase_id = f"uc-p8-{hashlib.sha256(data + tampered).hexdigest()[:12]}" + plugin_id = "plg-p8" + plugin_name = "sign-verify-plugin" + dependency = f"custom:{usecase_id}/{plugin_name}" + node_type_id = "custom.sign-verify" + + # --- signing path (3.3 / 3.6): checksum, KMS-sign, store .so + .sig + entry = builds.store_signed_artifact(usecase_id, arch, plugin_name, data) + record = { + "plugin_id": plugin_id, + "version": 1, + "usecase_id": usecase_id, + "name": plugin_name, + "artifacts": {arch: {**entry, "buildStatus": "succeeded"}}, + } + + # The recorded checksum is the SHA-256 of exactly the stored bytes, + # and the detached signature object sits alongside the artifact. + assert entry["checksum"] == hashlib.sha256(data).hexdigest() + stored = s3.get_object(Bucket=BUCKET, Key=entry["s3Key"])["Body"].read() + assert stored == data + s3.head_object(Bucket=BUCKET, Key=entry["s3Key"] + ".sig") + + # --- round trip (10.4): unmodified bytes verify successfully + manifest_key, checksum = packaging.verify_custom_plugin_artifact( + dependency, node_type_id, record, arch) + assert checksum == entry["checksum"] + assert manifest_key.endswith(f"/{plugin_name}.so") + + # --- tamper detection (10.4): modified stored bytes fail the + # checksum recompute through the PackagingError path + s3.put_object(Bucket=BUCKET, Key=entry["s3Key"], Body=tampered) + with pytest.raises(packaging.PackagingError) as checksum_failure: + packaging.verify_custom_plugin_artifact( + dependency, node_type_id, record, arch) + assert "checksum" in checksum_failure.value.message + assert checksum_failure.value.artifact == ( + f"custom-plugins/{arch}/{plugin_name}.so") + + # --- signature over different bytes (10.4): restore the original + # artifact so the checksum passes, but record a signature computed + # over the tampered bytes — KMS verification must reject it + s3.put_object(Bucket=BUCKET, Key=entry["s3Key"], Body=data) + wrong_signature = builds.sign_digest(hashlib.sha256(tampered).digest()) + forged_record = { + **record, + "artifacts": {arch: {**entry, "buildStatus": "succeeded", + "signature": wrong_signature}}, + } + with pytest.raises(packaging.PackagingError) as signature_failure: + packaging.verify_custom_plugin_artifact( + dependency, node_type_id, forged_record, arch) + assert "signature" in signature_failure.value.message + assert signature_failure.value.artifact == ( + f"custom-plugins/{arch}/{plugin_name}.so") diff --git a/edge-cv-portal/backend/tests/test_property_simulator_guard.py b/edge-cv-portal/backend/tests/test_property_simulator_guard.py new file mode 100644 index 00000000..73f1b760 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_simulator_guard.py @@ -0,0 +1,146 @@ +"""Property test for the Plugin_Simulator start guard (task 8.3). + +**Feature: custom-node-designer, Property 15: Simulator start guard equals x86_64 artifact presence** + +For all Plugin_Record versions with random per-architecture artifact +sets (random arch subsets, random buildStatus values, present, empty, +or missing s3Key, and malformed entries), the Plugin_Simulator permits +starting a run if and only if a successfully built x86_64 +Plugin_Artifact with a stored Plugin_Library key exists, and every +refusal describes the missing x86_64 build. + +**Validates: Requirements 7.5** + +The guard under test (`evaluate_simulation_guard`) is pure over the +Plugin_Record item dict, so it is exercised directly with no AWS +involvement. The module is imported through the shared moto-backed +session fixture only so the real `shared_utils` layer (not a test +fake) backs the import. +""" + +from __future__ import annotations + +import copy + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def simulator(aws_stack): + """The real plugin_simulator module, imported via the session stack.""" + return aws_stack.plugin_simulator + + +# --------------------------------------------------------------------------- +# Generators: arbitrary artifacts maps +# --------------------------------------------------------------------------- + +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") + +#: buildStatus values: the real lifecycle values plus arbitrary noise. +_build_status = st.one_of( + st.sampled_from(("succeeded", "failed", "building")), + st.text(max_size=12), + st.none(), +) + +#: s3Key values: a real Plugin_Library key, an empty string (recorded +#: but blank), or absent entirely (via the optional dict field below). +_s3_key = st.one_of( + st.just("workflow-plugins/custom/uc-p15/x86_64/plugin.so"), + st.just(""), + st.none(), +) + +#: A structurally well-formed per-arch artifact entry, with buildStatus +#: and s3Key each independently valid, wrong, or missing. +_well_formed_entry = st.fixed_dictionaries( + {}, + optional={ + "buildStatus": _build_status, + "s3Key": _s3_key, + "checksum": st.just("ab" * 32), + "signature": st.just("sig-bytes"), + "logTail": st.text(max_size=10), + }, +) + +#: Malformed entries: not a dict at all. +_malformed_entry = st.one_of( + st.none(), + st.text(max_size=10), + st.integers(), + st.lists(st.integers(), max_size=3), + st.booleans(), +) + +_entry = st.one_of(_well_formed_entry, _malformed_entry) + +#: Artifacts maps over random arch subsets (unknown arch names mixed in +#: to confirm they never satisfy the guard). +_artifacts = st.dictionaries( + keys=st.one_of(st.sampled_from(ARCHS), st.just("riscv64")), + values=_entry, + max_size=6, +) + +#: Plugin_Record version items: the artifacts field present with a +#: random map, present but None (cleared), or absent entirely. +_item = st.fixed_dictionaries( + { + "plugin_id": st.just("plugin-p15"), + "version": st.integers(min_value=1, max_value=99), + "lifecycle_state": st.sampled_from(("dev", "test", "prod")), + }, + optional={"artifacts": st.one_of(_artifacts, st.none())}, +) + + +def _reference_guard_passes(item): + """Requirement 7.5 restated: a run may start exactly when a + successfully built x86_64 Plugin_Artifact with a stored key exists.""" + artifacts = item.get("artifacts") or {} + entry = artifacts.get("x86_64") + return ( + isinstance(entry, dict) + and entry.get("buildStatus") == "succeeded" + and bool(entry.get("s3Key")) + ) + + +# --------------------------------------------------------------------------- +# Property 15 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(item=_item) +def test_simulator_start_guard_equals_x86_64_artifact_presence(simulator, item): + """**Feature: custom-node-designer, Property 15: Simulator start guard equals x86_64 artifact presence** + + For all Plugin_Record versions with random per-architecture + artifact sets, the guard permits a run if and only if a + successfully built x86_64 Plugin_Artifact exists, every refusal + carries the identifying rejection describing the missing x86_64 + build, and guard evaluation never mutates the record. + + **Validates: Requirements 7.5** + """ + before = copy.deepcopy(item) + allowed, error = simulator.evaluate_simulation_guard(item) + + # Guard evaluation only decides; it never mutates the record. + assert item == before + + if _reference_guard_passes(item): + assert allowed is True + assert error is None + else: + assert allowed is False + # The refusal identifies itself and describes the missing build. + assert error["code"] == "SIMULATION_REQUIRES_X86_64_BUILD" + assert "x86_64" in error["message"] + assert error["details"]["missing"] == "successful x86_64 Plugin_Artifact" + assert error["details"]["plugin_id"] == item["plugin_id"] + assert error["details"]["version"] == item["version"] diff --git a/edge-cv-portal/backend/tests/test_property_test_device_gate.py b/edge-cv-portal/backend/tests/test_property_test_device_gate.py new file mode 100644 index 00000000..83670b17 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_test_device_gate.py @@ -0,0 +1,140 @@ +"""Property test for the test-device deployment gate (task 10.6). + +**Feature: custom-node-designer, Property 14: Deployment gate restricts test-state plugins to Test_Devices** + +For all sets of workflow plugin lifecycle states and target devices +with random test_device flags, deployment submission is permitted if +and only if no plugin is in dev (or unknown — fail closed) state and +every test-state plugin targets only devices flagged as Test_Devices +(prod-state plugins deploy anywhere); every rejection identifies the +Plugin_Component and its Lifecycle_State plus exactly the offending +target devices. + +**Validates: Requirements 9.7, 9.8, 9.11** + +The gate under test (``evaluate_plugin_lifecycle_gate`` in +functions/deployments.py) is pure over plain dicts, so the property is +exercised directly with no AWS calls. The module is imported through +the shared moto-backed session fixture only so its module-level boto3 +clients bind to the mock (same re-import pattern as +test_deployment_plugin_gates.py). +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + """Import deployments inside the moto mock so its module-level boto3 + clients are intercepted.""" + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# --------------------------------------------------------------------------- +# Reference model: permission restated independently from the +# requirements (9.7, 9.8, 9.11) rather than derived from the +# implementation, so the test cannot silently agree with a wrong gate. +# --------------------------------------------------------------------------- + +def pair_permitted_model(state, test_device_flag): + """May this Plugin_Component reach this device? + + - prod deploys anywhere in the Use_Case (9.11); + - test only to devices flagged test_device (9.7, 9.8); + - dev, or any unknown/unresolvable state, never (fail closed). + """ + if state == "prod": + return True + if state == "test": + return test_device_flag + return False + + +def offending_pairs_model(closure_states, device_flags): + """The exact set of forbidden (component, device) pairs.""" + return { + (component, device) + for component, state in closure_states.items() + for device, flag in device_flags.items() + if not pair_permitted_model(state, flag) + } + + +# --------------------------------------------------------------------------- +# Random closures and device sets +# --------------------------------------------------------------------------- + +# The recognized lifecycle states plus unknown / absent values the gate +# must fail closed on. +LIFECYCLE_STATES = ("dev", "test", "prod", "archived", "", None) + +_component_names = st.integers(min_value=0, max_value=5).map( + lambda i: f"dda.plugin.p{i}") +_device_names = st.integers(min_value=0, max_value=5).map( + lambda i: f"device-{i}") + +# Deployments always target at least one device (the handler validates +# target_devices before the gates run), so device sets are nonempty; +# closures may be empty (a workflow without custom plugins). +_closures = st.dictionaries( + _component_names, st.sampled_from(LIFECYCLE_STATES), max_size=6) +_device_sets = st.dictionaries( + _device_names, st.booleans(), min_size=1, max_size=6) + + +# --------------------------------------------------------------------------- +# Property 14 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(closure_states=_closures, device_flags=_device_sets) +def test_gate_restricts_test_state_plugins_to_test_devices( + deployments, closure_states, device_flags): + """**Feature: custom-node-designer, Property 14: Deployment gate restricts test-state plugins to Test_Devices** + + For all random closures (components with random lifecycle states) + against random device sets (random test_device flags), the gate + passes if and only if every component/device pair is permitted (dev + or unknown never, test only to flagged devices, prod always), and + violations identify exactly the offending pairs with each + component's Lifecycle_State. + + **Validates: Requirements 9.7, 9.8, 9.11** + """ + violations = deployments.evaluate_plugin_lifecycle_gate( + closure_states, device_flags) + + expected_pairs = offending_pairs_model(closure_states, device_flags) + + # Submission is permitted iff no pair is forbidden (9.7, 9.11). + assert (violations == []) == (expected_pairs == set()) + + # Violations identify exactly the offending pairs — nothing missing, + # nothing extra (9.8). + actual_pairs = { + (violation["pluginComponent"], device) + for violation in violations + for device in violation["devices"] + } + assert actual_pairs == expected_pairs + + # Each rejection identifies the Plugin_Component and its + # Lifecycle_State as recorded in the closure (9.8), lists at least + # one offending device, and no component is reported twice. + components = [v["pluginComponent"] for v in violations] + assert len(components) == len(set(components)) + for violation in violations: + component = violation["pluginComponent"] + assert component in closure_states + assert violation["lifecycleState"] == closure_states[component] + assert violation["devices"] != [] diff --git a/edge-cv-portal/backend/tests/test_property_version_retention.py b/edge-cv-portal/backend/tests/test_property_version_retention.py new file mode 100644 index 00000000..a82d95ac --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_version_retention.py @@ -0,0 +1,234 @@ +"""Property test for Custom_Node_Type version retention and pinning (task 9.4). + +**Feature: custom-node-designer, Property 18: Version retention and pinned resolution** + +For all random sequences of Custom_Node_Type declaration updates, +every prior version item remains retrievable and unchanged with +version numbers strictly increasing (Requirement 14.1); and for all +random catalogs and pin maps, resolution returns exactly the pinned +version of a type when that version exists and the latest registered +version otherwise, with deprecated types remaining resolvable +(Requirement 14.2). + +**Validates: Requirements 14.1, 14.2** + +Two layers are exercised: + +- Retention (14.1): the versioning helpers `next_node_type_version` and + `new_node_type_item` from functions/custom_node_types.py, driven the + way the update handler drives them (query latest -> next version -> + put new item), against the real moto-backed CustomNodeTypes table + from conftest.py, so retrieval via `query_node_type_versions` proves + the prior version items actually survive each update byte-for-byte. + +- Pinned resolution (14.2): the pure `resolution_items` merge in + functions/node_catalog_resolution.py, exercised directly over plain + dicts with no AWS involvement. +""" + +from __future__ import annotations + +import copy +import uuid + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="session") +def node_types(aws_stack): + """The real custom_node_types module, imported via the session stack.""" + return aws_stack.custom_node_types + + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +_SAFE_TEXT = st.text( + alphabet=st.characters(min_codepoint=32, max_codepoint=126), + min_size=0, max_size=20) + +#: One declaration update: the payload the update handler would store as +#: the new version's declaration (contents are opaque to the versioning +#: helpers), plus the backing Plugin_Record version pinned at that update. +_update = st.fixed_dictionaries({ + "displayName": _SAFE_TEXT, + "category": st.sampled_from(["Sources", "Processing", "Sinks", "AI"]), + "param": st.integers(min_value=-1000, max_value=1000), + "plugin_version": st.integers(min_value=1, max_value=9), +}) + +_TYPE_ID_POOL = st.sampled_from( + ["custom.p18_a", "custom.p18_b", "custom.p18_c", + "custom.p18_d", "custom.p18_e", "custom.p18_f"]) + + +@st.composite +def catalog_and_pins(draw): + """A random stored catalog plus a random pin map. + + Returns (items, pins, versions_by_type) where `items` are + CustomNodeTypes version items in random order (random deprecated + flags), `pins` maps a subset of type ids to an existing or a + vanished version number, and `versions_by_type` records the + registered versions of each type. + """ + type_ids = draw(st.lists(_TYPE_ID_POOL, unique=True, + min_size=1, max_size=6)) + versions_by_type = {} + items = [] + for type_id in type_ids: + versions = sorted(draw(st.sets(st.integers(min_value=1, max_value=12), + min_size=1, max_size=5))) + versions_by_type[type_id] = versions + for version in versions: + items.append({ + "node_type_id": type_id, + "version": version, + "usecase_id": "uc-p18", + "usecase_ids": ["uc-p18"], + "plugin_id": f"plugin-{type_id}", + "plugin_version": version, + "declaration": {"typeId": type_id, "marker": version}, + "deprecated": draw(st.booleans()), + }) + items = list(draw(st.permutations(items))) if len(items) > 1 else items + + pins = {} + for type_id in type_ids: + pin_kind = draw(st.sampled_from(["none", "existing", "vanished"])) + if pin_kind == "existing": + pins[type_id] = draw(st.sampled_from(versions_by_type[type_id])) + elif pin_kind == "vanished": + # Requirement 14.2 fallback: a pin to a version that no + # longer exists resolves to the latest version of the type. + pins[type_id] = draw(st.integers(min_value=13, max_value=99)) + return items, pins, versions_by_type + + +# --------------------------------------------------------------------------- +# Property 18a: version retention across declaration updates (14.1) +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(updates=st.lists(_update, min_size=1, max_size=6)) +def test_updates_retain_every_prior_version(node_types, aws_stack, updates): + """**Feature: custom-node-designer, Property 18: Version retention and pinned resolution** + + For all random sequences of declaration updates on a + Custom_Node_Type, each update creates a new version item with a + strictly increasing version number, and after every update all + prior version items remain retrievable and unchanged, each still + pinning the backing Plugin_Record version recorded at its creation. + + **Validates: Requirements 14.1, 14.2** + """ + mod = node_types + table = aws_stack.tables.custom_node_types + + # A fresh type id per example isolates examples on the shared + # session table without truncation. + type_id = f"custom.p18_{uuid.uuid4().hex[:12]}" + + snapshots = [] # deep copies taken at creation time + latest_version = None + + for index, update in enumerate(updates): + # Drive the helpers as the register/update handlers do: + # query the latest version, number the next one, build and + # store the new version item. + version = mod.next_node_type_version(latest_version) + + # Strictly increasing numbering (14.1). + assert version == (1 if latest_version is None + else latest_version + 1) + + declaration = { + "typeId": type_id, + "displayName": update["displayName"], + "category": update["category"], + "param": update["param"], + } + item = mod.new_node_type_item( + node_type_id=type_id, + version=version, + usecase_id="uc-p18", + usecase_ids=["uc-p18"], + plugin_id="plugin-p18", + plugin_version=update["plugin_version"], + declaration=declaration, + user_id="user-p18", + timestamp=1_700_000_000_000 + index, + ) + table.put_item(Item=mod.to_dynamo_json(item)) + snapshots.append(copy.deepcopy(item)) + latest_version = version + + # After this update, every prior version item (and the new one) + # remains retrievable and unchanged (14.1). + stored = mod.query_node_type_versions(type_id) + assert [s["version"] for s in stored] == \ + list(range(len(snapshots), 0, -1)) # newest first, no gaps + + by_version = {s["version"]: s for s in stored} + for snapshot in snapshots: + assert by_version[snapshot["version"]] == snapshot + + # The retained items still pin the backing Plugin_Record version + # recorded at their creation, regardless of later updates (14.2 + # groundwork: packaging resolves the recorded plugin version). + stored = {s["version"]: s for s in mod.query_node_type_versions(type_id)} + for index, update in enumerate(updates): + assert stored[index + 1]["plugin_version"] == update["plugin_version"] + + +# --------------------------------------------------------------------------- +# Property 18b: pinned resolution (14.2) +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(data=catalog_and_pins()) +def test_resolution_honors_pins_and_falls_back_to_latest(data): + """**Feature: custom-node-designer, Property 18: Version retention and pinned resolution** + + For all random catalogs of Custom_Node_Type version items and all + random pin maps, resolution returns exactly one item per type: the + pinned version when it exists, the latest registered version + otherwise (including when the pin names a vanished version), with + deprecated items resolving like any other (14.3 interplay: saved + workflows stay resolvable) and the input items never mutated. + + **Validates: Requirements 14.1, 14.2** + """ + from node_catalog_resolution import resolution_items + + items, pins, versions_by_type = data + items_before = copy.deepcopy(items) + + resolved = resolution_items(items, pins) + + # Exactly one resolved item per registered type, ordered by type id. + assert [r["node_type_id"] for r in resolved] == sorted(versions_by_type) + + by_key = {(i["node_type_id"], i["version"]): i for i in items} + for entry in resolved: + type_id = entry["node_type_id"] + versions = versions_by_type[type_id] + pin = pins.get(type_id) + expected_version = pin if pin in versions else max(versions) + # The pinned version when it exists, the latest otherwise (14.2). + assert entry["version"] == expected_version + # The resolved entry is the stored version item itself: the + # declaration (and deprecated flag) recorded for that version. + assert entry == by_key[(type_id, expected_version)] + + # Without pins every type resolves to its latest version (14.2 + # fallback baseline). + unpinned = resolution_items(items) + assert {r["node_type_id"]: r["version"] for r in unpinned} == \ + {t: max(v) for t, v in versions_by_type.items()} + + # Resolution is a read: the stored items are never mutated. + assert items == items_before diff --git a/edge-cv-portal/backend/tests/test_property_workflow_dependencies.py b/edge-cv-portal/backend/tests/test_property_workflow_dependencies.py new file mode 100644 index 00000000..e355ddae --- /dev/null +++ b/edge-cv-portal/backend/tests/test_property_workflow_dependencies.py @@ -0,0 +1,184 @@ +"""Property test for Workflow_Component plugin dependencies (task 10.4). + +**Feature: custom-node-designer, Property 21: Workflow_Component dependencies are exactly the custom plugins** + +For all random compiled pluginDependencies lists (arbitrary mixes of +curated GStreamer plugin names, ``python:`` runtime packages, and +``custom:{usecase}/{name}`` Custom_Node_Type dependencies, duplicates +included) and random backing Plugin_Record maps: + +- ``split_plugin_dependencies`` routes every dependency to exactly one + bucket (curated / custom / python), preserving the multiset — no + dependency is lost, duplicated across buckets, or misrouted; +- ``custom:`` dependencies NEVER appear in the inline curated plugin + list (they are delivered by Plugin_Component dependency, not bundled + inline — Requirement 11.1); +- ``plugin_component_dependencies`` yields exactly one HARD Greengrass + dependency on ``dda.plugin.{pluginId}`` per distinct backing + Plugin_Record, pinned with ``VersionRequirement`` + ``>={v}.0.0 <{v+1}.0.0`` — no curated/python leakage, no extras; +- ``build_recipe`` includes that block as ``ComponentDependencies`` + exactly when it is non-empty. + +**Validates: Requirements 16.4, 11.1** + +The functions under test are pure over plain values, so they are +exercised directly with no AWS involvement. The module is imported +through the shared moto-backed session fixture only so the real +``shared_utils`` layer (not a test fake) backs the import, mirroring +test_workflow_packaging_custom_plugins.py. +""" + +from __future__ import annotations + +import sys + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +# --------------------------------------------------------------------------- +# Reference expectations, restated from Requirements 16.4 / 11.1 and the +# design (not imported from the implementation, so the test cannot +# silently agree with a wrong prefix or version scheme). +# --------------------------------------------------------------------------- + +PYTHON_PREFIX = "python:" +CUSTOM_PREFIX = "custom:" +PLUGIN_COMPONENT_PREFIX = "dda.plugin." + + +def expected_version_requirement(version: int) -> str: + return f">={version}.0.0 <{version + 1}.0.0" + + +# --------------------------------------------------------------------------- +# Strategies +# --------------------------------------------------------------------------- + +# The colon is deliberately excluded so a curated name can never +# accidentally spell a "python:"/"custom:" prefix. +_name_alphabet = "abcdefghijklmnopqrstuvwxyz0123456789-" +_names = st.text(alphabet=_name_alphabet, min_size=1, max_size=24) + +curated_deps = _names +python_deps = st.builds(lambda pkg: PYTHON_PREFIX + pkg, _names) +custom_deps = st.builds( + lambda usecase, name: f"{CUSTOM_PREFIX}{usecase}/{name}", _names, _names) + +# A compiled pluginDependencies list: any mix, duplicates allowed. +dependency_lists = st.lists( + st.one_of(curated_deps, python_deps, custom_deps), max_size=20) + +# A pool of distinct backing Plugin_Records (plugin_id -> version). +plugin_pools = st.dictionaries( + _names, st.integers(min_value=1, max_value=9999), min_size=1, max_size=6) + + +@st.composite +def dependency_scenarios(draw): + """A compiled dependency list plus a dep_records map assigning every + distinct custom dependency a backing Plugin_Record from a shared + pool (several Custom_Node_Types may share one plugin).""" + deps = draw(dependency_lists) + pool = draw(plugin_pools) + plugin_ids = sorted(pool) + dep_records = {} + for dep in sorted({d for d in deps if d.startswith(CUSTOM_PREFIX)}): + plugin_id = draw(st.sampled_from(plugin_ids)) + dep_records[dep] = { + "plugin_id": plugin_id, + "version": pool[plugin_id], + "lifecycle_state": "test", + } + return deps, dep_records + + +# Valid Target_Architecture subsets for build_recipe's final_keys. +ARCHS = ("x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", "arm64_jp6") +arch_sets = st.frozensets(st.sampled_from(ARCHS), min_size=1) + + +# --------------------------------------------------------------------------- +# Property 21 +# --------------------------------------------------------------------------- + +@settings(max_examples=25, deadline=None) +@given(scenario=dependency_scenarios(), archs=arch_sets) +def test_dependencies_are_exactly_the_custom_plugins(packaging, scenario, + archs): + """**Feature: custom-node-designer, Property 21: Workflow_Component dependencies are exactly the custom plugins** + + For all random compiled dependency lists and backing-record maps, + split_plugin_dependencies partitions every dependency into exactly + one bucket with custom plugins never in the inline curated list, + plugin_component_dependencies declares exactly one pinned HARD + dda.plugin.{pluginId} dependency per distinct backing Plugin_Record + and nothing else, and build_recipe carries the block as + ComponentDependencies iff it is non-empty. + + **Validates: Requirements 16.4, 11.1** + """ + deps, dep_records = scenario + + gst, custom, python = packaging.split_plugin_dependencies(deps) + + # --- Partition: every dependency routed to exactly one bucket, the + # multiset preserved (nothing lost, duplicated, or invented). + reassembled = sorted( + list(gst) + list(custom) + [PYTHON_PREFIX + p for p in python]) + assert reassembled == sorted(deps) + + # --- Routing is by prefix; custom deps NEVER inline (11.1, 16.4). + assert all(not d.startswith((CUSTOM_PREFIX, PYTHON_PREFIX)) for d in gst) + assert all(d.startswith(CUSTOM_PREFIX) for d in custom) + assert all(not p.startswith(PYTHON_PREFIX) for p in python) + assert set(custom) == {d for d in deps if d.startswith(CUSTOM_PREFIX)} + assert not any(d.startswith(CUSTOM_PREFIX) for d in gst) + + # --- ComponentDependencies: exactly one pinned HARD entry per + # distinct backing Plugin_Record — no curated/python leakage, no + # extras (16.4). + dependencies = packaging.plugin_component_dependencies(dep_records) + + expected = { + f"{PLUGIN_COMPONENT_PREFIX}{record['plugin_id']}": { + "VersionRequirement": + expected_version_requirement(record["version"]), + "DependencyType": "HARD", + } + for record in dep_records.values() + } + assert dependencies == expected + + # Only Plugin_Components of the workflow's custom plugins appear. + backing_ids = {r["plugin_id"] for r in dep_records.values()} + assert {name[len(PLUGIN_COMPONENT_PREFIX):] for name in dependencies} \ + == backing_ids + assert all(name.startswith(PLUGIN_COMPONENT_PREFIX) + for name in dependencies) + + # --- Recipe inclusion: ComponentDependencies present iff non-empty. + final_keys = { + arch: f"workflows/components/wf-1/1/{arch}/workflow-{arch}.zip" + for arch in archs + } + recipe = packaging.build_recipe( + "wf-1", 1, "bucket", final_keys, + component_dependencies=dependencies) + + if dependencies: + assert recipe["ComponentDependencies"] == dependencies + else: + assert "ComponentDependencies" not in recipe diff --git a/edge-cv-portal/backend/tests/test_user_admin_change_role.py b/edge-cv-portal/backend/tests/test_user_admin_change_role.py new file mode 100644 index 00000000..72968b23 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_change_role.py @@ -0,0 +1,455 @@ +""" +Unit tests for PUT /api/v1/admin/users/{username}/role in user_admin.py +(spec: portal-user-manager, task 2.4). + +Covers: role validation against the five defined Portal_Role values, +the audit-pending -> admin_update_user_attributes -> audit-final flow +recording previous and new role, the last-PortalAdmin guard (409 + the +reason, rejected attempt audited), UserNotFoundException -> 404, other +Cognito failures -> 502 "role change failed" with the role unchanged, +the audit-before-effect abort (pending audit failure -> 500 "not +applied" with Cognito untouched), and the PortalAdmin 403 gate. + +Cognito is a recording fake client (same pattern as +test_user_admin_listing.py / test_user_admin_set_password.py); the +two-phase audit entries run against real moto-backed DynamoDB. + +_Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_ +""" +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + +PORTAL_ROLES = ("PortalAdmin", "UseCaseAdmin", "DataScientist", + "Operator", "Viewer") + + +# ----------------------------------------------------- fake Cognito client + +def cognito_error(code, message, operation="AdminUpdateUserAttributes"): + return ClientError({"Error": {"Code": code, "Message": message}}, + operation) + + +class FakeCognitoClient: + """Recording fake for admin_get_user, list_users (paginated), and + admin_update_user_attributes over an in-memory user population.""" + + def __init__(self, users=None, page_size=60, update_error=None): + self.users = list(users or []) + self.page_size = page_size + self.update_error = update_error + self.get_calls = [] + self.list_calls = [] + self.update_calls = [] + + def _find(self, username): + for user in self.users: + if user["Username"] == username: + return user + return None + + def admin_get_user(self, **kwargs): + self.get_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", "AdminGetUser") + return { + "Username": user["Username"], + "UserAttributes": user.get("Attributes", []), + "Enabled": user.get("Enabled", True), + "UserStatus": user.get("UserStatus", "CONFIRMED"), + } + + def list_users(self, **kwargs): + self.list_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + start = int(kwargs.get("PaginationToken", "0")) + limit = min(int(kwargs.get("Limit", 60)), self.page_size) + page = self.users[start:start + limit] + response = {"Users": page} + next_start = start + len(page) + if next_start < len(self.users): + response["PaginationToken"] = str(next_start) + return response + + def admin_update_user_attributes(self, **kwargs): + self.update_calls.append(kwargs) + if self.update_error is not None: + raise self.update_error + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.") + for new_attr in kwargs["UserAttributes"]: + attrs = user.setdefault("Attributes", []) + for attr in attrs: + if attr["Name"] == new_attr["Name"]: + attr["Value"] = new_attr["Value"] + break + else: + attrs.append(dict(new_attr)) + + def role_of(self, username): + user = self._find(username) + attrs = {a["Name"]: a["Value"] for a in user.get("Attributes", [])} + return attrs.get("custom:role") + + +def cognito_user(username, role=None, enabled=True, email=None): + """Build a user record in the Cognito list_users response shape.""" + attrs = [{"Name": "email_verified", "Value": "true"}] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if role is not None: + attrs.append({"Name": "custom:role", "Value": role}) + return {"Username": username, "Attributes": attrs, + "Enabled": enabled, "UserStatus": "CONFIRMED"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with the + edge-credentials table created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + ddb = boto3.client("dynamodb", region_name=REGION) + if EDGE_CREDENTIALS_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(users=None, page_size=60, update_error=None): + fake = FakeCognitoClient(users=users, page_size=page_size, + update_error=update_error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def role_event(username, body=None, caller_role="PortalAdmin"): + return { + "httpMethod": "PUT", + "path": f"/api/v1/admin/users/{username}/role", + "pathParameters": {"username": username}, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": caller_role, + } + } + }, + } + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "caller_role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, audit_table, install_cognito, caller_role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito + calls and no audit entry.""" + fake = install_cognito(users=[cognito_user("op1", role="Viewer")]) + status, body = invoke(user_admin, role_event( + "op1", {"role": "Operator"}, caller_role=caller_role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.update_calls == [] + assert fake.get_calls == [] + assert audit_entries(audit_table) == [] + + +class TestRoleValidation: + @pytest.mark.parametrize( + "bad_role", ["Admin", "portaladmin", "", None, 42, "SuperUser"]) + def test_undefined_role_rejected_400_before_any_effect( + self, user_admin, audit_table, install_cognito, bad_role): + """Req 5.2: only the five defined Portal_Role values are + accepted; anything else is a 400 with no Cognito mutation.""" + fake = install_cognito(users=[cognito_user("op1", role="Viewer")]) + status, body = invoke(user_admin, role_event( + "op1", {"role": bad_role})) + assert status == 400 + assert body["error"] == "Invalid role" + assert fake.update_calls == [] + assert audit_entries(audit_table) == [] + + @pytest.mark.parametrize("role", PORTAL_ROLES) + def test_all_five_defined_roles_accepted( + self, user_admin, audit_table, install_cognito, role): + fake = install_cognito(users=[ + cognito_user("op1", role="Viewer"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, role_event("op1", {"role": role})) + assert status == 200 + assert fake.role_of("op1") == role + + +class TestRoleChangeSuccess: + def test_updates_custom_role_attribute_exactly( + self, user_admin, audit_table, install_cognito): + """Req 5.1: the exact new role is applied to custom:role via + admin_update_user_attributes.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, body = invoke(user_admin, role_event( + "op1", {"role": "DataScientist"})) + + assert status == 200 + assert body["username"] == "op1" + assert body["previous_role"] == "Operator" + assert body["role"] == "DataScientist" + assert fake.update_calls == [{ + "UserPoolId": POOL_ID, + "Username": "op1", + "UserAttributes": [ + {"Name": "custom:role", "Value": "DataScientist"}], + }] + + def test_success_audits_previous_and_new_role( + self, user_admin, audit_table, install_cognito): + """Req 5.4, 6.1: exactly one finalized audit entry with acting + user, affected account, action type, completion time, and the + previous and new role.""" + install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, role_event( + "op1", {"role": "UseCaseAdmin"})) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "success" + assert entry["action"] == "role_change" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "op1" + assert entry["user_id"] == "admin-1" + assert entry["completed_at"] > 0 + assert entry["details"]["previous_role"] == "Operator" + assert entry["details"]["new_role"] == "UseCaseAdmin" + + def test_previous_role_defaults_to_viewer_when_attribute_missing( + self, user_admin, audit_table, install_cognito): + install_cognito(users=[ + cognito_user("roleless", role=None), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, body = invoke(user_admin, role_event( + "roleless", {"role": "Operator"})) + assert status == 200 + assert body["previous_role"] == "Viewer" + + +class TestLastPortalAdminGuard: + def test_demoting_the_last_enabled_portal_admin_rejected_409( + self, user_admin, audit_table, install_cognito): + """Req 5.3, 5.5: the change would leave zero enabled + PortalAdmins -> 409 with the reason, role untouched, and the + rejected attempt audited with the reason.""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("op1", role="Operator"), + cognito_user("disabled-admin", role="PortalAdmin", + enabled=False), + ]) + status, body = invoke(user_admin, role_event( + "admin-1", {"role": "Viewer"})) + + assert status == 409 + assert "last remaining enabled PortalAdmin" in body["message"] + assert fake.update_calls == [] + assert fake.role_of("admin-1") == "PortalAdmin" + + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "rejected" + assert entry["action"] == "role_change" + assert entry["resource_id"] == "admin-1" + assert entry["user_id"] == "admin-1" + assert "last remaining enabled PortalAdmin" in \ + entry["details"]["reason"] + + def test_demotion_allowed_when_another_enabled_portal_admin_remains( + self, user_admin, audit_table, install_cognito): + """Req 5.3: with a second enabled PortalAdmin the guard does not + fire.""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("admin-2", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, role_event( + "admin-1", {"role": "Viewer"})) + assert status == 200 + assert fake.role_of("admin-1") == "Viewer" + + def test_changing_a_disabled_portal_admin_is_not_guarded( + self, user_admin, audit_table, install_cognito): + """A disabled PortalAdmin does not count toward the enabled pool, + so changing its role cannot reduce the enabled count.""" + install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("old-admin", role="PortalAdmin", enabled=False), + ]) + status, _ = invoke(user_admin, role_event( + "old-admin", {"role": "Viewer"})) + assert status == 200 + + def test_keeping_portal_admin_role_is_not_guarded( + self, user_admin, audit_table, install_cognito): + """Re-selecting PortalAdmin for the last admin never triggers + the guard (the enabled-PortalAdmin count is unchanged).""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, role_event( + "admin-1", {"role": "PortalAdmin"})) + assert status == 200 + assert fake.list_calls == [] + + def test_guard_counts_across_pagination( + self, user_admin, audit_table, install_cognito): + """The guard paginates the full pool: the only other enabled + PortalAdmin sits on a later list_users page.""" + users = [cognito_user(f"filler-{i}", role="Viewer") + for i in range(5)] + users.insert(0, cognito_user("admin-1", role="PortalAdmin")) + users.append(cognito_user("admin-2", role="PortalAdmin")) + fake = install_cognito(users=users, page_size=3) + + status, _ = invoke(user_admin, role_event( + "admin-1", {"role": "Operator"})) + assert status == 200 + assert len(fake.list_calls) == 3 # 3 + 3 + 1: all pages fetched + + +class TestFailurePaths: + def test_unknown_user_maps_to_404( + self, user_admin, audit_table, install_cognito): + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin")]) + status, body = invoke(user_admin, role_event( + "ghost", {"role": "Viewer"})) + assert status == 404 + assert body["error"] == "User not found" + assert fake.update_calls == [] + + def test_cognito_failure_maps_to_502_role_unchanged( + self, user_admin, audit_table, install_cognito): + """Req 5.6: a Cognito failure during the update -> 502 'role + change failed', role unchanged, audit finalized to failure.""" + fake = install_cognito( + users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ], + update_error=cognito_error( + "InternalErrorException", "Something went wrong"), + ) + status, body = invoke(user_admin, role_event( + "op1", {"role": "Viewer"})) + + assert status == 502 + assert body["error"] == "role change failed" + assert fake.role_of("op1") == "Operator" + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + +class TestAuditBeforeEffect: + def test_pending_audit_failure_blocks_the_action( + self, user_admin, audit_table, install_cognito, monkeypatch): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the role change is not applied - zero update calls and an error + stating the action was not applied.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, role_event( + "op1", {"role": "Viewer"})) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert fake.update_calls == [] + assert fake.role_of("op1") == "Operator" + + +class TestRequestValidation: + def test_missing_body_rejected_400( + self, user_admin, audit_table, install_cognito): + fake = install_cognito(users=[cognito_user("op1", role="Viewer")]) + status, _ = invoke(user_admin, role_event("op1", None)) + assert status == 400 + assert fake.update_calls == [] diff --git a/edge-cv-portal/backend/tests/test_user_admin_create.py b/edge-cv-portal/backend/tests/test_user_admin_create.py new file mode 100644 index 00000000..80543967 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_create.py @@ -0,0 +1,464 @@ +""" +Unit tests for validate_create_request and POST /api/v1/admin/users in +user_admin.py (spec: portal-user-manager, task 13.2). + +Covers: the pure validation gate (all three fields present and +non-empty with rejections naming the missing field, email shape, role +in the five defined Portal_Role values; a rejection performs no +User_Pool call), the validate -> audit-pending (account_create) -> +admin_create_user with custom:role / email / email_verified=true and +the Cognito-native email invitation (D12: default MessageAction, no +SES, no portal-generated password, no verifier capture) -> audit-final +flow, UsernameExistsException / AliasExistsException -> 409 "account +already exists" with the Cognito Message passed through (duplicate +username and email-alias conflict cases), +other Cognito errors -> 502 "account was not created" with audit-final +failure, the audit-before-effect abort, and the PortalAdmin 403 gate. + +Cognito is a recording fake client (same pattern as the other +test_user_admin_* files); the audit entries and the edge-credentials +table run against real moto-backed DynamoDB. + +_Requirements: 12.1, 12.3, 12.5, 12.6, 12.7, 12.8, 12.9, 12.11_ +""" +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + +VALID_BODY = { + "username": "newuser1", + "email": "new.user@example.com", + "role": "Operator", +} + + +# ----------------------------------------------------- fake Cognito client + +class FakeCognitoClient: + """Recording fake for the cognito-idp admin_create_user API.""" + + def __init__(self, error=None): + self.error = error + self.calls = [] + + def admin_create_user(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + + +def cognito_error(code, message): + return ClientError({"Error": {"Code": code, "Message": message}}, + "AdminCreateUser") + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with the + edge-credentials table created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + ddb = boto3.client("dynamodb", region_name=REGION) + if EDGE_CREDENTIALS_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def credentials_table(user_admin): + """The moto-backed edge-credentials table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + EDGE_CREDENTIALS_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"username": item["username"]}) + return table + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(error=None): + fake = FakeCognitoClient(error=error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def create_event(body=None, role="PortalAdmin"): + return { + "httpMethod": "POST", + "path": "/api/v1/admin/users", + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": role, + } + } + }, + } + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +# ---------------------------------------------- validate_create_request + +class TestValidateCreateRequest: + def test_valid_payload_passes(self, user_admin): + assert user_admin.validate_create_request(dict(VALID_BODY)) is None + + @pytest.mark.parametrize("field", ["username", "email", "role"]) + def test_missing_field_rejected_naming_the_field( + self, user_admin, field): + """Req 12.7: a missing field is rejected and the rejection names + the missing field.""" + body = dict(VALID_BODY) + del body[field] + rejection = user_admin.validate_create_request(body) + assert rejection is not None + assert rejection["field"] == field + assert field in rejection["message"] + + @pytest.mark.parametrize("field", ["username", "email", "role"]) + def test_empty_field_rejected_naming_the_field(self, user_admin, field): + body = dict(VALID_BODY) + body[field] = "" + rejection = user_admin.validate_create_request(body) + assert rejection is not None + assert rejection["field"] == field + + def test_none_body_rejected(self, user_admin): + rejection = user_admin.validate_create_request(None) + assert rejection is not None + assert rejection["field"] == "username" + + @pytest.mark.parametrize("email", [ + "no-at-sign.example.com", # no @ + "@example.com", # empty local part + "user@", # empty domain + "user@nodot", # domain without a dot + "user@@example.com", # two separators + "a@b@c.com", # two separators + ]) + def test_invalid_email_shapes_rejected(self, user_admin, email): + """Req 12.6: the email must be a non-empty local part, '@', and + a non-empty domain containing at least one dot.""" + body = dict(VALID_BODY, email=email) + rejection = user_admin.validate_create_request(body) + assert rejection is not None + assert rejection["field"] == "email" + + @pytest.mark.parametrize("email", [ + "user@example.com", + "first.last@sub.example.co", + "x@y.z", + ]) + def test_valid_email_shapes_pass(self, user_admin, email): + body = dict(VALID_BODY, email=email) + assert user_admin.validate_create_request(body) is None + + @pytest.mark.parametrize("role", [ + "Admin", "portaladmin", "SuperUser", "viewer", " Operator"]) + def test_undefined_role_rejected(self, user_admin, role): + """Req 12.8: the role must be one of the five defined + Portal_Role values.""" + body = dict(VALID_BODY, role=role) + rejection = user_admin.validate_create_request(body) + assert rejection is not None + assert rejection["field"] == "role" + + @pytest.mark.parametrize("role", [ + "PortalAdmin", "UseCaseAdmin", "DataScientist", "Operator", + "Viewer"]) + def test_all_five_defined_roles_pass(self, user_admin, role): + body = dict(VALID_BODY, role=role) + assert user_admin.validate_create_request(body) is None + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "role", ["Viewer", "Operator", "DataScientist", "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, credentials_table, audit_table, + install_cognito, role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito + calls and no audit entry.""" + fake = install_cognito() + status, body = invoke(user_admin, create_event( + dict(VALID_BODY), role=role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.calls == [] + assert audit_entries(audit_table) == [] + + +class TestCreateSuccess: + def test_creates_account_with_cognito_native_invitation( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.1, 12.3 (D12): admin_create_user carries the exact + submitted username, email (email_verified=true), and custom:role, + with the default MessageAction (Cognito sends the invitation) + and no portal-generated TemporaryPassword.""" + fake = install_cognito() + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 201 + assert body["username"] == VALID_BODY["username"] + assert body["email"] == VALID_BODY["email"] + assert body["role"] == VALID_BODY["role"] + + assert len(fake.calls) == 1 + call = fake.calls[0] + assert call["UserPoolId"] == POOL_ID + assert call["Username"] == VALID_BODY["username"] + attrs = {a["Name"]: a["Value"] for a in call["UserAttributes"]} + assert attrs == { + "email": VALID_BODY["email"], + "email_verified": "true", + "custom:role": VALID_BODY["role"], + } + # D12: the Cognito-native invitation is the default MessageAction + # (neither suppressed nor resent) and the portal generates no + # password. + assert "MessageAction" not in call + assert "TemporaryPassword" not in call + + def test_success_finalizes_audit_with_created_account_details( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.11: exactly one account_create audit entry, finalized + to success, carrying the acting administrator and the created + account's username, email, and role.""" + install_cognito() + status, _ = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 201 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["action"] == "account_create" + assert entry["result"] == "success" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == VALID_BODY["username"] + assert entry["user_id"] == "admin-1" + assert entry["completed_at"] > 0 + details = entry["details"] + assert details["username"] == VALID_BODY["username"] + assert details["email"] == VALID_BODY["email"] + assert details["role"] == VALID_BODY["role"] + + def test_no_verifier_captured_at_creation( + self, user_admin, credentials_table, audit_table, + install_cognito): + """D12: the invitation password is never held by the portal, so + no verifier record is written at creation.""" + install_cognito() + status, _ = invoke(user_admin, create_event(dict(VALID_BODY))) + assert status == 201 + assert credentials_table.scan()["Items"] == [] + + +class TestValidationGate: + @pytest.mark.parametrize("field", ["username", "email", "role"]) + def test_missing_field_rejected_400_naming_field_no_pool_call( + self, user_admin, credentials_table, audit_table, + install_cognito, field): + """Req 12.7: the 400 response identifies the missing field and + the rejection performs no User_Pool call and writes no audit + pending entry.""" + fake = install_cognito() + body = dict(VALID_BODY) + del body[field] + status, resp = invoke(user_admin, create_event(body)) + + assert status == 400 + assert resp["field"] == field + assert fake.calls == [] + assert audit_entries(audit_table) == [] + + def test_invalid_email_rejected_400_no_pool_call( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.6: an invalid email shape is rejected identifying the + email, with no User_Pool call.""" + fake = install_cognito() + status, resp = invoke(user_admin, create_event( + dict(VALID_BODY, email="user@nodot"))) + + assert status == 400 + assert resp["field"] == "email" + assert fake.calls == [] + assert audit_entries(audit_table) == [] + + def test_undefined_role_rejected_400_no_pool_call( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.8: a role outside the five defined values is rejected + with no User_Pool call.""" + fake = install_cognito() + status, resp = invoke(user_admin, create_event( + dict(VALID_BODY, role="SuperUser"))) + + assert status == 400 + assert resp["field"] == "role" + assert fake.calls == [] + assert audit_entries(audit_table) == [] + + def test_invalid_json_body_rejected_400( + self, user_admin, credentials_table, audit_table, + install_cognito): + fake = install_cognito() + event = create_event() + event["body"] = "{not json" + status, _ = invoke(user_admin, event) + assert status == 400 + assert fake.calls == [] + + +class TestAccountConflict: + def test_username_exists_maps_to_409_with_cognito_message( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.5: UsernameExistsException -> 409 "account already + exists" with the Cognito Message ("User account already exists" + for a true duplicate username) passed through, no account + created or modified, and the audit entry finalized to failure.""" + install_cognito(error=cognito_error( + "UsernameExistsException", "User account already exists.")) + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 409 + assert body["error"] == "account already exists" + assert body["message"] == "User account already exists." + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + assert entries[0]["action"] == "account_create" + + def test_email_alias_conflict_passes_cognito_message_through( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Email is an alias attribute on the pool, so a new username + with an email already used by another account raises + UsernameExistsException whose Message describes the email + conflict; the message is passed through so the administrator + sees the real reason.""" + email_conflict = "An account with the given email already exists." + install_cognito(error=cognito_error( + "UsernameExistsException", email_conflict)) + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 409 + assert body["error"] == "account already exists" + assert body["message"] == email_conflict + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + def test_alias_exists_exception_maps_to_409_with_message( + self, user_admin, credentials_table, audit_table, + install_cognito): + """AliasExistsException is mapped the same way: 409 with the + Cognito Message passed through.""" + alias_conflict = "An account with the email already exists." + install_cognito(error=cognito_error( + "AliasExistsException", alias_conflict)) + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 409 + assert body["error"] == "account already exists" + assert body["message"] == alias_conflict + assert credentials_table.scan()["Items"] == [] + + +class TestOtherFailures: + def test_other_cognito_error_maps_to_502( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 12.9: any other Cognito failure -> 502 "account was not + created" with no partial record and audit-final failure.""" + install_cognito(error=cognito_error( + "InternalErrorException", "Something went wrong")) + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 502 + assert body["error"] == "account was not created" + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + +class TestAuditBeforeEffect: + def test_pending_audit_failure_blocks_the_action( + self, user_admin, credentials_table, audit_table, + install_cognito, monkeypatch): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the account is not created - zero Cognito calls and an error + stating the action was not applied.""" + fake = install_cognito() + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, create_event(dict(VALID_BODY))) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert fake.calls == [] diff --git a/edge-cv-portal/backend/tests/test_user_admin_delete.py b/edge-cv-portal/backend/tests/test_user_admin_delete.py new file mode 100644 index 00000000..719ddea1 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_delete.py @@ -0,0 +1,617 @@ +""" +Unit tests for DELETE /api/v1/admin/users/{username} in user_admin.py +(spec: portal-user-manager, task 13.4). + +Covers the D13 ordering: admin_get_user captures username/email/role +for the audit entry (14.8) and maps UserNotFoundException -> 404 with +nothing modified (14.11) -> last-PortalAdmin guard -> 409 + reason +with the rejected attempt audited (14.3, 14.4) -> audit-pending +(account_delete) -> admin_delete_user (14.2) -> delete the +edge-credentials verifier record (14.5) -> mark sync staging pending +with enabled=false, deleted=true (7.8) -> audit-final. + +Failure semantics: a Cognito failure aborts before the verifier record +is touched (14.6); a verifier-delete failure after a successful +Cognito delete retains the record for a subsequent attempt, finalizes +the audit entry with a partial-cleanup detail, and returns an error +whose message states the account was deleted but its verifier record +was not removed (14.10 - the frontend classifier matches /deleted/i +and /not removed/i). + +Cognito is a recording fake client (same pattern as the other +test_user_admin_* files); the audit entries, the edge-credentials +table, and the account-sync staging table run against real +moto-backed DynamoDB. + +_Requirements: 14.2, 14.3, 14.4, 14.5, 14.6, 14.8, 14.10, 14.11, 7.8_ +""" +import json +import os +import re +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +ACCOUNT_SYNC_TABLE = "test-account-sync" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + + +# ----------------------------------------------------- fake Cognito client + +def cognito_error(code, message, operation="AdminDeleteUser"): + return ClientError({"Error": {"Code": code, "Message": message}}, + operation) + + +class FakeCognitoClient: + """Recording fake for admin_get_user, list_users (paginated), and + admin_delete_user over an in-memory pool.""" + + def __init__(self, users=None, page_size=60, delete_error=None): + self.users = list(users or []) + self.page_size = page_size + self.delete_error = delete_error + self.get_calls = [] + self.list_calls = [] + self.delete_calls = [] + + def _find(self, username): + for user in self.users: + if user["Username"] == username: + return user + return None + + def admin_get_user(self, **kwargs): + self.get_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", "AdminGetUser") + return { + "Username": user["Username"], + "UserAttributes": user.get("Attributes", []), + "Enabled": user.get("Enabled", True), + "UserStatus": user.get("UserStatus", "CONFIRMED"), + } + + def list_users(self, **kwargs): + self.list_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + start = int(kwargs.get("PaginationToken", "0")) + limit = min(int(kwargs.get("Limit", 60)), self.page_size) + page = self.users[start:start + limit] + response = {"Users": page} + next_start = start + len(page) + if next_start < len(self.users): + response["PaginationToken"] = str(next_start) + return response + + def admin_delete_user(self, **kwargs): + self.delete_calls.append(kwargs) + if self.delete_error is not None: + raise self.delete_error + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.") + self.users.remove(user) + + def exists(self, username): + return self._find(username) is not None + + +def cognito_user(username, role=None, enabled=True, email=None): + """Build a user record in the Cognito list_users response shape.""" + attrs = [{"Name": "email_verified", "Value": "true"}] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if role is not None: + attrs.append({"Name": "custom:role", "Value": role}) + return {"Username": username, "Attributes": attrs, + "Enabled": enabled, "UserStatus": "CONFIRMED"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with + the edge-credentials and account-sync tables created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + os.environ["ACCOUNT_SYNC_TABLE"] = ACCOUNT_SYNC_TABLE + os.environ.pop("ACCOUNT_SYNC_FUNCTION", None) + + ddb = boto3.client("dynamodb", region_name=REGION) + existing = ddb.list_tables()["TableNames"] + if EDGE_CREDENTIALS_TABLE not in existing: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + if ACCOUNT_SYNC_TABLE not in existing: + ddb.create_table( + TableName=ACCOUNT_SYNC_TABLE, + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def sync_table(user_admin): + """The moto-backed account-sync staging table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + ACCOUNT_SYNC_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"device_id": item["device_id"]}) + return table + + +@pytest.fixture +def credentials_table(user_admin): + """The moto-backed edge-credentials table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + EDGE_CREDENTIALS_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"username": item["username"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(users=None, page_size=60, delete_error=None): + fake = FakeCognitoClient(users=users, page_size=page_size, + delete_error=delete_error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def delete_event(username, caller_role="PortalAdmin"): + return { + "httpMethod": "DELETE", + "path": f"/api/v1/admin/users/{username}", + "pathParameters": {"username": username}, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": caller_role, + } + } + }, + } + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +def put_verifier(credentials_table, username): + credentials_table.put_item(Item={ + "username": username.lower(), + "verifier": {"algorithm": "pbkdf2-sha256", "iterations": 1, + "salt": "c2FsdA==", "hash": "aGFzaA=="}, + "updatedAt": 1, + }) + + +def verifier_record(credentials_table, username): + return credentials_table.get_item( + Key={"username": username.lower()}).get("Item") + + +def stage_device(sync_table, device_id, accounts, sync_id="sync-old", + pending=False, status="success"): + """Seed a device's staged account set in the sync-state table.""" + sync_table.put_item(Item={ + "device_id": device_id, + "accounts": accounts, + "syncId": sync_id, + "pendingChanges": pending, + "status": status, + }) + + +def sync_row(sync_table, device_id): + return sync_table.get_item(Key={"device_id": device_id})["Item"] + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "caller_role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito, caller_role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito + calls and no audit entry.""" + fake = install_cognito(users=[cognito_user("op1", role="Viewer")]) + status, body = invoke(user_admin, delete_event( + "op1", caller_role=caller_role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.get_calls == [] + assert fake.delete_calls == [] + assert audit_entries(audit_table) == [] + + +class TestDeleteSuccess: + def test_deletes_the_account_and_its_verifier_record( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 14.2, 14.5: a confirmed deletion invokes + admin_delete_user exactly and removes the account's + edge-credentials verifier record.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + put_verifier(credentials_table, "op1") + + status, body = invoke(user_admin, delete_event("op1")) + + assert status == 200 + assert body["username"] == "op1" + assert body["deleted"] is True + assert fake.delete_calls == [ + {"UserPoolId": POOL_ID, "Username": "op1"}] + assert fake.exists("op1") is False + assert verifier_record(credentials_table, "op1") is None + + def test_success_finalizes_account_delete_audit_with_identity( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 14.8: exactly one finalized account_delete entry carrying + the acting administrator and the deleted account's username, + email, and role at the time of deletion.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, delete_event("op1")) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["action"] == "account_delete" + assert entry["result"] == "success" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "op1" + assert entry["user_id"] == "admin-1" + assert entry["details"]["email"] == "op1@example.com" + assert entry["details"]["role"] == "Operator" + assert entry["completed_at"] > 0 + + def test_delete_marks_staged_syncs_pending_disabled_and_deleted( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 7.8: every device whose staged set carries the account is + refreshed with enabled=false, deleted=true, marked pending, and + assigned a fresh syncId.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + record = {"email": "op1@example.com", "role": "Operator", + "enabled": True} + stage_device(sync_table, "dev-1", {"op1": dict(record)}) + stage_device(sync_table, "dev-2", {"op1": dict(record)}) + stage_device(sync_table, "dev-3", {"other": dict(record)}) + + status, _ = invoke(user_admin, delete_event("op1")) + assert status == 200 + + for device_id in ("dev-1", "dev-2"): + row = sync_row(sync_table, device_id) + assert row["accounts"]["op1"]["enabled"] is False + assert row["accounts"]["op1"]["deleted"] is True + assert row["pendingChanges"] is True + assert row["status"] == "pending" + assert row["syncId"] != "sync-old" + # A device whose staged set does not carry the account is + # untouched. + untouched = sync_row(sync_table, "dev-3") + assert untouched["pendingChanges"] is False + assert untouched["syncId"] == "sync-old" + + def test_delete_without_a_verifier_record_succeeds( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Deleting an account that never became edge-login-capable + (no verifier record) succeeds - the DynamoDB delete_item of a + nonexistent key is a no-op.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, delete_event("op1")) + assert status == 200 + assert fake.exists("op1") is False + + +class TestNotFound: + def test_unknown_user_maps_to_404_with_nothing_modified( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 14.11: a deletion targeting a nonexistent account -> 404 + "not found" with no mutation and no audit entry.""" + put_verifier(credentials_table, "ghost") + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin")]) + + status, body = invoke(user_admin, delete_event("ghost")) + + assert status == 404 + assert body["error"] == "User not found" + assert "not found" in body["message"] + assert fake.delete_calls == [] + assert audit_entries(audit_table) == [] + # The verifier record (however it got there) is untouched: the + # 404 path modifies nothing. + assert verifier_record(credentials_table, "ghost") is not None + + +class TestLastPortalAdminGuard: + def test_deleting_the_last_enabled_portal_admin_rejected_409( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 14.3/14.4 (D14): deleting the last remaining enabled + PortalAdmin -> 409 with the reason, nothing modified, and the + rejected attempt audited.""" + put_verifier(credentials_table, "admin-1") + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("op1", role="Operator"), + cognito_user("disabled-admin", role="PortalAdmin", + enabled=False), + ]) + status, body = invoke(user_admin, delete_event("admin-1")) + + assert status == 409 + assert "last remaining enabled PortalAdmin" in body["message"] + assert fake.delete_calls == [] + assert fake.exists("admin-1") is True + assert verifier_record(credentials_table, "admin-1") is not None + + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "rejected" + assert entry["action"] == "account_delete" + assert entry["resource_id"] == "admin-1" + assert entry["user_id"] == "admin-1" + assert "last remaining enabled PortalAdmin" in \ + entry["details"]["reason"] + + def test_delete_allowed_when_another_enabled_portal_admin_remains( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("admin-2", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, delete_event("admin-1")) + assert status == 200 + assert fake.exists("admin-1") is False + + def test_deleting_a_disabled_portal_admin_is_not_guarded( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """A disabled PortalAdmin does not count toward the enabled + pool, so deleting it never reduces the count - the pool is not + paginated and the delete proceeds.""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("old-admin", role="PortalAdmin", enabled=False), + ]) + status, _ = invoke(user_admin, delete_event("old-admin")) + assert status == 200 + assert fake.list_calls == [] + assert fake.exists("old-admin") is False + + def test_deleting_a_non_portal_admin_is_not_guarded( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("op1", role="Operator"), + ]) + status, _ = invoke(user_admin, delete_event("op1")) + assert status == 200 + assert fake.list_calls == [] + + +class TestCognitoFailure: + def test_cognito_failure_aborts_before_the_verifier_is_touched( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito): + """Req 14.6: a Cognito failure -> 502 with the account and its + verifier record unchanged, audit-final failure, and no sync + staging.""" + put_verifier(credentials_table, "op1") + fake = install_cognito( + users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ], + delete_error=cognito_error( + "InternalErrorException", "Something went wrong"), + ) + stage_device(sync_table, "dev-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}}) + + status, body = invoke(user_admin, delete_event("op1")) + + assert status == 502 + assert body["error"] == "deletion failed" + assert fake.exists("op1") is True + assert verifier_record(credentials_table, "op1") is not None + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + assert entries[0]["action"] == "account_delete" + + row = sync_row(sync_table, "dev-1") + assert row["pendingChanges"] is False + assert row["syncId"] == "sync-old" + + +class TestPartialVerifierCleanupFailure: + def test_verifier_delete_failure_reports_partial_cleanup( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito, monkeypatch): + """Req 14.10: a verifier-delete failure after a successful + Cognito delete retains the record for a subsequent attempt, + finalizes the audit entry with a partial-cleanup detail, and + returns an error stating the account was deleted but its + verifier record was not removed.""" + put_verifier(credentials_table, "op1") + fake = install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + + real_table = user_admin.dynamodb.Table + + class FailingCredentialsTable: + def delete_item(self, **kwargs): + raise RuntimeError("DynamoDB unavailable") + + def table_router(name): + if name == EDGE_CREDENTIALS_TABLE: + return FailingCredentialsTable() + return real_table(name) + + monkeypatch.setattr(user_admin.dynamodb, "Table", table_router) + + status, body = invoke(user_admin, delete_event("op1")) + + # The account is gone from the pool; the error message carries + # the exact frontend-classifier phrases (/deleted/i and + # /not removed/i). + assert status == 502 + assert fake.exists("op1") is False + assert re.search(r"deleted", body["message"], re.IGNORECASE) + assert re.search(r"not removed", body["message"], re.IGNORECASE) + + # The verifier record is retained for a subsequent attempt. + assert verifier_record(credentials_table, "op1") is not None + + # The audit entry is finalized with a partial-cleanup detail. + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["action"] == "account_delete" + assert entry["result"] == "success" + assert "not removed" in entry["details"]["partial_cleanup"] + + def test_partial_cleanup_still_stages_the_deletion_for_sync( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito, monkeypatch): + """The account was deleted from the User_Pool, so devices are + still told it is disabled/deleted (7.8) even when the verifier + cleanup fails.""" + put_verifier(credentials_table, "op1") + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + stage_device(sync_table, "dev-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}}) + + real_table = user_admin.dynamodb.Table + + class FailingCredentialsTable: + def delete_item(self, **kwargs): + raise RuntimeError("DynamoDB unavailable") + + def table_router(name): + if name == EDGE_CREDENTIALS_TABLE: + return FailingCredentialsTable() + return real_table(name) + + monkeypatch.setattr(user_admin.dynamodb, "Table", table_router) + + status, _ = invoke(user_admin, delete_event("op1")) + assert status == 502 + + row = sync_row(sync_table, "dev-1") + assert row["accounts"]["op1"]["enabled"] is False + assert row["accounts"]["op1"]["deleted"] is True + assert row["pendingChanges"] is True + + +class TestAuditBeforeEffect: + def test_pending_audit_failure_blocks_the_deletion( + self, user_admin, audit_table, sync_table, credentials_table, + install_cognito, monkeypatch): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the deletion is not applied - zero mutation calls and an error + stating the action was not applied.""" + put_verifier(credentials_table, "op1") + fake = install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, delete_event("op1")) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert fake.delete_calls == [] + assert fake.exists("op1") is True + assert verifier_record(credentials_table, "op1") is not None diff --git a/edge-cv-portal/backend/tests/test_user_admin_disable_enable.py b/edge-cv-portal/backend/tests/test_user_admin_disable_enable.py new file mode 100644 index 00000000..53ca5054 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_disable_enable.py @@ -0,0 +1,608 @@ +""" +Unit tests for POST /api/v1/admin/users/{username}/disable and /enable +in user_admin.py (spec: portal-user-manager, task 13.3). + +Covers: the read-current-state-first no-op (already in the requested +state -> 200 returning the current state with no Cognito mutation, no +audit-pending write, and no sync staging, 13.6), the disable flow with +the last-PortalAdmin guard (shared predicate, D14: 409 + reason with +the rejected attempt audited before any mutation, 13.9) then +audit-pending (account_disable) -> admin_disable_user -> sync staging +pending with enabled=false -> audit-final, the enable flow +(account_enable -> admin_enable_user -> sync staging enabled=true -> +audit-final), Cognito failure -> 502 "action failed" with the state +unchanged and audit-final failure (13.7), the audit-before-effect +abort, and the PortalAdmin 403 gate. + +Cognito is a recording fake client (same pattern as the other +test_user_admin_* files); the audit entries and the account-sync +staging table run against real moto-backed DynamoDB. + +_Requirements: 13.2, 13.3, 13.6, 13.7, 13.9, 7.2, 7.8_ +""" +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +ACCOUNT_SYNC_TABLE = "test-account-sync" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + + +# ----------------------------------------------------- fake Cognito client + +def cognito_error(code, message, operation="AdminDisableUser"): + return ClientError({"Error": {"Code": code, "Message": message}}, + operation) + + +class FakeCognitoClient: + """Recording fake for admin_get_user, list_users (paginated), and + admin_disable_user / admin_enable_user over an in-memory pool.""" + + def __init__(self, users=None, page_size=60, + disable_error=None, enable_error=None): + self.users = list(users or []) + self.page_size = page_size + self.disable_error = disable_error + self.enable_error = enable_error + self.get_calls = [] + self.list_calls = [] + self.disable_calls = [] + self.enable_calls = [] + + def _find(self, username): + for user in self.users: + if user["Username"] == username: + return user + return None + + def admin_get_user(self, **kwargs): + self.get_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", "AdminGetUser") + return { + "Username": user["Username"], + "UserAttributes": user.get("Attributes", []), + "Enabled": user.get("Enabled", True), + "UserStatus": user.get("UserStatus", "CONFIRMED"), + } + + def list_users(self, **kwargs): + self.list_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + start = int(kwargs.get("PaginationToken", "0")) + limit = min(int(kwargs.get("Limit", 60)), self.page_size) + page = self.users[start:start + limit] + response = {"Users": page} + next_start = start + len(page) + if next_start < len(self.users): + response["PaginationToken"] = str(next_start) + return response + + def admin_disable_user(self, **kwargs): + self.disable_calls.append(kwargs) + if self.disable_error is not None: + raise self.disable_error + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.") + user["Enabled"] = False + + def admin_enable_user(self, **kwargs): + self.enable_calls.append(kwargs) + if self.enable_error is not None: + raise self.enable_error + assert kwargs["UserPoolId"] == POOL_ID + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", "AdminEnableUser") + user["Enabled"] = True + + def enabled_of(self, username): + return self._find(username)["Enabled"] + + +def cognito_user(username, role=None, enabled=True, email=None): + """Build a user record in the Cognito list_users response shape.""" + attrs = [{"Name": "email_verified", "Value": "true"}] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if role is not None: + attrs.append({"Name": "custom:role", "Value": role}) + return {"Username": username, "Attributes": attrs, + "Enabled": enabled, "UserStatus": "CONFIRMED"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with + the edge-credentials and account-sync tables created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + os.environ["ACCOUNT_SYNC_TABLE"] = ACCOUNT_SYNC_TABLE + os.environ.pop("ACCOUNT_SYNC_FUNCTION", None) + + ddb = boto3.client("dynamodb", region_name=REGION) + existing = ddb.list_tables()["TableNames"] + if EDGE_CREDENTIALS_TABLE not in existing: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + if ACCOUNT_SYNC_TABLE not in existing: + ddb.create_table( + TableName=ACCOUNT_SYNC_TABLE, + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def sync_table(user_admin): + """The moto-backed account-sync staging table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + ACCOUNT_SYNC_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"device_id": item["device_id"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(users=None, page_size=60, + disable_error=None, enable_error=None): + fake = FakeCognitoClient(users=users, page_size=page_size, + disable_error=disable_error, + enable_error=enable_error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def action_event(username, action, caller_role="PortalAdmin"): + return { + "httpMethod": "POST", + "path": f"/api/v1/admin/users/{username}/{action}", + "pathParameters": {"username": username}, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": caller_role, + } + } + }, + } + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +def stage_device(sync_table, device_id, accounts, sync_id="sync-old", + pending=False, status="success"): + """Seed a device's staged account set in the sync-state table.""" + sync_table.put_item(Item={ + "device_id": device_id, + "accounts": accounts, + "syncId": sync_id, + "pendingChanges": pending, + "status": status, + }) + + +def sync_row(sync_table, device_id): + return sync_table.get_item(Key={"device_id": device_id})["Item"] + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize("action", ["disable", "enable"]) + @pytest.mark.parametrize( + "caller_role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, audit_table, sync_table, install_cognito, + action, caller_role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito + calls and no audit entry.""" + fake = install_cognito(users=[cognito_user("op1", role="Viewer")]) + status, body = invoke(user_admin, action_event( + "op1", action, caller_role=caller_role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.get_calls == [] + assert fake.disable_calls == [] + assert fake.enable_calls == [] + assert audit_entries(audit_table) == [] + + +class TestDisableSuccess: + def test_disables_an_enabled_account( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 13.2: a confirmed disable of an enabled account invokes + admin_disable_user exactly and reports the resulting state.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, body = invoke(user_admin, action_event("op1", "disable")) + + assert status == 200 + assert body["username"] == "op1" + assert body["enabled"] is False + assert body["changed"] is True + assert fake.disable_calls == [ + {"UserPoolId": POOL_ID, "Username": "op1"}] + assert fake.enabled_of("op1") is False + + def test_success_finalizes_account_disable_audit( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 6.1: exactly one finalized account_disable entry with + acting user, affected account, and completion timestamp.""" + install_cognito(users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, action_event("op1", "disable")) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["action"] == "account_disable" + assert entry["result"] == "success" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "op1" + assert entry["user_id"] == "admin-1" + assert entry["completed_at"] > 0 + + def test_disable_marks_staged_syncs_pending_enabled_false( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 7.2, 7.8: every device whose staged set carries the + account is refreshed with enabled=false, marked pending, and + assigned a fresh syncId.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + record = {"email": "op1@example.com", "role": "Operator", + "enabled": True} + stage_device(sync_table, "dev-1", {"op1": dict(record)}) + stage_device(sync_table, "dev-2", {"op1": dict(record)}) + stage_device(sync_table, "dev-3", {"other": dict(record)}) + + status, _ = invoke(user_admin, action_event("op1", "disable")) + assert status == 200 + + for device_id in ("dev-1", "dev-2"): + row = sync_row(sync_table, device_id) + assert row["accounts"]["op1"]["enabled"] is False + assert row["pendingChanges"] is True + assert row["status"] == "pending" + assert row["syncId"] != "sync-old" + # A device whose staged set does not carry the account is + # untouched. + untouched = sync_row(sync_table, "dev-3") + assert untouched["pendingChanges"] is False + assert untouched["syncId"] == "sync-old" + + +class TestEnableSuccess: + def test_enables_a_disabled_account( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 13.3: a confirmed enable of a disabled account invokes + admin_enable_user exactly and reports the resulting state.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator", enabled=False), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, body = invoke(user_admin, action_event("op1", "enable")) + + assert status == 200 + assert body["username"] == "op1" + assert body["enabled"] is True + assert body["changed"] is True + assert fake.enable_calls == [ + {"UserPoolId": POOL_ID, "Username": "op1"}] + assert fake.enabled_of("op1") is True + + def test_success_finalizes_account_enable_audit( + self, user_admin, audit_table, sync_table, install_cognito): + install_cognito(users=[ + cognito_user("op1", role="Operator", enabled=False), + cognito_user("admin-1", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, action_event("op1", "enable")) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["action"] == "account_enable" + assert entry["result"] == "success" + assert entry["resource_id"] == "op1" + assert entry["user_id"] == "admin-1" + + def test_enable_marks_staged_syncs_pending_enabled_true( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 7.2: the enabled state change refreshes staged sets with + enabled=true and marks the devices pending.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", enabled=False), + cognito_user("admin-1", role="PortalAdmin"), + ]) + stage_device(sync_table, "dev-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": False}}) + + status, _ = invoke(user_admin, action_event("op1", "enable")) + assert status == 200 + + row = sync_row(sync_table, "dev-1") + assert row["accounts"]["op1"]["enabled"] is True + assert row["pendingChanges"] is True + assert row["syncId"] != "sync-old" + + def test_enable_never_runs_the_last_portal_admin_guard( + self, user_admin, audit_table, sync_table, install_cognito): + """Enabling can only grow the enabled-PortalAdmin count, so the + guard never fires - even for the only (disabled) PortalAdmin.""" + fake = install_cognito(users=[ + cognito_user("old-admin", role="PortalAdmin", enabled=False), + ]) + status, _ = invoke(user_admin, action_event("old-admin", "enable")) + assert status == 200 + assert fake.list_calls == [] + assert fake.enabled_of("old-admin") is True + + +class TestAlreadyInRequestedState: + @pytest.mark.parametrize("action,enabled", [ + ("disable", False), + ("enable", True), + ]) + def test_no_op_returns_current_state_without_side_effects( + self, user_admin, audit_table, sync_table, install_cognito, + action, enabled): + """Req 13.6: already in the requested state -> 200 no-op + returning the current state with no Cognito mutation, no + audit-pending write, and no sync staging.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator", enabled=enabled), + cognito_user("admin-1", role="PortalAdmin"), + ]) + stage_device(sync_table, "dev-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": enabled}}) + + status, body = invoke(user_admin, action_event("op1", action)) + + assert status == 200 + assert body["username"] == "op1" + assert body["enabled"] is enabled + assert body["changed"] is False + + assert fake.disable_calls == [] + assert fake.enable_calls == [] + assert audit_entries(audit_table) == [] + + row = sync_row(sync_table, "dev-1") + assert row["pendingChanges"] is False + assert row["syncId"] == "sync-old" + assert row["status"] == "success" + + +class TestLastPortalAdminGuard: + def test_disabling_the_last_enabled_portal_admin_rejected_409( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 5.3/13.9 (D14): disabling the last remaining enabled + PortalAdmin -> 409 with the reason, state untouched, and the + rejected attempt audited before any mutation.""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("op1", role="Operator"), + cognito_user("disabled-admin", role="PortalAdmin", + enabled=False), + ]) + status, body = invoke(user_admin, action_event( + "admin-1", "disable")) + + assert status == 409 + assert "last remaining enabled PortalAdmin" in body["message"] + assert fake.disable_calls == [] + assert fake.enabled_of("admin-1") is True + + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "rejected" + assert entry["action"] == "account_disable" + assert entry["resource_id"] == "admin-1" + assert entry["user_id"] == "admin-1" + assert "last remaining enabled PortalAdmin" in \ + entry["details"]["reason"] + + def test_disable_allowed_when_another_enabled_portal_admin_remains( + self, user_admin, audit_table, sync_table, install_cognito): + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("admin-2", role="PortalAdmin"), + ]) + status, _ = invoke(user_admin, action_event("admin-1", "disable")) + assert status == 200 + assert fake.enabled_of("admin-1") is False + + def test_disabling_a_non_portal_admin_is_not_guarded( + self, user_admin, audit_table, sync_table, install_cognito): + """Disabling a non-PortalAdmin cannot reduce the enabled- + PortalAdmin count, so the pool is never paginated.""" + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin"), + cognito_user("op1", role="Operator"), + ]) + status, _ = invoke(user_admin, action_event("op1", "disable")) + assert status == 200 + assert fake.list_calls == [] + + def test_guard_counts_across_pagination( + self, user_admin, audit_table, sync_table, install_cognito): + """The shared predicate paginates the full pool: the only other + enabled PortalAdmin sits on a later list_users page.""" + users = [cognito_user(f"filler-{i}", role="Viewer") + for i in range(5)] + users.insert(0, cognito_user("admin-1", role="PortalAdmin")) + users.append(cognito_user("admin-2", role="PortalAdmin")) + fake = install_cognito(users=users, page_size=3) + + status, _ = invoke(user_admin, action_event("admin-1", "disable")) + assert status == 200 + assert len(fake.list_calls) == 3 # 3 + 3 + 1: all pages fetched + + +class TestFailurePaths: + @pytest.mark.parametrize("action", ["disable", "enable"]) + def test_unknown_user_maps_to_404( + self, user_admin, audit_table, sync_table, install_cognito, + action): + fake = install_cognito(users=[ + cognito_user("admin-1", role="PortalAdmin")]) + status, body = invoke(user_admin, action_event("ghost", action)) + assert status == 404 + assert body["error"] == "User not found" + assert fake.disable_calls == [] + assert fake.enable_calls == [] + assert audit_entries(audit_table) == [] + + def test_cognito_failure_on_disable_maps_to_502_state_unchanged( + self, user_admin, audit_table, sync_table, install_cognito): + """Req 13.7: a Cognito failure -> 502 "action failed" with the + state unchanged, audit-final failure, and no sync staging.""" + fake = install_cognito( + users=[ + cognito_user("op1", role="Operator"), + cognito_user("admin-1", role="PortalAdmin"), + ], + disable_error=cognito_error( + "InternalErrorException", "Something went wrong"), + ) + stage_device(sync_table, "dev-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}}) + + status, body = invoke(user_admin, action_event("op1", "disable")) + + assert status == 502 + assert body["error"] == "action failed" + assert fake.enabled_of("op1") is True + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + assert entries[0]["action"] == "account_disable" + + row = sync_row(sync_table, "dev-1") + assert row["pendingChanges"] is False + assert row["syncId"] == "sync-old" + + def test_cognito_failure_on_enable_maps_to_502_state_unchanged( + self, user_admin, audit_table, sync_table, install_cognito): + fake = install_cognito( + users=[ + cognito_user("op1", role="Operator", enabled=False), + cognito_user("admin-1", role="PortalAdmin"), + ], + enable_error=cognito_error( + "InternalErrorException", "Something went wrong", + "AdminEnableUser"), + ) + status, body = invoke(user_admin, action_event("op1", "enable")) + + assert status == 502 + assert body["error"] == "action failed" + assert fake.enabled_of("op1") is False + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + assert entries[0]["action"] == "account_enable" + + +class TestAuditBeforeEffect: + @pytest.mark.parametrize("action,enabled", [ + ("disable", True), + ("enable", False), + ]) + def test_pending_audit_failure_blocks_the_action( + self, user_admin, audit_table, sync_table, install_cognito, + monkeypatch, action, enabled): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the action is not applied - zero mutation calls and an error + stating the action was not applied.""" + fake = install_cognito(users=[ + cognito_user("op1", role="Operator", enabled=enabled), + cognito_user("admin-1", role="PortalAdmin"), + ]) + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, action_event("op1", action)) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert fake.disable_calls == [] + assert fake.enable_calls == [] + assert fake.enabled_of("op1") is enabled diff --git a/edge-cv-portal/backend/tests/test_user_admin_edge_sync.py b/edge-cv-portal/backend/tests/test_user_admin_edge_sync.py new file mode 100644 index 00000000..47f6d423 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_edge_sync.py @@ -0,0 +1,661 @@ +""" +Unit tests for the Account_Sync_Service portal side in user_admin.py +(spec: portal-user-manager, task 3.1). + +Covers: the pure build_sync_document builder (whitelisted fields, no +plaintext, disabled/deleted accounts marked enabled=false and never +dropped, 8 KB shadow-limit validation), GET /api/v1/admin/edge-sync/ +devices (devices table joined with dda-portal-account-sync), POST +/api/v1/admin/edge-sync/devices/{deviceId} (staging the selected full +account set with a fresh syncId, pendingChanges=true, sync Lambda +invocation tolerated absent), and the attribute-change hook that marks +every device's staged set updated and pending after verifier capture +and role changes (Req 7.2). + +Cognito is a recording fake client (same pattern as the other +test_user_admin_* files); DynamoDB tables run against real moto. + +_Requirements: 7.1, 7.2, 7.3, 7.8_ +""" +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +ACCOUNT_SYNC_TABLE = "test-account-sync" +DEVICES_TABLE = "test-devices" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + + +# ----------------------------------------------------- fake Cognito client + +def cognito_error(code, message, operation="ListUsers"): + return ClientError({"Error": {"Code": code, "Message": message}}, + operation) + + +class FakeCognitoClient: + """Recording fake for list_users (paginated), admin_get_user, + admin_set_user_password, and admin_update_user_attributes.""" + + def __init__(self, users=None, page_size=60): + self.users = list(users or []) + self.list_calls = [] + self.page_size = page_size + + def _find(self, username): + for user in self.users: + if user["Username"] == username: + return user + return None + + def list_users(self, **kwargs): + self.list_calls.append(kwargs) + assert kwargs["UserPoolId"] == POOL_ID + start = int(kwargs.get("PaginationToken", "0")) + limit = min(int(kwargs.get("Limit", 60)), self.page_size) + page = self.users[start:start + limit] + response = {"Users": page} + next_start = start + len(page) + if next_start < len(self.users): + response["PaginationToken"] = str(next_start) + return response + + def admin_get_user(self, **kwargs): + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", "AdminGetUser") + return { + "Username": user["Username"], + "UserAttributes": user.get("Attributes", []), + "Enabled": user.get("Enabled", True), + "UserStatus": user.get("UserStatus", "CONFIRMED"), + } + + def admin_set_user_password(self, **kwargs): + if self._find(kwargs["Username"]) is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", + "AdminSetUserPassword") + + def admin_update_user_attributes(self, **kwargs): + user = self._find(kwargs["Username"]) + if user is None: + raise cognito_error("UserNotFoundException", + "User does not exist.", + "AdminUpdateUserAttributes") + for new_attr in kwargs["UserAttributes"]: + attrs = user.setdefault("Attributes", []) + for attr in attrs: + if attr["Name"] == new_attr["Name"]: + attr["Value"] = new_attr["Value"] + break + else: + attrs.append(dict(new_attr)) + + +def cognito_user(username, role=None, enabled=True, email=None): + attrs = [{"Name": "email_verified", "Value": "true"}] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if role is not None: + attrs.append({"Name": "custom:role", "Value": role}) + return {"Username": username, "Attributes": attrs, + "Enabled": enabled, "UserStatus": "CONFIRMED"} + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with + the edge-credentials and account-sync tables created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + os.environ["ACCOUNT_SYNC_TABLE"] = ACCOUNT_SYNC_TABLE + os.environ.pop("ACCOUNT_SYNC_FUNCTION", None) + + ddb = boto3.client("dynamodb", region_name=REGION) + existing = ddb.list_tables()["TableNames"] + for table_name in (EDGE_CREDENTIALS_TABLE,): + if table_name not in existing: + ddb.create_table( + TableName=table_name, + KeySchema=[{"AttributeName": "username", + "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + if ACCOUNT_SYNC_TABLE not in existing: + ddb.create_table( + TableName=ACCOUNT_SYNC_TABLE, + KeySchema=[{"AttributeName": "device_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "device_id", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +def _clear_table(table, key_names): + for item in table.scan()["Items"]: + table.delete_item(Key={k: item[k] for k in key_names}) + + +@pytest.fixture +def tables(user_admin): + """The moto-backed tables this feature touches, emptied per test.""" + import boto3 + resource = boto3.resource("dynamodb", region_name=REGION) + sync = resource.Table(ACCOUNT_SYNC_TABLE) + credentials = resource.Table(EDGE_CREDENTIALS_TABLE) + devices = resource.Table(DEVICES_TABLE) + audit = resource.Table(AUDIT_LOG_TABLE) + _clear_table(sync, ("device_id",)) + _clear_table(credentials, ("username",)) + _clear_table(devices, ("device_id",)) + _clear_table(audit, ("event_id", "timestamp")) + return {"sync": sync, "credentials": credentials, + "devices": devices, "audit": audit} + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + def _install(users=None, page_size=60): + fake = FakeCognitoClient(users=users, page_size=page_size) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def admin_event(method, path, path_parameters=None, body=None, + caller_role="PortalAdmin"): + return { + "httpMethod": method, + "path": path, + "pathParameters": path_parameters, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": caller_role, + } + } + }, + } + + +def list_devices_event(caller_role="PortalAdmin"): + return admin_event("GET", "/api/v1/admin/edge-sync/devices", + caller_role=caller_role) + + +def sync_event(device_id, usernames, caller_role="PortalAdmin"): + return admin_event( + "POST", f"/api/v1/admin/edge-sync/devices/{device_id}", + path_parameters={"deviceId": device_id}, + body={"usernames": usernames}, caller_role=caller_role) + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +class RecordingLambdaClient: + def __init__(self, error=None): + self.calls = [] + self.error = error + + def invoke(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + return {"StatusCode": 202} + + +# ------------------------------------------------- build_sync_document + +class TestBuildSyncDocument: + def test_complete_account_set_with_whitelisted_fields( + self, user_admin): + """Req 7.1, 7.3: the document carries exactly the selected + records with only {email, role, enabled, deleted?, verifier?}; + unexpected input keys (e.g. plaintext) are dropped + structurally.""" + verifier = user_admin.make_verifier("Sup3rSecret!pw", iterations=10) + accounts = { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True, "verifier": verifier, + "password": "Sup3rSecret!pw"}, + "viewer": {"email": "v@example.com", "role": "Viewer", + "enabled": True}, + } + document = user_admin.build_sync_document(accounts, "sync-1") + + assert document["syncId"] == "sync-1" + assert document["version"] == 1 + assert set(document["accounts"]) == {"op1", "viewer"} + assert document["accounts"]["op1"] == { + "email": "op1@example.com", + "role": "Operator", + "enabled": True, + "verifier": { + "algorithm": verifier["algorithm"], + "iterations": 10, + "salt": verifier["salt"], + "hash": verifier["hash"], + }, + } + assert "Sup3rSecret!pw" not in json.dumps(document) + assert "verifier" not in document["accounts"]["viewer"] + + def test_disabled_and_deleted_accounts_marked_never_dropped( + self, user_admin): + """Req 7.8: disabled/deleted accounts appear marked + enabled=false; a deleted account is flagged, never dropped.""" + accounts = { + "disabled-op": {"email": "d@example.com", "role": "Operator", + "enabled": False}, + "gone": {"email": "g@example.com", "role": "Viewer", + "enabled": True, "deleted": True}, + } + document = user_admin.build_sync_document(accounts, "sync-2") + + assert document["accounts"]["disabled-op"]["enabled"] is False + assert document["accounts"]["gone"]["enabled"] is False + assert document["accounts"]["gone"]["deleted"] is True + assert set(document["accounts"]) == {"disabled-op", "gone"} + + def test_missing_role_defaults_to_viewer(self, user_admin): + document = user_admin.build_sync_document( + {"u": {"email": "u@example.com", "enabled": True}}, "s") + assert document["accounts"]["u"]["role"] == "Viewer" + + def test_document_over_8kb_raises_with_explicit_reason( + self, user_admin): + accounts = { + f"user-{i:04d}": {"email": "x" * 60 + "@example.com", + "role": "Operator", "enabled": True} + for i in range(100) + } + with pytest.raises(user_admin.SyncDocumentTooLarge) as excinfo: + user_admin.build_sync_document(accounts, "sync-3") + assert "8 KB" in str(excinfo.value) + + def test_document_within_limit_builds(self, user_admin): + accounts = {"u": {"email": "u@example.com", "role": "Viewer", + "enabled": True}} + assert user_admin.build_sync_document(accounts, "s") + + +# ------------------------------------------- GET /admin/edge-sync/devices + +class TestListSyncDevices: + @pytest.mark.parametrize( + "caller_role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, tables, caller_role): + status, body = invoke( + user_admin, list_devices_event(caller_role=caller_role)) + assert status == 403 + assert body["error"] == "Access denied" + + def test_device_without_sync_row_reports_never_synced( + self, user_admin, tables): + tables["devices"].put_item(Item={"device_id": "edge-1"}) + status, body = invoke(user_admin, list_devices_event()) + + assert status == 200 + assert body["count"] == 1 + assert body["devices"] == [{ + "device_id": "edge-1", + "lastSyncStatus": None, + "lastSyncAt": None, + "pendingChanges": False, + "failureReason": None, + }] + + def test_join_carries_sync_state_fields(self, user_admin, tables): + """Req 7.4 display data: lastSyncStatus, lastSyncAt, + pendingChanges, and failureReason come from the sync-state + table.""" + tables["devices"].put_item(Item={"device_id": "edge-1"}) + tables["devices"].put_item(Item={"device_id": "edge-2"}) + tables["sync"].put_item(Item={ + "device_id": "edge-1", "syncId": "s1", "status": "success", + "lastSyncAt": 1700000000000, "pendingChanges": False, + }) + tables["sync"].put_item(Item={ + "device_id": "edge-2", "syncId": "s2", "status": "failed", + "failureReason": "device unreachable", + "pendingChanges": True, + }) + + status, body = invoke(user_admin, list_devices_event()) + + assert status == 200 + by_id = {d["device_id"]: d for d in body["devices"]} + assert by_id["edge-1"]["lastSyncStatus"] == "success" + assert by_id["edge-1"]["lastSyncAt"] == 1700000000000 + assert by_id["edge-1"]["pendingChanges"] is False + assert by_id["edge-2"]["lastSyncStatus"] == "failed" + assert by_id["edge-2"]["failureReason"] == "device unreachable" + assert by_id["edge-2"]["pendingChanges"] is True + + def test_staged_device_missing_from_devices_table_still_listed( + self, user_admin, tables): + tables["sync"].put_item(Item={ + "device_id": "ghost-device", "syncId": "s", "status": + "pending", "pendingChanges": True, + }) + status, body = invoke(user_admin, list_devices_event()) + assert status == 200 + assert [d["device_id"] for d in body["devices"]] == ["ghost-device"] + + +# ------------------------------- POST /admin/edge-sync/devices/{deviceId} + +class TestSyncDevice: + @pytest.mark.parametrize( + "caller_role", ["Viewer", "Operator", "DataScientist", + "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, tables, install_cognito, caller_role): + fake = install_cognito(users=[cognito_user("op1")]) + status, body = invoke(user_admin, sync_event( + "edge-1", ["op1"], caller_role=caller_role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.list_calls == [] + assert tables["sync"].scan()["Items"] == [] + + @pytest.mark.parametrize("bad", [None, [], ["op1", 42], "op1", [""]]) + def test_invalid_usernames_body_rejected_400( + self, user_admin, tables, install_cognito, bad): + install_cognito(users=[cognito_user("op1")]) + status, body = invoke(user_admin, admin_event( + "POST", "/api/v1/admin/edge-sync/devices/edge-1", + path_parameters={"deviceId": "edge-1"}, + body={"usernames": bad})) + assert status == 400 + assert tables["sync"].scan()["Items"] == [] + + def test_unknown_usernames_rejected_400_nothing_staged( + self, user_admin, tables, install_cognito): + install_cognito(users=[cognito_user("op1")]) + status, body = invoke(user_admin, sync_event( + "edge-1", ["op1", "ghost"])) + assert status == 400 + assert body["error"] == "Unknown usernames" + assert "ghost" in body["message"] + assert tables["sync"].scan()["Items"] == [] + + def test_stages_full_account_set_with_fresh_sync_id_and_pending( + self, user_admin, tables, install_cognito): + """Req 7.1, 7.3, 7.8: the staged set carries each selected + account's email, role, enabled state, and captured verifier + (never plaintext); a disabled account is staged marked + enabled=false, not dropped.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("Viewer1", role=None, email="v1@example.com", + enabled=False), + cognito_user("unselected", role="Viewer"), + ]) + # op1 has a captured verifier (edge-login-capable) + tables["credentials"].put_item(Item={ + "username": "op1", + "verifier": user_admin.make_verifier("S0me!Password", 10), + "updatedAt": 1, + }) + + status, body = invoke(user_admin, sync_event( + "edge-1", ["op1", "Viewer1"])) + + assert status == 200 + assert body["device_id"] == "edge-1" + assert body["accountCount"] == 2 + assert body["pendingChanges"] is True + assert body["syncId"] + # Sync Lambda absent (env unset) is tolerated gracefully + assert body["syncInvoked"] is False + + rows = tables["sync"].scan()["Items"] + assert len(rows) == 1 + row = rows[0] + assert row["device_id"] == "edge-1" + assert row["syncId"] == body["syncId"] + assert row["status"] == "pending" + assert row["pendingChanges"] is True + assert set(row["accounts"]) == {"op1", "Viewer1"} + assert row["accounts"]["op1"]["email"] == "op1@example.com" + assert row["accounts"]["op1"]["role"] == "Operator" + assert row["accounts"]["op1"]["enabled"] is True + assert row["accounts"]["op1"]["verifier"]["algorithm"] == \ + "pbkdf2-sha256" + # Disabled account staged marked enabled=false, never dropped + assert row["accounts"]["Viewer1"]["enabled"] is False + assert row["accounts"]["Viewer1"]["role"] == "Viewer" + # Never plaintext anywhere in the staged set (7.3) + assert "S0me!Password" not in json.dumps( + row["accounts"], default=str) + + def test_restaging_preserves_last_sync_at_and_clears_failure( + self, user_admin, tables, install_cognito): + install_cognito(users=[cognito_user("op1", role="Operator")]) + tables["sync"].put_item(Item={ + "device_id": "edge-1", "syncId": "old-sync", + "status": "failed", "failureReason": "device unreachable", + "lastSyncAt": 1700000000000, "pendingChanges": False, + "accounts": {}, + }) + + status, body = invoke(user_admin, sync_event("edge-1", ["op1"])) + + assert status == 200 + row = tables["sync"].get_item( + Key={"device_id": "edge-1"})["Item"] + assert row["syncId"] == body["syncId"] != "old-sync" + assert row["status"] == "pending" + assert row["pendingChanges"] is True + assert row["lastSyncAt"] == 1700000000000 + assert "failureReason" not in row + + def test_invokes_sync_lambda_when_configured( + self, user_admin, tables, install_cognito, monkeypatch): + install_cognito(users=[cognito_user("op1", role="Operator")]) + recorder = RecordingLambdaClient() + monkeypatch.setattr(user_admin, "ACCOUNT_SYNC_FUNCTION", + "test-account-sync-fn") + monkeypatch.setattr(user_admin, "lambda_client", recorder) + + status, body = invoke(user_admin, sync_event("edge-1", ["op1"])) + + assert status == 200 + assert body["syncInvoked"] is True + assert len(recorder.calls) == 1 + call = recorder.calls[0] + assert call["FunctionName"] == "test-account-sync-fn" + assert call["InvocationType"] == "Event" + payload = json.loads(call["Payload"]) + assert payload["device_id"] == "edge-1" + assert payload["syncId"] == body["syncId"] + + def test_sync_lambda_invoke_failure_tolerated( + self, user_admin, tables, install_cognito, monkeypatch): + """An invoke failure never fails the staging: the row stays + pending for the scheduled attempt.""" + install_cognito(users=[cognito_user("op1", role="Operator")]) + recorder = RecordingLambdaClient( + error=RuntimeError("lambda unavailable")) + monkeypatch.setattr(user_admin, "ACCOUNT_SYNC_FUNCTION", + "test-account-sync-fn") + monkeypatch.setattr(user_admin, "lambda_client", recorder) + + status, body = invoke(user_admin, sync_event("edge-1", ["op1"])) + + assert status == 200 + assert body["syncInvoked"] is False + row = tables["sync"].get_item( + Key={"device_id": "edge-1"})["Item"] + assert row["pendingChanges"] is True + + def test_document_over_8kb_rejected_400_nothing_staged( + self, user_admin, tables, install_cognito): + users = [ + cognito_user(f"user-{i:04d}", role="Operator", + email="x" * 60 + "@example.com") + for i in range(100) + ] + install_cognito(users=users) + + status, body = invoke(user_admin, sync_event( + "edge-1", [u["Username"] for u in users])) + + assert status == 400 + assert body["error"] == "Sync document too large" + assert "8 KB" in body["message"] + assert tables["sync"].scan()["Items"] == [] + + +# --------------------------------------- attribute-change hook (Req 7.2) + +def password_event(username, body): + return admin_event( + "POST", f"/api/v1/admin/users/{username}/password", + path_parameters={"username": username}, body=body) + + +def role_event(username, body): + return admin_event( + "PUT", f"/api/v1/admin/users/{username}/role", + path_parameters={"username": username}, body=body) + + +class TestAttributeChangePropagation: + def _stage(self, tables, device_id, accounts, sync_id="initial-sync"): + tables["sync"].put_item(Item={ + "device_id": device_id, "syncId": sync_id, + "status": "success", "pendingChanges": False, + "lastSyncAt": 1700000000000, "accounts": accounts, + }) + + def test_verifier_capture_marks_every_staged_device_pending( + self, user_admin, tables, install_cognito): + """Req 7.2: after a password set captures a fresh verifier, + every device whose staged set contains the account is + refreshed (new verifier, fresh syncId, pendingChanges=true).""" + install_cognito(users=[cognito_user( + "op1", role="Operator", email="op1@example.com")]) + staged = {"op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}} + self._stage(tables, "edge-1", dict(staged)) + self._stage(tables, "edge-2", dict(staged), sync_id="other-sync") + self._stage(tables, "edge-3", { + "someone-else": {"email": "x@example.com", "role": "Viewer", + "enabled": True}}) + + status, _ = invoke(user_admin, password_event( + "op1", {"password": "N3w!Password4Op1", "permanent": True})) + assert status == 200 + + for device_id, old_sync_id in (("edge-1", "initial-sync"), + ("edge-2", "other-sync")): + row = tables["sync"].get_item( + Key={"device_id": device_id})["Item"] + assert row["pendingChanges"] is True, device_id + assert row["status"] == "pending" + assert row["syncId"] != old_sync_id + record = row["accounts"]["op1"] + assert record["verifier"]["algorithm"] == "pbkdf2-sha256" + assert record["email"] == "op1@example.com" + # Never plaintext in the staged set (7.3) + assert "N3w!Password4Op1" not in json.dumps( + row["accounts"], default=str) + + # A device whose staged set does not contain the account is + # untouched. + row = tables["sync"].get_item(Key={"device_id": "edge-3"})["Item"] + assert row["pendingChanges"] is False + assert row["syncId"] == "initial-sync" + + def test_role_change_refreshes_staged_record_and_marks_pending( + self, user_admin, tables, install_cognito): + """Req 7.2: a successful role change updates the staged record's + role on every device containing the account.""" + install_cognito(users=[ + cognito_user("op1", role="Operator", email="op1@example.com"), + cognito_user("admin-1", role="PortalAdmin"), + ]) + self._stage(tables, "edge-1", { + "op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}}) + + status, _ = invoke(user_admin, role_event( + "op1", {"role": "DataScientist"})) + assert status == 200 + + row = tables["sync"].get_item(Key={"device_id": "edge-1"})["Item"] + assert row["pendingChanges"] is True + assert row["status"] == "pending" + assert row["syncId"] != "initial-sync" + assert row["accounts"]["op1"]["role"] == "DataScientist" + assert row["accounts"]["op1"]["enabled"] is True + + def test_staged_username_matched_case_insensitively( + self, user_admin, tables, install_cognito): + """Staged sets key accounts by the Cognito username; the hook + matches case-insensitively (the credentials table lowercases).""" + install_cognito(users=[cognito_user( + "Op1", role="Operator", email="op1@example.com")]) + self._stage(tables, "edge-1", { + "Op1": {"email": "op1@example.com", "role": "Operator", + "enabled": True}}) + + status, _ = invoke(user_admin, password_event( + "Op1", {"password": "N3w!Password4Op1", "permanent": True})) + assert status == 200 + + row = tables["sync"].get_item(Key={"device_id": "edge-1"})["Item"] + assert row["pendingChanges"] is True + assert "verifier" in row["accounts"]["Op1"] + + def test_propagation_failure_never_fails_the_primary_action( + self, user_admin, tables, install_cognito, monkeypatch): + """A failing sync-table update is logged, not raised: the + password change itself still succeeds.""" + install_cognito(users=[cognito_user( + "op1", role="Operator", email="op1@example.com")]) + + real_table = user_admin.dynamodb.Table + + def failing_table(name): + if name == ACCOUNT_SYNC_TABLE: + raise RuntimeError("sync table unavailable") + return real_table(name) + + monkeypatch.setattr(user_admin.dynamodb, "Table", failing_table) + + status, body = invoke(user_admin, password_event( + "op1", {"password": "N3w!Password4Op1", "permanent": True})) + assert status == 200 + assert body["username"] == "op1" diff --git a/edge-cv-portal/backend/tests/test_user_admin_forgot_password.py b/edge-cv-portal/backend/tests/test_user_admin_forgot_password.py new file mode 100644 index 00000000..d8524bba --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_forgot_password.py @@ -0,0 +1,436 @@ +""" +Unit tests for POST /api/v1/admin/users/{username}/forgot-password in +user_admin.py (spec: portal-user-manager, task 2.3). + +Covers: the verified-email check (400 before any generation) -> +generate_temp_password -> audit-pending -> SES SendEmail -> +admin_set_user_password(Permanent=False) -> verifier capture -> +audit-final flow; the SES-before-set ordering (delivery failure leaves +credentials untouched); set-password failure after a successful send +(action reports failure, emailed password never became valid); +UserNotFoundException -> 404; pending audit failure -> 500 "not +applied"; the PortalAdmin 403 gate; and that the temporary password +value never appears in the response or audit entries. + +Cognito and SES are recording fake clients sharing a call timeline +(same pattern as test_user_admin_set_password.py); the edge-credentials +verifier write and the two-phase audit entries run against real +moto-backed DynamoDB. + +_Requirements: 4.1, 4.3, 4.4, 4.5, 6.1, 6.3_ +""" +import base64 +import hashlib +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" +SENDER = "no-reply@portal.example.com" + + +# ------------------------------------------------------------ fake clients + +def cognito_error(code, message, operation="AdminSetUserPassword"): + return ClientError({"Error": {"Code": code, "Message": message}}, + operation) + + +def user_attributes(email="operator1@example.com", email_verified="true"): + attrs = [] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if email_verified is not None: + attrs.append({"Name": "email_verified", "Value": email_verified}) + return attrs + + +class FakeCognitoClient: + """Recording fake for admin_get_user + admin_set_user_password.""" + + def __init__(self, timeline, get_user_attributes=None, + get_user_error=None, set_password_error=None): + self.timeline = timeline + self.get_user_attributes = ( + user_attributes() if get_user_attributes is None + else get_user_attributes) + self.get_user_error = get_user_error + self.set_password_error = set_password_error + + def admin_get_user(self, **kwargs): + self.timeline.append(("cognito.admin_get_user", kwargs)) + if self.get_user_error is not None: + raise self.get_user_error + return { + "Username": kwargs["Username"], + "UserAttributes": self.get_user_attributes, + } + + def admin_set_user_password(self, **kwargs): + self.timeline.append(("cognito.admin_set_user_password", kwargs)) + if self.set_password_error is not None: + raise self.set_password_error + + def calls(self, operation): + return [kwargs for op, kwargs in self.timeline + if op == f"cognito.{operation}"] + + +class FakeSesClient: + """Recording fake for the SES send_email API.""" + + def __init__(self, timeline, error=None): + self.timeline = timeline + self.error = error + + def send_email(self, **kwargs): + self.timeline.append(("ses.send_email", kwargs)) + if self.error is not None: + raise self.error + return {"MessageId": "test-message-id"} + + @property + def sends(self): + return [kwargs for op, kwargs in self.timeline + if op == "ses.send_email"] + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with the + edge-credentials table created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + ddb = boto3.client("dynamodb", region_name=REGION) + if EDGE_CREDENTIALS_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def credentials_table(user_admin): + """The moto-backed edge-credentials table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + EDGE_CREDENTIALS_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"username": item["username"]}) + return table + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def install_clients(user_admin, monkeypatch): + """Wire fake cognito + ses clients sharing one timeline into the + module under test.""" + def _install(**cognito_kwargs): + timeline = [] + ses_error = cognito_kwargs.pop("ses_error", None) + fake_cognito = FakeCognitoClient(timeline, **cognito_kwargs) + fake_ses = FakeSesClient(timeline, error=ses_error) + monkeypatch.setattr(user_admin, "cognito_client", fake_cognito) + monkeypatch.setattr(user_admin, "ses_client", fake_ses) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + monkeypatch.setattr(user_admin, "SES_SENDER_ADDRESS", SENDER) + return fake_cognito, fake_ses, timeline + return _install + + +# ---------------------------------------------------------------- helpers + +def forgot_event(username, role="PortalAdmin"): + return { + "httpMethod": "POST", + "path": f"/api/v1/admin/users/{username}/forgot-password", + "pathParameters": {"username": username}, + "body": None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": role, + } + } + }, + } + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +def emailed_password(ses_send): + """Extract the temporary password value from the sent email body.""" + body = ses_send["Message"]["Body"]["Text"]["Data"] + for line in body.splitlines(): + if line.startswith("Temporary password: "): + return line[len("Temporary password: "):] + raise AssertionError("no temporary password line in the email body") + + +def verify_password_against(verifier, password): + """Recompute the PBKDF2 hash to check the stored verifier matches.""" + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + base64.b64decode(verifier["salt"]), + int(verifier["iterations"]), + dklen=32, + ) + return base64.b64encode(derived).decode("ascii") == verifier["hash"] + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "role", ["Viewer", "Operator", "DataScientist", "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, credentials_table, audit_table, + install_clients, role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito/SES + calls, no verifier write, and no audit entry.""" + _, _, timeline = install_clients() + status, body = invoke( + user_admin, forgot_event("operator1", role=role)) + assert status == 403 + assert body["error"] == "Access denied" + assert timeline == [] + assert credentials_table.scan()["Items"] == [] + assert audit_entries(audit_table) == [] + + +class TestForgotPasswordSuccess: + def test_emails_temp_password_and_sets_it_non_permanent( + self, user_admin, credentials_table, audit_table, + install_clients): + """Req 4.1: a policy-conformant temporary password is delivered + to the registered email from the configured sender, and the same + value is applied with Permanent=False.""" + cognito, ses, _ = install_clients() + status, body = invoke(user_admin, forgot_event("operator1")) + + assert status == 200 + assert body["username"] == "operator1" + + assert len(ses.sends) == 1 + send = ses.sends[0] + assert send["Source"] == SENDER + assert send["Destination"]["ToAddresses"] == [ + "operator1@example.com"] + password = emailed_password(send) + + set_calls = cognito.calls("admin_set_user_password") + assert set_calls == [{ + "UserPoolId": POOL_ID, + "Username": "operator1", + "Password": password, + "Permanent": False, + }] + + # The generated password conforms to the pool policy (4.1). + assert len(password) >= 12 + assert any(c.islower() for c in password) + assert any(c.isupper() for c in password) + assert any(c.isdigit() for c in password) + assert any(c in user_admin.PASSWORD_SYMBOLS for c in password) + + def test_ses_send_happens_before_the_password_set( + self, user_admin, credentials_table, audit_table, + install_clients): + """Req 4.5 (ordering): the email is delivered before Cognito is + touched, so a delivery failure leaves credentials untouched.""" + _, _, timeline = install_clients() + status, _ = invoke(user_admin, forgot_event("operator1")) + + assert status == 200 + operations = [op for op, _ in timeline] + assert operations == [ + "cognito.admin_get_user", + "ses.send_email", + "cognito.admin_set_user_password", + ] + + def test_success_captures_verifier_for_the_emailed_password( + self, user_admin, credentials_table, audit_table, + install_clients): + """A successful flow writes a PBKDF2 verifier row keyed by the + normalized lowercase username matching the emailed value (D3).""" + _, ses, _ = install_clients() + status, _ = invoke(user_admin, forgot_event("OpUser1")) + + assert status == 200 + items = credentials_table.scan()["Items"] + assert len(items) == 1 + item = items[0] + assert item["username"] == "opuser1" + assert item["updatedAt"] > 0 + assert verify_password_against( + item["verifier"], emailed_password(ses.sends[0])) + + def test_success_finalizes_exactly_one_audit_entry( + self, user_admin, credentials_table, audit_table, + install_clients): + """Req 6.1: exactly one finalized audit entry with acting user, + affected account, action type, and completion time.""" + install_clients() + status, _ = invoke(user_admin, forgot_event("operator1")) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "success" + assert entry["action"] == "forgot_password" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "operator1" + assert entry["user_id"] == "admin-1" + assert entry["completed_at"] > 0 + + def test_temp_password_never_in_response_or_audit( + self, user_admin, credentials_table, audit_table, + install_clients): + """Req 4.3 / 6.3: the temporary password value appears in neither + the HTTP response nor any audit entry.""" + _, ses, _ = install_clients() + response = user_admin.handler(forgot_event("operator1"), None) + + assert response["statusCode"] == 200 + password = emailed_password(ses.sends[0]) + assert password not in json.dumps(response) + assert password not in json.dumps( + audit_entries(audit_table), default=str) + + +class TestUnverifiedEmail: + @pytest.mark.parametrize("email_verified", [None, "false", "False"]) + def test_unverified_email_rejected_400_before_any_generation( + self, user_admin, credentials_table, audit_table, + install_clients, email_verified): + """Req 4.4: no verified email -> 400 with nothing generated, + sent, applied, or audited.""" + cognito, ses, _ = install_clients( + get_user_attributes=user_attributes( + email_verified=email_verified)) + status, body = invoke(user_admin, forgot_event("operator1")) + + assert status == 400 + assert body["error"] == "No verified email address" + assert ses.sends == [] + assert cognito.calls("admin_set_user_password") == [] + assert credentials_table.scan()["Items"] == [] + assert audit_entries(audit_table) == [] + + +class TestDeliveryFailure: + def test_ses_failure_leaves_credentials_untouched( + self, user_admin, credentials_table, audit_table, + install_clients): + """Req 4.5: SES delivery failure -> 'temporary password was not + sent', no admin_set_user_password call, no verifier write, audit + finalized as failure.""" + cognito, _, _ = install_clients(ses_error=cognito_error( + "MessageRejected", "Email address is not verified.", + operation="SendEmail")) + status, body = invoke(user_admin, forgot_event("operator1")) + + assert status == 502 + assert body["error"] == "temporary password was not sent" + assert cognito.calls("admin_set_user_password") == [] + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + def test_set_password_failure_after_send_reports_failure( + self, user_admin, credentials_table, audit_table, + install_clients): + """Cognito failure after a successful send: the emailed password + never became valid -> the action reports failure, no verifier.""" + install_clients(set_password_error=cognito_error( + "InternalErrorException", "Something went wrong")) + status, body = invoke(user_admin, forgot_event("operator1")) + + assert status == 502 + assert body["error"] == "forgot-password failed" + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + +class TestUserNotFound: + def test_unknown_user_maps_to_404( + self, user_admin, credentials_table, audit_table, + install_clients): + cognito, ses, _ = install_clients(get_user_error=cognito_error( + "UserNotFoundException", "User does not exist.", + operation="AdminGetUser")) + status, body = invoke(user_admin, forgot_event("ghost")) + + assert status == 404 + assert body["error"] == "User not found" + assert ses.sends == [] + assert cognito.calls("admin_set_user_password") == [] + assert audit_entries(audit_table) == [] + + +class TestAuditBeforeEffect: + def test_pending_audit_failure_blocks_the_action( + self, user_admin, credentials_table, audit_table, + install_clients, monkeypatch): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the action is not applied - no email sent, zero Cognito password + mutations, no verifier.""" + cognito, ses, _ = install_clients() + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, forgot_event("operator1")) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert ses.sends == [] + assert cognito.calls("admin_set_user_password") == [] + assert credentials_table.scan()["Items"] == [] diff --git a/edge-cv-portal/backend/tests/test_user_admin_listing.py b/edge-cv-portal/backend/tests/test_user_admin_listing.py new file mode 100644 index 00000000..ab4520a9 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_listing.py @@ -0,0 +1,265 @@ +""" +Unit tests for GET /api/v1/admin/users account listing in user_admin.py +(spec: portal-user-manager, task 2.1). + +Covers: full Cognito list_users pagination, the edge-credentials +edge_capable join (normalized lowercase usernames), the Viewer default +role, the returned field set, the PortalAdmin 403 gate, and the 502 +retrieval-failure path. + +Cognito is a recording fake client (moto's cognito-idp backend needs an +extra optional dependency, and the spec's test plan uses recording fakes +for cognito-idp anyway); the edge-credentials join runs against the real +moto-backed DynamoDB table. + +_Requirements: 1.5, 1.7, 2.1_ +""" +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +POOL_ID = "us-east-1_testpool" + + +# ----------------------------------------------------- fake Cognito client + +class FakeCognitoClient: + """Recording fake for the cognito-idp list_users API with pagination.""" + + def __init__(self, users=None, page_size=60, error=None): + self.users = list(users or []) + self.page_size = page_size + self.error = error + self.calls = [] + + def list_users(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + assert kwargs["UserPoolId"] == POOL_ID + start = int(kwargs.get("PaginationToken", "0")) + limit = min(int(kwargs.get("Limit", 60)), self.page_size) + page = self.users[start:start + limit] + response = {"Users": page} + next_start = start + len(page) + if next_start < len(self.users): + response["PaginationToken"] = str(next_start) + return response + + +def cognito_user(username, email=None, role=None, email_verified=True, + enabled=True, status="CONFIRMED"): + """Build a user record in the Cognito list_users response shape.""" + attrs = [{"Name": "email_verified", + "Value": "true" if email_verified else "false"}] + if email is not None: + attrs.append({"Name": "email", "Value": email}) + if role is not None: + attrs.append({"Name": "custom:role", "Value": role}) + return {"Username": username, "Attributes": attrs, + "Enabled": enabled, "UserStatus": status} + + +def not_found_error(): + return ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "User pool us-east-1_doesnotexist does not exist."}}, + "ListUsers", + ) + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with the + edge-credentials table created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + ddb = boto3.client("dynamodb", region_name=REGION) + if EDGE_CREDENTIALS_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def credentials_table(user_admin): + """The moto-backed edge-credentials table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + EDGE_CREDENTIALS_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"username": item["username"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(users=None, page_size=60, error=None): + fake = FakeCognitoClient(users=users, page_size=page_size, + error=error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def list_event(role="PortalAdmin"): + return { + "httpMethod": "GET", + "path": "/api/v1/admin/users", + "requestContext": { + "authorizer": { + "claims": { + "sub": "caller-1", + "email": "caller@example.com", + "cognito:username": "caller-1", + "custom:role": role, + } + } + }, + } + + +def invoke_list(user_admin, role="PortalAdmin"): + response = user_admin.handler(list_event(role), None) + return response["statusCode"], json.loads(response["body"]) + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "role", ["Viewer", "Operator", "DataScientist", "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403(self, user_admin, credentials_table, + install_cognito, role): + """Req 1.5: non-PortalAdmin callers get 403 and no operation + is performed (zero Cognito calls).""" + fake = install_cognito(users=[cognito_user("operator1")]) + status, body = invoke_list(user_admin, role=role) + assert status == 403 + assert "users" not in body + assert body["error"] == "Access denied" + assert fake.calls == [] + + +class TestAccountListing: + def test_lists_all_accounts_with_expected_fields( + self, user_admin, credentials_table, install_cognito): + """Req 2.1: every account is returned with username, email, + email_verified, role, User_Pool status, and enabled state.""" + install_cognito(users=[ + cognito_user("operator1", email="op1@example.com", + role="Operator"), + cognito_user("newbie", email="new@example.com", role="Viewer", + email_verified=False, + status="FORCE_CHANGE_PASSWORD"), + cognito_user("disabled-admin", email="da@example.com", + role="PortalAdmin", enabled=False), + ]) + + status, body = invoke_list(user_admin) + assert status == 200 + assert body["total_count"] == 3 + rows = {u["username"]: u for u in body["users"]} + assert set(rows) == {"operator1", "newbie", "disabled-admin"} + + assert rows["operator1"] == { + "username": "operator1", + "email": "op1@example.com", + "email_verified": True, + "role": "Operator", + "user_status": "CONFIRMED", + "enabled": True, + "edge_capable": False, + } + + assert rows["newbie"]["email_verified"] is False + assert rows["newbie"]["user_status"] == "FORCE_CHANGE_PASSWORD" + assert rows["newbie"]["enabled"] is True + + assert rows["disabled-admin"]["enabled"] is False + assert rows["disabled-admin"]["role"] == "PortalAdmin" + + def test_role_defaults_to_viewer_when_attribute_missing( + self, user_admin, credentials_table, install_cognito): + install_cognito(users=[ + cognito_user("roleless", email="r@example.com", role=None)]) + + status, body = invoke_list(user_admin) + assert status == 200 + assert body["users"][0]["role"] == "Viewer" + + def test_pagination_returns_every_user( + self, user_admin, credentials_table, install_cognito): + """list_users pages must be followed until the PaginationToken + is exhausted so the listing is complete.""" + expected = {f"user-{i:03d}" for i in range(8)} + fake = install_cognito( + users=[cognito_user(u, email=f"{u}@example.com", role="Viewer") + for u in sorted(expected)], + page_size=3, + ) + + status, body = invoke_list(user_admin) + assert status == 200 + assert body["total_count"] == 8 + assert {u["username"] for u in body["users"]} == expected + # 3 + 3 + 2: every page fetched, tokens threaded through + assert len(fake.calls) == 3 + assert "PaginationToken" not in fake.calls[0] + assert fake.calls[1]["PaginationToken"] == "3" + assert fake.calls[2]["PaginationToken"] == "6" + + +class TestEdgeCapableJoin: + def test_verifier_row_marks_account_edge_capable( + self, user_admin, credentials_table, install_cognito): + """Accounts with a verifier row in the edge-credentials table + (keyed by normalized lowercase username) are edge_capable.""" + install_cognito(users=[ + cognito_user("OpUser1", email="op@example.com", role="Operator"), + cognito_user("plainuser", email="p@example.com", role="Viewer"), + ]) + credentials_table.put_item(Item={ + "username": "opuser1", + "verifier": user_admin.make_verifier("Sup3r-Secret-Pw!", + iterations=10), + "updatedAt": 1700000000000, + }) + + status, body = invoke_list(user_admin) + assert status == 200 + rows = {u["username"]: u for u in body["users"]} + assert rows["OpUser1"]["edge_capable"] is True + assert rows["plainuser"]["edge_capable"] is False + + +class TestListFailure: + def test_cognito_failure_returns_502(self, user_admin, credentials_table, + install_cognito): + """A retrieval failure surfaces as an error, never a partial + list (backend side of Req 2.5).""" + install_cognito(error=not_found_error()) + status, body = invoke_list(user_admin) + assert status == 502 + assert body["error"] == "Failed to retrieve account list" + assert "users" not in body diff --git a/edge-cv-portal/backend/tests/test_user_admin_scaffold.py b/edge-cv-portal/backend/tests/test_user_admin_scaffold.py new file mode 100644 index 00000000..34cbeacc --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_scaffold.py @@ -0,0 +1,167 @@ +""" +Unit tests for the user_admin.py Lambda scaffold +(spec: portal-user-manager, task 1.2). + +Covers the PortalAdmin gate on every routed handler, the router's +CORS-preflight and not-found paths, and the two pure credential +functions: generate_temp_password (policy conformance) and +make_verifier (PBKDF2-SHA256 verifier shape and correctness). + +_Requirements: 1.5, 4.1, 7.3_ +""" +import base64 +import hashlib +import json +import string +import sys + +import pytest + + +@pytest.fixture(scope="module") +def user_admin(): + """Import the real module (conftest puts functions/ + shared layer on + sys.path). The scaffold's gate and pure functions make no AWS calls.""" + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +def make_event(method, path, role="PortalAdmin"): + return { + "httpMethod": method, + "path": path, + "requestContext": { + "authorizer": { + "claims": { + "sub": "caller-1", + "email": "caller@example.com", + "cognito:username": "caller-1", + "custom:role": role, + } + } + }, + } + + +ROUTES = [ + ("GET", "/api/v1/admin/users"), + ("POST", "/api/v1/admin/users/someuser/password"), + ("POST", "/api/v1/admin/users/someuser/forgot-password"), + ("PUT", "/api/v1/admin/users/someuser/role"), + ("GET", "/api/v1/admin/edge-sync/devices"), + ("POST", "/api/v1/admin/edge-sync/devices/device-1"), +] + + +class TestPortalAdminGate: + """Req 1.5: every handler rejects non-PortalAdmin callers with 403.""" + + @pytest.mark.parametrize("method,path", ROUTES) + @pytest.mark.parametrize( + "role", ["Viewer", "Operator", "DataScientist", "UseCaseAdmin"]) + def test_non_portal_admin_gets_403_on_every_route( + self, user_admin, method, path, role): + response = user_admin.handler(make_event(method, path, role), None) + assert response["statusCode"] == 403 + body = json.loads(response["body"]) + assert body["error"] == "Access denied" + + def test_missing_authorizer_claims_treated_as_viewer_403(self, user_admin): + event = {"httpMethod": "PUT", + "path": "/api/v1/admin/users/x/role", + "requestContext": {}} + response = user_admin.handler(event, None) + assert response["statusCode"] == 403 + + @pytest.mark.parametrize("method,path", [ + r for r in ROUTES if r != ("GET", "/api/v1/admin/users")]) + def test_portal_admin_passes_the_gate(self, user_admin, method, path): + """PortalAdmin callers reach the handler bodies (501 placeholders + for endpoints implemented by later tasks) rather than 403.""" + response = user_admin.handler( + make_event(method, path, "PortalAdmin"), None) + assert response["statusCode"] != 403 + + +class TestRouter: + def test_options_preflight_returns_200(self, user_admin): + response = user_admin.handler( + {"httpMethod": "OPTIONS", "path": "/api/v1/admin/users"}, None) + assert response["statusCode"] == 200 + assert "Access-Control-Allow-Methods" in response["headers"] + + def test_unknown_route_returns_404(self, user_admin): + response = user_admin.handler( + make_event("GET", "/api/v1/admin/unknown"), None) + assert response["statusCode"] == 404 + + +class TestGenerateTempPassword: + """Req 4.1: temporary passwords conform to the pool Password_Policy.""" + + def test_default_length_is_16(self, user_admin): + assert len(user_admin.generate_temp_password()) == 16 + + @pytest.mark.parametrize("length", [12, 16, 24]) + def test_contains_all_required_character_classes(self, user_admin, length): + password = user_admin.generate_temp_password(length) + assert len(password) == length + assert any(c in string.ascii_lowercase for c in password) + assert any(c in string.ascii_uppercase for c in password) + assert any(c in string.digits for c in password) + assert any(c in user_admin.PASSWORD_SYMBOLS for c in password) + + def test_only_uses_allowed_alphabet(self, user_admin): + alphabet = set(string.ascii_letters + string.digits + + user_admin.PASSWORD_SYMBOLS) + password = user_admin.generate_temp_password() + assert set(password) <= alphabet + + @pytest.mark.parametrize("length", [0, 8, 11]) + def test_rejects_lengths_below_policy_minimum(self, user_admin, length): + with pytest.raises(ValueError): + user_admin.generate_temp_password(length) + + def test_successive_calls_differ(self, user_admin): + passwords = {user_admin.generate_temp_password() for _ in range(5)} + assert len(passwords) == 5 + + +class TestMakeVerifier: + """Req 7.3: credential material is a salted one-way verifier only.""" + + def test_verifier_shape_and_defaults(self, user_admin): + verifier = user_admin.make_verifier("Sup3r-Secret-Pw!") + assert set(verifier) == {"algorithm", "iterations", "salt", "hash"} + assert verifier["algorithm"] == "pbkdf2-sha256" + assert verifier["iterations"] == 210000 + assert len(base64.b64decode(verifier["salt"])) == 16 + assert len(base64.b64decode(verifier["hash"])) == 32 + + def test_iteration_count_is_parameterizable(self, user_admin): + verifier = user_admin.make_verifier("Sup3r-Secret-Pw!", iterations=10) + assert verifier["iterations"] == 10 + + def test_hash_matches_pbkdf2_recomputation(self, user_admin): + password = "Sup3r-Secret-Pw!" + verifier = user_admin.make_verifier(password, iterations=10) + expected = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + base64.b64decode(verifier["salt"]), + verifier["iterations"], + dklen=32, + ) + assert base64.b64decode(verifier["hash"]) == expected + + def test_same_password_gets_fresh_salt_and_hash(self, user_admin): + first = user_admin.make_verifier("Sup3r-Secret-Pw!", iterations=10) + second = user_admin.make_verifier("Sup3r-Secret-Pw!", iterations=10) + assert first["salt"] != second["salt"] + assert first["hash"] != second["hash"] + + def test_plaintext_absent_from_serialized_verifier(self, user_admin): + password = "Sup3r-Secret-Pw!" + verifier = user_admin.make_verifier(password, iterations=10) + assert password not in json.dumps(verifier) diff --git a/edge-cv-portal/backend/tests/test_user_admin_set_password.py b/edge-cv-portal/backend/tests/test_user_admin_set_password.py new file mode 100644 index 00000000..f14d7d91 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_user_admin_set_password.py @@ -0,0 +1,360 @@ +""" +Unit tests for POST /api/v1/admin/users/{username}/password in +user_admin.py (spec: portal-user-manager, task 2.2). + +Covers: the audit-pending -> admin_set_user_password -> verifier capture +-> audit-final flow, the InvalidPasswordException -> 400 policy +pass-through with no verifier write, UserNotFoundException -> 404, +other Cognito errors -> 502 "password change failed", the +audit-before-effect abort (pending audit failure -> 500 "not applied" +with Cognito untouched), and the PortalAdmin 403 gate. + +Cognito is a recording fake client (same pattern as +test_user_admin_listing.py); the edge-credentials verifier write and +the two-phase audit entries run against real moto-backed DynamoDB. + +_Requirements: 3.1, 3.3, 3.5, 6.1, 6.4_ +""" +import base64 +import hashlib +import json +import os +import sys + +import pytest +from botocore.exceptions import ClientError + +REGION = "us-east-1" +EDGE_CREDENTIALS_TABLE = "test-edge-credentials" +AUDIT_LOG_TABLE = "test-audit-log" +POOL_ID = "us-east-1_testpool" + +VALID_PASSWORD = "Sup3r-Secret-Pw!" + + +# ----------------------------------------------------- fake Cognito client + +class FakeCognitoClient: + """Recording fake for the cognito-idp admin_set_user_password API.""" + + def __init__(self, error=None): + self.error = error + self.calls = [] + + def admin_set_user_password(self, **kwargs): + self.calls.append(kwargs) + if self.error is not None: + raise self.error + + +def cognito_error(code, message): + return ClientError({"Error": {"Code": code, "Message": message}}, + "AdminSetUserPassword") + + +POLICY_MESSAGE = ("Password did not conform with policy: " + "Password must have symbol characters") + + +# --------------------------------------------------------------- fixtures + +@pytest.fixture(scope="module") +def user_admin(aws_stack): + """The real user_admin module imported inside the moto mock, with the + edge-credentials table created.""" + import boto3 + + os.environ["EDGE_CREDENTIALS_TABLE"] = EDGE_CREDENTIALS_TABLE + ddb = boto3.client("dynamodb", region_name=REGION) + if EDGE_CREDENTIALS_TABLE not in ddb.list_tables()["TableNames"]: + ddb.create_table( + TableName=EDGE_CREDENTIALS_TABLE, + KeySchema=[{"AttributeName": "username", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "username", "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + sys.modules.pop("user_admin", None) + import user_admin as module + return module + + +@pytest.fixture +def credentials_table(user_admin): + """The moto-backed edge-credentials table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + EDGE_CREDENTIALS_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"username": item["username"]}) + return table + + +@pytest.fixture +def audit_table(user_admin): + """The moto-backed audit-log table, emptied per test.""" + import boto3 + table = boto3.resource("dynamodb", region_name=REGION).Table( + AUDIT_LOG_TABLE) + for item in table.scan()["Items"]: + table.delete_item(Key={"event_id": item["event_id"], + "timestamp": item["timestamp"]}) + return table + + +@pytest.fixture +def install_cognito(user_admin, monkeypatch): + """Wire a fake cognito client + pool id into the module under test.""" + def _install(error=None): + fake = FakeCognitoClient(error=error) + monkeypatch.setattr(user_admin, "cognito_client", fake) + monkeypatch.setattr(user_admin, "USER_POOL_ID", POOL_ID) + return fake + return _install + + +# ---------------------------------------------------------------- helpers + +def password_event(username, body=None, role="PortalAdmin", + with_path_parameters=True): + event = { + "httpMethod": "POST", + "path": f"/api/v1/admin/users/{username}/password", + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": "admin-1", + "email": "admin@example.com", + "cognito:username": "admin-1", + "custom:role": role, + } + } + }, + } + if with_path_parameters: + event["pathParameters"] = {"username": username} + return event + + +def invoke(user_admin, event): + response = user_admin.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def audit_entries(audit_table): + return audit_table.scan()["Items"] + + +def verify_password_against(verifier, password): + """Recompute the PBKDF2 hash to check the stored verifier matches.""" + derived = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + base64.b64decode(verifier["salt"]), + int(verifier["iterations"]), + dklen=32, + ) + return base64.b64encode(derived).decode("ascii") == verifier["hash"] + + +# ------------------------------------------------------------------ tests + +class TestPortalAdminGate: + @pytest.mark.parametrize( + "role", ["Viewer", "Operator", "DataScientist", "UseCaseAdmin"]) + def test_non_portal_admin_rejected_403( + self, user_admin, credentials_table, audit_table, + install_cognito, role): + """Req 1.5: non-PortalAdmin callers get 403 with zero Cognito + calls, no verifier write, and no audit entry.""" + fake = install_cognito() + status, body = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": True}, + role=role)) + assert status == 403 + assert body["error"] == "Access denied" + assert fake.calls == [] + assert credentials_table.scan()["Items"] == [] + assert audit_entries(audit_table) == [] + + +class TestPasswordChangeSuccess: + @pytest.mark.parametrize("permanent", [True, False]) + def test_sets_password_with_selected_permanence( + self, user_admin, credentials_table, audit_table, + install_cognito, permanent): + """Req 3.1: the exact password and admin-selected permanence are + passed to admin_set_user_password.""" + fake = install_cognito() + status, body = invoke(user_admin, password_event( + "operator1", + {"password": VALID_PASSWORD, "permanent": permanent})) + + assert status == 200 + assert body["username"] == "operator1" + assert body["permanent"] is permanent + assert fake.calls == [{ + "UserPoolId": POOL_ID, + "Username": "operator1", + "Password": VALID_PASSWORD, + "Permanent": permanent, + }] + + def test_success_captures_verifier_keyed_lowercase( + self, user_admin, credentials_table, audit_table, + install_cognito): + """A successful set writes a PBKDF2 verifier row keyed by the + normalized lowercase username with updatedAt (design D3).""" + install_cognito() + status, _ = invoke(user_admin, password_event( + "OpUser1", {"password": VALID_PASSWORD, "permanent": True})) + + assert status == 200 + items = credentials_table.scan()["Items"] + assert len(items) == 1 + item = items[0] + assert item["username"] == "opuser1" + assert item["updatedAt"] > 0 + verifier = item["verifier"] + assert verifier["algorithm"] == "pbkdf2-sha256" + assert verify_password_against(verifier, VALID_PASSWORD) + + def test_success_finalizes_exactly_one_audit_entry( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 6.1: exactly one audit entry, finalized to success, with + acting user, affected account, action type, and completion time; + no password material anywhere in it.""" + install_cognito() + status, _ = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": True})) + + assert status == 200 + entries = audit_entries(audit_table) + assert len(entries) == 1 + entry = entries[0] + assert entry["result"] == "success" + assert entry["action"] == "password_change" + assert entry["resource_type"] == "user_account" + assert entry["resource_id"] == "operator1" + assert entry["user_id"] == "admin-1" + assert entry["completed_at"] > 0 + assert VALID_PASSWORD not in json.dumps(entries, default=str) + + def test_response_never_contains_the_password( + self, user_admin, credentials_table, audit_table, + install_cognito): + install_cognito() + response = user_admin.handler(password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": False}), + None) + assert VALID_PASSWORD not in json.dumps(response) + + def test_username_extracted_from_raw_path_without_path_parameters( + self, user_admin, credentials_table, audit_table, + install_cognito): + fake = install_cognito() + status, body = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": True}, + with_path_parameters=False)) + assert status == 200 + assert fake.calls[0]["Username"] == "operator1" + + +class TestPolicyViolation: + def test_invalid_password_maps_to_400_with_policy_message( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 3.3: InvalidPasswordException -> 400 with the policy + message passed through, no verifier written.""" + install_cognito(error=cognito_error( + "InvalidPasswordException", POLICY_MESSAGE)) + status, body = invoke(user_admin, password_event( + "operator1", {"password": "weak", "permanent": True})) + + assert status == 400 + assert body["message"] == POLICY_MESSAGE + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + +class TestOtherFailures: + def test_user_not_found_maps_to_404( + self, user_admin, credentials_table, audit_table, + install_cognito): + install_cognito(error=cognito_error( + "UserNotFoundException", "User does not exist.")) + status, body = invoke(user_admin, password_event( + "ghost", {"password": VALID_PASSWORD, "permanent": True})) + + assert status == 404 + assert body["error"] == "User not found" + assert credentials_table.scan()["Items"] == [] + + def test_other_cognito_error_maps_to_502( + self, user_admin, credentials_table, audit_table, + install_cognito): + """Req 3.5: non-policy Cognito failures -> 502 'password change + failed', account untouched, no verifier written.""" + install_cognito(error=cognito_error( + "InternalErrorException", "Something went wrong")) + status, body = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": True})) + + assert status == 502 + assert body["error"] == "password change failed" + assert credentials_table.scan()["Items"] == [] + + entries = audit_entries(audit_table) + assert len(entries) == 1 + assert entries[0]["result"] == "failure" + + +class TestAuditBeforeEffect: + def test_pending_audit_failure_blocks_the_action( + self, user_admin, credentials_table, audit_table, + install_cognito, monkeypatch): + """Req 6.4/6.5: when the pending audit entry cannot be recorded, + the action is not applied - zero Cognito calls, no verifier, and + an error stating the action was not applied.""" + fake = install_cognito() + + def failing_audit(*args, **kwargs): + raise RuntimeError("audit table unavailable") + + monkeypatch.setattr(user_admin, "record_audit_event_strict", + failing_audit) + status, body = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD, "permanent": True})) + + assert status == 500 + assert body["message"] == "The action was not applied" + assert fake.calls == [] + assert credentials_table.scan()["Items"] == [] + + +class TestRequestValidation: + def test_missing_password_rejected_400_before_any_effect( + self, user_admin, credentials_table, audit_table, + install_cognito): + fake = install_cognito() + status, _ = invoke(user_admin, password_event( + "operator1", {"permanent": True})) + assert status == 400 + assert fake.calls == [] + + def test_missing_permanence_selection_rejected_400( + self, user_admin, credentials_table, audit_table, + install_cognito): + """The permanence setting is an explicit required boolean (3.1: + the admin-selected setting is what gets applied).""" + fake = install_cognito() + status, _ = invoke(user_admin, password_event( + "operator1", {"password": VALID_PASSWORD})) + assert status == 400 + assert fake.calls == [] diff --git a/edge-cv-portal/backend/tests/test_workflow_generation.py b/edge-cv-portal/backend/tests/test_workflow_generation.py new file mode 100644 index 00000000..df41e51c --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_generation.py @@ -0,0 +1,566 @@ +""" +Workflow generation integration tests (workflow-manager Requirements +10.2, 10.5, 10.7). + +Task 10.4 (spec: workflow-manager). + +POST /workflows/generate (functions/workflow_generator.py) is exercised +against the shared moto stack from conftest.py with the Bedrock Converse +API mocked (design.md "Integration tests": Bedrock invocation with mocked +Converse API). Covered: + +1. Prompt/catalog assembly (10.2): the Converse call carries a system + prompt embedding the serialized node catalog, a toolConfig whose + create_workflow inputSchema IS the Workflow_Definition JSON Schema with + toolChoice forced, and the configured model id / inference parameters. +2. Tool-use output handling: a valid create_workflow tool input returns + 200 with the canonical definition plus the complete findings list, and + the chat session (DynamoDB) and canvas snapshot (S3) are persisted; + unparseable tool output returns 422 with no session mutation; a + response without the tool call returns 502 NO_WORKFLOW_RETURNED. +3. Follow-up modification flow (10.5): a second prompt in the same + session replays the history and sends a user turn embedding the + current canvas definition with an instruction to modify rather than + regenerate; a client-provided current_definition is authoritative. +4. Timeout and invocation failure (10.7): a read timeout returns 504 + GENERATION_TIMEOUT carrying the configured timeout; the configured + timeout is clamped to at most 60 seconds when building the client; + ClientError returns 502 - all without touching the session. + +_Requirements: 10.2, 10.5, 10.7_ +""" +import json +import os +import sys +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError, ReadTimeoutError + +from conftest import REGION, TEST_ENV + +CHAT_SESSIONS_TABLE_NAME = "test-workflow-chat-sessions" +SETTINGS_TABLE_NAME = "test-settings-generation" + +TOOL_NAME = "create_workflow" + +# Minimal catalog-conformant Workflow_Definition: camera_source -> capture, +# every required parameter set, acyclic, all nodes reachable - so the +# Workflow_Validator reports zero errors. +VALID_DEFINITION = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], +} + + +def converse_tool_response(tool_input, text=None): + """A Converse API response whose assistant message calls create_workflow.""" + content = [] + if text is not None: + content.append({"text": text}) + content.append({"toolUse": {"toolUseId": "tool-1", "name": TOOL_NAME, + "input": tool_input}}) + return { + "output": {"message": {"role": "assistant", "content": content}}, + "stopReason": "tool_use", + } + + +@pytest.fixture(scope="module") +def gen_env(aws_stack): + """Chat-sessions + settings tables and a freshly imported + workflow_generator module bound to them inside moto.""" + import boto3 + + os.environ["WORKFLOW_CHAT_SESSIONS_TABLE"] = CHAT_SESSIONS_TABLE_NAME + os.environ["SETTINGS_TABLE"] = SETTINGS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=CHAT_SESSIONS_TABLE_NAME, + KeySchema=[{"AttributeName": "session_id", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "session_id", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=SETTINGS_TABLE_NAME, + KeySchema=[{"AttributeName": "setting_key", "KeyType": "HASH"}], + AttributeDefinitions=[{"AttributeName": "setting_key", + "AttributeType": "S"}], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the module binds the table names above and + # moto-intercepted boto3 clients (conftest pattern). + for module_name in ("workflow_generator", "workflow_validation"): + sys.modules.pop(module_name, None) + import workflow_generator + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + module=workflow_generator, + sessions_table=resource.Table(CHAT_SESSIONS_TABLE_NAME), + settings_table=resource.Table(SETTINGS_TABLE_NAME), + s3=boto3.client("s3", region_name=REGION), + bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + ) + + +@pytest.fixture(autouse=True) +def clean_bedrock_config(gen_env): + """Each test starts from the default Bedrock_Configuration.""" + gen_env.settings_table.delete_item( + Key={"setting_key": "bedrock_configuration"}) + yield + + +@pytest.fixture +def bedrock(gen_env, monkeypatch): + """Mocked Converse API client injected through get_bedrock_client. + + ``client_requests`` records every (region, timeout_seconds) the + handler asked a client for, so tests can assert the clamped timeout. + """ + client = MagicMock(name="bedrock-runtime") + client.converse.return_value = converse_tool_response( + VALID_DEFINITION, text="Here is the workflow.") + client_requests = [] + + def fake_get_bedrock_client(region, timeout_seconds): + client_requests.append((region, timeout_seconds)) + return client + + monkeypatch.setattr(gen_env.module, "get_bedrock_client", + fake_get_bedrock_client) + return SimpleNamespace(client=client, client_requests=client_requests) + + +@pytest.fixture +def ctx(env): + """A fresh Use_Case and a DataScientist authorized on it.""" + usecase_id = env.create_usecase() + user = env.make_user() + env.assign_role(user, usecase_id, "DataScientist") + return SimpleNamespace(usecase_id=usecase_id, user=user) + + +def generate(gen_env, ctx, prompt, session_id=None, current_definition=None, + temperature=None): + """POST /workflows/generate; returns (status_code, parsed_body).""" + body = {"usecase_id": ctx.usecase_id, "prompt": prompt} + if session_id is not None: + body["session_id"] = session_id + if current_definition is not None: + body["current_definition"] = current_definition + if temperature is not None: + body["temperature"] = temperature + event = { + "httpMethod": "POST", + "resource": "/workflows/generate", + "path": "/workflows/generate", + "body": json.dumps(body), + "requestContext": { + "authorizer": { + "claims": { + "sub": ctx.user["user_id"], + "email": ctx.user["email"], + "cognito:username": ctx.user["username"], + "custom:role": ctx.user["role"], + } + } + }, + } + response = gen_env.module.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def put_bedrock_config(gen_env, **values): + """Store a Bedrock_Configuration item the way the settings API does.""" + def to_ddb(value): + return Decimal(str(value)) if isinstance(value, float) else value + gen_env.settings_table.put_item(Item={ + "setting_key": "bedrock_configuration", + "value": {k: to_ddb(v) for k, v in values.items()}, + }) + + +def sessions_for(gen_env, usecase_id): + """Chat session items belonging to a Use_Case (fresh per test).""" + items = gen_env.sessions_table.scan()["Items"] + return [i for i in items if i.get("usecase_id") == usecase_id] + + +def s3_keys_for(gen_env, usecase_id): + """All portal-S3 keys under the Use_Case's workflows prefix.""" + response = gen_env.s3.list_objects_v2( + Bucket=gen_env.bucket, Prefix=f"workflows/{usecase_id}/") + return [obj["Key"] for obj in response.get("Contents", [])] + + +def read_snapshot(gen_env, usecase_id, session_id): + obj = gen_env.s3.get_object( + Bucket=gen_env.bucket, + Key=f"workflows/{usecase_id}/chat-sessions/{session_id}/current_definition.json", + ) + return json.loads(obj["Body"].read().decode("utf-8")) + + +# =========================================================================== +# 1. Prompt and catalog assembly (Requirement 10.2) +# =========================================================================== + +class TestPromptAndCatalogAssembly: + + def test_system_prompt_embeds_serialized_node_catalog(self, gen_env, + bedrock, ctx): + """The Converse call carries the prompt and the full serialized + node type catalog in the system prompt (Requirement 10.2).""" + prompt = "Capture frames from the camera to disk" + status, _ = generate(gen_env, ctx, prompt) + assert status == 200 + + assert bedrock.client.converse.call_count == 1 + kwargs = bedrock.client.converse.call_args.kwargs + system_text = kwargs["system"][0]["text"] + assert gen_env.module.serialized_catalog_json() in system_text + + messages = kwargs["messages"] + assert messages[-1]["role"] == "user" + assert prompt in messages[-1]["content"][0]["text"] + + def test_tool_config_is_definition_schema_with_forced_choice(self, gen_env, + bedrock, ctx): + """The create_workflow tool inputSchema IS the Workflow_Definition + JSON Schema and toolChoice forces the tool (Requirement 10.2).""" + from workflow_core.serializer import WORKFLOW_DEFINITION_SCHEMA + + status, _ = generate(gen_env, ctx, "Any pipeline") + assert status == 200 + + tool_config = bedrock.client.converse.call_args.kwargs["toolConfig"] + assert tool_config["toolChoice"] == {"tool": {"name": TOOL_NAME}} + + (tool,) = tool_config["tools"] + assert tool["toolSpec"]["name"] == TOOL_NAME + sent_schema = tool["toolSpec"]["inputSchema"]["json"] + expected = {k: v for k, v in WORKFLOW_DEFINITION_SCHEMA.items() + if k not in ("$schema", "$id")} + assert sent_schema == expected + + def test_configured_model_and_inference_parameters_are_used(self, gen_env, + bedrock, ctx): + """The Bedrock_Configuration from the settings table drives the + model id, inference parameters, region, and timeout (10.2, 10.7). + When both sampling parameters are configured, only temperature is + sent - recent Anthropic models reject temperature + top_p + together.""" + put_bedrock_config(gen_env, model_id="amazon.nova-pro-v1:0", + region="eu-west-1", max_tokens=1024, + temperature=0.7, top_p=0.5, timeout_seconds=30) + + status, payload = generate(gen_env, ctx, "Any pipeline") + assert status == 200 + assert payload["model_id"] == "amazon.nova-pro-v1:0" + + kwargs = bedrock.client.converse.call_args.kwargs + assert kwargs["modelId"] == "amazon.nova-pro-v1:0" + assert kwargs["inferenceConfig"] == {"maxTokens": 1024, + "temperature": 0.7} + assert "topP" not in kwargs["inferenceConfig"] + assert bedrock.client_requests[-1] == ("eu-west-1", 30) + + def test_default_configuration_sends_no_sampling_parameters( + self, gen_env, bedrock, ctx): + """With nothing stored, the default configuration is unset for + both sampling parameters, so the invocation carries neither + temperature nor topP - they are sent only when explicitly + configured or overridden.""" + status, _ = generate(gen_env, ctx, "Any pipeline") + assert status == 200 + + inference_config = bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"] + assert "temperature" not in inference_config + assert "topP" not in inference_config + + def test_top_p_is_sent_when_temperature_is_explicitly_null( + self, gen_env, bedrock, ctx): + """A stored configuration with temperature explicitly null sends + topP (and no temperature) so top_p-only sampling is possible.""" + put_bedrock_config(gen_env, temperature=None, top_p=0.5) + + status, _ = generate(gen_env, ctx, "Any pipeline") + assert status == 200 + + inference_config = bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"] + assert inference_config["topP"] == 0.5 + assert "temperature" not in inference_config + + +# =========================================================================== +# 1b. Per-request temperature override +# =========================================================================== + +class TestTemperatureOverride: + """POST /workflows/generate accepts an optional `temperature` (0..1) + that overrides the configured value for that invocation only and, per + the existing rule, suppresses top_p.""" + + def test_body_temperature_overrides_configured_value(self, gen_env, + bedrock, ctx): + """A body temperature replaces the settings-table temperature for + this invocation; top_p stays suppressed.""" + put_bedrock_config(gen_env, temperature=0.7, top_p=0.5) + + status, _ = generate(gen_env, ctx, "Any pipeline", temperature=0.05) + assert status == 200 + + inference_config = bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"] + assert inference_config["temperature"] == 0.05 + assert "topP" not in inference_config + + def test_body_temperature_overrides_null_configured_temperature( + self, gen_env, bedrock, ctx): + """Even a configuration with temperature explicitly null (top_p + sampling) is overridden: the request temperature is sent and + top_p is suppressed.""" + put_bedrock_config(gen_env, temperature=None, top_p=0.5) + + status, _ = generate(gen_env, ctx, "Any pipeline", temperature=0.0) + assert status == 200 + + inference_config = bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"] + assert inference_config["temperature"] == 0.0 + assert "topP" not in inference_config + + def test_boundary_values_zero_and_one_are_accepted(self, gen_env, + bedrock, ctx): + for value in (0, 1, 0.5): + status, _ = generate(gen_env, ctx, "Any pipeline", + temperature=value) + assert status == 200 + assert bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"]["temperature"] == float(value) + + @pytest.mark.parametrize("value", [-0.1, 1.5, 2, "0.5", True, [0.2]]) + def test_invalid_temperature_is_rejected_with_400(self, gen_env, bedrock, + ctx, value): + """Values outside [0, 1] or non-numbers reject with 400 + INVALID_TEMPERATURE before any Bedrock invocation.""" + status, payload = generate(gen_env, ctx, "Any pipeline", + temperature=value) + assert status == 400 + assert payload["error"]["code"] == "INVALID_TEMPERATURE" + assert bedrock.client.converse.call_count == 0 + + def test_absent_temperature_uses_configured_value(self, gen_env, bedrock, + ctx): + """Without a body temperature the configured value is used + unchanged (the override is per-invocation only).""" + put_bedrock_config(gen_env, temperature=0.7, top_p=0.5) + + status, _ = generate(gen_env, ctx, "Any pipeline") + assert status == 200 + assert bedrock.client.converse.call_args.kwargs[ + "inferenceConfig"]["temperature"] == 0.7 + + +# =========================================================================== +# 2. Tool-use output handling +# =========================================================================== + +class TestToolUseOutputHandling: + + def test_valid_tool_output_returns_definition_findings_and_session( + self, gen_env, bedrock, ctx): + """A valid create_workflow tool input yields 200 with the canonical + definition plus complete findings, a persisted chat session, and + the canvas snapshot in S3 (Requirements 10.2, 10.5).""" + status, payload = generate(gen_env, ctx, "Camera to capture") + assert status == 200 + + definition = payload["definition"] + assert definition["schemaVersion"] == 1 + assert {n["id"] for n in definition["nodes"]} == {"n1", "n2"} + assert [c["id"] for c in definition["connections"]] == ["c1"] + + assert isinstance(payload["findings"], list) + assert payload["error_count"] == 0 + assert payload["validation_passed"] is True + assert payload["assistant_text"] == "Here is the workflow." + + # Session persisted with both turns and the snapshot key. + (session,) = sessions_for(gen_env, ctx.usecase_id) + assert session["session_id"] == payload["session_id"] + assert session["user_id"] == ctx.user["user_id"] + assert [m["role"] for m in session["messages"]] == ["user", "assistant"] + assert session["current_definition_key"].endswith( + f"chat-sessions/{payload['session_id']}/current_definition.json") + + # Snapshot in S3 equals the returned canonical definition. + snapshot = read_snapshot(gen_env, ctx.usecase_id, payload["session_id"]) + assert snapshot == definition + + def test_unparseable_tool_output_returns_422_without_session_mutation( + self, gen_env, bedrock, ctx): + """Tool output that is not a valid Workflow_Definition yields 422 + and persists nothing, leaving the canvas untouched.""" + bedrock.client.converse.return_value = converse_tool_response( + {"schemaVersion": 1, "nodes": "not-a-list"}) + + status, payload = generate(gen_env, ctx, "Camera to capture") + assert status == 422 + assert payload["error"]["code"] == "GENERATED_DEFINITION_INVALID" + assert payload["error"]["details"]["path"] + + assert sessions_for(gen_env, ctx.usecase_id) == [] + assert s3_keys_for(gen_env, ctx.usecase_id) == [] + + def test_response_without_tool_call_returns_502(self, gen_env, bedrock, + ctx): + """A model response with no create_workflow tool call yields 502 + NO_WORKFLOW_RETURNED and persists nothing.""" + bedrock.client.converse.return_value = { + "output": {"message": {"role": "assistant", + "content": [{"text": "I cannot help."}]}}, + "stopReason": "end_turn", + } + + status, payload = generate(gen_env, ctx, "Camera to capture") + assert status == 502 + assert payload["error"]["code"] == "NO_WORKFLOW_RETURNED" + assert payload["error"]["details"]["stop_reason"] == "end_turn" + + assert sessions_for(gen_env, ctx.usecase_id) == [] + assert s3_keys_for(gen_env, ctx.usecase_id) == [] + + +# =========================================================================== +# 3. Follow-up modification flow (Requirement 10.5) +# =========================================================================== + +class TestFollowUpModification: + + def test_followup_embeds_current_definition_and_replays_history( + self, gen_env, bedrock, ctx): + """A follow-up prompt in the same session replays the history and + sends the current canvas definition with a modification + instruction, not a from-scratch request (Requirement 10.5).""" + status, first = generate(gen_env, ctx, "Camera to capture") + assert status == 200 + session_id = first["session_id"] + stored_snapshot = read_snapshot(gen_env, ctx.usecase_id, session_id) + + status, second = generate(gen_env, ctx, "Add a rotate step", + session_id=session_id) + assert status == 200 + assert second["session_id"] == session_id + + assert bedrock.client.converse.call_count == 2 + messages = bedrock.client.converse.call_args.kwargs["messages"] + + # History replayed: first user turn, assistant turn, new user turn. + assert [m["role"] for m in messages] == ["user", "assistant", "user"] + assert "Camera to capture" in messages[0]["content"][0]["text"] + + followup_text = messages[2]["content"][0]["text"] + assert followup_text.startswith("Add a rotate step") + assert "CURRENT CANVAS WORKFLOW DEFINITION (JSON):" in followup_text + # The session's stored canvas snapshot is embedded verbatim. + assert json.dumps(stored_snapshot, sort_keys=True) in followup_text + assert ("Apply the requested change to this current definition " + "rather than generating a new workflow from scratch" + ) in followup_text + + # Session history now holds both exchanges. + (session,) = sessions_for(gen_env, ctx.usecase_id) + assert [m["role"] for m in session["messages"]] == \ + ["user", "assistant", "user", "assistant"] + + def test_client_provided_current_definition_is_authoritative( + self, gen_env, bedrock, ctx): + """A current_definition sent by the client (the live canvas) is + embedded in the user turn in canonical form (Requirement 10.5).""" + from workflow_core.serializer import parse, serialize + + status, _ = generate(gen_env, ctx, "Rename the capture path", + current_definition=VALID_DEFINITION) + assert status == 200 + + expected_canonical = serialize( + parse(json.dumps(VALID_DEFINITION)).graph) + user_text = bedrock.client.converse.call_args.kwargs[ + "messages"][-1]["content"][0]["text"] + assert "CURRENT CANVAS WORKFLOW DEFINITION (JSON):" in user_text + assert expected_canonical in user_text + + +# =========================================================================== +# 4. Timeout and invocation failure behavior (Requirement 10.7) +# =========================================================================== + +class TestTimeoutAndFailure: + + def test_read_timeout_returns_504_with_configured_timeout(self, gen_env, + bedrock, ctx): + """A Bedrock read timeout yields 504 GENERATION_TIMEOUT carrying + the configured timeout, the client was built with that timeout, + and no session is persisted so the prompt can be retried + (Requirement 10.7).""" + put_bedrock_config(gen_env, timeout_seconds=45) + bedrock.client.converse.side_effect = ReadTimeoutError( + endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com") + + status, payload = generate(gen_env, ctx, "Camera to capture") + assert status == 504 + assert payload["error"]["code"] == "GENERATION_TIMEOUT" + assert payload["error"]["details"]["timeout_seconds"] == 45 + assert bedrock.client_requests[-1][1] == 45 + + assert sessions_for(gen_env, ctx.usecase_id) == [] + assert s3_keys_for(gen_env, ctx.usecase_id) == [] + + def test_configured_timeout_is_clamped_to_60_seconds(self, gen_env, + bedrock, ctx): + """A stored timeout above the 60-second maximum is clamped before + the client is built (Requirement 10.7: at most 60 seconds).""" + put_bedrock_config(gen_env, timeout_seconds=300) + + status, _ = generate(gen_env, ctx, "Camera to capture") + assert status == 200 + assert bedrock.client_requests[-1][1] == 60 + + def test_invocation_client_error_returns_502(self, gen_env, bedrock, ctx): + """A Bedrock invocation failure yields 502 with the Bedrock error + code and persists no session (Requirement 10.7).""" + bedrock.client.converse.side_effect = ClientError( + {"Error": {"Code": "ThrottlingException", + "Message": "Rate exceeded"}}, + "Converse", + ) + + status, payload = generate(gen_env, ctx, "Camera to capture") + assert status == 502 + assert payload["error"]["code"] == "BEDROCK_INVOCATION_FAILED" + assert payload["error"]["details"]["bedrock_error_code"] == \ + "ThrottlingException" + + assert sessions_for(gen_env, ctx.usecase_id) == [] + assert s3_keys_for(gen_env, ctx.usecase_id) == [] diff --git a/edge-cv-portal/backend/tests/test_workflow_guards.py b/edge-cv-portal/backend/tests/test_workflow_guards.py new file mode 100644 index 00000000..52de4b27 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_guards.py @@ -0,0 +1,274 @@ +""" +API guard unit tests (Workflow Manager). + +Task 6.5 (spec: workflow-manager). Focused guard-level coverage: + - workflow_guards.check_workflow_version_validated: packaging / + publishing / deployment rejection on validation errors and on + missing passed-validation records (Requirements 4.7, 4.10) + - workflow_packaging.validation_guard: 400 on a failed validation + record, 409 on a missing one (Requirements 4.7, 4.10) + - workflows.deployment_references_workflow / + find_active_workflow_deployments: delete-with-active-deployments + identifying exactly the referencing active deployments + (Requirement 5.6) + +Runs against the shared moto stack from conftest.py; the guard modules +are imported inside the mock so their module-level boto3 clients are +intercepted. + +Validates: Requirements 4.7, 4.10, 5.6 +""" +import json +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import TEST_ENV + + +@pytest.fixture(scope="module") +def mods(aws_stack): + """Import the modules under test while the moto mock is active. + + Follows the conftest pattern: pop any previously imported copies so + the fresh imports bind moto-intercepted module-level boto3 clients. + (aws_stack already imported the real shared_utils + workflows.) + """ + for module_name in ("workflow_guards", "workflow_packaging"): + sys.modules.pop(module_name, None) + import workflow_guards + import workflow_packaging + return SimpleNamespace( + guards=workflow_guards, + packaging=workflow_packaging, + workflows=aws_stack.workflows, + ) + + +def put_version(aws_stack, validation_status=None, version=1): + """Insert a WorkflowVersions item; returns its unique workflow_id.""" + workflow_id = f"wf-{uuid.uuid4()}" + item = { + "workflow_id": workflow_id, + "version": version, + "s3_definition_key": f"workflows/uc/{workflow_id}/versions/{version}/workflow.json", + } + if validation_status is not None: + item["validation_status"] = validation_status + aws_stack.tables.versions.put_item(Item=item) + return workflow_id + + +def error_finding(message="cycle detected"): + return {"severity": "error", "code": "CYCLE", "message": message, "nodeId": "n1"} + + +def warning_finding(message="deprecated parameter"): + return {"severity": "warning", "code": "DEPRECATED", "message": message, "nodeId": "n2"} + + +# ---------------------------------------------------------------- 4.7, 4.10 +class TestCheckWorkflowVersionValidated: + """workflow_guards.check_workflow_version_validated""" + + def test_passed_with_no_findings_returns_none(self, aws_stack, mods): + """A version with a recorded passed run and no findings passes the + guard (Req 4.10).""" + workflow_id = put_version( + aws_stack, {"status": "passed", "validated_at": 1000} + ) + assert mods.guards.check_workflow_version_validated(workflow_id, 1) is None + + def test_passed_with_only_warnings_returns_none(self, aws_stack, mods): + """Warning-severity findings do not block packaging/publishing/ + deployment; only errors do (Req 4.10 zero *errors*).""" + workflow_id = put_version( + aws_stack, + {"status": "passed", "findings": [warning_finding()]}, + ) + assert mods.guards.check_workflow_version_validated(workflow_id, 1) is None + + def test_inline_error_findings_rejected_with_errors(self, aws_stack, mods): + """A version whose recorded findings contain errors is rejected + with 409 and the validation errors for display (Req 4.7).""" + errors = [error_finding("cycle a->b->a"), error_finding("missing param")] + workflow_id = put_version( + aws_stack, + { + "status": "failed", + "validated_at": 2000, + "findings": errors + [warning_finding()], + }, + ) + failure = mods.guards.check_workflow_version_validated(workflow_id, 1) + assert failure is not None + assert failure["status_code"] == 409 + assert failure["code"] == "WORKFLOW_VALIDATION_ERRORS" + # Only the error-severity findings are surfaced as errors. + assert failure["details"]["errors"] == errors + assert failure["details"]["workflow_id"] == workflow_id + assert failure["details"]["version"] == 1 + assert failure["details"]["validated_at"] == 2000 + + def test_error_findings_loaded_from_s3_rejected(self, aws_stack, mods): + """Findings referenced via findings_key are loaded from portal S3 + and error findings reject the request (Req 4.7).""" + errors = [error_finding("unreachable node")] + findings_key = f"workflows/findings/{uuid.uuid4()}.json" + aws_stack.s3.put_object( + Bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + Key=findings_key, + Body=json.dumps({"findings": errors + [warning_finding()]}), + ) + workflow_id = put_version( + aws_stack, + {"status": "failed", "findings_key": findings_key}, + ) + failure = mods.guards.check_workflow_version_validated(workflow_id, 1) + assert failure is not None + assert failure["status_code"] == 409 + assert failure["code"] == "WORKFLOW_VALIDATION_ERRORS" + assert failure["details"]["errors"] == errors + + def test_status_none_rejected_as_not_validated(self, aws_stack, mods): + """A version whose recorded status is 'none' has no passed run and + is rejected with 409 (Req 4.10).""" + workflow_id = put_version(aws_stack, {"status": "none"}) + failure = mods.guards.check_workflow_version_validated(workflow_id, 1) + assert failure is not None + assert failure["status_code"] == 409 + assert failure["code"] == "WORKFLOW_VERSION_NOT_VALIDATED" + assert failure["details"]["validation_status"] == "none" + + def test_missing_validation_record_rejected_as_not_validated(self, aws_stack, mods): + """A version never validated (no validation_status attribute) is + rejected with 409 (Req 4.10).""" + workflow_id = put_version(aws_stack, validation_status=None) + failure = mods.guards.check_workflow_version_validated(workflow_id, 1) + assert failure is not None + assert failure["status_code"] == 409 + assert failure["code"] == "WORKFLOW_VERSION_NOT_VALIDATED" + + def test_missing_version_rejected_404(self, mods): + failure = mods.guards.check_workflow_version_validated("no-such-wf", 3) + assert failure is not None + assert failure["status_code"] == 404 + assert failure["code"] == "WORKFLOW_VERSION_NOT_FOUND" + assert failure["details"] == {"workflow_id": "no-such-wf", "version": 3} + + +# ---------------------------------------------------------------- 4.7, 4.10 +class TestPackagingValidationGuard: + """workflow_packaging.validation_guard (packaging-route guard)""" + + @staticmethod + def unpack(response): + return response["statusCode"], json.loads(response["body"]) + + def test_passed_record_allows_packaging(self, mods): + version_item = {"version": 1, "validation_status": {"status": "passed"}} + assert mods.packaging.validation_guard(version_item) is None + + def test_failed_record_rejected_400_with_findings_reference(self, mods): + """A validated-and-failed version gets 400 VALIDATION_FAILED with + the findings reference (Req 4.7).""" + version_item = { + "version": 2, + "validation_status": { + "status": "failed", + "findings_key": "workflows/findings/abc.json", + "validated_at": 3000, + }, + } + status, payload = self.unpack(mods.packaging.validation_guard(version_item)) + assert status == 400 + assert payload["error"]["code"] == "VALIDATION_FAILED" + assert payload["error"]["details"]["version"] == 2 + assert payload["error"]["details"]["findings_key"] == "workflows/findings/abc.json" + + def test_missing_record_rejected_409_validation_required(self, mods): + """A version with no validation record gets 409 VALIDATION_REQUIRED + (Req 4.10).""" + status, payload = self.unpack(mods.packaging.validation_guard({"version": 1})) + assert status == 409 + assert payload["error"]["code"] == "VALIDATION_REQUIRED" + assert payload["error"]["details"]["version"] == 1 + + def test_stale_or_unknown_status_rejected_409(self, mods): + version_item = {"version": 1, "validation_status": {"status": "running"}} + status, payload = self.unpack(mods.packaging.validation_guard(version_item)) + assert status == 409 + assert payload["error"]["code"] == "VALIDATION_REQUIRED" + + +# ---------------------------------------------------------------------- 5.6 +class TestDeleteWithActiveDeploymentsGuard: + """workflows.deployment_references_workflow / + find_active_workflow_deployments (delete-rejection guard)""" + + def test_reference_via_association_attributes(self, mods): + deployment = {"component_type": "workflow", "workflow_id": "wf-1"} + assert mods.workflows.deployment_references_workflow(deployment, "wf-1") is True + assert mods.workflows.deployment_references_workflow(deployment, "wf-2") is False + + def test_reference_via_component_name(self, mods): + deployment = {"components": [{"component_name": "dda.workflow.wf-1"}]} + assert mods.workflows.deployment_references_workflow(deployment, "wf-1") is True + assert mods.workflows.deployment_references_workflow(deployment, "wf-2") is False + + def test_unrelated_deployment_does_not_reference(self, mods): + deployment = { + "component_type": "model", + "workflow_id": "wf-1", + "components": [{"component_name": "dda.model.something"}, "not-a-dict"], + } + assert mods.workflows.deployment_references_workflow(deployment, "wf-1") is False + + def test_find_returns_exactly_the_active_referencing_deployments(self, env, mods): + """Only active deployments that reference the workflow are + identified; inactive and unrelated ones are excluded (Req 5.6).""" + usecase_id = env.create_usecase() + workflow_id = f"wf-{uuid.uuid4()}" + + dep_assoc = env.put_deployment( + usecase_id, status="IN_PROGRESS", + component_type="workflow", workflow_id=workflow_id, + ) + dep_component = env.put_deployment( + usecase_id, status="ACTIVE", + components=[{"component_name": f"dda.workflow.{workflow_id}"}], + ) + # Inactive deployment of this workflow: excluded. + env.put_deployment( + usecase_id, status="FAILED", + component_type="workflow", workflow_id=workflow_id, + ) + # Active deployment of a different workflow: excluded. + env.put_deployment( + usecase_id, status="ACTIVE", + component_type="workflow", workflow_id="other-workflow", + ) + + found = mods.workflows.find_active_workflow_deployments(usecase_id, workflow_id) + assert found == sorted([dep_assoc, dep_component]) + + def test_find_status_matching_is_case_insensitive(self, env, mods): + usecase_id = env.create_usecase() + workflow_id = f"wf-{uuid.uuid4()}" + dep = env.put_deployment( + usecase_id, status="in_progress", + component_type="workflow", workflow_id=workflow_id, + ) + found = mods.workflows.find_active_workflow_deployments(usecase_id, workflow_id) + assert found == [dep] + + def test_find_returns_empty_when_no_active_references(self, env, mods): + usecase_id = env.create_usecase() + workflow_id = f"wf-{uuid.uuid4()}" + env.put_deployment( + usecase_id, status="CANCELLED", + component_type="workflow", workflow_id=workflow_id, + ) + assert mods.workflows.find_active_workflow_deployments(usecase_id, workflow_id) == [] diff --git a/edge-cv-portal/backend/tests/test_workflow_model_staging.py b/edge-cv-portal/backend/tests/test_workflow_model_staging.py new file mode 100644 index 00000000..36cba4dd --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_model_staging.py @@ -0,0 +1,665 @@ +""" +Triton model staging for workflow test runs (workflow_model_staging.py ++ the start_test_run wiring in workflow_testing.py). + +Cloud test runs execute model_inference nodes for real: starting a run +resolves each node's modelName against the Use_Case's model registry, +picks a CPU-runnable Greengrass component variant (-x86-64-cpu +preferred, then -onnx), copies the component's S3 model artifact zip +into the portal artifacts bucket under the run's prefix, and forwards +the staging manifest [{nodeId, modelName, s3Key}] through the state +machine input (staged_models_json -> STAGED_MODELS env). A model +without a CPU-compatible variant fails the run with a clear per-node +error record before any execution starts (Requirement 12.10 +semantics). + +Covers: +1. Variant selection: -x86-64-cpu preferred over -onnx, -onnx as the + fallback, and the exact no-CPU-variant error otherwise. +2. Artifact copy staging against moto S3 with a fake Greengrass client + (recipe -> artifact URI -> portal-bucket copy, shared models copied + once, per-node error records for unregistered models / missing + artifacts). +3. Manifest passing: POST /workflows/{id}/test-runs forwards + staged_models + staged_models_json in the execution input; staging + errors fail the run (results document + TestRuns failure) without + starting an execution; workflows without model nodes stage nothing. +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION, TEST_ENV + +# Module-unique table names (the shared moto stack is session-scoped; +# other test modules create their own TestDatasets/TestRuns tables). +DATASETS_TABLE_NAME = "staging-test-datasets" +RUNS_TABLE_NAME = "staging-test-runs" +MODELS_TABLE_NAME = "staging-models" + +SOURCE_BUCKET = "usecase-data-bucket" + +ARN_PREFIX = "arn:aws:greengrass:us-east-1:123456789012:components:" + + +def component_arn(name, version="2.0.0"): + return "{0}{1}:versions:{2}".format(ARN_PREFIX, name, version) + + +ARN_CPU = component_arn("model-cookies-binary-x86-64-cpu") +ARN_ONNX = component_arn("model-cookies-binary-onnx") +ARN_JP5 = component_arn("model-cookies-binary-jetson-xavier-jp5") +ARN_JP6 = component_arn("model-cookies-binary-jetson-xavier-jp6") + + +def recipe(artifact_uri=None, extra_artifacts=()): + """A Greengrass component recipe shaped like the live model + components (Manifests[].Artifacts[].Uri -> the model zip).""" + artifacts = list(extra_artifacts) + if artifact_uri: + artifacts.append({"Uri": artifact_uri, + "Digest": "x", "Algorithm": "SHA-256"}) + return { + "RecipeFormatVersion": "2020-01-25", + "ComponentName": "model-cookies-binary-x86-64-cpu", + "ComponentVersion": "2.0.0", + "Manifests": [{ + "Platform": {"os": "linux", "architecture": "amd64"}, + "Artifacts": artifacts, + }], + } + + +class FakeGreengrass: + """greengrass:GetComponent stub returning canned recipes (moto has + no greengrassv2 backend).""" + + def __init__(self, recipes_by_arn): + self.recipes_by_arn = recipes_by_arn + self.calls = [] + + def get_component(self, arn, recipeOutputFormat=None): + self.calls.append(arn) + if arn not in self.recipes_by_arn: + from botocore.exceptions import ClientError + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": arn}}, "GetComponent") + return { + "recipe": json.dumps(self.recipes_by_arn[arn]).encode("utf-8"), + "recipeOutputFormat": "JSON", + } + + +class FakeStepFunctions: + def __init__(self): + self.calls = [] + + def start_execution(self, **kwargs): + self.calls.append(kwargs) + return {"executionArn": + "arn:aws:states:us-east-1:123456789012:execution:test:" + + kwargs.get("name", "x")} + + +# A definition with one camera source feeding two model inference nodes +# (one model shared is exercised separately). start_test_run does not +# validate, so the raw JSON shape is all that matters here. +def model_definition(*inference_nodes): + nodes = [{"id": "src", "type": "camera_source", + "position": {"x": 0, "y": 0}, "parameters": {}}] + connections = [] + for index, (node_id, model_name) in enumerate(inference_nodes): + parameters = {} if model_name is None else {"modelName": model_name} + nodes.append({"id": node_id, "type": "model_inference", + "position": {"x": 100, "y": index * 100}, + "parameters": parameters}) + connections.append({ + "id": "c{0}".format(index), + "from": {"node": "src", "port": "out"}, + "to": {"node": node_id, "port": "in"}, + }) + return {"schemaVersion": 1, "nodes": nodes, "connections": connections} + + +@pytest.fixture(scope="module") +def staging_env(aws_stack): + """Module tables + freshly imported workflow_testing bound to them.""" + import boto3 + + os.environ["TEST_DATASETS_TABLE"] = DATASETS_TABLE_NAME + os.environ["TEST_RUNS_TABLE"] = RUNS_TABLE_NAME + os.environ["MODELS_TABLE"] = MODELS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=DATASETS_TABLE_NAME, + KeySchema=[{"AttributeName": "dataset_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "dataset_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-datasets-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=RUNS_TABLE_NAME, + KeySchema=[{"AttributeName": "test_run_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "test_run_id", "AttributeType": "S"}, + {"AttributeName": "workflow_id", "AttributeType": "S"}, + {"AttributeName": "started_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "workflow-runs-index", + "KeySchema": [ + {"AttributeName": "workflow_id", "KeyType": "HASH"}, + {"AttributeName": "started_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=MODELS_TABLE_NAME, + KeySchema=[{"AttributeName": "model_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "model_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + {"AttributeName": "created_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-models-index", + "KeySchema": [ + {"AttributeName": "usecase_id", "KeyType": "HASH"}, + {"AttributeName": "created_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + + s3 = boto3.client("s3", region_name=REGION) + s3.create_bucket(Bucket=SOURCE_BUCKET) + + for module_name in ("workflow_testing", "workflow_model_staging"): + sys.modules.pop(module_name, None) + import workflow_model_staging + import workflow_testing + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + testing=workflow_testing, + staging=workflow_model_staging, + datasets_table=resource.Table(DATASETS_TABLE_NAME), + runs_table=resource.Table(RUNS_TABLE_NAME), + models_table=resource.Table(MODELS_TABLE_NAME), + s3=s3, + bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + source_bucket=SOURCE_BUCKET, + ) + + +@pytest.fixture +def ctx(env): + """A fresh Use_Case and a DataScientist (workflow:test) on it.""" + usecase_id = env.create_usecase() + user = env.make_user() + env.assign_role(user, usecase_id, "DataScientist") + return SimpleNamespace(usecase_id=usecase_id, user=user) + + +def register_model(staging_env, usecase_id, name, component_arns, + created_at=1): + staging_env.models_table.put_item(Item={ + "model_id": "model-{0}".format(uuid.uuid4()), + "usecase_id": usecase_id, + "name": name, + "version": "2.0.0", + "created_at": created_at, + "component_arns": component_arns, + }) + + +def put_artifact(staging_env, key=None, body=b"PK-model-zip-bytes"): + key = key or "model_artifacts/model-abc/abc_greengrass_model_component.zip" + staging_env.s3.put_object(Bucket=staging_env.source_bucket, Key=key, + Body=body) + return "s3://{0}/{1}".format(staging_env.source_bucket, key), body + + +# =========================================================================== +# 1. Variant selection +# =========================================================================== + +class TestCpuVariantSelection: + + def test_prefers_x86_64_cpu_over_onnx(self, staging_env): + arns = {"x86_64-cpu": ARN_CPU, "onnx": ARN_ONNX, + "jetson-xavier-jp5": ARN_JP5} + assert staging_env.staging.select_cpu_component_arn(arns) == ARN_CPU + + def test_falls_back_to_onnx(self, staging_env): + arns = {"onnx": ARN_ONNX, "jetson-xavier-jp5": ARN_JP5} + assert staging_env.staging.select_cpu_component_arn(arns) == ARN_ONNX + + def test_no_cpu_variant_yields_none(self, staging_env): + arns = {"jetson-xavier-jp5": ARN_JP5, "jetson-xavier-jp6": ARN_JP6} + assert staging_env.staging.select_cpu_component_arn(arns) is None + assert staging_env.staging.select_cpu_component_arn({}) is None + assert staging_env.staging.select_cpu_component_arn(None) is None + + def test_selection_is_by_component_name_suffix_not_key(self, staging_env): + # The device-type key can be arbitrary; the component NAME suffix + # decides (the recipes/name are what encode the architecture). + arns = {"weird-key": ARN_CPU} + assert staging_env.staging.select_cpu_component_arn(arns) == ARN_CPU + + def test_no_cpu_variant_message_is_exact(self, staging_env): + assert staging_env.staging.no_cpu_variant_message("m") == ( + "Model m has no CPU-compatible (x86_64/ONNX) variant for " + "cloud testing") + + def test_newest_registry_item_with_cpu_variant_wins(self, staging_env): + items = [ + {"name": "m", "created_at": 1, + "component_arns": {"x86_64-cpu": component_arn("m-x86-64-cpu", "1.0.0")}}, + {"name": "m", "created_at": 3, + "component_arns": {"jetson-xavier-jp5": ARN_JP5}}, + {"name": "m", "created_at": 2, + "component_arns": {"x86_64-cpu": component_arn("m-x86-64-cpu", "2.0.0")}}, + ] + item, arn = staging_env.staging.resolve_model_item(items, "m") + assert item["created_at"] == 2 + assert arn == component_arn("m-x86-64-cpu", "2.0.0") + + def test_registered_without_variant_vs_unregistered(self, staging_env): + items = [{"name": "m", "created_at": 1, + "component_arns": {"jetson-xavier-jp5": ARN_JP5}}] + item, arn = staging_env.staging.resolve_model_item(items, "m") + assert item is not None and arn is None + item, arn = staging_env.staging.resolve_model_item(items, "other") + assert item is None and arn is None + + def test_training_job_name_resolves_component_base_registry_name(self, staging_env): + # The dropdown stores the training-job model_name (yolo_test); the + # registry item carries the component base name (model-yolo-test). + items = [{"name": "model-yolo-test", "created_at": 1, + "component_arns": {"x86_64-cpu": component_arn("model-yolo-test-x86-64-cpu", "1.0.0")}}] + item, arn = staging_env.staging.resolve_model_item(items, "yolo_test") + assert item is not None + assert arn == component_arn("model-yolo-test-x86-64-cpu", "1.0.0") + + def test_hyphenated_training_name_resolves_prefixed_registry_name(self, staging_env): + items = [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"x86_64-cpu": component_arn("model-cookies-binary-x86-64-cpu", "1.0.0")}}] + item, arn = staging_env.staging.resolve_model_item(items, "cookies-binary") + assert item is not None and arn is not None + + def test_exact_match_wins_over_prefixed_candidate(self, staging_env): + # An exact-name registry item is preferred over the model- prefixed + # spelling of the same dropdown name. + items = [ + {"name": "cookies-binary", "created_at": 1, + "component_arns": {"x86_64-cpu": component_arn("cookies-binary-x86-64-cpu", "1.0.0")}}, + {"name": "model-cookies-binary", "created_at": 2, + "component_arns": {"x86_64-cpu": component_arn("model-cookies-binary-x86-64-cpu", "2.0.0")}}, + ] + item, arn = staging_env.staging.resolve_model_item(items, "cookies-binary") + assert item["name"] == "cookies-binary" + + def test_registry_name_candidates_order_and_dedup(self, staging_env): + assert staging_env.staging.registry_name_candidates("yolo_test") == [ + "yolo_test", "yolo-test", "model-yolo_test", "model-yolo-test"] + assert staging_env.staging.registry_name_candidates("model-x") == ["model-x"] + + +class TestDefinitionParsing: + + def test_model_inference_nodes_extracted_in_order(self, staging_env): + definition = model_definition(("infA", "model-a"), ("infB", "model-b")) + assert staging_env.staging.model_inference_nodes(definition) == [ + {"nodeId": "infA", "modelName": "model-a"}, + {"nodeId": "infB", "modelName": "model-b"}, + ] + + def test_missing_model_name_yields_none(self, staging_env): + definition = model_definition(("inf", None)) + assert staging_env.staging.model_inference_nodes(definition) == [ + {"nodeId": "inf", "modelName": None}, + ] + + def test_non_model_definitions_yield_nothing(self, staging_env): + assert staging_env.staging.model_inference_nodes(None) == [] + assert staging_env.staging.model_inference_nodes({}) == [] + assert staging_env.staging.model_inference_nodes( + {"nodes": [{"id": "n", "type": "capture", "parameters": {}}]}) == [] + + +class TestRecipeArtifactLocation: + + def test_zip_artifact_uri_parsed(self, staging_env): + uri = "s3://bkt/model_artifacts/m/x_greengrass_model_component.zip" + location = staging_env.staging.artifact_location_from_recipe( + recipe(uri)) + assert location == ( + "bkt", "model_artifacts/m/x_greengrass_model_component.zip") + + def test_zip_preferred_over_other_artifacts(self, staging_env): + r = recipe("s3://bkt/m/model.zip", + extra_artifacts=[{"Uri": "s3://bkt/m/notes.txt"}]) + assert staging_env.staging.artifact_location_from_recipe(r) == ( + "bkt", "m/model.zip") + + def test_recipe_without_s3_artifact_yields_none(self, staging_env): + assert staging_env.staging.artifact_location_from_recipe( + recipe(None)) is None + assert staging_env.staging.artifact_location_from_recipe({}) is None + + +# =========================================================================== +# 2. Artifact copy staging (moto S3 + fake Greengrass) +# =========================================================================== + +class TestArtifactCopyStaging: + + def stage(self, staging_env, nodes, model_items, recipes, + results_key="workflows/uc/test-runs/run-1/results.json"): + greengrass = FakeGreengrass(recipes) + staged, errors = staging_env.staging.stage_models_for_run( + nodes, model_items, greengrass, + staging_env.s3, staging_env.s3, + staging_env.bucket, results_key) + return staged, errors, greengrass + + def test_artifact_copied_under_run_prefix(self, staging_env): + uri, body = put_artifact(staging_env) + staged, errors, greengrass = self.stage( + staging_env, + [{"nodeId": "inf", "modelName": "model-cookies-binary"}], + [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"x86_64-cpu": ARN_CPU}}], + {ARN_CPU: recipe(uri)}) + + assert errors == [] + assert staged == [{ + "nodeId": "inf", + "modelName": "model-cookies-binary", + "s3Key": "workflows/uc/test-runs/run-1/models/" + "model-cookies-binary.zip", + }] + assert greengrass.calls == [ARN_CPU] + copied = staging_env.s3.get_object( + Bucket=staging_env.bucket, Key=staged[0]["s3Key"]) + assert copied["Body"].read() == body + + def test_shared_model_copied_once_with_one_entry_per_node(self, + staging_env): + uri, _ = put_artifact(staging_env, key="m/shared.zip") + staged, errors, greengrass = self.stage( + staging_env, + [{"nodeId": "infA", "modelName": "model-cookies-binary"}, + {"nodeId": "infB", "modelName": "model-cookies-binary"}], + [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"onnx": ARN_ONNX}}], + {ARN_ONNX: recipe(uri)}) + + assert errors == [] + assert [entry["nodeId"] for entry in staged] == ["infA", "infB"] + assert len({entry["s3Key"] for entry in staged}) == 1 + # The recipe (and the copy) happened exactly once. + assert greengrass.calls == [ARN_ONNX] + + def test_no_cpu_variant_records_the_exact_error(self, staging_env): + staged, errors, greengrass = self.stage( + staging_env, + [{"nodeId": "inf", "modelName": "model-cookies-binary"}], + [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"jetson-xavier-jp5": ARN_JP5}}], + {}) + + assert staged == [] + assert greengrass.calls == [] + assert len(errors) == 1 + record = errors[0] + assert record["nodeId"] == "inf" + assert record["status"] == "error" + assert record["error"]["code"] == "MODEL_NO_CPU_VARIANT" + assert record["error"]["message"] == ( + "Model model-cookies-binary has no CPU-compatible (x86_64/ONNX) " + "variant for cloud testing") + + def test_unregistered_model_records_error(self, staging_env): + staged, errors, _ = self.stage( + staging_env, + [{"nodeId": "inf", "modelName": "ghost-model"}], + [], {}) + assert staged == [] + assert errors[0]["error"]["code"] == "MODEL_NOT_REGISTERED" + assert "ghost-model" in errors[0]["error"]["message"] + + def test_missing_source_artifact_records_error(self, staging_env): + # Recipe points at a key that does not exist in the source bucket. + uri = "s3://{0}/does/not/exist.zip".format(staging_env.source_bucket) + staged, errors, _ = self.stage( + staging_env, + [{"nodeId": "inf", "modelName": "model-cookies-binary"}], + [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"x86_64-cpu": ARN_CPU}}], + {ARN_CPU: recipe(uri)}) + assert staged == [] + assert errors[0]["nodeId"] == "inf" + assert errors[0]["error"]["code"] == "MODEL_STAGING_FAILED" + assert "could not be copied" in errors[0]["error"]["message"] + + def test_recipe_without_artifact_records_error(self, staging_env): + staged, errors, _ = self.stage( + staging_env, + [{"nodeId": "inf", "modelName": "model-cookies-binary"}], + [{"name": "model-cookies-binary", "created_at": 1, + "component_arns": {"x86_64-cpu": ARN_CPU}}], + {ARN_CPU: recipe(None)}) + assert staged == [] + assert errors[0]["error"]["code"] == "MODEL_STAGING_FAILED" + assert "declares no S3 model artifact" in errors[0]["error"]["message"] + + +# =========================================================================== +# 3. Manifest passing through POST /workflows/{id}/test-runs +# =========================================================================== + +def invoke(staging_env, ctx, method, resource, body=None, resource_id=None): + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", resource_id or ""), + "pathParameters": {"id": resource_id} if resource_id else None, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": ctx.user["user_id"], + "email": ctx.user["email"], + "cognito:username": ctx.user["username"], + "custom:role": ctx.user["role"], + } + } + }, + } + response = staging_env.testing.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +class TestStartRunManifestPassing: + + def stage_workflow(self, staging_env, ctx, monkeypatch, definition): + """A stored workflow version + dataset, stubbed Step Functions, + and staging clients bound to moto S3 + a fake Greengrass.""" + import boto3 + resource = boto3.resource("dynamodb", region_name=REGION) + workflow_id = "wf-{0}".format(uuid.uuid4()) + resource.Table(TEST_ENV["WORKFLOWS_TABLE"]).put_item(Item={ + "workflow_id": workflow_id, + "usecase_id": ctx.usecase_id, + "name": "wf", + "latest_version": 1, + }) + definition_key = "workflows/{0}/defs/{1}.json".format( + ctx.usecase_id, workflow_id) + staging_env.s3.put_object( + Bucket=staging_env.bucket, Key=definition_key, + Body=json.dumps(definition).encode("utf-8"), + ) + resource.Table(TEST_ENV["WORKFLOW_VERSIONS_TABLE"]).put_item(Item={ + "workflow_id": workflow_id, + "version": 1, + "s3_definition_key": definition_key, + }) + dataset_id = "ds-{0}".format(uuid.uuid4()) + staging_env.datasets_table.put_item(Item={ + "dataset_id": dataset_id, + "usecase_id": ctx.usecase_id, + "s3_prefix": "workflows/{0}/test-datasets/{1}/".format( + ctx.usecase_id, dataset_id), + }) + fake_sfn = FakeStepFunctions() + monkeypatch.setattr(staging_env.testing, "stepfunctions", fake_sfn) + monkeypatch.setattr( + staging_env.testing, "TEST_RUN_STATE_MACHINE_ARN", + "arn:aws:states:us-east-1:123456789012:stateMachine:test-runner") + return workflow_id, dataset_id, fake_sfn + + def bind_staging_clients(self, staging_env, monkeypatch, recipes): + greengrass = FakeGreengrass(recipes) + monkeypatch.setattr( + staging_env.testing, "model_staging_clients", + lambda usecase: (greengrass, staging_env.s3)) + return greengrass + + def start(self, staging_env, ctx, workflow_id, dataset_id): + return invoke(staging_env, ctx, "POST", "/workflows/{id}/test-runs", + {"dataset_id": dataset_id}, resource_id=workflow_id) + + def test_staged_manifest_forwarded_in_execution_input( + self, staging_env, ctx, monkeypatch): + uri, _ = put_artifact(staging_env, key="m/wf-model.zip") + register_model(staging_env, ctx.usecase_id, "model-cookies-binary", + {"x86_64-cpu": ARN_CPU, "jetson-xavier-jp5": ARN_JP5}) + workflow_id, dataset_id, fake_sfn = self.stage_workflow( + staging_env, ctx, monkeypatch, + model_definition(("inf", "model-cookies-binary"))) + self.bind_staging_clients(staging_env, monkeypatch, + {ARN_CPU: recipe(uri)}) + + status, body = self.start(staging_env, ctx, workflow_id, dataset_id) + assert status == 202, body + assert body["test_run"]["status"] == "running" + + assert len(fake_sfn.calls) == 1 + inp = json.loads(fake_sfn.calls[0]["input"]) + assert inp["staged_models"] == [{ + "nodeId": "inf", + "modelName": "model-cookies-binary", + "s3Key": "workflows/{0}/test-runs/{1}/models/" + "model-cookies-binary.zip".format( + ctx.usecase_id, inp["test_run_id"]), + }] + assert json.loads(inp["staged_models_json"]) == inp["staged_models"] + # The copy actually landed in the portal artifacts bucket. + staging_env.s3.head_object(Bucket=staging_env.bucket, + Key=inp["staged_models"][0]["s3Key"]) + + def test_no_cpu_variant_fails_run_without_starting_execution( + self, staging_env, ctx, monkeypatch): + register_model(staging_env, ctx.usecase_id, "model-cookies-binary", + {"jetson-xavier-jp5": ARN_JP5}) + workflow_id, dataset_id, fake_sfn = self.stage_workflow( + staging_env, ctx, monkeypatch, + model_definition(("inf", "model-cookies-binary"))) + self.bind_staging_clients(staging_env, monkeypatch, {}) + + status, body = self.start(staging_env, ctx, workflow_id, dataset_id) + assert status == 202, body + run = body["test_run"] + assert run["status"] == "failed" + assert run["failure"]["nodeId"] == "inf" + assert run["failure"]["message"] == ( + "Model model-cookies-binary has no CPU-compatible (x86_64/ONNX) " + "variant for cloud testing") + assert run["failure"]["timeout"] is False + assert fake_sfn.calls == [] + + # The per-node error record is readable through the results + # document (the shape GET /test-runs/{id} serves). + stored = staging_env.runs_table.get_item( + Key={"test_run_id": run["test_run_id"]})["Item"] + assert stored["status"] == "failed" + results = json.loads(staging_env.s3.get_object( + Bucket=staging_env.bucket, + Key=stored["results_s3_key"])["Body"].read()) + assert results["nodes"] == [{ + "nodeId": "inf", + "status": "error", + "outputs": [], + "stubActivity": [], + "error": { + "code": "MODEL_NO_CPU_VARIANT", + "message": "Model model-cookies-binary has no CPU-compatible " + "(x86_64/ONNX) variant for cloud testing", + }, + }] + + def test_workflow_without_model_nodes_stages_nothing( + self, staging_env, ctx, monkeypatch): + definition = { + "schemaVersion": 1, + "nodes": [{"id": "src", "type": "camera_source", + "position": {"x": 0, "y": 0}, "parameters": {}}], + "connections": [], + } + workflow_id, dataset_id, fake_sfn = self.stage_workflow( + staging_env, ctx, monkeypatch, definition) + + def must_not_be_called(usecase): # pragma: no cover - guard + raise AssertionError("staging clients built without model nodes") + + monkeypatch.setattr(staging_env.testing, "model_staging_clients", + must_not_be_called) + + status, body = self.start(staging_env, ctx, workflow_id, dataset_id) + assert status == 202, body + assert body["test_run"]["status"] == "running" + inp = json.loads(fake_sfn.calls[0]["input"]) + assert inp["staged_models"] == [] + assert inp["staged_models_json"] == "[]" + + def test_one_bad_model_fails_even_when_others_stage( + self, staging_env, ctx, monkeypatch): + uri, _ = put_artifact(staging_env, key="m/good-model.zip") + register_model(staging_env, ctx.usecase_id, "good-model", + {"onnx": component_arn("good-model-onnx")}) + register_model(staging_env, ctx.usecase_id, "gpu-only-model", + {"jetson-xavier-jp5": + component_arn("gpu-only-model-jetson-xavier-jp5")}) + workflow_id, dataset_id, fake_sfn = self.stage_workflow( + staging_env, ctx, monkeypatch, + model_definition(("infA", "good-model"), + ("infB", "gpu-only-model"))) + self.bind_staging_clients( + staging_env, monkeypatch, + {component_arn("good-model-onnx"): recipe(uri)}) + + status, body = self.start(staging_env, ctx, workflow_id, dataset_id) + assert status == 202, body + assert body["test_run"]["status"] == "failed" + assert body["test_run"]["failure"]["nodeId"] == "infB" + assert fake_sfn.calls == [] diff --git a/edge-cv-portal/backend/tests/test_workflow_packaging_atomicity.py b/edge-cv-portal/backend/tests/test_workflow_packaging_atomicity.py new file mode 100644 index 00000000..6193d481 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_packaging_atomicity.py @@ -0,0 +1,379 @@ +""" +Unit tests for Component_Packager packaging atomicity +(functions/workflow_packaging.py). + +Task 7.2 (spec: workflow-manager). Simulated artifact upload failures +assert stage cleanup, failing-artifact reporting, and the absence of +partial component versions: on any packaging failure the staging and +promoted prefixes are deleted, the failing artifact is identified in a +502 PACKAGING_FAILED response, and no Greengrass component version is +registered (a version that fails to become DEPLOYABLE is deleted). +_Requirements: 7.5_ +""" +import io +import json +import sys +import uuid +import zipfile +from unittest.mock import MagicMock + +import pytest +from botocore.exceptions import ClientError + +STAGING_ROOT = "workflows/staging" +COMPONENTS_ROOT = "workflows/components" +PLUGIN_PREFIX = "workflow-plugins" + + +# -------------------------------------------------------------------------- +# Fixtures / helpers +# -------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (portal DynamoDB / S3) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +class FailingClientProxy: + """Wraps a real (moto-backed) boto3 client, raising ClientError from + one named operation when the request Key matches, to simulate an + artifact upload / promote failure.""" + + def __init__(self, real_client, fail_op, key_contains=""): + self._real = real_client + self._fail_op = fail_op + self._key_contains = key_contains + + def __getattr__(self, name): + attr = getattr(self._real, name) + if name != self._fail_op: + return attr + + def wrapper(**kwargs): + if self._key_contains in kwargs.get("Key", ""): + raise ClientError( + {"Error": {"Code": "InternalError", + "Message": "simulated upload failure"}}, + name, + ) + return attr(**kwargs) + + return wrapper + + +def make_deployable_greengrass(component_state="DEPLOYABLE"): + """A fake Use_Case-account greengrassv2 client.""" + gg = MagicMock(name="greengrassv2") + gg.create_component_version.return_value = { + "arn": f"arn:aws:greengrass:us-east-1:123456789012:components:test:versions:{uuid.uuid4()}" + } + gg.describe_component.return_value = { + "status": {"componentState": component_state, "message": "simulated"} + } + return gg + + +def make_definition(): + """folder_source -> dewarp -> capture. dewarp requires the non-bundled + 'dda-dewarp' GStreamer plugin on every architecture, so packaging pulls + plugins/{arch}/dda-dewarp.so from the curated plugin library.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "dw", "type": "dewarp", "position": {"x": 200, "y": 0}, + "parameters": {}}, + {"id": "cap", "type": "capture", "position": {"x": 400, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "dw", "port": "in"}}, + {"id": "c2", "from": {"node": "dw", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +class PackagingEnv: + """Per-test packaging harness: a validated workflow version, a + Use_Case with an S3 bucket, the seeded plugin library, and patched + Use_Case-account clients.""" + + def __init__(self, env, packaging, monkeypatch): + self.env = env + self.packaging = packaging + self.monkeypatch = monkeypatch + self.s3 = env.s3 + + # No 2-second polling waits in tests. + monkeypatch.setattr(packaging, "COMPONENT_STATUS_POLL_SECONDS", 0) + + # A per-test plugin library prefix in the shared portal bucket, so + # binaries seeded by one test never leak into another. + self.plugin_prefix = f"{PLUGIN_PREFIX}-{uuid.uuid4()}" + monkeypatch.setattr( + packaging, "WORKFLOW_PLUGIN_LIBRARY_PREFIX", self.plugin_prefix) + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Packaging Test", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + + status, payload = env.invoke("POST", "/workflows", self.user, body={ + "usecase_id": self.usecase_id, + "name": "packaged workflow", + "definition": make_definition(), + }) + assert status == 201, payload + self.workflow_id = payload["workflow"]["workflow_id"] + + # Record a passed Workflow_Validator run so packaging is allowed. + env.stack.tables.versions.update_item( + Key={"workflow_id": self.workflow_id, "version": 1}, + UpdateExpression="SET validation_status = :v", + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}, + }, + ) + + # ------------------------------------------------------------- setup + def seed_plugin_library(self, archs): + for arch in archs: + self.s3.put_object( + Bucket=self.env.bucket, + Key=f"{self.plugin_prefix}/{arch}/dda-dewarp.so", + Body=b"\x7fELF fake plugin " + arch.encode(), + ) + + def patch_usecase_clients(self, usecase_s3=None, greengrass=None): + """Replace get_usecase_client in workflow_packaging so the + cross-account role assumption is bypassed.""" + usecase_s3 = usecase_s3 if usecase_s3 is not None else self.s3 + greengrass = greengrass if greengrass is not None else make_deployable_greengrass() + self.greengrass = greengrass + + def fake_get_usecase_client(service_name, usecase, session_name=None, region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "s3": + return usecase_s3 + if service_name == "greengrassv2": + return greengrass + raise AssertionError(f"unexpected usecase client: {service_name}") + + self.monkeypatch.setattr( + self.packaging, "get_usecase_client", fake_get_usecase_client) + return greengrass + + # ------------------------------------------------------------ invoke + def package(self, architectures): + event = self.env.event( + "POST", "/workflows/{id}/package", self.user, + workflow_id=self.workflow_id, + body={"architectures": architectures}, + ) + response = self.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ----------------------------------------------------------- asserts + def keys_under(self, prefix, bucket=None): + listed = self.s3.list_objects_v2( + Bucket=bucket or self.usecase_bucket, Prefix=prefix) + return [o["Key"] for o in listed.get("Contents", [])] + + @property + def staging_prefix(self): + return f"{STAGING_ROOT}/{self.workflow_id}/" + + @property + def final_prefix(self): + return f"{COMPONENTS_ROOT}/{self.workflow_id}/1" + + def version_item(self): + return self.env.stack.tables.versions.get_item( + Key={"workflow_id": self.workflow_id, "version": 1})["Item"] + + def assert_nothing_packaged(self): + """Requirement 7.5 post-failure state: no staged objects, no final + artifacts, no registered component version, no packaging record.""" + assert self.keys_under(self.staging_prefix) == [] + assert self.keys_under(self.final_prefix) == [] + self.greengrass.create_component_version.assert_not_called() + # The version record holds no component registration. + assert not self.version_item().get("component_arn") + + +@pytest.fixture +def pkg_env(env, packaging, monkeypatch): + return PackagingEnv(env, packaging, monkeypatch) + + +# -------------------------------------------------------------------------- +# Failure at artifact upload (staging) +# -------------------------------------------------------------------------- + +class TestUploadFailure: + def test_staging_upload_failure_cleans_stage_and_registers_nothing(self, pkg_env): + """A failed upload of one architecture's zip deletes every staged + object, reports that artifact, and registers no component (7.5).""" + pkg_env.seed_plugin_library(["x86_64", "arm64_jp5"]) + flaky_s3 = FailingClientProxy( + pkg_env.s3, fail_op="put_object", key_contains="arm64_jp5") + pkg_env.patch_usecase_clients(usecase_s3=flaky_s3) + + status, payload = pkg_env.package(["x86_64", "arm64_jp5"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert (payload["error"]["details"]["failing_artifact"] + == "arm64_jp5/workflow-arm64_jp5.zip") + # The x86_64 zip staged before the failure must not linger. + pkg_env.assert_nothing_packaged() + + +# -------------------------------------------------------------------------- +# Failure at promotion (staging -> final prefix) +# -------------------------------------------------------------------------- + +class TestPromoteFailure: + def test_promote_failure_removes_already_promoted_artifacts(self, pkg_env): + """When promotion fails after another architecture already promoted, + both the stage and the partially promoted final prefix are deleted, + the failing artifact is reported, and nothing registers (7.5).""" + pkg_env.seed_plugin_library(["x86_64", "arm64_jp5"]) + flaky_s3 = FailingClientProxy( + pkg_env.s3, fail_op="copy_object", key_contains="arm64_jp5") + pkg_env.patch_usecase_clients(usecase_s3=flaky_s3) + + status, payload = pkg_env.package(["x86_64", "arm64_jp5"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert (payload["error"]["details"]["failing_artifact"] + == "arm64_jp5/workflow-arm64_jp5.zip") + pkg_env.assert_nothing_packaged() + + +# -------------------------------------------------------------------------- +# Failure assembling artifacts: missing plugin library binary +# -------------------------------------------------------------------------- + +class TestMissingPluginArtifact: + def test_missing_plugin_so_identifies_artifact_and_registers_nothing(self, pkg_env): + """A plugin binary absent from the curated library fails packaging + with the plugins/{arch}/{plugin}.so artifact identified (7.5).""" + # Deliberately no seed_plugin_library call. + pkg_env.patch_usecase_clients() + + status, payload = pkg_env.package(["x86_64"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert (payload["error"]["details"]["failing_artifact"] + == "plugins/x86_64/dda-dewarp.so") + assert "dda-dewarp" in payload["error"]["message"] + pkg_env.assert_nothing_packaged() + + +# -------------------------------------------------------------------------- +# Failure at component registration +# -------------------------------------------------------------------------- + +class TestRegistrationFailure: + def test_create_component_version_error_cleans_all_uploaded_artifacts(self, pkg_env): + """A registration API failure after successful uploads still deletes + the stage and the promoted final artifacts (7.5).""" + pkg_env.seed_plugin_library(["x86_64"]) + gg = make_deployable_greengrass() + gg.create_component_version.side_effect = ClientError( + {"Error": {"Code": "AccessDeniedException", "Message": "denied"}}, + "CreateComponentVersion", + ) + pkg_env.patch_usecase_clients(greengrass=gg) + + status, payload = pkg_env.package(["x86_64"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert "component dda.workflow." in payload["error"]["details"]["failing_artifact"] + assert pkg_env.keys_under(pkg_env.staging_prefix) == [] + assert pkg_env.keys_under(pkg_env.final_prefix) == [] + assert not pkg_env.version_item().get("component_arn") + + def test_non_deployable_component_version_is_deleted(self, pkg_env): + """A registered version that never becomes DEPLOYABLE is deleted so + no partial or broken component version remains (7.5).""" + pkg_env.seed_plugin_library(["x86_64"]) + gg = make_deployable_greengrass(component_state="FAILED") + pkg_env.patch_usecase_clients(greengrass=gg) + + status, payload = pkg_env.package(["x86_64"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert "component dda.workflow." in payload["error"]["details"]["failing_artifact"] + # The broken component version was removed from the registry. + created_arn = gg.create_component_version.return_value["arn"] + gg.delete_component.assert_called_once_with(arn=created_arn) + # And no uploaded artifact remains anywhere. + assert pkg_env.keys_under(pkg_env.staging_prefix) == [] + assert pkg_env.keys_under(pkg_env.final_prefix) == [] + assert not pkg_env.version_item().get("component_arn") + + +# -------------------------------------------------------------------------- +# Success path: final artifacts persist, stage is cleaned, one registration +# -------------------------------------------------------------------------- + +class TestSuccessfulPackaging: + def test_success_promotes_artifacts_cleans_stage_and_registers_once(self, pkg_env): + pkg_env.seed_plugin_library(["x86_64", "arm64_jp5"]) + gg = pkg_env.patch_usecase_clients() + + status, payload = pkg_env.package(["x86_64", "arm64_jp5"]) + + assert status == 201, payload + assert payload["component_name"] == f"dda.workflow.{pkg_env.workflow_id}" + assert payload["component_version"] == "1.0.0" + + # Final artifacts exist for both architectures; the stage is gone. + final_keys = sorted(pkg_env.keys_under(pkg_env.final_prefix)) + assert final_keys == [ + f"{pkg_env.final_prefix}/arm64_jp5/workflow-arm64_jp5.zip", + f"{pkg_env.final_prefix}/x86_64/workflow-x86_64.zip", + ] + assert pkg_env.keys_under(pkg_env.staging_prefix) == [] + + # Exactly one component version registered, recorded on the version. + gg.create_component_version.assert_called_once() + gg.delete_component.assert_not_called() + item = pkg_env.version_item() + assert item["component_arn"] == gg.create_component_version.return_value["arn"] + + # The promoted zip is a complete artifact (manifest, definition, + # compiled pipeline, and the plugin binary from the library). + body = pkg_env.s3.get_object( + Bucket=pkg_env.usecase_bucket, + Key=f"{pkg_env.final_prefix}/x86_64/workflow-x86_64.zip", + )["Body"].read() + with zipfile.ZipFile(io.BytesIO(body)) as zf: + names = set(zf.namelist()) + assert {"manifest.json", "workflow.json", + "compiled_pipeline.json", + "plugins/x86_64/dda-dewarp.so"} <= names + manifest = json.loads(zf.read("manifest.json")) + assert manifest["componentVersion"] == "1.0.0" + assert manifest["pluginDependencies"] == ["dda-dewarp"] diff --git a/edge-cv-portal/backend/tests/test_workflow_packaging_binding_points.py b/edge-cv-portal/backend/tests/test_workflow_packaging_binding_points.py new file mode 100644 index 00000000..aa9d1e92 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_packaging_binding_points.py @@ -0,0 +1,600 @@ +""" +Unit tests for Component_Packager Camera_Input_Node binding points +(functions/workflow_packaging.py). + +Task 10.1 (spec: camera-registry-sync). Packaging a workflow containing a +Camera_Input_Node appends a ``bindingPoints`` entry per camera node to +compiled_pipeline.json — nodeId, nodeType, bindingHint from the +definition, rendered default parameters, and arch-specific slots +(v4l2src ``device`` arg on x86_64/x86_64_nvidia; ``adapterBinding: true`` +with empty slots on JP4/JP5; CSI sensor selection on JP6) — and records +the ``has_binding_points`` / ``camera_input_nodes`` discriminator on the +workflow version item. Compiled elements keep their fully rendered +defaults, and workflows without camera nodes serialize exactly as before. +_Requirements: 8.6, 11.5_ + +Task 10.3 (spec: camera-registry-sync) adds the packaging snapshot tests +(``TestPackagingSnapshots``): the packaged compiled_pipeline.json of a +camera workflow is byte-for-byte the pre-feature compiler serialization +of the same definition plus only the ``bindingPoints`` section, and a +workflow without Camera_Input_Nodes packages byte-identically to the +compiler's own output — pre-feature definitions and deployments are +untouched by the feature. +_Requirements: 11.1, 11.5_ +""" +import io +import json +import sys +import uuid +import zipfile +from unittest.mock import MagicMock + +import pytest + +COMPONENTS_ROOT = "workflows/components" + + +# -------------------------------------------------------------------------- +# Fixtures / helpers +# -------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (portal DynamoDB / S3) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +def make_deployable_greengrass(): + gg = MagicMock(name="greengrassv2") + gg.create_component_version.return_value = { + "arn": f"arn:aws:greengrass:us-east-1:123456789012:components:test:versions:{uuid.uuid4()}" + } + gg.describe_component.return_value = { + "status": {"componentState": "DEPLOYABLE", "message": "simulated"} + } + return gg + + +def camera_definition(): + """camera_source -> capture. Every plugin dependency of both nodes is + LocalServer-bundled on every architecture, so packaging needs no + curated plugin library seeding.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "cam", "type": "camera_source", "position": {"x": 0, "y": 0}, + "parameters": {"device": "/dev/video1"}}, + {"id": "cap", "type": "capture", "position": {"x": 200, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "cam", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +def cameraless_definition(): + """folder_source -> capture: no Camera_Input_Node anywhere.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "cap", "type": "capture", "position": {"x": 200, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +class BindingPointsEnv: + """Packaging harness: a validated workflow version, a Use_Case with an + S3 bucket, and patched Use_Case-account clients.""" + + def __init__(self, env, packaging, monkeypatch, definition): + self.env = env + self.packaging = packaging + monkeypatch.setattr(packaging, "COMPONENT_STATUS_POLL_SECONDS", 0) + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + env.s3.create_bucket(Bucket=self.usecase_bucket) + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Binding Points Test", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + + status, payload = env.invoke("POST", "/workflows", self.user, body={ + "usecase_id": self.usecase_id, + "name": "camera workflow", + "definition": definition, + }) + assert status == 201, payload + self.workflow_id = payload["workflow"]["workflow_id"] + + env.stack.tables.versions.update_item( + Key={"workflow_id": self.workflow_id, "version": 1}, + UpdateExpression="SET validation_status = :v", + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}, + }, + ) + + self.greengrass = make_deployable_greengrass() + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + if service_name == "s3": + return env.s3 + if service_name == "greengrassv2": + return self.greengrass + raise AssertionError(f"unexpected usecase client: {service_name}") + + monkeypatch.setattr(packaging, "get_usecase_client", + fake_get_usecase_client) + + def package(self, architectures): + event = self.env.event( + "POST", "/workflows/{id}/package", self.user, + workflow_id=self.workflow_id, + body={"architectures": architectures}, + ) + response = self.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def compiled_pipeline_text(self, arch): + """The exact compiled_pipeline.json text inside the artifact zip.""" + key = (f"{COMPONENTS_ROOT}/{self.workflow_id}/1/" + f"{arch}/workflow-{arch}.zip") + body = self.env.s3.get_object( + Bucket=self.usecase_bucket, Key=key)["Body"].read() + with zipfile.ZipFile(io.BytesIO(body)) as zf: + return zf.read("compiled_pipeline.json").decode("utf-8") + + def compiled_pipeline(self, arch): + return json.loads(self.compiled_pipeline_text(arch)) + + def version_item(self): + return self.env.stack.tables.versions.get_item( + Key={"workflow_id": self.workflow_id, "version": 1})["Item"] + + +# -------------------------------------------------------------------------- +# Pure helpers +# -------------------------------------------------------------------------- + +class TestCameraBackedTypeIds: + def test_snake_case_flag_marks_type_camera_backed(self, packaging): + items = [{"declaration": {"typeId": "custom_rtsp", + "camera_backed": True}}] + assert packaging.camera_backed_type_ids(items) == {"custom_rtsp"} + + def test_camel_case_flag_marks_type_camera_backed(self, packaging): + items = [{"declaration": {"typeId": "custom_rtsp", + "cameraBacked": True}}] + assert packaging.camera_backed_type_ids(items) == {"custom_rtsp"} + + def test_absent_or_false_flag_is_not_camera_backed(self, packaging): + items = [ + {"declaration": {"typeId": "plain_type"}}, + {"declaration": {"typeId": "flagged_off", "camera_backed": False}}, + {"declaration": None}, + {}, + ] + assert packaging.camera_backed_type_ids(items) == set() + + +class TestBindingHintExtraction: + def test_hint_extracted_from_node_data(self, packaging): + definition = camera_definition() + definition["nodes"][0]["data"] = { + "cameraBindingHint": {"cameraSourceId": "cfg-a1b2", + "cameraName": "Line 1", + "sourceDeviceId": "thing-1"}, + } + hints = packaging.binding_hints_from_definition(definition) + assert hints == {"cam": {"cameraSourceId": "cfg-a1b2", + "cameraName": "Line 1", + "sourceDeviceId": "thing-1"}} + + def test_definitions_without_node_data_yield_no_hints(self, packaging): + assert packaging.binding_hints_from_definition(camera_definition()) == {} + assert packaging.binding_hints_from_definition({"nodes": []}) == {} + + +# -------------------------------------------------------------------------- +# Packaging a camera workflow emits bindingPoints (8.6) +# -------------------------------------------------------------------------- + +class TestBindingPointsEmission: + ARCHS = ["x86_64", "x86_64_nvidia", "arm64_jp5", "arm64_jp6"] + + @pytest.fixture + def camera_env(self, env, packaging, monkeypatch): + e = BindingPointsEnv(env, packaging, monkeypatch, camera_definition()) + status, payload = e.package(self.ARCHS) + assert status == 201, payload + return e + + def test_v4l2_archs_slot_points_at_the_rendered_device_arg(self, camera_env): + """On x86_64 / x86_64_nvidia the binding point carries the node's + rendered default parameters and one slot addressing the v4l2src + ``device`` argument in this document.""" + for arch in ("x86_64", "x86_64_nvidia"): + compiled = camera_env.compiled_pipeline(arch) + points = compiled["bindingPoints"] + assert len(points) == 1 + point = points[0] + assert point["nodeId"] == "cam" + assert point["nodeType"] == "camera_source" + # Rendered defaults: explicit device + declared gain/exposure. + assert point["parameters"] == {"device": "/dev/video1", + "gain": 4, "exposure": 5000000} + assert point["slots"] == [{"param": "device", "segment": 0, + "element": 0, "arg": "device"}] + # The slot resolves to the fully rendered compiled element. + element = compiled["segments"][0]["elements"][0] + assert element["nodeId"] == "cam" + assert element["factory"] == "v4l2src" + assert element["args"]["device"] == "/dev/video1" + assert "bindingHint" not in point # no hint in the definition + + def test_jp5_binding_point_is_adapter_bound_with_empty_slots(self, camera_env): + point = camera_env.compiled_pipeline("arm64_jp5")["bindingPoints"][0] + assert point["adapterBinding"] is True + assert point["slots"] == [] + assert point["parameters"]["device"] == "/dev/video1" + + def test_jp6_binding_point_selects_the_csi_sensor(self, camera_env): + point = camera_env.compiled_pipeline("arm64_jp6")["bindingPoints"][0] + assert point["csiSensorBinding"] is True + assert point["slots"] == [] + + def test_version_item_records_the_binding_discriminator(self, camera_env): + """has_binding_points: true plus the Camera_Input_Node list with the + per-arch compiled device paths (the strict-vs-legacy discriminator + the Deployment_Service reads).""" + item = camera_env.version_item() + assert item["has_binding_points"] is True + nodes = item["camera_input_nodes"] + assert len(nodes) == 1 + assert nodes[0]["node_id"] == "cam" + assert nodes[0]["node_type"] == "camera_source" + assert nodes[0]["compiled_device_paths"] == { + "x86_64": "/dev/video1", "x86_64_nvidia": "/dev/video1"} + assert "binding_hint" not in nodes[0] + + def test_portal_compiled_document_carries_the_same_binding_points( + self, camera_env): + """The compiled document persisted to portal S3 matches the one in + the artifact zip, bindingPoints included.""" + item = camera_env.version_item() + key = item["compiled_arch_keys"]["x86_64"] + body = camera_env.env.s3.get_object( + Bucket=camera_env.env.bucket, Key=key)["Body"].read() + assert json.loads(body) == camera_env.compiled_pipeline("x86_64") + + +# -------------------------------------------------------------------------- +# Workflows without Camera_Input_Nodes are untouched (11.5) +# -------------------------------------------------------------------------- + +class TestCameralessWorkflow: + def test_no_binding_points_and_discriminator_false(self, env, packaging, + monkeypatch): + e = BindingPointsEnv(env, packaging, monkeypatch, + cameraless_definition()) + status, payload = e.package(["x86_64"]) + assert status == 201, payload + + compiled = e.compiled_pipeline("x86_64") + assert "bindingPoints" not in compiled + + item = e.version_item() + assert item["has_binding_points"] is False + assert item["camera_input_nodes"] == [] + + +# -------------------------------------------------------------------------- +# Task 10.3 - packaging snapshot tests (11.1, 11.5) +# +# "Pre-feature output" is reproduced exactly the way the packager built +# compiled_pipeline.json before this feature: parse the stored +# Workflow_Definition, compile with the same CompileContext, and take the +# compiler's canonical serialization (CompiledPipeline.to_json). The +# snapshots assert the packaged document is that output byte-for-byte, +# plus only the appended bindingPoints section for camera workflows. +# -------------------------------------------------------------------------- + +def pre_feature_compiled(env, workflow_id, arch): + """Compile the stored definition the pre-feature way: same stored + document, same CompileContext, plain compiler output.""" + from workflow_core.compiler import CompileContext + from workflow_core.compiler import compile as compile_workflow + from workflow_core.catalog.custom import resolve_catalog + from workflow_core.serializer import parse + + version_item = env.stack.tables.versions.get_item( + Key={"workflow_id": workflow_id, "version": 1})["Item"] + definition_json = env.s3.get_object( + Bucket=env.bucket, + Key=version_item["s3_definition_key"])["Body"].read().decode("utf-8") + + parse_result = parse(definition_json) + assert parse_result.ok + context = CompileContext(workflow_id=workflow_id, workflow_version="1") + compiled = compile_workflow(parse_result.graph, arch, context, + simulation=False, catalog=resolve_catalog([])) + assert not isinstance(compiled, list), compiled + return compiled + + +class TestPackagingSnapshots: + """Packaged compiled_pipeline.json versus the pre-feature snapshot + (Requirements 11.1, 11.5).""" + + ARCHS = ["x86_64", "arm64_jp5"] + + def test_camera_workflow_is_pre_feature_output_plus_binding_points( + self, env, packaging, monkeypatch): + """A camera workflow's compiled_pipeline.json is byte-for-byte the + pre-feature compiler serialization with only the bindingPoints + section added: identical rendered segments, defaults, and + serialization form on every architecture.""" + definition = camera_definition() + definition["nodes"][0]["data"] = { + "cameraBindingHint": {"cameraSourceId": "cfg-a1b2", + "cameraName": "Line 1", + "sourceDeviceId": "thing-1"}, + } + e = BindingPointsEnv(env, packaging, monkeypatch, definition) + status, payload = e.package(self.ARCHS) + assert status == 201, payload + + for arch in self.ARCHS: + packaged_text = e.compiled_pipeline_text(arch) + packaged_doc = json.loads(packaged_text) + binding_points = packaged_doc.pop("bindingPoints") + + # The document minus bindingPoints IS the pre-feature output: + # same segments, same rendered element args and defaults. + expected = pre_feature_compiled(env, e.workflow_id, arch) + assert packaged_doc == expected.to_dict() + + # Byte-level: pre-feature dict + bindingPoints, re-serialized + # in the compiler's canonical form, reproduces the packaged + # file exactly - nothing else changed. + expected_doc = expected.to_dict() + expected_doc["bindingPoints"] = binding_points + assert packaged_text == json.dumps( + expected_doc, sort_keys=True, indent=2, ensure_ascii=True) + + # The appended section is the camera node's binding point, + # hint included. + (point,) = binding_points + assert point["nodeId"] == "cam" + assert point["bindingHint"] == {"cameraSourceId": "cfg-a1b2", + "cameraName": "Line 1", + "sourceDeviceId": "thing-1"} + + # Version-item discriminator recorded for the camera workflow. + item = e.version_item() + assert item["has_binding_points"] is True + assert [n["node_id"] for n in item["camera_input_nodes"]] == ["cam"] + + def test_cameraless_workflow_is_byte_identical_to_pre_feature_output( + self, env, packaging, monkeypatch): + """Packaging a workflow without Camera_Input_Nodes produces a + compiled_pipeline.json byte-identical to the compiler's own + serialization - exactly what packaging produced before this + feature (Requirements 11.1, 11.5).""" + e = BindingPointsEnv(env, packaging, monkeypatch, + cameraless_definition()) + status, payload = e.package(self.ARCHS) + assert status == 201, payload + + for arch in self.ARCHS: + expected = pre_feature_compiled(env, e.workflow_id, arch) + assert e.compiled_pipeline_text(arch) == expected.to_json() + + # Discriminator recorded as the legacy/no-binding shape. + item = e.version_item() + assert item["has_binding_points"] is False + assert item["camera_input_nodes"] == [] + + +# -------------------------------------------------------------------------- +# Task 7.1 (spec: aravis-camera-input) - Aravis binding points +# +# An aravis_camera_source node is a Camera_Input_Node: packaging emits a +# bindingPoints entry carrying ``aravisBinding: true`` with empty slots on +# every physical device architecture, parameters holding the rendered +# camera_id/gain/exposure (defaults-overlaid) values, and the bindingHint +# when the definition carries one. The version item records the node in +# camera_input_nodes with has_binding_points: true. Aravis-free workflows +# package byte-identically to their pre-feature output. +# _Requirements: 4.1, 4.2, 4.3_ +# -------------------------------------------------------------------------- + +ARAVIS_HINT = {"cameraSourceId": "arv-1a2b3c4d5e6f", + "cameraName": "Basler GigE Line 2", + "sourceDeviceId": "thing-2"} + + +def aravis_definition(hint=None): + """aravis_camera_source -> capture. The node's plugin dependencies + (app, videoconvertscale) are LocalServer-bundled on every + architecture, so packaging needs no curated plugin library seeding.""" + aravis_node = {"id": "arv", "type": "aravis_camera_source", + "position": {"x": 0, "y": 0}, + "parameters": {"camera_id": "Aravis-Fake-GV01"}} + if hint is not None: + aravis_node["data"] = {"cameraBindingHint": hint} + return { + "schemaVersion": 1, + "nodes": [ + aravis_node, + {"id": "cap", "type": "capture", "position": {"x": 200, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "arv", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +def mixed_camera_definition(): + """camera_source -> capture plus aravis_camera_source -> capture: + both Camera_Input_Node types in one definition.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "cam", "type": "camera_source", "position": {"x": 0, "y": 0}, + "parameters": {"device": "/dev/video1"}}, + {"id": "arv", "type": "aravis_camera_source", + "position": {"x": 0, "y": 200}, + "parameters": {"camera_id": "Aravis-Fake-GV01", "gain": 10}}, + {"id": "cap1", "type": "capture", "position": {"x": 200, "y": 0}, + "parameters": {"output_path": "/out1"}}, + {"id": "cap2", "type": "capture", "position": {"x": 200, "y": 200}, + "parameters": {"output_path": "/out2"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "cam", "port": "out"}, + "to": {"node": "cap1", "port": "in"}}, + {"id": "c2", "from": {"node": "arv", "port": "out"}, + "to": {"node": "cap2", "port": "in"}}, + ], + } + + +class TestAravisBindingPoints: + """aravisBinding emission for aravis_camera_source (Requirements + 4.1, 4.2).""" + + #: Every physical device architecture (the sim document is never + #: packaged). + ARCHS = ["x86_64", "x86_64_nvidia", "arm64_jp4", "arm64_jp5", + "arm64_jp6"] + + @pytest.fixture + def aravis_env(self, env, packaging, monkeypatch): + e = BindingPointsEnv(env, packaging, monkeypatch, + aravis_definition(hint=ARAVIS_HINT)) + status, payload = e.package(self.ARCHS) + assert status == 201, payload + return e + + def test_aravis_binding_marker_on_every_device_arch(self, aravis_env): + """The Aravis node's entry carries aravisBinding: true with empty + slots and the rendered (defaults-overlaid) camera_id/gain/exposure + parameters on every device architecture, hint included.""" + for arch in self.ARCHS: + points = aravis_env.compiled_pipeline(arch)["bindingPoints"] + assert len(points) == 1, arch + point = points[0] + assert point["nodeId"] == "arv" + assert point["nodeType"] == "aravis_camera_source" + assert point["aravisBinding"] is True + assert point["slots"] == [] + # Explicit camera_id + declared gain/exposure defaults. + assert point["parameters"] == {"camera_id": "Aravis-Fake-GV01", + "gain": 4, "exposure": 5000000} + assert point["bindingHint"] == ARAVIS_HINT + # Never the other camera markers. + assert "adapterBinding" not in point + assert "csiSensorBinding" not in point + + def test_version_item_records_the_aravis_node(self, aravis_env): + """camera_input_nodes lists the Aravis node through the existing + recording path with has_binding_points: true (Requirement 4.1).""" + item = aravis_env.version_item() + assert item["has_binding_points"] is True + nodes = item["camera_input_nodes"] + assert len(nodes) == 1 + assert nodes[0]["node_id"] == "arv" + assert nodes[0]["node_type"] == "aravis_camera_source" + assert nodes[0]["binding_hint"] == ARAVIS_HINT + # No parameter ever lands in an element argument -> no device paths. + assert nodes[0]["compiled_device_paths"] == {} + + def test_mixed_definition_emits_both_nodes_entries(self, env, packaging, + monkeypatch): + """camera_source and aravis_camera_source in one definition each + get their own binding point with their own marker/slots; neither + disturbs the other (Requirements 4.1, 4.2).""" + e = BindingPointsEnv(env, packaging, monkeypatch, + mixed_camera_definition()) + status, payload = e.package(["x86_64", "arm64_jp5"]) + assert status == 201, payload + + for arch in ("x86_64", "arm64_jp5"): + points = {p["nodeId"]: p + for p in e.compiled_pipeline(arch)["bindingPoints"]} + assert set(points) == {"cam", "arv"} + + aravis = points["arv"] + assert aravis["aravisBinding"] is True + assert aravis["slots"] == [] + assert aravis["parameters"] == {"camera_id": "Aravis-Fake-GV01", + "gain": 10, "exposure": 5000000} + + cam = points["cam"] + assert "aravisBinding" not in cam + if arch == "x86_64": + # camera_source keeps its v4l2src device slot, resolving + # to the rendered device argument in this document. + (slot,) = cam["slots"] + assert slot["param"] == "device" + assert slot["arg"] == "device" + compiled = e.compiled_pipeline(arch) + element = (compiled["segments"][slot["segment"]] + ["elements"][slot["element"]]) + assert element["nodeId"] == "cam" + assert element["factory"] == "v4l2src" + assert element["args"]["device"] == "/dev/video1" + else: + assert cam["adapterBinding"] is True + assert cam["slots"] == [] + + item = e.version_item() + assert item["has_binding_points"] is True + assert {(n["node_id"], n["node_type"]) + for n in item["camera_input_nodes"]} == { + ("cam", "camera_source"), + ("arv", "aravis_camera_source")} + + def test_aravis_free_definition_output_is_unchanged(self, env, packaging, + monkeypatch): + """An Aravis-free camera workflow's compiled document is exactly + the pre-feature output plus the pre-existing bindingPoints section + - no aravisBinding marker anywhere (Requirement 4.3).""" + e = BindingPointsEnv(env, packaging, monkeypatch, camera_definition()) + status, payload = e.package(["x86_64", "arm64_jp5"]) + assert status == 201, payload + + for arch in ("x86_64", "arm64_jp5"): + packaged_text = e.compiled_pipeline_text(arch) + packaged_doc = json.loads(packaged_text) + binding_points = packaged_doc.pop("bindingPoints") + assert all("aravisBinding" not in p for p in binding_points) + + expected_doc = pre_feature_compiled(env, e.workflow_id, + arch).to_dict() + assert packaged_doc == expected_doc + expected_doc["bindingPoints"] = binding_points + assert packaged_text == json.dumps( + expected_doc, sort_keys=True, indent=2, ensure_ascii=True) diff --git a/edge-cv-portal/backend/tests/test_workflow_packaging_custom_plugins.py b/edge-cv-portal/backend/tests/test_workflow_packaging_custom_plugins.py new file mode 100644 index 00000000..3000e479 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_packaging_custom_plugins.py @@ -0,0 +1,519 @@ +""" +Unit tests for Component_Packager custom-plugin integration +(functions/workflow_packaging.py, custom-node-designer task 10.1). + +Covers packaging of workflows containing Custom_Node_Types against the +merged catalog resolving pinned Custom_Node_Type versions: + +- custom: plugin dependencies are delivered by a Greengrass + ComponentDependencies entry on dda.plugin.{pluginId} pinned to the + recorded Plugin_Record version and are NEVER bundled inline, while + curated plugins keep the inline plugins/{arch}/*.so bundling + (Requirements 11.1, 16.4); +- per-plugin pluginChecksums / pluginComponents are written into each + arch manifest.json (Requirements 10.4, 10.6); +- packaging gates: dev lifecycle state, missing per-arch Plugin_Artifact, + and missing Plugin_Component version reject with the Custom_Node_Type + and arch/state identified (Requirements 11.2, 11.3); +- artifact verification: streamed SHA-256 recompute + KMS signature + verification failing via the existing PackagingError path — stage + cleanup, no partial component (Requirement 10.4); +- x86_64_nvidia in ARCH_TO_GG_PLATFORM with the 'runtime: nvidia' + platform attribute and the plain x86_64 manifest ordered after + x86_64_nvidia (design: Target_Architecture x86_64_nvidia). + +Runs against the moto-backed stack from conftest.py. Property-based +coverage of the same logic lands with tasks 10.2-10.4. +""" +import base64 +import hashlib +import io +import json +import os +import sys +import uuid +import zipfile +from unittest.mock import MagicMock + +import pytest + +from conftest import TEST_ENV + +STAGING_ROOT = "workflows/staging" +CURATED_PLUGIN_PREFIX = "workflow-plugins" + + +@pytest.fixture(scope="module") +def packaging(aws_stack): + """Import workflow_packaging inside the moto mock so its module-level + boto3 clients (DynamoDB / S3 / KMS) are intercepted.""" + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +def make_deployable_greengrass(component_state="DEPLOYABLE"): + gg = MagicMock(name="greengrassv2") + gg.create_component_version.return_value = { + "arn": f"arn:aws:greengrass:us-east-1:123456789012:components:test:versions:{uuid.uuid4()}" + } + gg.describe_component.return_value = { + "status": {"componentState": component_state, "message": "simulated"} + } + return gg + + +class CustomPluginPackagingEnv: + """Packaging harness with a registered Custom_Node_Type backed by a + Plugin_Record whose artifacts are genuinely KMS-signed in moto.""" + + def __init__(self, env, packaging, monkeypatch): + self.env = env + self.packaging = packaging + self.monkeypatch = monkeypatch + self.s3 = env.s3 + self.kms = env.stack.kms + self.bucket = TEST_ENV["PORTAL_ARTIFACTS_BUCKET"] + self.signing_key = os.environ["PLUGIN_SIGNING_KEY_ARN"] + + monkeypatch.setattr(packaging, "COMPONENT_STATUS_POLL_SECONDS", 0) + + # Per-test curated plugin library prefix (as in the atomicity tests) + self.curated_prefix = f"{CURATED_PLUGIN_PREFIX}-{uuid.uuid4()}" + monkeypatch.setattr( + packaging, "WORKFLOW_PLUGIN_LIBRARY_PREFIX", self.curated_prefix) + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Custom Plugin Packaging Test", + "account_id": "123456789012", + "s3_bucket": self.usecase_bucket, + }) + + # ------------------------------------------------------------- setup + def sign(self, data: bytes) -> str: + response = self.kms.sign( + KeyId=self.signing_key, + Message=hashlib.sha256(data).digest(), + MessageType="DIGEST", + SigningAlgorithm="ECDSA_SHA_256", + ) + return base64.b64encode(response["Signature"]).decode("ascii") + + def seed_plugin_record(self, archs=("x86_64",), lifecycle_state="test", + name="blur-regions", component_registered=True): + """A Plugin_Record version with KMS-signed Plugin_Library artifacts + and (optionally) a registered Plugin_Component pointer.""" + plugin_id = f"plg-{uuid.uuid4()}" + artifacts = {} + for arch in archs: + data = f"\x7fELF {name} {arch}".encode() + key = (f"workflow-plugins/custom/{self.usecase_id}/{arch}/" + f"{name}.so") + self.s3.put_object(Bucket=self.bucket, Key=key, Body=data) + artifacts[arch] = { + "buildStatus": "succeeded", + "s3Key": key, + "checksum": hashlib.sha256(data).hexdigest(), + "signature": self.sign(data), + "logTail": "", + } + component = {} + if component_registered: + component = { + "name": f"dda.plugin.{plugin_id}", + "version": "1.0.0", + "arn": f"arn:aws:greengrass:us-east-1:123456789012:components:dda.plugin.{plugin_id}:versions:1.0.0", + "architectures": sorted(archs), + "status": "registered", + } + record = { + "plugin_id": plugin_id, + "version": 1, + "usecase_id": self.usecase_id, + "name": name, + "lifecycle_state": lifecycle_state, + "artifacts": artifacts, + "component": component, + "created_at": 1, + } + self.env.stack.tables.plugin_records.put_item(Item=record) + return record + + def register_node_type(self, record, archs=("x86_64",)): + """A CustomNodeTypes item whose declaration mappings carry the + custom:{usecase}/{name} plugin dependency (as registration does).""" + type_id = f"custom.{record['name']}-{uuid.uuid4().hex[:8]}" + dependency = f"custom:{self.usecase_id}/{record['name']}" + declaration = { + "typeId": type_id, + "category": "preprocessing", + "displayName": "Blur Regions", + "inputs": [{"name": "in", "portType": "VideoFrames"}], + "outputs": [{"name": "out", "portType": "VideoFrames"}], + "parameters": [{ + "name": "radius", "paramType": "int", "required": True, + "default": 5, "constraints": {"min": 1, "max": 64}, + "description": "Blur kernel radius in pixels", + "examples": [5, 9], + }], + "mappings": [{ + "arch": arch, + "elementChain": [{"factory": "blurregions", + "argsTemplate": {"radius": "{radius}"}}], + "pluginDependencies": [dependency], + } for arch in archs], + "hardwareDependent": False, + } + self.env.stack.tables.custom_node_types.put_item(Item={ + "node_type_id": type_id, + "version": 1, + "usecase_id": self.usecase_id, + "usecase_ids": [], + "plugin_id": record["plugin_id"], + "plugin_version": record["version"], + "declaration": declaration, + "deprecated": False, + "created_at": 1, + }) + return type_id, dependency + + def seed_curated_library(self, archs): + for arch in archs: + self.s3.put_object( + Bucket=self.env.bucket, + Key=f"{self.curated_prefix}/{arch}/dda-dewarp.so", + Body=b"\x7fELF fake curated plugin " + arch.encode(), + ) + + def create_workflow(self, type_id, include_dewarp=False): + nodes = [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "blur", "type": type_id, "position": {"x": 200, "y": 0}, + "parameters": {"radius": 5}}, + ] + connections = [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "blur", "port": "in"}}, + ] + previous = "blur" + if include_dewarp: + nodes.append({"id": "dw", "type": "dewarp", + "position": {"x": 400, "y": 0}, "parameters": {}}) + connections.append({"id": "c2", + "from": {"node": "blur", "port": "out"}, + "to": {"node": "dw", "port": "in"}}) + previous = "dw" + nodes.append({"id": "cap", "type": "capture", + "position": {"x": 600, "y": 0}, + "parameters": {"output_path": "/out"}}) + connections.append({"id": "c3", + "from": {"node": previous, "port": "out"}, + "to": {"node": "cap", "port": "in"}}) + definition = {"schemaVersion": 1, "nodes": nodes, + "connections": connections} + + status, payload = self.env.invoke("POST", "/workflows", self.user, body={ + "usecase_id": self.usecase_id, + "name": "custom plugin workflow", + "definition": definition, + }) + assert status == 201, payload + self.workflow_id = payload["workflow"]["workflow_id"] + + # Record a passed Workflow_Validator run so packaging is allowed. + self.env.stack.tables.versions.update_item( + Key={"workflow_id": self.workflow_id, "version": 1}, + UpdateExpression="SET validation_status = :v", + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}, + }, + ) + return self.workflow_id + + def patch_usecase_clients(self, greengrass=None): + greengrass = greengrass or make_deployable_greengrass() + self.greengrass = greengrass + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + if service_name == "s3": + return self.s3 + if service_name == "greengrassv2": + return greengrass + raise AssertionError(f"unexpected usecase client: {service_name}") + + self.monkeypatch.setattr( + self.packaging, "get_usecase_client", fake_get_usecase_client) + return greengrass + + # ------------------------------------------------------------ invoke + def package(self, architectures): + event = self.env.event( + "POST", "/workflows/{id}/package", self.user, + workflow_id=self.workflow_id, + body={"architectures": architectures}, + ) + response = self.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ----------------------------------------------------------- asserts + def keys_under(self, prefix, bucket=None): + listed = self.s3.list_objects_v2( + Bucket=bucket or self.usecase_bucket, Prefix=prefix) + return [o["Key"] for o in listed.get("Contents", [])] + + def packaged_zip(self, arch): + key = (f"workflows/components/{self.workflow_id}/1/" + f"{arch}/workflow-{arch}.zip") + body = self.s3.get_object(Bucket=self.usecase_bucket, + Key=key)["Body"].read() + return zipfile.ZipFile(io.BytesIO(body)) + + def assert_nothing_packaged(self): + assert self.keys_under(f"{STAGING_ROOT}/{self.workflow_id}/") == [] + assert self.keys_under( + f"workflows/components/{self.workflow_id}/1") == [] + self.greengrass.create_component_version.assert_not_called() + + +@pytest.fixture +def cenv(env, packaging, monkeypatch): + return CustomPluginPackagingEnv(env, packaging, monkeypatch) + + +# --------------------------------------------------------- success (16.4) + +class TestCustomPluginSuccess: + def test_component_dependency_checksums_and_no_inline_bundling(self, cenv): + """A verified custom plugin is declared as a pinned Plugin_Component + dependency with its checksum in manifest.json, never bundled inline, + while curated plugins keep the inline bundling (11.1, 10.4, 16.4).""" + cenv.seed_curated_library(["x86_64"]) + record = cenv.seed_plugin_record() + type_id, dependency = cenv.register_node_type(record) + cenv.create_workflow(type_id, include_dewarp=True) + gg = cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + assert status == 201, payload + + component_name = f"dda.plugin.{record['plugin_id']}" + with cenv.packaged_zip("x86_64") as zf: + names = set(zf.namelist()) + # Curated plugin bundled inline, custom plugin absent (16.4) + assert "plugins/x86_64/dda-dewarp.so" in names + assert not any("blur-regions.so" in n for n in names) + manifest = json.loads(zf.read("manifest.json")) + + assert manifest["pluginDependencies"] == ["dda-dewarp"] + so_checksum = record["artifacts"]["x86_64"]["checksum"] + assert manifest["pluginChecksums"] == { + f"{component_name}/blur-regions.so": so_checksum} + assert manifest["pluginComponents"] == {component_name: "1.0.0"} + + # The recipe declares the HARD dependency pinned to the recorded + # Plugin_Record version (16.4). + recipe = json.loads( + gg.create_component_version.call_args.kwargs["inlineRecipe"]) + assert recipe["ComponentDependencies"] == { + component_name: { + "VersionRequirement": ">=1.0.0 <2.0.0", + "DependencyType": "HARD", + } + } + + def test_workflow_without_custom_nodes_declares_no_dependencies(self, cenv): + """Built-in-only workflows package exactly as before: no + ComponentDependencies block, empty pluginChecksums.""" + cenv.seed_curated_library(["x86_64"]) + record = cenv.seed_plugin_record() + type_id, _ = cenv.register_node_type(record) + # Workflow uses only built-in nodes despite the registered type. + status, payload = cenv.env.invoke("POST", "/workflows", cenv.user, body={ + "usecase_id": cenv.usecase_id, + "name": "builtin workflow", + "definition": { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", + "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "dw", "type": "dewarp", + "position": {"x": 200, "y": 0}, "parameters": {}}, + {"id": "cap", "type": "capture", + "position": {"x": 400, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "dw", "port": "in"}}, + {"id": "c2", "from": {"node": "dw", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + }, + }) + assert status == 201, payload + cenv.workflow_id = payload["workflow"]["workflow_id"] + cenv.env.stack.tables.versions.update_item( + Key={"workflow_id": cenv.workflow_id, "version": 1}, + UpdateExpression="SET validation_status = :v", + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}}, + ) + gg = cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + assert status == 201, payload + recipe = json.loads( + gg.create_component_version.call_args.kwargs["inlineRecipe"]) + assert "ComponentDependencies" not in recipe + with cenv.packaged_zip("x86_64") as zf: + manifest = json.loads(zf.read("manifest.json")) + assert manifest["pluginChecksums"] == {} + assert manifest["pluginComponents"] == {} + + +# ------------------------------------------------- gates (11.2, 11.3, 16.4) + +class TestPackagingGates: + def test_dev_lifecycle_state_rejected_identifying_type_and_state(self, cenv): + """A dev-state backing Plugin_Record rejects packaging identifying + the Custom_Node_Type and its Lifecycle_State (11.3).""" + record = cenv.seed_plugin_record(lifecycle_state="dev") + type_id, _ = cenv.register_node_type(record) + cenv.create_workflow(type_id) + cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_LIFECYCLE_VIOLATION" + assert type_id in payload["error"]["message"] + assert "'dev'" in payload["error"]["message"] + finding = payload["error"]["details"]["findings"][0] + assert finding["node_type_id"] == type_id + assert finding["lifecycle_state"] == "dev" + cenv.assert_nothing_packaged() + + def test_missing_arch_artifact_rejected_identifying_type_and_arch(self, cenv): + """A selected Target_Architecture without a built Plugin_Artifact + rejects packaging identifying the Custom_Node_Type and the missing + architecture (11.2).""" + record = cenv.seed_plugin_record(archs=("x86_64",)) + type_id, _ = cenv.register_node_type( + record, archs=("x86_64", "arm64_jp5")) + cenv.create_workflow(type_id) + cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64", "arm64_jp5"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_ARTIFACT_MISSING" + assert type_id in payload["error"]["message"] + assert "arm64_jp5" in payload["error"]["message"] + finding = payload["error"]["details"]["findings"][0] + assert finding["node_type_id"] == type_id + assert finding["arch"] == "arm64_jp5" + cenv.assert_nothing_packaged() + + def test_missing_plugin_component_rejected(self, cenv): + """A backing Plugin_Record without a registered Plugin_Component + version rejects packaging (16.4: the Workflow_Component recipe + depends on it).""" + record = cenv.seed_plugin_record(component_registered=False) + type_id, _ = cenv.register_node_type(record) + cenv.create_workflow(type_id) + cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + + assert status == 409 + assert payload["error"]["code"] == "PLUGIN_COMPONENT_MISSING" + assert type_id in payload["error"]["message"] + cenv.assert_nothing_packaged() + + +# --------------------------------------------------- verification (10.4) + +class TestArtifactVerification: + def test_tampered_artifact_bytes_fail_packaging(self, cenv): + """Tampered Plugin_Library bytes fail the checksum recompute via the + PackagingError path: 502, stage cleaned, no component (10.4).""" + record = cenv.seed_plugin_record() + # Tamper with the stored .so after checksum/signature recording. + cenv.s3.put_object(Bucket=cenv.bucket, + Key=record["artifacts"]["x86_64"]["s3Key"], + Body=b"\x7fELF tampered bytes") + type_id, _ = cenv.register_node_type(record) + cenv.create_workflow(type_id) + cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert (payload["error"]["details"]["failing_artifact"] + == "custom-plugins/x86_64/blur-regions.so") + assert "checksum" in payload["error"]["message"] + cenv.assert_nothing_packaged() + + def test_wrong_signature_fails_packaging(self, cenv): + """A signature that does not verify against the artifact bytes fails + packaging via the PackagingError path (10.4).""" + record = cenv.seed_plugin_record() + # Replace the recorded signature with one over different bytes. + record["artifacts"]["x86_64"]["signature"] = cenv.sign(b"other bytes") + cenv.env.stack.tables.plugin_records.update_item( + Key={"plugin_id": record["plugin_id"], "version": 1}, + UpdateExpression="SET artifacts = :a", + ExpressionAttributeValues={":a": record["artifacts"]}, + ) + type_id, _ = cenv.register_node_type(record) + cenv.create_workflow(type_id) + cenv.patch_usecase_clients() + + status, payload = cenv.package(["x86_64"]) + + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + assert (payload["error"]["details"]["failing_artifact"] + == "custom-plugins/x86_64/blur-regions.so") + assert "signature" in payload["error"]["message"] + cenv.assert_nothing_packaged() + + +# --------------------------------------- x86_64_nvidia recipe (design) + +class TestX8664NvidiaRecipe: + def test_arch_map_gains_x86_64_nvidia(self, packaging): + assert packaging.ARCH_TO_GG_PLATFORM["x86_64_nvidia"] == "amd64" + + def test_nvidia_manifest_attribute_and_ordering(self, packaging): + """Both amd64 flavors packaged: the x86_64_nvidia manifest carries + 'runtime: nvidia' and precedes the plain x86_64 manifest so + attribute-less amd64 devices match plain x86_64.""" + recipe = packaging.build_recipe("wf-1", 1, "bucket", { + "x86_64": "workflows/components/wf-1/1/x86_64/workflow-x86_64.zip", + "x86_64_nvidia": "workflows/components/wf-1/1/x86_64_nvidia/workflow-x86_64_nvidia.zip", + }) + platforms = [m["Platform"] for m in recipe["Manifests"]] + assert platforms == [ + {"os": "linux", "architecture": "amd64", "runtime": "nvidia"}, + {"os": "linux", "architecture": "amd64"}, + ] + + def test_plain_x86_64_only_manifest_carries_no_runtime(self, packaging): + recipe = packaging.build_recipe("wf-1", 1, "bucket", { + "x86_64": "workflows/components/wf-1/1/x86_64/workflow-x86_64.zip", + }) + assert recipe["Manifests"][0]["Platform"] == { + "os": "linux", "architecture": "amd64"} diff --git a/edge-cv-portal/backend/tests/test_workflow_packaging_deployment_integration.py b/edge-cv-portal/backend/tests/test_workflow_packaging_deployment_integration.py new file mode 100644 index 00000000..af8344be --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_packaging_deployment_integration.py @@ -0,0 +1,850 @@ +""" +Integration tests for Workflow_Component packaging and deployment +(functions/workflow_packaging.py + functions/deployments.py). + +Task 7.4 (spec: workflow-manager). Packaging runs against moto-backed S3 +with a mocked Greengrass registry, asserting the per-architecture +artifact sets and the component registration call contents +(Requirements 7.1, 7.2, 7.4), including Custom_Python_Node code and +dependencies in the artifacts (Requirement 7.3). Deployment runs against +a stateful fake of the Use_Case-account greengrassv2/iot clients, +asserting deployment creation for device and thing-group targets with +association records (Requirements 8.1, 8.2), per-device status listing +(Requirement 8.3), the pre-submit LocalServer compatibility check +(Requirement 8.4), and revision semantics that preserve the target's +existing components while replacing the older Workflow_Component version +(Requirement 8.5). + +Packaging *atomicity* (Requirement 7.5) is covered separately by +test_workflow_packaging_atomicity.py and is not duplicated here. +""" +import io +import json +import sys +import uuid +import zipfile +from unittest.mock import MagicMock + +import pytest +from boto3.dynamodb.conditions import Key +from botocore.exceptions import ClientError, ParamValidationError + +COMPONENTS_ROOT = "workflows/components" +PLUGIN_PREFIX = "workflow-plugins" +REGION = "us-east-1" +ACCOUNT_ID = "123456789012" + +# Resolved default when neither WORKFLOW_MIN_LOCAL_SERVER_VERSION nor +# DDA_LOCAL_SERVER_VERSION is configured (conftest sets neither). +MIN_LOCAL_SERVER = "1.0.0" + + +# -------------------------------------------------------------------------- +# Module imports (inside the moto mock so module-level boto3 clients are +# intercepted) +# -------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def packaging(aws_stack): + sys.modules.pop("workflow_packaging", None) + import workflow_packaging + + return workflow_packaging + + +@pytest.fixture(scope="module") +def deployments(aws_stack): + for module_name in ("deployments", "workflow_guards"): + sys.modules.pop(module_name, None) + import deployments + + return deployments + + +# -------------------------------------------------------------------------- +# Fake Use_Case-account clients +# -------------------------------------------------------------------------- + +class _FakePaginator: + def __init__(self, pages_fn): + self._pages_fn = pages_fn + + def paginate(self, **kwargs): + return iter(self._pages_fn(**kwargs)) + + +class FakeGreengrass: + """Stateful fake of the Use_Case-account greengrassv2 client covering + the operations the workflow deployment flow uses: installed-component + listing (compatibility check, 8.4), deployment listing/creation with + Greengrass revision semantics (8.2, 8.5), and per-device effective + deployment status (8.3).""" + + def __init__(self): + self._deployments = {} # deployment_id -> record + self._order = [] # creation order (latest last) + self.installed = {} # thing_name -> [{componentName, componentVersion}] + self.effective = {} # thing_name -> [effectiveDeployments entries] + self.nucleus_versions = {} # thing_name -> running Nucleus version + self.create_deployment_calls = [] + + # ------------------------------------------------------------- setup + def register_device(self, thing_name, local_server_version=None, + arch="x86_64", nucleus_version=None): + """A core device; with a LocalServer component when a version is + given, otherwise with no LocalServer installed. A `nucleus_version` + makes the device report a running Nucleus via get_core_device (the + deployment flow pins auto-included AWS components to it).""" + self.installed.setdefault(thing_name, []) + if local_server_version is not None: + self.installed[thing_name].append({ + "componentName": f"aws.edgeml.dda.LocalServer.{arch}", + "componentVersion": local_server_version, + }) + if nucleus_version is not None: + self.nucleus_versions[thing_name] = nucleus_version + + def seed_deployment(self, target_arn, components, name="pre-existing"): + """An already-effective Greengrass deployment for a target.""" + deployment_id = f"dep-{uuid.uuid4()}" + self._store(deployment_id, target_arn, name, components, "COMPLETED") + return deployment_id + + def report_effective(self, thing_name, deployment_id, status, reason=""): + self.effective.setdefault(thing_name, []).append({ + "deploymentId": deployment_id, + "coreDeviceExecutionStatus": status, + "reason": reason, + "description": "", + }) + + def _store(self, deployment_id, target_arn, name, components, status): + self._deployments[deployment_id] = { + "deploymentId": deployment_id, + "targetArn": target_arn, + "deploymentName": name, + "components": dict(components), + "deploymentStatus": status, + } + self._order.append(deployment_id) + + # ------------------------------------------------- client API surface + def get_paginator(self, operation): + return _FakePaginator(getattr(self, f"_pages_{operation}")) + + def _pages_list_installed_components(self, coreDeviceThingName=None, **_): + return [{"installedComponents": + list(self.installed.get(coreDeviceThingName, []))}] + + def list_deployments(self, targetArn=None, **_): + matches = [self._deployments[d] for d in self._order + if targetArn in (None, self._deployments[d]["targetArn"])] + latest = matches[-1] if matches else None + return {"deployments": [ + dict(record, isLatestForTarget=record is latest) + for record in matches + ]} + + def get_deployment(self, deploymentId=None): + record = self._deployments.get(deploymentId) + if not record: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no such deployment"}}, + "GetDeployment") + return dict(record) + + def get_core_device(self, coreDeviceThingName=None): + version = self.nucleus_versions.get(coreDeviceThingName) + if version is None: + raise ClientError( + {"Error": {"Code": "ResourceNotFoundException", + "Message": "no such core device"}}, + "GetCoreDevice") + return {"coreDeviceThingName": coreDeviceThingName, + "coreVersion": version} + + def create_deployment(self, **params): + # The real CreateDeployment API requires componentVersion on every + # component entry; a missing/empty one is rejected client-side with a + # ParamValidationError ("Missing required parameter in + # components.: componentVersion"). Mirror that so unpinned + # entries can't slip through the fake. Known latent exception: the + # Nucleus auto-include's unpinned `{}` fallback (deployments.py) would + # also be rejected by the real API but pre-dates this validation and + # keeps its own fallback semantics, so it is exempted here. + for comp_name, comp_config in params.get("components", {}).items(): + if comp_name == "aws.greengrass.Nucleus": + continue + if not (comp_config or {}).get("componentVersion"): + raise ParamValidationError( + report=(f"Missing required parameter in " + f"components.{comp_name}: componentVersion")) + self.create_deployment_calls.append(params) + deployment_id = f"dep-{uuid.uuid4()}" + self._store(deployment_id, params["targetArn"], + params.get("deploymentName", ""), + params.get("components", {}), "IN_PROGRESS") + return { + "deploymentId": deployment_id, + "iotJobId": f"job-{deployment_id}", + "iotJobArn": (f"arn:aws:iot:{REGION}:{ACCOUNT_ID}:" + f"job/job-{deployment_id}"), + } + + def list_effective_deployments(self, coreDeviceThingName=None, **_): + return {"effectiveDeployments": + list(self.effective.get(coreDeviceThingName, []))} + + +class FakeIot: + """Fake iot client: thing-group membership resolution (8.1).""" + + def __init__(self): + self.thing_groups = {} # group name -> [thing names] + + def get_paginator(self, operation): + assert operation == "list_things_in_thing_group" + return _FakePaginator(self._pages_list_things) + + def _pages_list_things(self, thingGroupName=None, **_): + return [{"things": list(self.thing_groups.get(thingGroupName, []))}] + + +def make_registry_greengrass(): + """Fake Use_Case-account greengrassv2 client for component + registration (packaging side).""" + gg = MagicMock(name="greengrassv2-registry") + + def _create(**kwargs): + arn = (f"arn:aws:greengrass:{REGION}:{ACCOUNT_ID}:" + f"components:wf:versions:{uuid.uuid4()}") + return {"arn": arn} + + gg.create_component_version.side_effect = _create + gg.describe_component.return_value = { + "status": {"componentState": "DEPLOYABLE", "message": ""} + } + return gg + + +# -------------------------------------------------------------------------- +# Workflow definitions +# -------------------------------------------------------------------------- + +def make_dewarp_definition(output_path="/out"): + """folder_source -> dewarp -> capture. dewarp requires the non-bundled + 'dda-dewarp' GStreamer plugin on every architecture.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "dw", "type": "dewarp", "position": {"x": 200, "y": 0}, + "parameters": {}}, + {"id": "cap", "type": "capture", "position": {"x": 400, "y": 0}, + "parameters": {"output_path": output_path}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "dw", "port": "in"}}, + {"id": "c2", "from": {"node": "dw", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +CUSTOM_PYTHON_CODE = ( + "def handle(frame, metadata):\n" + " metadata['seen'] = True\n" + " return frame, metadata\n" +) +CUSTOM_PYTHON_REQUIREMENTS = "numpy==1.26.4\npillow==10.3.0" + + +def make_custom_python_definition(): + """folder_source -> custom_python -> capture. The Custom_Python_Node's + code and declared dependencies must ship in the artifacts (7.3).""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "pynode", "type": "custom_python", + "position": {"x": 200, "y": 0}, + "parameters": { + "code": CUSTOM_PYTHON_CODE, + "requirements": CUSTOM_PYTHON_REQUIREMENTS, + "input_port_type": "VideoFrames", + "output_port_type": "VideoFrames", + }}, + {"id": "cap", "type": "capture", "position": {"x": 400, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "pynode", "port": "in"}}, + {"id": "c2", "from": {"node": "pynode", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +CUSTOM_PYTHON_PREPROCESS_CODE = ( + "def process_frame(frame, metadata):\n" + " return cv2.GaussianBlur(frame, (5, 5), 0)\n" +) +CUSTOM_PYTHON_PREPROCESS_REQUIREMENTS = "scikit-image==0.24.0" + + +def make_custom_python_preprocess_definition(): + """folder_source -> custom_python_preprocess -> capture. The new + preprocessing node's code and declared dependencies must ship in the + artifacts exactly like custom_python's (custom-python-frames + Requirements 2.3, 2.4, 2.5).""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "prenode", "type": "custom_python_preprocess", + "position": {"x": 200, "y": 0}, + "parameters": { + "code": CUSTOM_PYTHON_PREPROCESS_CODE, + "requirements": CUSTOM_PYTHON_PREPROCESS_REQUIREMENTS, + }}, + {"id": "cap", "type": "capture", "position": {"x": 400, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "prenode", "port": "in"}}, + {"id": "c2", "from": {"node": "prenode", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +# -------------------------------------------------------------------------- +# Harness +# -------------------------------------------------------------------------- + +class FleetEnv: + """Packaging + deployment harness: a validated workflow version, a + Use_Case with an S3 bucket, the seeded plugin library, a mocked + Greengrass registry for packaging, and a stateful Greengrass/IoT fake + for deployments.""" + + def __init__(self, env, packaging, deployments, monkeypatch, + definition=None): + self.env = env + self.packaging = packaging + self.deployments = deployments + self.s3 = env.s3 + + monkeypatch.setattr(packaging, "COMPONENT_STATUS_POLL_SECONDS", 0) + + # Per-test plugin library prefix so seeded binaries never leak + # between tests sharing the session bucket. + self.plugin_prefix = f"{PLUGIN_PREFIX}-{uuid.uuid4()}" + monkeypatch.setattr( + packaging, "WORKFLOW_PLUGIN_LIBRARY_PREFIX", self.plugin_prefix) + + self.user = env.make_user(role="UseCaseAdmin") + self.usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + self.s3.create_bucket(Bucket=self.usecase_bucket) + self.usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": self.usecase_id, + "name": "Fleet Test", + "account_id": ACCOUNT_ID, + "s3_bucket": self.usecase_bucket, + }) + + status, payload = env.invoke("POST", "/workflows", self.user, body={ + "usecase_id": self.usecase_id, + "name": "fleet workflow", + "definition": definition or make_dewarp_definition(), + }) + assert status == 201, payload + self.workflow_id = payload["workflow"]["workflow_id"] + self.mark_validated(1) + + # Packaging side: real moto S3 + mocked component registry. + self.registry = make_registry_greengrass() + + def packaging_client(service_name, usecase, session_name=None, + region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "s3": + return self.s3 + if service_name == "greengrassv2": + return self.registry + raise AssertionError(f"unexpected packaging client: {service_name}") + + monkeypatch.setattr(packaging, "get_usecase_client", packaging_client) + + # Deployment side: stateful Greengrass + IoT fakes. + self.gg = FakeGreengrass() + self.iot = FakeIot() + + def deployment_client(service_name, usecase, session_name=None, + region=None): + assert usecase["usecase_id"] == self.usecase_id + if service_name == "greengrassv2": + return self.gg + if service_name == "iot": + return self.iot + raise AssertionError(f"unexpected deployment client: {service_name}") + + monkeypatch.setattr(deployments, "get_usecase_client", deployment_client) + + # ------------------------------------------------------------- setup + def mark_validated(self, version): + self.env.stack.tables.versions.update_item( + Key={"workflow_id": self.workflow_id, "version": version}, + UpdateExpression="SET validation_status = :v", + ExpressionAttributeValues={ + ":v": {"status": "passed", "validated_at": 1, + "findings_key": "findings/none.json"}, + }, + ) + + def seed_plugins(self, archs, plugins=("dda-dewarp",)): + for arch in archs: + for plugin in plugins: + self.s3.put_object( + Bucket=self.env.bucket, + Key=f"{self.plugin_prefix}/{arch}/{plugin}.so", + Body=self.plugin_body(arch, plugin), + ) + + @staticmethod + def plugin_body(arch, plugin="dda-dewarp"): + return f"\x7fELF fake {plugin} {arch}".encode() + + def save_new_version(self, definition): + status, payload = self.env.invoke( + "PUT", "/workflows/{id}", self.user, + workflow_id=self.workflow_id, body={"definition": definition}) + assert status == 200, payload + version = int(payload["workflow"]["latest_version"]) + self.mark_validated(version) + return version + + # ------------------------------------------------------------ invoke + def package(self, architectures, version=None): + body = {"architectures": architectures} + if version is not None: + body["version"] = version + event = self.env.event("POST", "/workflows/{id}/package", self.user, + workflow_id=self.workflow_id, body=body) + response = self.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def deploy(self, **body): + body = {"component_type": "workflow", "usecase_id": self.usecase_id, + "workflow_id": self.workflow_id, **body} + event = self.env.event("POST", "/deployments", self.user, body=body) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + def list_workflow_deployments(self): + event = self.env.event("GET", "/deployments", self.user, query={ + "usecase_id": self.usecase_id, "workflow_id": self.workflow_id}) + response = self.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + # ----------------------------------------------------------- asserts + def zip_contents(self, arch, version=1): + key = (f"{COMPONENTS_ROOT}/{self.workflow_id}/{version}/" + f"{arch}/workflow-{arch}.zip") + body = self.s3.get_object( + Bucket=self.usecase_bucket, Key=key)["Body"].read() + return zipfile.ZipFile(io.BytesIO(body)) + + def association_record(self, deployment_id): + return self.env.stack.tables.deployments.get_item( + Key={"deployment_id": deployment_id}).get("Item") + + def association_records(self): + response = self.env.stack.tables.deployments.query( + IndexName="usecase-deployments-index", + KeyConditionExpression=Key("usecase_id").eq(self.usecase_id)) + return response.get("Items", []) + + def thing_arn(self, thing_name): + return f"arn:aws:iot:{REGION}:{ACCOUNT_ID}:thing/{thing_name}" + + def thing_group_arn(self, group_name): + return f"arn:aws:iot:{REGION}:{ACCOUNT_ID}:thinggroup/{group_name}" + + +@pytest.fixture +def fleet(env, packaging, deployments, monkeypatch): + return FleetEnv(env, packaging, deployments, monkeypatch) + + +# ========================================================================== +# Packaging: per-architecture artifact sets (Requirements 7.1, 7.4) +# ========================================================================== + +class TestPackagingArtifactSets: + ARCHS = ["x86_64", "arm64_jp5", "arm64_jp6"] + + def test_artifact_set_per_selected_architecture(self, fleet): + """Packaging produces one complete artifact set per user-selected + architecture: definition, arch-specific compiled pipeline, and the + arch-specific plugin binaries from the library (7.1, 7.4).""" + fleet.seed_plugins(self.ARCHS) + + status, payload = fleet.package(self.ARCHS) + + assert status == 201, payload + assert sorted(payload["architectures"]) == sorted(self.ARCHS) + assert sorted(payload["artifacts"]) == sorted(self.ARCHS) + + for arch in self.ARCHS: + expected_key = (f"{COMPONENTS_ROOT}/{fleet.workflow_id}/1/" + f"{arch}/workflow-{arch}.zip") + assert payload["artifacts"][arch] == \ + f"s3://{fleet.usecase_bucket}/{expected_key}" + + with fleet.zip_contents(arch) as zf: + names = set(zf.namelist()) + assert {"manifest.json", "workflow.json", + "compiled_pipeline.json", + f"plugins/{arch}/dda-dewarp.so"} <= names + + # The Workflow_Definition itself ships in the component (7.1). + definition = json.loads(zf.read("workflow.json")) + assert {n["id"] for n in definition["nodes"]} == \ + {"src", "dw", "cap"} + + # The compiled pipeline is architecture-specific (7.4). + compiled = json.loads(zf.read("compiled_pipeline.json")) + assert compiled["targetArch"] == arch + assert "dda-dewarp" in compiled["pluginDependencies"] + + # Plugin binaries come from that architecture's library + # artifacts, not another architecture's (7.1, 7.4). + assert zf.read(f"plugins/{arch}/dda-dewarp.so") == \ + fleet.plugin_body(arch) + + manifest = json.loads(zf.read("manifest.json")) + assert manifest["targetArch"] == arch + assert manifest["workflowId"] == fleet.workflow_id + assert manifest["workflowVersion"] == 1 + assert manifest["pluginDependencies"] == ["dda-dewarp"] + assert manifest["minLocalServerVersion"] == MIN_LOCAL_SERVER + + def test_component_registration_call(self, fleet): + """The registered component is dda.workflow.{workflowId} version + {workflowVersion}.0.0 with one platform manifest per selected + architecture and an install-only lifecycle (7.2, 7.4).""" + fleet.seed_plugins(self.ARCHS) + + status, payload = fleet.package(self.ARCHS) + + assert status == 201, payload + fleet.registry.create_component_version.assert_called_once() + kwargs = fleet.registry.create_component_version.call_args.kwargs + recipe = json.loads(kwargs["inlineRecipe"]) + + # Component naming derived from the workflow id and version (7.2). + assert recipe["ComponentName"] == f"dda.workflow.{fleet.workflow_id}" + assert recipe["ComponentVersion"] == "1.0.0" + assert payload["component_name"] == recipe["ComponentName"] + assert payload["component_version"] == "1.0.0" + + # One platform manifest per selected architecture (7.4); the two + # arm64 JetPack builds are disambiguated by a platform variant. + manifests = {tuple(sorted(m["Platform"].items())): m + for m in recipe["Manifests"]} + assert len(recipe["Manifests"]) == len(self.ARCHS) + platforms = [dict(k) for k in manifests] + assert {"os": "linux", "architecture": "amd64"} in platforms + assert {"os": "linux", "architecture": "aarch64", + "variant": "arm64_jp5"} in platforms + assert {"os": "linux", "architecture": "aarch64", + "variant": "arm64_jp6"} in platforms + + for manifest in recipe["Manifests"]: + # Install-only lifecycle: no Run step, so deploying or removing + # the component never disturbs LocalServer (7.2, 13.3). + assert set(manifest["Lifecycle"]) == {"Install"} + [artifact] = manifest["Artifacts"] + assert artifact["Unarchive"] == "ZIP" + assert artifact["Uri"].startswith( + f"s3://{fleet.usecase_bucket}/{COMPONENTS_ROOT}/" + f"{fleet.workflow_id}/1/") + assert recipe["Lifecycle"] == {} + + # Registration is tagged to the workflow and Use_Case (7.2). + assert kwargs["tags"]["dda-portal:workflow-id"] == fleet.workflow_id + assert kwargs["tags"]["dda-portal:workflow-version"] == "1" + assert kwargs["tags"]["dda-portal:usecase-id"] == fleet.usecase_id + + # The registered component ARN is recorded on the version. + item = fleet.env.stack.tables.versions.get_item( + Key={"workflow_id": fleet.workflow_id, "version": 1})["Item"] + assert item["component_arn"] == payload["component_arn"] + + +# ========================================================================== +# Packaging: Custom_Python_Node artifacts (Requirement 7.3) +# ========================================================================== + +class TestCustomPythonArtifacts: + def test_custom_python_code_and_requirements_ship_in_artifacts( + self, env, packaging, deployments, monkeypatch): + fleet = FleetEnv(env, packaging, deployments, monkeypatch, + definition=make_custom_python_definition()) + fleet.seed_plugins(["x86_64"], plugins=("dda-emlpython",)) + + status, payload = fleet.package(["x86_64"]) + + assert status == 201, payload + with fleet.zip_contents("x86_64") as zf: + names = set(zf.namelist()) + assert "python/pynode/handler.py" in names + assert "python/pynode/requirements.txt" in names + assert zf.read("python/pynode/handler.py").decode() == \ + CUSTOM_PYTHON_CODE + assert zf.read("python/pynode/requirements.txt").decode() == \ + CUSTOM_PYTHON_REQUIREMENTS + + manifest = json.loads(zf.read("manifest.json")) + assert manifest["customPythonNodeIds"] == ["pynode"] + # The emlpython bridge plugin ships alongside the code. + assert "plugins/x86_64/dda-emlpython.so" in names + + def test_custom_python_preprocess_ships_in_every_arch_zip( + self, env, packaging, deployments, monkeypatch): + """A custom_python_preprocess node packages its handler.py and + requirements.txt into every architecture zip with the node id + listed in the manifest's customPythonNodeIds (custom-python-frames + Requirements 2.3, 2.4, 2.5).""" + archs = ["x86_64", "arm64_jp5", "arm64_jp6"] + fleet = FleetEnv(env, packaging, deployments, monkeypatch, + definition=make_custom_python_preprocess_definition()) + fleet.seed_plugins(archs, plugins=("dda-emlpython",)) + + status, payload = fleet.package(archs) + + assert status == 201, payload + for arch in archs: + with fleet.zip_contents(arch) as zf: + names = set(zf.namelist()) + assert "python/prenode/handler.py" in names + assert "python/prenode/requirements.txt" in names + assert zf.read("python/prenode/handler.py").decode() == \ + CUSTOM_PYTHON_PREPROCESS_CODE + assert zf.read("python/prenode/requirements.txt").decode() == \ + CUSTOM_PYTHON_PREPROCESS_REQUIREMENTS + + # The compiled pipeline carries the node's emlpython + # element with the packaged handler path (2.3). + compiled = json.loads(zf.read("compiled_pipeline.json")) + elements = [element + for segment in compiled["segments"] + for element in segment["elements"] + if element.get("nodeId") == "prenode"] + assert [e["factory"] for e in elements] == ["emlpython"] + assert elements[0]["args"]["handler-path"] == \ + "python/prenode/handler.py" + + manifest = json.loads(zf.read("manifest.json")) + assert manifest["customPythonNodeIds"] == ["prenode"] + # The emlpython bridge plugin ships alongside the code. + assert f"plugins/{arch}/dda-emlpython.so" in names + + +# ========================================================================== +# Deployment creation and association records (Requirements 8.1, 8.2) +# ========================================================================== + +class TestWorkflowDeploymentCreation: + def test_deploy_to_device_creates_deployment_and_association(self, fleet): + fleet.seed_plugins(["x86_64"]) + assert fleet.package(["x86_64"])[0] == 201 + fleet.gg.register_device("line-a-camera-01", + local_server_version="1.2.0") + + status, payload = fleet.deploy(target_devices=["line-a-camera-01"]) + + assert status == 201, payload + # A Greengrass deployment containing the Workflow_Component was + # created for the device target (8.1, 8.2). + [call] = fleet.gg.create_deployment_calls + assert call["targetArn"] == fleet.thing_arn("line-a-camera-01") + assert call["components"] == { + f"dda.workflow.{fleet.workflow_id}": {"componentVersion": "1.0.0"} + } + assert call["tags"]["dda-portal:workflow-id"] == fleet.workflow_id + + # Association record: workflow version -> deployment -> devices (8.2). + record = fleet.association_record(payload["deployment_id"]) + assert record is not None + assert record["component_type"] == "workflow" + assert record["workflow_id"] == fleet.workflow_id + assert int(record["workflow_version"]) == 1 + assert record["component_name"] == f"dda.workflow.{fleet.workflow_id}" + assert record["component_version"] == "1.0.0" + assert record["target_devices"] == ["line-a-camera-01"] + assert record["target_arn"] == fleet.thing_arn("line-a-camera-01") + assert record["created_by"] == fleet.user["user_id"] + + def test_deploy_to_thing_group_records_member_devices(self, fleet): + fleet.seed_plugins(["x86_64"]) + assert fleet.package(["x86_64"])[0] == 201 + members = ["line-b-01", "line-b-02"] + fleet.iot.thing_groups["line-b"] = members + for thing in members: + fleet.gg.register_device(thing, local_server_version="1.5.0") + + status, payload = fleet.deploy(target_thing_group="line-b") + + assert status == 201, payload + [call] = fleet.gg.create_deployment_calls + assert call["targetArn"] == fleet.thing_group_arn("line-b") + + # The association records the group and its resolved members (8.2). + record = fleet.association_record(payload["deployment_id"]) + assert record["target_thing_group"] == "line-b" + assert record["target_devices"] == members + assert record["target_arn"] == fleet.thing_group_arn("line-b") + assert int(record["workflow_version"]) == 1 + + +# ========================================================================== +# Per-device deployment status listing (Requirement 8.3) +# ========================================================================== + +class TestPerDeviceStatusListing: + def test_listing_reports_status_per_target_device(self, fleet): + fleet.seed_plugins(["x86_64"]) + assert fleet.package(["x86_64"])[0] == 201 + members = ["cell-01", "cell-02", "cell-03"] + fleet.iot.thing_groups["cell-group"] = members + for thing in members: + fleet.gg.register_device(thing, local_server_version="1.2.0") + + status, payload = fleet.deploy(target_thing_group="cell-group") + assert status == 201, payload + deployment_id = payload["deployment_id"] + + # Devices report through their effective deployments; one succeeded, + # one failed, one has not reported yet (8.3). + fleet.gg.report_effective("cell-01", deployment_id, "SUCCEEDED") + fleet.gg.report_effective("cell-02", deployment_id, "FAILED", + reason="component install error") + + status, payload = fleet.list_workflow_deployments() + + assert status == 200, payload + assert payload["count"] == 1 + [listed] = payload["deployments"] + assert listed["deployment_id"] == deployment_id + assert listed["workflow_id"] == fleet.workflow_id + assert listed["workflow_version"] == 1 + + by_device = {d["device"]: d for d in listed["device_statuses"]} + assert set(by_device) == set(members) + assert by_device["cell-01"]["deployment_status"] == "SUCCEEDED" + assert by_device["cell-02"]["deployment_status"] == "FAILED" + assert by_device["cell-02"]["reason"] == "component install error" + assert by_device["cell-03"]["deployment_status"] == "PENDING" + + +# ========================================================================== +# Pre-submit LocalServer compatibility check (Requirement 8.4) +# ========================================================================== + +class TestLocalServerCompatibility: + def test_incompatible_devices_reported_before_submission(self, fleet): + fleet.seed_plugins(["x86_64"]) + assert fleet.package(["x86_64"])[0] == 201 + fleet.gg.register_device("good-device", local_server_version="1.2.0") + fleet.gg.register_device("old-device", local_server_version="0.9.0") + fleet.gg.register_device("bare-device") # no LocalServer at all + + status, payload = fleet.deploy( + target_devices=["good-device", "old-device", "bare-device"]) + + assert status == 409 + assert payload["error"]["code"] == "INCOMPATIBLE_LOCAL_SERVER" + details = payload["error"]["details"] + assert details["min_local_server_version"] == MIN_LOCAL_SERVER + + incompatible = {d["device"]: d for d in details["incompatible_devices"]} + assert set(incompatible) == {"old-device", "bare-device"} + assert incompatible["old-device"]["local_server_version"] == "0.9.0" + assert "older" in incompatible["old-device"]["reason"] + assert incompatible["bare-device"]["local_server_version"] is None + assert "No LocalServer" in incompatible["bare-device"]["reason"] + + # Reported *before* submission: no deployment was created and no + # association record was written (8.4). + assert fleet.gg.create_deployment_calls == [] + assert fleet.association_records() == [] + + +# ========================================================================== +# Revision semantics (Requirement 8.5) +# ========================================================================== + +class TestRevisionSemantics: + def test_newer_version_revises_target_and_preserves_components(self, fleet): + fleet.seed_plugins(["x86_64"]) + assert fleet.package(["x86_64"])[0] == 201 + + device = "rev-device" + fleet.gg.register_device(device, local_server_version="2.0.0") + target_arn = fleet.thing_arn(device) + # The device already runs LocalServer via an existing deployment. + fleet.gg.seed_deployment(target_arn, { + "aws.edgeml.dda.LocalServer.x86_64": {"componentVersion": "2.0.0"}, + }) + + # Deploying v1 revises the existing deployment without dropping the + # components already on the device (8.5, 13.3). + status, first = fleet.deploy(target_devices=[device]) + assert status == 201, first + assert first["is_revision"] is True + first_call = fleet.gg.create_deployment_calls[-1] + assert first_call["targetArn"] == target_arn + assert first_call["components"] == { + "aws.edgeml.dda.LocalServer.x86_64": {"componentVersion": "2.0.0"}, + f"dda.workflow.{fleet.workflow_id}": {"componentVersion": "1.0.0"}, + } + + # A newer workflow version is saved, validated, and packaged. + version_2 = fleet.save_new_version( + make_dewarp_definition(output_path="/out/v2")) + assert version_2 == 2 + status, payload = fleet.package(["x86_64"], version=2) + assert status == 201, payload + assert payload["component_version"] == "2.0.0" + + # Deploying v2 to the same device replaces the older + # Workflow_Component version while preserving everything else (8.5). + status, second = fleet.deploy(target_devices=[device], + workflow_version=2) + assert status == 201, second + assert second["is_revision"] is True + assert second["superseded_deployment_id"] == first["deployment_id"] + assert second["component_version"] == "2.0.0" + + second_call = fleet.gg.create_deployment_calls[-1] + assert second_call["components"] == { + "aws.edgeml.dda.LocalServer.x86_64": {"componentVersion": "2.0.0"}, + f"dda.workflow.{fleet.workflow_id}": {"componentVersion": "2.0.0"}, + } + + # The association records reflect the revision chain (8.2, 8.5). + record = fleet.association_record(second["deployment_id"]) + assert int(record["workflow_version"]) == 2 + assert record["component_version"] == "2.0.0" + assert record["is_revision"] is True + assert record["superseded_deployment_id"] == first["deployment_id"] diff --git a/edge-cv-portal/backend/tests/test_workflow_rbac_audit.py b/edge-cv-portal/backend/tests/test_workflow_rbac_audit.py new file mode 100644 index 00000000..b1df5d4c --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_rbac_audit.py @@ -0,0 +1,672 @@ +""" +RBAC and audit tests (Workflow Manager). + +Task 7.5 (spec: workflow-manager). + +1. Parameterized role x action matrix covering every portal role + (Viewer, Operator, DataScientist, UseCaseAdmin, PortalAdmin) against + every workflow action (read/create/edit/save/delete/test/package/ + deploy), asserting rbac_manager.has_permission agrees with the design + matrix - both for JWT-supplied roles and for Use_Case role + assignments in the UserRoles table (Requirements 11.1, 11.2, 11.3, + 11.4). Endpoint-level spot checks run through the real handlers + (workflows.handler, workflow_packaging.handler, deployments.handler). + +2. Audit log writes: create/modify/delete via workflows.handler, + packaging success/failure via workflow_packaging.handler, and deploy + via deployments.handler each write a record to the existing audit + log table with the action, the acting user, and a timestamp + (Requirement 11.5). Denied operations write an unauthorized_access + record (Requirement 11.4). + +Runs against the shared moto stack from conftest.py; handler modules +are imported inside the mock so their module-level boto3 clients are +intercepted. For permission-denial endpoint tests the mocked Use_Case +account clients are never reached. + +_Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_ +""" +import json +import sys +import uuid +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from conftest import REGION, TEST_ENV + + +# --------------------------------------------------------------------------- +# Design RBAC matrix (design.md section 11, Requirements 11.1-11.3) +# --------------------------------------------------------------------------- + +ACTIONS = ("read", "create", "edit", "save", "delete", "test", "package", "deploy") + +ACTION_PERMISSION = { + "read": "WORKFLOW_READ", + "create": "WORKFLOW_CREATE", + "edit": "WORKFLOW_EDIT", + "save": "WORKFLOW_SAVE", + "delete": "WORKFLOW_DELETE", + "test": "WORKFLOW_TEST", + "package": "WORKFLOW_PACKAGE", + "deploy": "WORKFLOW_DEPLOY", +} + +ROLE_ALLOWED_ACTIONS = { + # Viewer: read-only view of workflows and deployment status (11.3) + "Viewer": {"read"}, + # Operator: package and deploy (11.2) + "Operator": {"read", "package", "deploy"}, + # DataScientist: create, edit, save (11.1) plus delete and test + "DataScientist": {"read", "create", "edit", "save", "delete", "test"}, + # UseCaseAdmin: all workflow actions (11.1, 11.2) + "UseCaseAdmin": set(ACTIONS), + # PortalAdmin: everything + "PortalAdmin": set(ACTIONS), +} + +ROLES = tuple(ROLE_ALLOWED_ACTIONS) + +MATRIX_CASES = [(role, action) for role in ROLES for action in ACTIONS] +MATRIX_IDS = [f"{role}-{action}" for role, action in MATRIX_CASES] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def mods(aws_stack): + """Import the modules under test while the moto mock is active. + + Follows the conftest pattern: pop previously imported copies so the + fresh imports bind moto-intercepted module-level boto3 clients. + (aws_stack already imported the real shared_utils + workflows.) + """ + for module_name in ("workflow_guards", "workflow_packaging", "deployments"): + sys.modules.pop(module_name, None) + import shared_utils + import workflow_packaging + import deployments + return SimpleNamespace( + shared_utils=shared_utils, + rbac_manager=shared_utils.rbac_manager, + Permission=shared_utils.Permission, + workflows=aws_stack.workflows, + packaging=workflow_packaging, + deployments=deployments, + ) + + +@pytest.fixture +def audit_table(aws_stack): + """The moto-backed audit log table shared_utils.log_audit_event writes to.""" + import boto3 + + return boto3.resource("dynamodb", region_name=REGION).Table( + TEST_ENV["AUDIT_LOG_TABLE"] + ) + + +def audit_events(audit_table, user_id, action=None): + """All audit records written for one acting user (each test uses a + fresh uuid-based user, so this isolates per-test events).""" + items, kwargs = [], {} + while True: + response = audit_table.scan(**kwargs) + items.extend(response.get("Items", [])) + last_key = response.get("LastEvaluatedKey") + if not last_key: + break + kwargs["ExclusiveStartKey"] = last_key + events = [i for i in items if i.get("user_id") == user_id] + if action is not None: + events = [e for e in events if e.get("action") == action] + return events + + +# --------------------------------------------------------------------------- +# Workflow definitions +# --------------------------------------------------------------------------- + +def make_definition(topic="line-1/results"): + """Minimal valid Workflow_Definition (camera -> mqtt).""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "cam", "type": "camera_source", + "position": {"x": 0, "y": 0}, "parameters": {}}, + {"id": "out", "type": "mqtt_publish", + "position": {"x": 400, "y": 120}, "parameters": {"topic": topic}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "cam", "port": "video"}, + "to": {"node": "out", "port": "in"}}, + ], + } + + +def make_plugin_definition(): + """folder_source -> dewarp -> capture; dewarp requires the non-bundled + 'dda-dewarp' plugin, exercising the plugin-library packaging path.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "src", "type": "folder_source", "position": {"x": 0, "y": 0}, + "parameters": {"location": "/data/images"}}, + {"id": "dw", "type": "dewarp", "position": {"x": 200, "y": 0}, + "parameters": {}}, + {"id": "cap", "type": "capture", "position": {"x": 400, "y": 0}, + "parameters": {"output_path": "/out"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "src", "port": "out"}, + "to": {"node": "dw", "port": "in"}}, + {"id": "c2", "from": {"node": "dw", "port": "out"}, + "to": {"node": "cap", "port": "in"}}, + ], + } + + +def create_workflow(env, user, usecase_id, definition=None, name="RBAC test workflow"): + status, payload = env.invoke("POST", "/workflows", user, body={ + "usecase_id": usecase_id, + "name": name, + "description": "rbac/audit test", + "definition": definition or make_definition(), + }) + assert status == 201, payload + return payload["workflow"]["workflow_id"] + + +def mark_version_validated(env, workflow_id, version=1, **extra): + """Record a passed Workflow_Validator run (and optional extra + attributes) on a WorkflowVersions item.""" + update = "SET validation_status = :v" + values = {":v": {"status": "passed", "validated_at": 1}} + for i, (attr, value) in enumerate(extra.items()): + update += f", {attr} = :x{i}" + values[f":x{i}"] = value + env.stack.tables.versions.update_item( + Key={"workflow_id": workflow_id, "version": version}, + UpdateExpression=update, + ExpressionAttributeValues=values, + ) + + +# =========================================================================== +# 1. Role x action permission matrix (Requirements 11.1, 11.2, 11.3, 11.4) +# =========================================================================== + +class TestRoleActionMatrix: + """rbac_manager.has_permission agrees with the design matrix for every + role x workflow-action combination.""" + + @pytest.mark.parametrize("role,action", MATRIX_CASES, ids=MATRIX_IDS) + def test_jwt_role_matches_design_matrix(self, mods, role, action): + """Role supplied via JWT claims (custom:role), no Use_Case + assignment rows (Requirements 11.1-11.4).""" + user_id = f"user-{uuid.uuid4()}" + usecase_id = f"uc-{uuid.uuid4()}" + permission = mods.Permission[ACTION_PERMISSION[action]] + expected = action in ROLE_ALLOWED_ACTIONS[role] + + granted = mods.rbac_manager.has_permission( + user_id, usecase_id, permission, user_info={"role": role}) + + assert granted is expected, ( + f"{role} {'should' if expected else 'must not'} hold " + f"workflow:{action}") + + @pytest.mark.parametrize("role,action", MATRIX_CASES, ids=MATRIX_IDS) + def test_usecase_assigned_role_matches_design_matrix(self, env, mods, role, action): + """Role assigned per Use_Case in the UserRoles table takes + precedence over the JWT default (here Viewer) - the matrix must + hold for Use_Case role assignments too (Requirements 11.1-11.3).""" + user = env.make_user(role="Viewer") + usecase_id = f"uc-{uuid.uuid4()}" + env.assign_role(user, usecase_id, role) + permission = mods.Permission[ACTION_PERMISSION[action]] + expected = action in ROLE_ALLOWED_ACTIONS[role] + + granted = mods.rbac_manager.has_permission( + user["user_id"], usecase_id, permission, user_info=user) + + assert granted is expected + + def test_usecase_role_does_not_leak_to_other_usecases(self, env, mods): + """A Use_Case role assignment grants permissions only within that + Use_Case (Requirements 11.1, 11.4).""" + user = env.make_user(role="Viewer") + assigned_usecase = f"uc-{uuid.uuid4()}" + other_usecase = f"uc-{uuid.uuid4()}" + env.assign_role(user, assigned_usecase, "UseCaseAdmin") + + permission = mods.Permission.WORKFLOW_CREATE + assert mods.rbac_manager.has_permission( + user["user_id"], assigned_usecase, permission, user_info=user) is True + assert mods.rbac_manager.has_permission( + user["user_id"], other_usecase, permission, user_info=user) is False + + @pytest.mark.parametrize("role", ROLES) + def test_bedrock_config_write_is_portal_admin_only(self, mods, role): + """bedrock-config:write belongs to PortalAdmin only (design + section 11 matrix).""" + user_id = f"user-{uuid.uuid4()}" + usecase_id = f"uc-{uuid.uuid4()}" + granted = mods.rbac_manager.has_permission( + user_id, usecase_id, mods.Permission.BEDROCK_CONFIG_WRITE, + user_info={"role": role}) + assert granted is (role == "PortalAdmin") + + +# =========================================================================== +# 1b. Endpoint-level RBAC spot checks through the real handlers (11.4) +# =========================================================================== + +class TestWorkflowsHandlerRbac: + """workflows.handler create/delete spot checks.""" + + def test_operator_cannot_create_workflow(self, env): + """Operator lacks workflow:create -> 403 (Requirements 11.1, 11.4).""" + operator = env.make_user(role="Operator") + usecase_id = env.create_usecase() + status, payload = env.invoke("POST", "/workflows", operator, body={ + "usecase_id": usecase_id, + "name": "nope", + "definition": make_definition(), + }) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + assert ("workflow:create" + in payload["error"]["details"]["required_permissions"]) + + def test_operator_cannot_delete_but_can_read(self, env): + """Operator lacks workflow:delete but holds workflow:read + (Requirements 11.2, 11.3, 11.4).""" + creator = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, creator, usecase_id) + + operator = env.make_user(role="Operator") + status, payload = env.invoke( + "DELETE", "/workflows/{id}", operator, workflow_id=workflow_id) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + + status, _ = env.invoke( + "GET", "/workflows/{id}", operator, workflow_id=workflow_id) + assert status == 200 + + def test_data_scientist_can_create_edit_and_delete(self, env): + """DataScientist creates, saves changes to, and deletes a workflow + (Requirement 11.1).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id) + + status, payload = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=workflow_id, + body={"definition": make_definition(topic="edited")}) + assert status == 200, payload + + status, payload = env.invoke( + "DELETE", "/workflows/{id}", user, workflow_id=workflow_id) + assert status == 200, payload + + +class TestPackagingHandlerRbac: + """workflow_packaging.handler permission checks. For denials the + Use_Case account clients are never reached, so nothing is mocked.""" + + @pytest.fixture + def packaged_workflow(self, env): + creator = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, creator, usecase_id) + return usecase_id, workflow_id + + def package(self, env, mods, user, workflow_id): + event = env.event("POST", "/workflows/{id}/package", user, + workflow_id=workflow_id, + body={"architectures": ["x86_64"]}) + response = mods.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + @pytest.mark.parametrize("role", ["Viewer", "DataScientist"]) + def test_roles_without_package_permission_get_403( + self, env, mods, packaged_workflow, role): + """Viewer and DataScientist lack workflow:package -> 403 + (Requirements 11.2, 11.4).""" + _, workflow_id = packaged_workflow + user = env.make_user(role=role) + status, payload = self.package(env, mods, user, workflow_id) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + assert ("workflow:package" + in payload["error"]["details"]["required_permissions"]) + + def test_operator_passes_rbac_and_reaches_validation_guard( + self, env, mods, packaged_workflow): + """Operator holds workflow:package: the request is not denied and + proceeds to the validation guard (409, not 403) (Requirement 11.2).""" + _, workflow_id = packaged_workflow + operator = env.make_user(role="Operator") + status, payload = self.package(env, mods, operator, workflow_id) + assert status == 409 + assert payload["error"]["code"] == "VALIDATION_REQUIRED" + + +class TestDeploymentsHandlerRbac: + """deployments.handler workflow-deployment permission checks. For + denials the mocked Greengrass/IoT clients are never reached.""" + + def deploy(self, env, mods, user, usecase_id, workflow_id): + event = env.event("POST", "/deployments", user, body={ + "component_type": "workflow", + "usecase_id": usecase_id, + "workflow_id": workflow_id, + "target_devices": ["device-1"], + }) + response = mods.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + @pytest.mark.parametrize("role", ["Viewer", "DataScientist"]) + def test_roles_without_deploy_permission_get_403(self, env, mods, role): + """Viewer and DataScientist lack workflow:deploy -> 403 + (Requirements 11.2, 11.4).""" + usecase_id = env.create_usecase() + user = env.make_user(role=role) + status, payload = self.deploy(env, mods, user, usecase_id, "wf-any") + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + assert ("workflow:deploy" + in payload["error"]["details"]["required_permissions"]) + + def test_operator_passes_rbac_check(self, env, mods): + """Operator holds workflow:deploy: the request is not denied and + proceeds to the workflow lookup (404, not 403) (Requirement 11.2).""" + usecase_id = env.create_usecase() + operator = env.make_user(role="Operator") + status, payload = self.deploy( + env, mods, operator, usecase_id, "wf-does-not-exist") + assert status == 404 + assert payload["error"]["code"] == "WORKFLOW_NOT_FOUND" + + +# =========================================================================== +# 2. Audit log writes (Requirement 11.5, denials 11.4) +# =========================================================================== + +def assert_audit_record(record, user_id, resource_id, result="success"): + """Common Requirement 11.5 shape: action, acting user, timestamp.""" + assert record["user_id"] == user_id + assert record["resource_type"] == "workflow" + assert record["resource_id"] == resource_id + assert record["result"] == result + assert int(record["timestamp"]) > 0 + + +class TestWorkflowStoreAudit: + """Audit writes for create / modify / delete via workflows.handler.""" + + def test_create_writes_audit_record(self, env, audit_table): + """Creating a workflow records create_workflow with the acting + user and a timestamp (Requirement 11.5).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id, name="Audited") + + events = audit_events(audit_table, user["user_id"], "create_workflow") + assert len(events) == 1 + assert_audit_record(events[0], user["user_id"], workflow_id) + assert events[0]["details"]["usecase_id"] == usecase_id + assert events[0]["details"]["name"] == "Audited" + assert int(events[0]["details"]["version"]) == 1 + + def test_modify_writes_audit_record(self, env, audit_table): + """Saving changes records update_workflow with the new version + (Requirement 11.5).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id) + + status, _ = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=workflow_id, + body={"definition": make_definition(topic="revised")}) + assert status == 200 + + events = audit_events(audit_table, user["user_id"], "update_workflow") + assert len(events) == 1 + assert_audit_record(events[0], user["user_id"], workflow_id) + assert int(events[0]["details"]["version"]) == 2 + + def test_delete_writes_audit_record(self, env, audit_table): + """Deleting a workflow records delete_workflow (Requirement 11.5).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id) + + status, _ = env.invoke( + "DELETE", "/workflows/{id}", user, workflow_id=workflow_id) + assert status == 200 + + events = audit_events(audit_table, user["user_id"], "delete_workflow") + assert len(events) == 1 + assert_audit_record(events[0], user["user_id"], workflow_id) + assert events[0]["details"]["usecase_id"] == usecase_id + + def test_denied_operation_writes_unauthorized_access_record( + self, env, audit_table): + """A denied workflow operation records unauthorized_access with + result denied (Requirement 11.4).""" + viewer = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + status, _ = env.invoke("POST", "/workflows", viewer, body={ + "usecase_id": usecase_id, + "name": "denied", + "definition": make_definition(), + }) + assert status == 403 + + events = audit_events(audit_table, viewer["user_id"], "unauthorized_access") + assert len(events) == 1 + record = events[0] + assert record["result"] == "denied" + assert "workflow:create" in record["details"]["required_permissions"] + assert record["details"]["usecase_id"] == usecase_id + + +class TestPackagingAudit: + """Audit writes for packaging success and failure via + workflow_packaging.handler (Requirement 11.5).""" + + @pytest.fixture + def pkg(self, env, mods, monkeypatch): + """A validated workflow whose definition needs the dda-dewarp + plugin, a Use_Case with an S3 bucket, and a per-test plugin + library prefix. Cross-account clients are patched with local + moto S3 + a fake DEPLOYABLE Greengrass registry.""" + monkeypatch.setattr(mods.packaging, "COMPONENT_STATUS_POLL_SECONDS", 0) + plugin_prefix = f"workflow-plugins-{uuid.uuid4()}" + monkeypatch.setattr( + mods.packaging, "WORKFLOW_PLUGIN_LIBRARY_PREFIX", plugin_prefix) + + user = env.make_user(role="UseCaseAdmin") + usecase_bucket = f"usecase-bucket-{uuid.uuid4()}" + env.s3.create_bucket(Bucket=usecase_bucket) + usecase_id = f"uc-{uuid.uuid4()}" + env.stack.tables.usecases.put_item(Item={ + "usecase_id": usecase_id, + "name": "Packaging Audit Test", + "account_id": "123456789012", + "s3_bucket": usecase_bucket, + }) + workflow_id = create_workflow( + env, user, usecase_id, definition=make_plugin_definition()) + mark_version_validated(env, workflow_id) + + greengrass = MagicMock(name="greengrassv2") + greengrass.create_component_version.return_value = { + "arn": ("arn:aws:greengrass:us-east-1:123456789012:" + f"components:test:versions:{uuid.uuid4()}") + } + greengrass.describe_component.return_value = { + "status": {"componentState": "DEPLOYABLE", "message": ""} + } + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + if service_name == "s3": + return env.s3 + if service_name == "greengrassv2": + return greengrass + raise AssertionError(f"unexpected usecase client: {service_name}") + + monkeypatch.setattr( + mods.packaging, "get_usecase_client", fake_get_usecase_client) + + def seed_plugin_library(): + env.s3.put_object( + Bucket=env.bucket, + Key=f"{plugin_prefix}/x86_64/dda-dewarp.so", + Body=b"\x7fELF fake plugin x86_64", + ) + + def package(): + event = env.event("POST", "/workflows/{id}/package", user, + workflow_id=workflow_id, + body={"architectures": ["x86_64"]}) + response = mods.packaging.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + return SimpleNamespace( + user=user, usecase_id=usecase_id, workflow_id=workflow_id, + seed_plugin_library=seed_plugin_library, package=package) + + def test_packaging_success_writes_audit_record(self, pkg, audit_table): + """Successful packaging records package_workflow with result + success, component identity, and a timestamp (Requirement 11.5).""" + pkg.seed_plugin_library() + status, payload = pkg.package() + assert status == 201, payload + + events = audit_events(audit_table, pkg.user["user_id"], "package_workflow") + assert len(events) == 1 + record = events[0] + assert_audit_record(record, pkg.user["user_id"], pkg.workflow_id) + assert record["details"]["usecase_id"] == pkg.usecase_id + assert int(record["details"]["version"]) == 1 + assert record["details"]["component_name"] == f"dda.workflow.{pkg.workflow_id}" + assert record["details"]["component_version"] == "1.0.0" + + def test_packaging_failure_writes_audit_record(self, pkg, audit_table): + """A failed packaging attempt records package_workflow with result + failure and the failing artifact (Requirement 11.5).""" + # Plugin library deliberately not seeded -> missing artifact. + status, payload = pkg.package() + assert status == 502 + assert payload["error"]["code"] == "PACKAGING_FAILED" + + events = audit_events(audit_table, pkg.user["user_id"], "package_workflow") + assert len(events) == 1 + record = events[0] + assert_audit_record(record, pkg.user["user_id"], pkg.workflow_id, + result="failure") + assert record["details"]["failing_artifact"] == "plugins/x86_64/dda-dewarp.so" + + +class TestDeploymentAudit: + """Audit writes for workflow deployment via deployments.handler + (Requirement 11.5).""" + + @pytest.fixture + def deploy_env(self, env, mods, monkeypatch): + """A validated + packaged workflow version and patched Use_Case + account clients so create_workflow_deployment runs to completion + against moto DynamoDB only.""" + creator = env.make_user(role="DataScientist") + operator = env.make_user(role="Operator") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, creator, usecase_id) + mark_version_validated( + env, workflow_id, + component_arn=("arn:aws:greengrass:us-east-1:123456789012:" + f"components:dda.workflow.{workflow_id}:versions:1.0.0")) + + greengrass = MagicMock(name="greengrassv2") + greengrass.create_deployment.return_value = { + "deploymentId": f"gg-dep-{uuid.uuid4()}", + "iotJobId": "job-1", + "iotJobArn": "arn:aws:iot:us-east-1:123456789012:job/job-1", + } + iot = MagicMock(name="iot") + + def fake_get_usecase_client(service_name, usecase, session_name=None, + region=None): + return {"greengrassv2": greengrass, "iot": iot}[service_name] + + monkeypatch.setattr( + mods.deployments, "get_usecase_client", fake_get_usecase_client) + # No existing deployment for the target; all devices compatible. + monkeypatch.setattr( + mods.deployments, "find_latest_deployment_for_target", + lambda client, target_arn: None) + monkeypatch.setattr( + mods.deployments, "check_local_server_compatibility", + lambda client, things, min_version: []) + + def deploy(user): + event = env.event("POST", "/deployments", user, body={ + "component_type": "workflow", + "usecase_id": usecase_id, + "workflow_id": workflow_id, + "workflow_version": 1, + "target_devices": ["device-1"], + }) + response = mods.deployments.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + return SimpleNamespace( + operator=operator, usecase_id=usecase_id, + workflow_id=workflow_id, greengrass=greengrass, deploy=deploy) + + def test_deploy_writes_audit_record(self, env, deploy_env, audit_table): + """A successful workflow deployment records deploy_workflow with + the acting user, deployment id, and timestamp (Requirement 11.5).""" + status, payload = deploy_env.deploy(deploy_env.operator) + assert status == 201, payload + deployment_id = payload["deployment_id"] + + events = audit_events( + audit_table, deploy_env.operator["user_id"], "deploy_workflow") + assert len(events) == 1 + record = events[0] + assert_audit_record(record, deploy_env.operator["user_id"], + deploy_env.workflow_id) + assert record["details"]["usecase_id"] == deploy_env.usecase_id + assert int(record["details"]["workflow_version"]) == 1 + assert record["details"]["deployment_id"] == deployment_id + assert record["details"]["target_devices"] == ["device-1"] + + def test_denied_deploy_writes_unauthorized_access_record( + self, env, deploy_env, audit_table): + """A denied deployment attempt records unauthorized_access with + result denied (Requirement 11.4).""" + viewer = env.make_user(role="Viewer") + status, payload = deploy_env.deploy(viewer) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + + events = audit_events( + audit_table, viewer["user_id"], "unauthorized_access") + assert len(events) == 1 + record = events[0] + assert record["result"] == "denied" + assert "workflow:deploy" in record["details"]["required_permissions"] + assert record["details"]["operation"] == "deploy_workflow" + # No deployment was created and no success audit written. + deploy_env.greengrass.create_deployment.assert_not_called() + assert audit_events(audit_table, viewer["user_id"], "deploy_workflow") == [] diff --git a/edge-cv-portal/backend/tests/test_workflow_testing_errors.py b/edge-cv-portal/backend/tests/test_workflow_testing_errors.py new file mode 100644 index 00000000..712f0f15 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflow_testing_errors.py @@ -0,0 +1,1160 @@ +""" +Test-run error path unit tests (workflow-manager Requirements 12.10, +12.11, 12.12). + +Task 11.5 (spec: workflow-manager). + +Exercised against the shared moto stack from conftest.py with the +TestDatasets / TestRuns tables created module-scoped here (they are not +part of the shared conftest stack): + +1. Dataset upload boundaries (12.11, functions/workflow_testing.py): + the declared-size pre-check accepts exactly 500 MB and rejects one + byte over with DATASET_TOO_LARGE persisting nothing; unsupported + declared extensions / content types are rejected at initiate; the + finalize step re-verifies against what actually landed in S3 - + actual total size over the limit and non-JPEG/PNG magic bytes each + reject with the reason, delete every uploaded object, and persist + no TestDatasets record. +2. Validator/compiler failure short-circuit (12.12, + functions/workflow_test_steps.py): step_validate on a definition + with validation errors writes per-node error records to the results + document, marks the TestRuns item failed, and returns {ok: False} + without ever compiling or executing the pipeline; step_compile with + an unmapped target architecture short-circuits identically. +3. Mid-run failure retention (12.10): step_collect on a results + document where one node failed marks the run failed with the + failing node identified while the completed records produced before + the failure remain readable; step_record_timeout marks the run + failed-with-timeout leaving the partial results document untouched. + +_Requirements: 12.10, 12.11, 12.12_ +""" +import json +import os +import sys +import uuid +from types import SimpleNamespace + +import pytest + +from conftest import REGION, TEST_ENV + +TEST_DATASETS_TABLE_NAME = "test-test-datasets" +TEST_RUNS_TABLE_NAME = "test-test-runs" + +MB = 1024 * 1024 +MAX_DATASET_BYTES = 500 * MB + +# Tiny but genuine JPEG/PNG heads (magic bytes are all the verifier reads). +JPEG_BYTES = b"\xff\xd8\xff\xe0" + b"\x00" * 32 +PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 + +# Catalog-conformant definition: camera_source -> capture with every +# required parameter set (validates clean, compiles for x86_64). +VALID_DEFINITION = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], +} + +# Same shape but the capture node is missing its required output_path +# parameter -> V4_MISSING_REQUIRED_PARAMETER on n2. +INVALID_DEFINITION = { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": "capture", + "position": {"x": 350, "y": 100}, "parameters": {}}, + ], + "connections": [ + {"id": "c1", + "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + ], +} + + +@pytest.fixture(scope="module") +def testing_env(aws_stack): + """TestDatasets/TestRuns tables plus freshly imported workflow_testing + and workflow_test_steps modules bound to them inside moto.""" + import boto3 + + os.environ["TEST_DATASETS_TABLE"] = TEST_DATASETS_TABLE_NAME + os.environ["TEST_RUNS_TABLE"] = TEST_RUNS_TABLE_NAME + + client = boto3.client("dynamodb", region_name=REGION) + client.create_table( + TableName=TEST_DATASETS_TABLE_NAME, + KeySchema=[{"AttributeName": "dataset_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "dataset_id", "AttributeType": "S"}, + {"AttributeName": "usecase_id", "AttributeType": "S"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "usecase-datasets-index", + "KeySchema": [{"AttributeName": "usecase_id", "KeyType": "HASH"}], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + client.create_table( + TableName=TEST_RUNS_TABLE_NAME, + KeySchema=[{"AttributeName": "test_run_id", "KeyType": "HASH"}], + AttributeDefinitions=[ + {"AttributeName": "test_run_id", "AttributeType": "S"}, + {"AttributeName": "workflow_id", "AttributeType": "S"}, + {"AttributeName": "started_at", "AttributeType": "N"}, + ], + GlobalSecondaryIndexes=[{ + "IndexName": "workflow-runs-index", + "KeySchema": [ + {"AttributeName": "workflow_id", "KeyType": "HASH"}, + {"AttributeName": "started_at", "KeyType": "RANGE"}, + ], + "Projection": {"ProjectionType": "ALL"}, + }], + BillingMode="PAY_PER_REQUEST", + ) + + # Re-import so the modules bind the table names above and + # moto-intercepted boto3 clients (conftest pattern). + # node_catalog_resolution is popped too so the merged-catalog + # resolution the steps handler now performs (custom-node-designer + # 12.1) binds moto-intercepted clients as well. + for module_name in ("workflow_testing", "workflow_test_steps", + "node_catalog_resolution"): + sys.modules.pop(module_name, None) + import workflow_testing + import workflow_test_steps + + resource = boto3.resource("dynamodb", region_name=REGION) + yield SimpleNamespace( + stack=aws_stack, + testing=workflow_testing, + steps=workflow_test_steps, + datasets_table=resource.Table(TEST_DATASETS_TABLE_NAME), + runs_table=resource.Table(TEST_RUNS_TABLE_NAME), + s3=boto3.client("s3", region_name=REGION), + bucket=TEST_ENV["PORTAL_ARTIFACTS_BUCKET"], + ) + + +@pytest.fixture +def ctx(env): + """A fresh Use_Case and a DataScientist (workflow:test) on it.""" + usecase_id = env.create_usecase() + user = env.make_user() + env.assign_role(user, usecase_id, "DataScientist") + return SimpleNamespace(usecase_id=usecase_id, user=user) + + +def invoke(testing_env, ctx, method, resource, body=None, resource_id=None): + """Invoke the workflow_testing handler; returns (status, parsed body).""" + event = { + "httpMethod": method, + "resource": resource, + "path": resource.replace("{id}", resource_id or ""), + "pathParameters": {"id": resource_id} if resource_id else None, + "queryStringParameters": None, + "body": json.dumps(body) if body is not None else None, + "requestContext": { + "authorizer": { + "claims": { + "sub": ctx.user["user_id"], + "email": ctx.user["email"], + "cognito:username": ctx.user["username"], + "custom:role": ctx.user["role"], + } + } + }, + } + response = testing_env.testing.handler(event, None) + return response["statusCode"], json.loads(response["body"]) + + +def initiate(testing_env, ctx, files, name="ds"): + return invoke(testing_env, ctx, "POST", "/test-datasets", { + "usecase_id": ctx.usecase_id, "name": name, "files": files, + }) + + +def upload_and_finalize(testing_env, ctx, files): + """Initiate, upload each file's content as a single part, finalize. + + ``files`` is [{name, content, declared_size?}]; a declared_size + smaller than the content lets a test slip past the declared-size + pre-check so the finalize-time server-side verification is what + trips. Returns (status, body, s3_prefix).""" + declared = [{"name": f["name"], + "size": f.get("declared_size", len(f["content"]))} + for f in files] + status, body = initiate(testing_env, ctx, declared) + assert status == 201, body + prefix = body["s3_prefix"] + + finalize_files = [] + for spec, upload in zip(files, body["upload"]["files"]): + part = testing_env.s3.upload_part( + Bucket=testing_env.bucket, Key=upload["key"], + UploadId=upload["upload_id"], PartNumber=1, + Body=spec["content"], + ) + finalize_files.append({ + "key": upload["key"], + "upload_id": upload["upload_id"], + "parts": [{"part_number": 1, "etag": part["ETag"]}], + }) + + status, body = invoke(testing_env, ctx, "POST", "/test-datasets", { + "action": "finalize", + "usecase_id": ctx.usecase_id, + "dataset_id": body["dataset_id"], + "name": "ds", + "files": finalize_files, + }) + return status, body, prefix + + +def dataset_records(testing_env, usecase_id): + """TestDatasets items of one Use_Case (fresh per test).""" + items = testing_env.datasets_table.scan()["Items"] + return [i for i in items if i.get("usecase_id") == usecase_id] + + +def objects_under(testing_env, prefix): + response = testing_env.s3.list_objects_v2( + Bucket=testing_env.bucket, Prefix=prefix) + return [o["Key"] for o in response.get("Contents", [])] + + +# =========================================================================== +# 1. Dataset upload boundaries (Requirement 12.11) +# =========================================================================== + +class TestDatasetUploadBoundaries: + + def test_declared_total_exactly_500mb_is_accepted(self, testing_env, ctx): + """The declared-size pre-check accepts a total of exactly 500 MB + and hands back presigned multipart uploads (12.3, 12.11).""" + status, body = initiate(testing_env, ctx, [ + {"name": "a.jpg", "size": 400 * MB}, + {"name": "b.png", "size": 100 * MB}, + ]) + assert status == 201 + assert len(body["upload"]["files"]) == 2 + assert all(f["parts"] for f in body["upload"]["files"]) + # Nothing is committed until finalize verifies the content. + assert dataset_records(testing_env, ctx.usecase_id) == [] + + def test_declared_total_one_byte_over_500mb_is_rejected(self, testing_env, + ctx): + """One byte over the 500 MB declared total rejects with + DATASET_TOO_LARGE and persists nothing (12.11).""" + status, body = initiate(testing_env, ctx, [ + {"name": "a.jpg", "size": 400 * MB}, + {"name": "b.png", "size": 100 * MB + 1}, + ]) + assert status == 400 + assert body["error"]["code"] == "DATASET_TOO_LARGE" + assert body["error"]["details"]["total_bytes"] == MAX_DATASET_BYTES + 1 + assert body["error"]["details"]["max_bytes"] == MAX_DATASET_BYTES + + assert dataset_records(testing_env, ctx.usecase_id) == [] + assert objects_under( + testing_env, f"workflows/{ctx.usecase_id}/test-datasets/") == [] + + def test_unsupported_declared_extension_is_rejected(self, testing_env, ctx): + """A declared file with a non-JPEG/PNG extension rejects with + UNSUPPORTED_FORMAT identifying the file (12.11).""" + status, body = initiate(testing_env, ctx, [ + {"name": "ok.png", "size": 10}, + {"name": "movie.gif", "size": 10}, + ]) + assert status == 400 + assert body["error"]["code"] == "UNSUPPORTED_FORMAT" + assert body["error"]["details"]["file"] == "movie.gif" + assert dataset_records(testing_env, ctx.usecase_id) == [] + + def test_unsupported_declared_content_type_is_rejected(self, testing_env, + ctx): + """A declared content type outside image/jpeg+image/png rejects + with UNSUPPORTED_FORMAT (12.11).""" + status, body = initiate(testing_env, ctx, [ + {"name": "sneaky.png", "size": 10, "content_type": "image/gif"}, + ]) + assert status == 400 + assert body["error"]["code"] == "UNSUPPORTED_FORMAT" + assert dataset_records(testing_env, ctx.usecase_id) == [] + + def test_finalize_actual_size_over_limit_rejects_and_cleans_up( + self, testing_env, ctx, monkeypatch): + """Server-side verification uses the sizes that actually landed in + S3: a total over the limit rejects with DATASET_TOO_LARGE, deletes + every uploaded object, and persists no record (12.11).""" + # Shrink the module limit so real (small) uploads can exceed it. + # The client declares sizes under the limit (passing the initiate + # pre-check) but uploads more - only the finalize-time check + # against actual S3 object sizes can catch that. + monkeypatch.setattr(testing_env.testing, "MAX_DATASET_BYTES", + len(JPEG_BYTES) + len(PNG_BYTES) - 1) + status, body, prefix = upload_and_finalize(testing_env, ctx, [ + {"name": "a.jpg", "content": JPEG_BYTES, "declared_size": 1}, + {"name": "b.png", "content": PNG_BYTES, "declared_size": 1}, + ]) + assert status == 400 + assert body["error"]["code"] == "DATASET_TOO_LARGE" + assert body["error"]["details"]["total_bytes"] == \ + len(JPEG_BYTES) + len(PNG_BYTES) + + # Uploaded objects are removed and nothing is persisted. + assert objects_under(testing_env, prefix) == [] + assert dataset_records(testing_env, ctx.usecase_id) == [] + + def test_finalize_magic_byte_failure_rejects_and_cleans_up(self, + testing_env, + ctx): + """An uploaded object whose content is not a JPEG/PNG (magic-byte + check) rejects with UNSUPPORTED_FORMAT naming the file, deletes the + uploaded objects, and persists no record (12.11).""" + status, body, prefix = upload_and_finalize(testing_env, ctx, [ + {"name": "real.png", "content": PNG_BYTES}, + {"name": "fake.png", "content": b"GIF89a not an image at all"}, + ]) + assert status == 400 + assert body["error"]["code"] == "UNSUPPORTED_FORMAT" + assert body["error"]["details"]["file"] == "fake.png" + + assert objects_under(testing_env, prefix) == [] + assert dataset_records(testing_env, ctx.usecase_id) == [] + + def test_finalize_within_limits_commits_the_dataset(self, testing_env, + ctx): + """Sanity check of the accept path: verified uploads within limits + commit exactly one TestDatasets record (12.3).""" + status, body, prefix = upload_and_finalize(testing_env, ctx, [ + {"name": "a.jpg", "content": JPEG_BYTES}, + {"name": "b.png", "content": PNG_BYTES}, + ]) + assert status == 201 + assert body["dataset"]["file_count"] == 2 + assert body["dataset"]["total_bytes"] == len(JPEG_BYTES) + len(PNG_BYTES) + assert body["dataset"]["format"] == "jpeg+png" + assert len(objects_under(testing_env, prefix)) == 2 + assert len(dataset_records(testing_env, ctx.usecase_id)) == 1 + + +# =========================================================================== +# 2. Validator / compiler failure short-circuit (Requirement 12.12) +# =========================================================================== + +def stage_run(testing_env, definition, target_arch="x86_64", + usecase_id=None, custom_node_type_pins=None): + """Stage a stored definition + TestRuns item; returns the step input.""" + test_run_id = f"run-{uuid.uuid4()}" + usecase_id = usecase_id or f"uc-{uuid.uuid4()}" + definition_key = f"workflows/{usecase_id}/defs/{test_run_id}.json" + results_key = f"workflows/{usecase_id}/test-runs/{test_run_id}/results.json" + testing_env.s3.put_object( + Bucket=testing_env.bucket, Key=definition_key, + Body=json.dumps(definition).encode("utf-8"), + ) + testing_env.runs_table.put_item(Item={ + "test_run_id": test_run_id, + "workflow_id": "wf-1", + "usecase_id": usecase_id, + "status": "running", + "started_at": 1, + "results_s3_key": results_key, + "failure": None, + }) + return { + "test_run_id": test_run_id, + "workflow_id": "wf-1", + "workflow_version": 1, + "usecase_id": usecase_id, + "definition_s3_key": definition_key, + "results_s3_key": results_key, + "artifacts_bucket": testing_env.bucket, + "target_arch": target_arch, + "simulation": True, + "custom_node_type_pins": custom_node_type_pins or {}, + } + + +def read_results_document(testing_env, results_key): + obj = testing_env.s3.get_object(Bucket=testing_env.bucket, Key=results_key) + return json.loads(obj["Body"].read().decode("utf-8")) + + +def get_run(testing_env, test_run_id): + return testing_env.runs_table.get_item( + Key={"test_run_id": test_run_id})["Item"] + + +def compiled_key_absent(testing_env, results_key): + compiled_key = results_key.rsplit("/", 1)[0] + "/compiled_pipeline.json" + listed = testing_env.s3.list_objects_v2( + Bucket=testing_env.bucket, Prefix=compiled_key) + return listed.get("KeyCount", 0) == 0 + + +class TestValidationAndCompileShortCircuit: + + def test_step_validate_errors_short_circuit_the_run(self, testing_env): + """step_validate on a definition with validation errors records + each error with its node identifier in the results document, marks + the TestRuns item failed, and returns {ok: False} - the pipeline + is never compiled or executed (12.12).""" + inp = stage_run(testing_env, INVALID_DEFINITION) + outcome = testing_env.steps.handler( + {"step": "validate", "input": inp}, None) + + assert outcome["ok"] is False + assert outcome["stage"] == "validate" + assert outcome["errors"] + + # Per-node error records in the results document (12.7, 12.12). + document = read_results_document(testing_env, inp["results_s3_key"]) + assert document["nodes"] + for record in document["nodes"]: + assert record["status"] == "error" + assert record["error"]["code"] + assert record["error"]["message"] + assert any(r["nodeId"] == "n2" for r in document["nodes"]) + + # TestRuns item failed with the failing node and no timeout. + run = get_run(testing_env, inp["test_run_id"]) + assert run["status"] == "failed" + assert run["failure"]["nodeId"] == "n2" + assert run["failure"]["timeout"] is False + assert "not" in run["failure"]["message"] and \ + "executed" in run["failure"]["message"] + + # The pipeline was never executed: no compiled document staged. + assert compiled_key_absent(testing_env, inp["results_s3_key"]) + + def test_step_compile_errors_short_circuit_the_run(self, testing_env): + """step_compile on a valid definition with an unmapped target + architecture records each compile error with its node identifier, + marks the run failed, and returns {ok: False} without staging a + compiled document (12.12).""" + inp = stage_run(testing_env, VALID_DEFINITION, + target_arch="not-a-real-arch") + # The definition itself validates clean. + assert testing_env.steps.handler( + {"step": "validate", "input": inp}, None)["ok"] is True + + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + + assert outcome["ok"] is False + assert outcome["stage"] == "compile" + assert all(e["code"] == "UNMAPPED_ARCHITECTURE" + for e in outcome["errors"]) + + document = read_results_document(testing_env, inp["results_s3_key"]) + node_ids = {r["nodeId"] for r in document["nodes"]} + assert node_ids == {"n1", "n2"} + assert all(r["status"] == "error" for r in document["nodes"]) + + run = get_run(testing_env, inp["test_run_id"]) + assert run["status"] == "failed" + assert run["failure"]["nodeId"] in ("n1", "n2") + assert run["failure"]["timeout"] is False + + assert compiled_key_absent(testing_env, inp["results_s3_key"]) + + def test_step_compile_succeeds_for_mapped_architecture(self, testing_env): + """Control: the same valid definition compiles for x86_64 and + stages the compiled document (the short-circuit above is caused by + the unmapped architecture, not the definition).""" + inp = stage_run(testing_env, VALID_DEFINITION) + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is True + assert not compiled_key_absent(testing_env, inp["results_s3_key"]) + assert get_run(testing_env, inp["test_run_id"])["status"] == "running" + + +# =========================================================================== +# 3. Mid-run failure retention (Requirement 12.10) +# =========================================================================== + +COMPLETED_RECORD = { + "nodeId": "n1", "status": "completed", + "outputs": [{"frame": 1}], "stubActivity": [], "error": None, +} +FAILED_RECORD = { + "nodeId": "n2", "status": "failed", "outputs": [], "stubActivity": [], + "error": {"code": "RUNTIME_ERROR", "message": "capture write failed"}, +} + + +class TestMidRunFailureRetention: + + def put_results(self, testing_env, inp, records): + testing_env.s3.put_object( + Bucket=testing_env.bucket, Key=inp["results_s3_key"], + Body=json.dumps({"nodes": records}).encode("utf-8"), + ) + + def test_step_collect_marks_failed_node_and_retains_prior_results( + self, testing_env): + """step_collect on a results document with a mid-run node failure + marks the run failed with the failing node identified and the + error description, while the per-node records produced before the + failure remain in the results document (12.10).""" + inp = stage_run(testing_env, VALID_DEFINITION) + self.put_results(testing_env, inp, [COMPLETED_RECORD, FAILED_RECORD]) + + outcome = testing_env.steps.handler( + {"step": "collect", "input": inp}, None) + + assert outcome["status"] == "failed" + assert outcome["failing_node_id"] == "n2" + assert outcome["node_count"] == 2 + + run = get_run(testing_env, inp["test_run_id"]) + assert run["status"] == "failed" + assert run["failure"]["nodeId"] == "n2" + assert run["failure"]["message"] == "capture write failed" + assert run["failure"]["timeout"] is False + + # Partial results are retained: the completed record is untouched. + document = read_results_document(testing_env, inp["results_s3_key"]) + assert document["nodes"] == [COMPLETED_RECORD, FAILED_RECORD] + + def test_step_collect_completes_when_no_record_failed(self, testing_env): + """Control: all-completed records finalize the run as completed.""" + inp = stage_run(testing_env, VALID_DEFINITION) + self.put_results(testing_env, inp, [COMPLETED_RECORD]) + + outcome = testing_env.steps.handler( + {"step": "collect", "input": inp}, None) + assert outcome == {"status": "completed", "node_count": 1} + assert get_run(testing_env, inp["test_run_id"])["status"] == "completed" + + def test_step_record_timeout_marks_timeout_and_keeps_partial_results( + self, testing_env): + """step_record_timeout marks the run failed with a timeout + indication and leaves the partial results document untouched + (12.13, retention per 12.10).""" + inp = stage_run(testing_env, VALID_DEFINITION) + self.put_results(testing_env, inp, [COMPLETED_RECORD]) + + outcome = testing_env.steps.handler( + {"step": "record_timeout", "input": inp}, None) + assert outcome == {"ok": True, "status": "failed", "timeout": True} + + run = get_run(testing_env, inp["test_run_id"]) + assert run["status"] == "failed" + assert run["failure"]["timeout"] is True + assert "10 minute" in run["failure"]["message"] + + document = read_results_document(testing_env, inp["results_s3_key"]) + assert document["nodes"] == [COMPLETED_RECORD] + + def test_step_record_failure_marks_failed_and_keeps_partial_results( + self, testing_env): + """step_record_failure (sandbox task failure) marks the run failed + with the delivered cause while partial results are retained + (12.10).""" + inp = stage_run(testing_env, VALID_DEFINITION) + self.put_results(testing_env, inp, [COMPLETED_RECORD]) + + inp = dict(inp, errorInfo={"Error": "States.TaskFailed", + "Cause": "sandbox container exited 1"}) + outcome = testing_env.steps.handler( + {"step": "record_failure", "input": inp}, None) + assert outcome == {"ok": True, "status": "failed", "timeout": False} + + run = get_run(testing_env, inp["test_run_id"]) + assert run["status"] == "failed" + assert run["failure"]["message"] == "sandbox container exited 1" + assert run["failure"]["timeout"] is False + + document = read_results_document(testing_env, inp["results_s3_key"]) + assert document["nodes"] == [COMPLETED_RECORD] + + +# =========================================================================== +# 3b. Readable failure messages for ECS task causes +# =========================================================================== + +def ecs_task_cause(**overrides): + """A representative ECS task description blob as Step Functions + delivers it in errorInfo.Cause for a failed Fargate RunTask.sync.""" + task = { + "Attachments": [{ + "Id": "att-1", "Type": "eni", "Status": "DELETED", + "Details": [{"Name": "networkInterfaceId", + "Value": "eni-0123456789abcdef0"}], + }], + "ClusterArn": "arn:aws:ecs:us-east-1:123456789012:cluster/test", + "TaskArn": "arn:aws:ecs:us-east-1:123456789012:task/test/abc123", + "LastStatus": "STOPPED", + "StopCode": "EssentialContainerExited", + "StoppedReason": "Essential container in task exited", + "Containers": [{ + "Name": "sandbox", + "ExitCode": 1, + "LastStatus": "STOPPED", + }], + } + task.update(overrides) + return json.dumps(task) + + +class TestReadableEcsFailureMessages: + """step_record_failure with an ECS task JSON Cause records a concise + human-readable message instead of the raw blob (Attachments/eni/...).""" + + def record_failure(self, testing_env, cause): + inp = stage_run(testing_env, VALID_DEFINITION) + inp = dict(inp, errorInfo={"Error": "States.TaskFailed", + "Cause": cause}) + outcome = testing_env.steps.handler( + {"step": "record_failure", "input": inp}, None) + assert outcome == {"ok": True, "status": "failed", "timeout": False} + return get_run(testing_env, inp["test_run_id"]) + + def test_exit_code_yields_concise_container_message(self, testing_env): + """A stopped-task blob with a container exit code records 'The + sandbox container exited with code 1' - not the raw JSON.""" + run = self.record_failure(testing_env, ecs_task_cause()) + message = run["failure"]["message"] + assert message == "The sandbox container exited with code 1" + assert "Attachments" not in message + assert "eni" not in message + + def test_container_reason_is_preferred_with_exit_code_appended( + self, testing_env): + """A container-level Reason (e.g. image pull / OOM) wins, with the + exit code appended when known.""" + cause = ecs_task_cause(Containers=[{ + "Name": "sandbox", + "ExitCode": 137, + "Reason": "OutOfMemoryError: Container killed due to memory usage", + }]) + run = self.record_failure(testing_env, cause) + assert run["failure"]["message"] == \ + ("OutOfMemoryError: Container killed due to memory usage " + "(exit code 137)") + + def test_stopped_reason_used_when_no_container_detail(self, testing_env): + """Without container Reason/ExitCode the task StoppedReason is + used; StopCode is the last resort.""" + cause = ecs_task_cause( + Containers=[{"Name": "sandbox", "LastStatus": "STOPPED"}], + StoppedReason="Timeout waiting for network interface provisioning") + run = self.record_failure(testing_env, cause) + assert run["failure"]["message"] == \ + "Timeout waiting for network interface provisioning" + + def test_non_json_cause_keeps_truncation_fallback(self, testing_env): + """A plain-text Cause keeps the existing truncate-at-512 fallback.""" + run = self.record_failure(testing_env, "x" * 600) + assert run["failure"]["message"] == "x" * 512 + + def test_json_cause_without_ecs_fields_keeps_fallback(self, testing_env): + """JSON that is not an ECS task shape (no Containers/StoppedReason/ + StopCode/TaskArn) is stored as-is via the fallback path.""" + cause = json.dumps({"errorMessage": "boom", "errorType": "Runtime"}) + run = self.record_failure(testing_env, cause) + assert run["failure"]["message"] == cause + + +# =========================================================================== +# 4. Step-level progress reporting (live status while a run executes) +# =========================================================================== + +def progress_messages(run): + """The messages of the run's `progress` entries, in append order.""" + entries = run.get("progress") or [] + for entry in entries: + assert set(entry) == {"at", "message"} + # DynamoDB stores numbers as Decimal; the API layer converts. + assert int(entry["at"]) > 0 + return [entry["message"] for entry in entries] + + +class TestRunProgressReporting: + """Each state-machine step appends human-readable {at, message} + entries to the TestRuns item's `progress` list, and GET + /test-runs/{id} surfaces it (together with the per-node results + flushed so far) so the portal can show what the run is doing while + the user waits.""" + + def test_step_validate_appends_validating_then_passed(self, testing_env): + inp = stage_run(testing_env, VALID_DEFINITION) + testing_env.steps.handler({"step": "validate", "input": inp}, None) + + run = get_run(testing_env, inp["test_run_id"]) + assert progress_messages(run) == [ + "Validating the workflow definition", + "Validation passed", + ] + # Status semantics are unchanged by progress recording. + assert run["status"] == "running" + + def test_progress_entries_accumulate_across_steps(self, testing_env): + """Entries append in run order: the compile step announces itself + on entry; on success it appends the compilation result and the + sandbox-start entry, because the Fargate RunSandbox state that + follows runs no Lambda of its own.""" + inp = stage_run(testing_env, VALID_DEFINITION) + testing_env.steps.handler({"step": "validate", "input": inp}, None) + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is True + + run = get_run(testing_env, inp["test_run_id"]) + assert progress_messages(run) == [ + "Validating the workflow definition", + "Validation passed", + "Compiling for x86_64 (simulation mode)", + "Compilation succeeded", + "Starting the sandbox container...", + ] + + def test_failed_compile_appends_a_failure_entry(self, testing_env): + """A short-circuited compile never reaches the sandbox: no + sandbox-start entry is appended, and marking the run failed + appends a failure entry (12.12).""" + inp = stage_run(testing_env, VALID_DEFINITION, + target_arch="not-a-real-arch") + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is False + + run = get_run(testing_env, inp["test_run_id"]) + messages = progress_messages(run) + assert messages[0] == "Compiling for not-a-real-arch (simulation mode)" + assert messages[-1].startswith("Test run failed: ") + assert "Starting the sandbox container..." not in messages + assert run["status"] == "failed" + + def test_step_collect_appends_collecting_then_completed(self, testing_env): + inp = stage_run(testing_env, VALID_DEFINITION) + testing_env.s3.put_object( + Bucket=testing_env.bucket, Key=inp["results_s3_key"], + Body=json.dumps({"nodes": [COMPLETED_RECORD]}).encode("utf-8"), + ) + testing_env.steps.handler({"step": "collect", "input": inp}, None) + + run = get_run(testing_env, inp["test_run_id"]) + assert progress_messages(run) == [ + "Collecting per-node results", + "Test run completed", + ] + assert run["status"] == "completed" + + def test_record_timeout_appends_a_failure_entry(self, testing_env): + inp = stage_run(testing_env, VALID_DEFINITION) + testing_env.steps.handler({"step": "record_timeout", "input": inp}, None) + + run = get_run(testing_env, inp["test_run_id"]) + messages = progress_messages(run) + assert messages[-1] == ("Test run failed: Test run exceeded the " + "10 minute execution limit") + + def test_get_test_run_returns_progress_and_partial_results( + self, testing_env, ctx): + """GET /test-runs/{id} on an in-flight run returns the progress + entries plus the per-node results the sandbox has flushed + incrementally so far, so the portal can render live progress.""" + test_run_id = f"run-{uuid.uuid4()}" + results_key = (f"workflows/{ctx.usecase_id}/test-runs/" + f"{test_run_id}/results.json") + testing_env.runs_table.put_item(Item={ + "test_run_id": test_run_id, + "workflow_id": "wf-1", + "usecase_id": ctx.usecase_id, + "status": "running", + "progress": [ + {"at": 1000, "message": "Validating the workflow definition"}, + {"at": 2000, "message": "Validation passed"}, + {"at": 3000, "message": "Starting the sandbox container..."}, + ], + "started_at": 1, + "results_s3_key": results_key, + "failure": None, + }) + # Two of the workflow's nodes have reported so far (12.10). + testing_env.s3.put_object( + Bucket=testing_env.bucket, Key=results_key, + Body=json.dumps({"nodes": [COMPLETED_RECORD, + COMPLETED_RECORD]}).encode("utf-8"), + ) + + status, body = invoke(testing_env, ctx, "GET", "/test-runs/{id}", + resource_id=test_run_id) + assert status == 200 + assert body["test_run"]["status"] == "running" + assert body["test_run"]["progress"] == [ + {"at": 1000, "message": "Validating the workflow definition"}, + {"at": 2000, "message": "Validation passed"}, + {"at": 3000, "message": "Starting the sandbox container..."}, + ] + assert len(body["node_results"]) == 2 + + +# =========================================================================== +# 5. Simulated inference outcome on test-run start (Requirement 12.6) +# =========================================================================== + +class FakeStepFunctions: + """Records start_execution calls the way workflow_testing issues them.""" + + def __init__(self): + self.calls = [] + + def start_execution(self, **kwargs): + self.calls.append(kwargs) + return {"executionArn": + "arn:aws:states:us-east-1:123456789012:execution:test:" + + kwargs.get("name", "x")} + + +class TestSimulatedInferenceValidation: + """validate_simulated_inference: shape validation of the optional + simulated_inference request field (model inference is stubbed in the + cloud sandbox; the user configures the injected outcome).""" + + def validate(self, testing_env, value): + return testing_env.testing.validate_simulated_inference(value) + + def test_absent_yields_the_default(self, testing_env): + normalized, err = self.validate(testing_env, None) + assert err is None + assert normalized == {"is_anomalous": False, "confidence": 0.9} + + def test_valid_payload_is_normalized(self, testing_env): + normalized, err = self.validate( + testing_env, {"is_anomalous": True, "confidence": 0.25}) + assert err is None + assert normalized == {"is_anomalous": True, "confidence": 0.25} + + def test_partial_payload_fills_defaults(self, testing_env): + normalized, err = self.validate(testing_env, {"is_anomalous": True}) + assert err is None + assert normalized == {"is_anomalous": True, "confidence": 0.9} + + def test_boundary_confidences_accepted(self, testing_env): + for confidence in (0, 1, 0.0, 1.0): + normalized, err = self.validate( + testing_env, {"confidence": confidence}) + assert err is None + assert normalized["confidence"] == float(confidence) + + def _assert_rejected(self, testing_env, value): + normalized, err = self.validate(testing_env, value) + assert normalized is None + assert err["statusCode"] == 400 + body = json.loads(err["body"]) + assert body["error"]["code"] == "INVALID_SIMULATED_INFERENCE" + + def test_bad_shapes_rejected(self, testing_env): + self._assert_rejected(testing_env, "anomalous") + self._assert_rejected(testing_env, [1, 2]) + self._assert_rejected(testing_env, {"is_anomalous": "yes"}) + self._assert_rejected(testing_env, {"confidence": "0.9"}) + self._assert_rejected(testing_env, {"confidence": True}) + self._assert_rejected(testing_env, {"confidence": -0.1}) + self._assert_rejected(testing_env, {"confidence": 1.1}) + self._assert_rejected(testing_env, {"is_anomalous": False, + "extra": 1}) + + +class TestSimulatedInferenceStartRun: + """POST /workflows/{id}/test-runs forwards the validated + simulated_inference outcome into the state machine input (including + the pre-serialized simulated_inference_json the RunSandbox container + override maps to the SIMULATED_INFERENCE env value), and rejects bad + shapes with 400 before any execution is started (12.6).""" + + def stage(self, testing_env, ctx, monkeypatch): + """A stored workflow version + dataset in ctx's Use_Case, with the + Step Functions client stubbed to record executions.""" + import boto3 + resource = boto3.resource("dynamodb", region_name=REGION) + workflow_id = f"wf-{uuid.uuid4()}" + resource.Table(TEST_ENV["WORKFLOWS_TABLE"]).put_item(Item={ + "workflow_id": workflow_id, + "usecase_id": ctx.usecase_id, + "name": "wf", + "latest_version": 1, + }) + definition_key = f"workflows/{ctx.usecase_id}/defs/{workflow_id}.json" + testing_env.s3.put_object( + Bucket=testing_env.bucket, Key=definition_key, + Body=json.dumps(VALID_DEFINITION).encode("utf-8"), + ) + resource.Table(TEST_ENV["WORKFLOW_VERSIONS_TABLE"]).put_item(Item={ + "workflow_id": workflow_id, + "version": 1, + "s3_definition_key": definition_key, + }) + dataset_id = f"ds-{uuid.uuid4()}" + testing_env.datasets_table.put_item(Item={ + "dataset_id": dataset_id, + "usecase_id": ctx.usecase_id, + "s3_prefix": f"workflows/{ctx.usecase_id}/test-datasets/{dataset_id}/", + }) + fake_sfn = FakeStepFunctions() + monkeypatch.setattr(testing_env.testing, "stepfunctions", fake_sfn) + monkeypatch.setattr(testing_env.testing, "TEST_RUN_STATE_MACHINE_ARN", + "arn:aws:states:us-east-1:123456789012:" + "stateMachine:test-runner") + return workflow_id, dataset_id, fake_sfn + + def start(self, testing_env, ctx, workflow_id, body): + return invoke(testing_env, ctx, "POST", "/workflows/{id}/test-runs", + body, resource_id=workflow_id) + + def execution_input(self, fake_sfn): + assert len(fake_sfn.calls) == 1 + return json.loads(fake_sfn.calls[0]["input"]) + + def test_default_outcome_forwarded_when_absent(self, testing_env, ctx, + monkeypatch): + workflow_id, dataset_id, fake_sfn = self.stage( + testing_env, ctx, monkeypatch) + status, body = self.start(testing_env, ctx, workflow_id, + {"dataset_id": dataset_id}) + assert status == 202, body + inp = self.execution_input(fake_sfn) + assert inp["simulated_inference"] == {"is_anomalous": False, + "confidence": 0.9} + assert json.loads(inp["simulated_inference_json"]) == \ + inp["simulated_inference"] + + def test_configured_outcome_forwarded(self, testing_env, ctx, monkeypatch): + workflow_id, dataset_id, fake_sfn = self.stage( + testing_env, ctx, monkeypatch) + status, body = self.start(testing_env, ctx, workflow_id, { + "dataset_id": dataset_id, + "simulated_inference": {"is_anomalous": True, "confidence": 0.25}, + }) + assert status == 202, body + inp = self.execution_input(fake_sfn) + assert inp["simulated_inference"] == {"is_anomalous": True, + "confidence": 0.25} + assert json.loads(inp["simulated_inference_json"]) == \ + {"is_anomalous": True, "confidence": 0.25} + + def test_bad_shape_rejected_before_any_execution(self, testing_env, ctx, + monkeypatch): + workflow_id, dataset_id, fake_sfn = self.stage( + testing_env, ctx, monkeypatch) + status, body = self.start(testing_env, ctx, workflow_id, { + "dataset_id": dataset_id, + "simulated_inference": {"confidence": 2}, + }) + assert status == 400 + assert body["error"]["code"] == "INVALID_SIMULATED_INFERENCE" + assert fake_sfn.calls == [] + + +# =========================================================================== +# 5. Custom_Node_Types in cloud test runs +# (custom-node-designer task 13.1, Requirements 12.1, 12.2) +# =========================================================================== + +from test_custom_node_types import make_declaration # noqa: E402 + + +def custom_definition(type_id): + """camera_source -> (VideoFrames in/out) -> capture.""" + return { + "schemaVersion": 1, + "nodes": [ + {"id": "n1", "type": "camera_source", + "position": {"x": 100, "y": 100}, "parameters": {}}, + {"id": "n2", "type": type_id, + "position": {"x": 350, "y": 100}, "parameters": {"radius": 5}}, + {"id": "n3", "type": "capture", + "position": {"x": 600, "y": 100}, + "parameters": {"output_path": "/data/captures"}}, + ], + "connections": [ + {"id": "c1", "from": {"node": "n1", "port": "out"}, + "to": {"node": "n2", "port": "in"}}, + {"id": "c2", "from": {"node": "n2", "port": "out"}, + "to": {"node": "n3", "port": "in"}}, + ], + } + + +def seed_custom_node_type(testing_env, usecase_id, type_id, + plugin_name, with_artifact=True): + """A registered Custom_Node_Type version item plus its backing + Plugin_Record — with or without a successful x86_64 Plugin_Artifact + promoted to the Plugin_Library prefix of the portal bucket.""" + plugin_id = f"plg-{uuid.uuid4().hex[:8]}" + artifacts = {} + if with_artifact: + so_key = (f"workflow-plugins/custom/{usecase_id}/x86_64/" + f"{plugin_name}.so") + testing_env.s3.put_object(Bucket=testing_env.bucket, Key=so_key, + Body=b"\x7fELF custom plugin bytes") + artifacts["x86_64"] = { + "buildStatus": "succeeded", "s3Key": so_key, + "checksum": "abc123", "signature": "c2ln", "logTail": "", + } + testing_env.stack.tables.plugin_records.put_item(Item={ + "plugin_id": plugin_id, "version": 1, "usecase_id": usecase_id, + "name": plugin_name, "lifecycle_state": "test", + "artifacts": artifacts, + }) + declaration = make_declaration(type_id) + for mapping in declaration["mappings"]: + mapping["pluginDependencies"] = [f"custom:{usecase_id}/{plugin_name}"] + testing_env.stack.tables.custom_node_types.put_item(Item={ + "node_type_id": type_id, "version": 1, "usecase_id": usecase_id, + "plugin_id": plugin_id, "plugin_version": 1, + "declaration": declaration, "deprecated": False, + }) + return plugin_id + + +def compiled_document(testing_env, inp): + key = inp["results_s3_key"].rsplit("/", 1)[0] + "/compiled_pipeline.json" + obj = testing_env.s3.get_object(Bucket=testing_env.bucket, Key=key) + return json.loads(obj["Body"].read().decode("utf-8")) + + +def elements_for_node(document, node_id): + return [e for s in document["segments"] for e in s["elements"] + if e.get("nodeId") == node_id] + + +def read_custom_plugins_manifest(testing_env, inp): + key = inp["results_s3_key"].rsplit("/", 1)[0] + "/custom_plugins.json" + obj = testing_env.s3.get_object(Bucket=testing_env.bucket, Key=key) + return json.loads(obj["Body"].read().decode("utf-8")) + + +def manifest_absent(testing_env, inp): + key = inp["results_s3_key"].rsplit("/", 1)[0] + "/custom_plugins.json" + listed = testing_env.s3.list_objects_v2( + Bucket=testing_env.bucket, Prefix=key) + return listed.get("KeyCount", 0) == 0 + + +class TestCustomPluginCompile: + """The compile step compiles against the merged catalog of the run's + Use_Case, stages custom x86_64 Plugin_Artifacts under the run's + prefix for the sandbox task, and substitutes the pass-through + recording stub for Custom_Node_Types without an x86_64 build + (custom-node-designer 12.1, 12.2).""" + + def test_compile_uses_merged_catalog_and_stages_custom_plugin( + self, testing_env): + """A workflow using a Custom_Node_Type with a successful x86_64 + Plugin_Artifact validates and compiles against the merged catalog; + the real declared element chain is emitted for the custom node and + the artifact is staged under the run's plugins/ prefix with a + manifest entry (12.1).""" + usecase_id = f"uc-{uuid.uuid4()}" + type_id = "custom.blur_real" + seed_custom_node_type(testing_env, usecase_id, type_id, "blurplug", + with_artifact=True) + inp = stage_run(testing_env, custom_definition(type_id), + usecase_id=usecase_id) + + # The merged catalog makes the custom type a known type (12.1). + assert testing_env.steps.handler( + {"step": "validate", "input": inp}, None)["ok"] is True + + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is True, outcome + assert outcome["stubbed_custom_node_types"] == [] + + # The custom node compiled to its real declared element chain. + document = compiled_document(testing_env, inp) + factories = [e["factory"] for e in elements_for_node(document, "n2")] + assert factories == ["blurregions"] + + # The x86_64 artifact is staged under the run's plugins/ prefix. + manifest = read_custom_plugins_manifest(testing_env, inp) + assert manifest["stubbedNodeTypeIds"] == [] + assert [p["nodeTypeId"] for p in manifest["plugins"]] == [type_id] + staged_key = manifest["plugins"][0]["s3Key"] + assert staged_key == (inp["results_s3_key"].rsplit("/", 1)[0] + + "/plugins/blurplug.so") + staged = testing_env.s3.get_object( + Bucket=testing_env.bucket, Key=staged_key) + assert staged["Body"].read() == b"\x7fELF custom plugin bytes" + + def test_compile_stubs_custom_node_without_x86_64_artifact( + self, testing_env): + """A Custom_Node_Type without a successful x86_64 Plugin_Artifact + is substituted with the pass-through recording stub — an identity + element named custom_stub_ — and identified as stubbed in + the manifest; nothing is staged for it (12.2).""" + usecase_id = f"uc-{uuid.uuid4()}" + type_id = "custom.blur_stubbed" + seed_custom_node_type(testing_env, usecase_id, type_id, "stubplug", + with_artifact=False) + inp = stage_run(testing_env, custom_definition(type_id), + usecase_id=usecase_id) + + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is True, outcome + assert outcome["stubbed_custom_node_types"] == [type_id] + + # The stub passes frames through unchanged and carries the + # per-node name the harness identifies stubbed nodes by. + document = compiled_document(testing_env, inp) + elements = elements_for_node(document, "n2") + assert [e["factory"] for e in elements] == ["identity"] + assert elements[0]["args"]["name"] == "custom_stub_n2" + assert "custom:" not in json.dumps(document.get("pluginDependencies")) + + manifest = read_custom_plugins_manifest(testing_env, inp) + assert manifest["plugins"] == [] + assert manifest["stubbedNodeTypeIds"] == [type_id] + + # No artifact staged under the run's plugins/ prefix. + plugins_prefix = (inp["results_s3_key"].rsplit("/", 1)[0] + + "/plugins/") + assert objects_under(testing_env, plugins_prefix) == [] + + def test_builtin_only_run_writes_no_custom_manifest(self, testing_env): + """Runs without custom nodes stay byte-identical to the + pre-existing flow: no custom_plugins.json is written.""" + inp = stage_run(testing_env, VALID_DEFINITION) + outcome = testing_env.steps.handler( + {"step": "compile", "input": inp}, None) + assert outcome["ok"] is True + assert outcome["stubbed_custom_node_types"] == [] + assert manifest_absent(testing_env, inp) + + def test_stub_decision_is_exactly_artifact_absence(self, testing_env): + """Pure decision examples: a type is stubbed iff its backing + record lacks a successful x86_64 artifact entry (12.2).""" + steps = testing_env.steps + entries = { + "t.real": {"buildStatus": "succeeded", "s3Key": "k.so"}, + "t.failed": {"buildStatus": "failed", "logTail": "boom"}, + "t.keyless": {"buildStatus": "succeeded"}, + "t.missing": None, + } + stubbed = steps.stubbed_custom_type_ids(sorted(entries), entries) + assert stubbed == frozenset({"t.failed", "t.keyless", "t.missing"}) diff --git a/edge-cv-portal/backend/tests/test_workflows_store.py b/edge-cv-portal/backend/tests/test_workflows_store.py new file mode 100644 index 00000000..ac70e2d6 --- /dev/null +++ b/edge-cv-portal/backend/tests/test_workflows_store.py @@ -0,0 +1,412 @@ +""" +Integration tests for the Workflow_Store API (functions/workflows.py). + +Task 6.4 (spec: workflow-manager). CRUD, versioning, duplication, and +delete-with-deployments run against local DynamoDB / S3 (moto) with the +real shared_utils RBAC layer and the real workflow_core serializer. +_Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_ +""" +import json + +import pytest + + +def make_definition(topic="line-1/results"): + """A minimal valid Workflow_Definition in canonical field/order form.""" + return { + "schemaVersion": 1, + "nodes": [ + { + "id": "cam", + "type": "camera_source", + "position": {"x": 0, "y": 0}, + "parameters": {}, + }, + { + "id": "out", + "type": "mqtt_publish", + "position": {"x": 400, "y": 120}, + "parameters": {"topic": topic}, + }, + ], + "connections": [ + { + "id": "c1", + "from": {"node": "cam", "port": "video"}, + "to": {"node": "out", "port": "in"}, + } + ], + } + + +def create_workflow(env, user, usecase_id, name="Line inspection", **overrides): + body = { + "usecase_id": usecase_id, + "name": name, + "description": "test workflow", + "definition": make_definition(), + } + body.update(overrides) + status, payload = env.invoke("POST", "/workflows", user, body=body) + assert status == 201, payload + return payload["workflow"] + + +# --------------------------------------------------------------------- 5.1 +class TestCreate: + def test_create_persists_scoped_metadata_and_definition(self, env): + """Saving a workflow persists it scoped to account + Use_Case with + name, description, and timestamps (Req 5.1).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + + workflow = create_workflow(env, user, usecase_id) + + assert workflow["usecase_id"] == usecase_id + assert workflow["account_id"] == "123456789012" + assert workflow["name"] == "Line inspection" + assert workflow["description"] == "test workflow" + assert workflow["latest_version"] == 1 + assert workflow["created_by"] == user["user_id"] + assert workflow["created_at"] == workflow["updated_at"] + assert isinstance(workflow["created_at"], int) + + # The definition document is stored in portal S3 under the + # per-usecase / per-workflow prefix. + key = ( + f"workflows/{usecase_id}/{workflow['workflow_id']}" + f"/versions/1/workflow.json" + ) + stored = json.loads( + env.s3.get_object(Bucket=env.bucket, Key=key)["Body"].read() + ) + assert stored["schemaVersion"] == 1 + assert {n["id"] for n in stored["nodes"]} == {"cam", "out"} + + def test_create_missing_fields_rejected(self, env): + user = env.make_user(role="DataScientist") + status, payload = env.invoke( + "POST", "/workflows", user, body={"name": "no usecase or definition"} + ) + assert status == 400 + assert payload["error"]["code"] == "MISSING_FIELDS" + + def test_create_invalid_definition_rejected(self, env): + """A schema-violating definition is rejected with the serializer's + descriptive error (Req 5.1 stores only valid definitions).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + status, payload = env.invoke( + "POST", "/workflows", user, + body={ + "usecase_id": usecase_id, + "name": "bad", + "definition": {"schemaVersion": 1}, # missing nodes/connections + }, + ) + assert status == 400 + assert payload["error"]["code"] == "SCHEMA_VIOLATION" + + +# --------------------------------------------------------------------- 5.2 +class TestVersioning: + def test_save_changes_creates_new_version_and_retains_prior(self, env): + """Saving changes creates a new version; prior versions stay + loadable with their original contents (Req 5.2).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow = create_workflow(env, user, usecase_id) + workflow_id = workflow["workflow_id"] + v1_definition = make_definition() + v2_definition = make_definition(topic="line-2/results") + + status, payload = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=workflow_id, + body={"definition": v2_definition, "description": "revised"}, + ) + assert status == 200, payload + assert payload["version"] == 2 + assert payload["workflow"]["latest_version"] == 2 + assert payload["workflow"]["description"] == "revised" + + # Version 1 is retained with its original definition. + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=workflow_id, + query={"version": "1"}, + ) + assert status == 200 + assert payload["version"] == 1 + assert payload["definition"] == v1_definition + + # Latest returns the new definition. + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 200 + assert payload["version"] == 2 + assert payload["definition"] == v2_definition + + def test_version_history_lists_all_versions_newest_first(self, env): + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id)["workflow_id"] + for topic in ("t2", "t3"): + status, _ = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=workflow_id, + body={"definition": make_definition(topic=topic)}, + ) + assert status == 200 + + status, payload = env.invoke( + "GET", "/workflows/{id}/versions", user, workflow_id=workflow_id + ) + assert status == 200 + assert payload["latest_version"] == 3 + assert [v["version"] for v in payload["versions"]] == [3, 2, 1] + + +# --------------------------------------------------------------------- 5.3 +class TestList: + def test_list_returns_only_authorized_usecases(self, env): + """Listing returns workflows of Use_Cases the user is assigned to, + not workflows of other Use_Cases (Req 5.3).""" + creator = env.make_user(role="DataScientist") + usecase_a = env.create_usecase("A") + usecase_b = env.create_usecase("B") + wf_a = create_workflow(env, creator, usecase_a, name="wf-a") + create_workflow(env, creator, usecase_b, name="wf-b") + + member = env.make_user(role="DataScientist") + env.assign_role(member, usecase_a, "DataScientist") + + status, payload = env.invoke("GET", "/workflows", member) + assert status == 200 + listed_ids = {w["workflow_id"] for w in payload["workflows"]} + assert listed_ids == {wf_a["workflow_id"]} + assert payload["count"] == 1 + + def test_list_scoped_to_single_usecase_via_query(self, env): + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + created = { + create_workflow(env, user, usecase_id, name=f"wf-{i}")["workflow_id"] + for i in range(2) + } + status, payload = env.invoke( + "GET", "/workflows", user, query={"usecase_id": usecase_id} + ) + assert status == 200 + assert {w["workflow_id"] for w in payload["workflows"]} == created + + +# --------------------------------------------------------------------- 5.4 +class TestOpen: + def test_open_returns_definition_exactly_as_saved(self, env): + """Opening a saved workflow returns the stored definition with all + nodes, positions, configurations, and connections (Req 5.4).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + definition = make_definition() + workflow_id = create_workflow(env, user, usecase_id)["workflow_id"] + + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 200 + assert payload["workflow"]["workflow_id"] == workflow_id + assert payload["definition"] == definition + # positions and parameters survive the round trip untouched + nodes = {n["id"]: n for n in payload["definition"]["nodes"]} + assert nodes["out"]["position"] == {"x": 400, "y": 120} + assert nodes["out"]["parameters"] == {"topic": "line-1/results"} + + def test_open_missing_workflow_returns_404(self, env): + user = env.make_user(role="DataScientist") + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id="does-not-exist" + ) + assert status == 404 + assert payload["error"]["code"] == "WORKFLOW_NOT_FOUND" + + +# ---------------------------------------------------------------- 5.5, 5.6 +class TestDelete: + def test_delete_removes_workflow_versions_and_documents(self, env): + """Deleting a workflow with no active deployments removes the + metadata, every version record, and stored documents (Req 5.5).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id)["workflow_id"] + status, _ = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=workflow_id, + body={"definition": make_definition(topic="t2")}, + ) + assert status == 200 + + status, payload = env.invoke( + "DELETE", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 200, payload + + # Workflow is gone from the API. + status, _ = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 404 + + # All version records are gone. + versions = env.stack.tables.versions.query( + KeyConditionExpression="workflow_id = :wid", + ExpressionAttributeValues={":wid": workflow_id}, + )["Items"] + assert versions == [] + + # All stored S3 documents are gone. + listed = env.s3.list_objects_v2( + Bucket=env.bucket, + Prefix=f"workflows/{usecase_id}/{workflow_id}/", + ) + assert listed.get("KeyCount", 0) == 0 + + def test_delete_rejected_with_referencing_deployment_ids(self, env): + """Deletion is rejected with 409 and the ids of active deployments + that reference the workflow (Req 5.6).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id)["workflow_id"] + dep_assoc = env.put_deployment( + usecase_id, status="IN_PROGRESS", + component_type="workflow", workflow_id=workflow_id, + ) + dep_component = env.put_deployment( + usecase_id, status="ACTIVE", + components=[{"component_name": f"dda.workflow.{workflow_id}"}], + ) + # An inactive deployment must not block deletion. + env.put_deployment( + usecase_id, status="FAILED", + component_type="workflow", workflow_id=workflow_id, + ) + # An active deployment of a different workflow must not block. + env.put_deployment( + usecase_id, status="ACTIVE", + component_type="workflow", workflow_id="other-workflow", + ) + + status, payload = env.invoke( + "DELETE", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 409 + assert payload["error"]["code"] == "WORKFLOW_HAS_ACTIVE_DEPLOYMENTS" + assert sorted(payload["error"]["details"]["deployment_ids"]) == sorted( + [dep_assoc, dep_component] + ) + + # The workflow and its versions remain intact. + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 200 + assert payload["version"] == 1 + + def test_delete_allowed_once_deployments_inactive(self, env): + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, user, usecase_id)["workflow_id"] + env.put_deployment( + usecase_id, status="CANCELLED", + component_type="workflow", workflow_id=workflow_id, + ) + status, payload = env.invoke( + "DELETE", "/workflows/{id}", user, workflow_id=workflow_id + ) + assert status == 200, payload + + +# --------------------------------------------------------------------- 5.7 +class TestDuplicate: + def test_duplicate_creates_copy_under_new_name(self, env): + """Duplicating creates a new workflow whose definition is a copy of + the source's latest version, under a new name (Req 5.7).""" + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + source = create_workflow(env, user, usecase_id, name="Original") + source_id = source["workflow_id"] + latest_definition = make_definition(topic="latest") + status, _ = env.invoke( + "PUT", "/workflows/{id}", user, workflow_id=source_id, + body={"definition": latest_definition}, + ) + assert status == 200 + + status, payload = env.invoke( + "POST", "/workflows/{id}/duplicate", user, workflow_id=source_id, + body={"name": "Copy of original"}, + ) + assert status == 201, payload + copy = payload["workflow"] + assert copy["workflow_id"] != source_id + assert copy["name"] == "Copy of original" + assert copy["usecase_id"] == usecase_id + assert copy["latest_version"] == 1 + + # Copy holds the source's latest definition. + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=copy["workflow_id"] + ) + assert status == 200 + assert payload["definition"] == latest_definition + + # Source is unchanged. + status, payload = env.invoke( + "GET", "/workflows/{id}", user, workflow_id=source_id + ) + assert status == 200 + assert payload["workflow"]["name"] == "Original" + assert payload["version"] == 2 + + def test_duplicate_default_name_appends_copy(self, env): + user = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + source_id = create_workflow(env, user, usecase_id, name="Original")["workflow_id"] + status, payload = env.invoke( + "POST", "/workflows/{id}/duplicate", user, workflow_id=source_id, body={} + ) + assert status == 201 + assert payload["workflow"]["name"] == "Original (copy)" + + +# ------------------------------------------------------------------- RBAC +class TestAuthorization: + def test_viewer_cannot_create_workflow(self, env): + """A role without workflow:create is denied with an authorization + error (supports Req 5.3 scoping / 11.4).""" + viewer = env.make_user(role="Viewer") + usecase_id = env.create_usecase() + status, payload = env.invoke( + "POST", "/workflows", viewer, + body={ + "usecase_id": usecase_id, + "name": "nope", + "definition": make_definition(), + }, + ) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + + def test_viewer_cannot_delete_but_can_read(self, env): + creator = env.make_user(role="DataScientist") + usecase_id = env.create_usecase() + workflow_id = create_workflow(env, creator, usecase_id)["workflow_id"] + + viewer = env.make_user(role="Viewer") + status, payload = env.invoke( + "DELETE", "/workflows/{id}", viewer, workflow_id=workflow_id + ) + assert status == 403 + assert payload["error"]["code"] == "FORBIDDEN" + + status, _ = env.invoke( + "GET", "/workflows/{id}", viewer, workflow_id=workflow_id + ) + assert status == 200 diff --git a/edge-cv-portal/deploy-frontend.sh b/edge-cv-portal/deploy-frontend.sh index 57fa90cd..eac857e5 100755 --- a/edge-cv-portal/deploy-frontend.sh +++ b/edge-cv-portal/deploy-frontend.sh @@ -100,9 +100,21 @@ npm ci echo "Step 3: Building application..." npm run build -# Deploy to S3 +# Deploy to S3. +# Hashed assets are immutable and safe to cache for a year; the entry +# point (index.html) and runtime config (config.json) must always be +# revalidated so browsers pick up new deployments immediately instead of +# holding a stale index.html that references deleted hashed chunks. echo "Step 4: Deploying to S3..." -aws s3 sync dist/ s3://$BUCKET_NAME/ --delete +aws s3 sync dist/ s3://$BUCKET_NAME/ --delete \ + --exclude "index.html" --exclude "config.json" \ + --cache-control "public, max-age=31536000, immutable" +aws s3 cp dist/index.html s3://$BUCKET_NAME/index.html \ + --cache-control "no-cache" +if [ -f dist/config.json ]; then + aws s3 cp dist/config.json s3://$BUCKET_NAME/config.json \ + --cache-control "no-cache" +fi # Invalidate CloudFront cache echo "Step 5: Invalidating CloudFront cache..." diff --git a/edge-cv-portal/frontend/package-lock.json b/edge-cv-portal/frontend/package-lock.json index 47a6261f..4cab6465 100644 --- a/edge-cv-portal/frontend/package-lock.json +++ b/edge-cv-portal/frontend/package-lock.json @@ -11,6 +11,7 @@ "@cloudscape-design/components": "^3.0.0", "@cloudscape-design/global-styles": "^1.0.0", "@tanstack/react-query": "^5.0.0", + "@xyflow/react": "12.11.2", "aws-amplify": "^6.0.25", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -4217,6 +4218,55 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4251,14 +4301,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.27", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -4269,7 +4319,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -4388,6 +4438,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/ace-builds": { "version": "1.43.4", "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.43.4.tgz", @@ -4597,6 +4689,12 @@ "node": ">=18" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", @@ -4673,12 +4771,73 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-path": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", "license": "BSD-3-Clause" }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", @@ -4688,6 +4847,50 @@ "d3-path": "1" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -6145,6 +6348,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -6965,6 +7177,34 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/edge-cv-portal/frontend/package.json b/edge-cv-portal/frontend/package.json index a5fe629e..5b4cbb2b 100644 --- a/edge-cv-portal/frontend/package.json +++ b/edge-cv-portal/frontend/package.json @@ -6,6 +6,7 @@ "@cloudscape-design/components": "^3.0.0", "@cloudscape-design/global-styles": "^1.0.0", "@tanstack/react-query": "^5.0.0", + "@xyflow/react": "12.11.2", "aws-amplify": "^6.0.25", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/edge-cv-portal/frontend/src/App.tsx b/edge-cv-portal/frontend/src/App.tsx index 259ba6b7..fc674cdf 100644 --- a/edge-cv-portal/frontend/src/App.tsx +++ b/edge-cv-portal/frontend/src/App.tsx @@ -26,8 +26,18 @@ import CreateDeployment from './pages/CreateDeployment'; import Components from './pages/Components'; import ComponentDetail from './pages/ComponentDetail'; import ComponentConfiguration from './pages/ComponentConfiguration'; +import WorkflowBuilder from './pages/workflows/WorkflowBuilder'; +import PluginLibrary from './pages/node-designer/PluginLibrary'; +import PluginDetail from './pages/node-designer/PluginDetail'; +import CreateWizard from './pages/node-designer/CreateWizard'; +import GeneratePanel from './pages/node-designer/GeneratePanel'; +import RegistrationWizard from './pages/node-designer/RegistrationWizard'; +import ReviewQueue from './pages/node-designer/ReviewQueue'; +import SimulatorView from './pages/node-designer/SimulatorView'; +import ImportView from './pages/node-designer/ImportView'; import Settings from './pages/Settings'; import AuditLogs from './pages/AuditLogs'; +import UserManager from './pages/admin/UserManager'; import Login from './pages/Login'; import Layout from './components/Layout'; import ProtectedRoute from './components/ProtectedRoute'; @@ -84,7 +94,21 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> } /> + } /> } /> diff --git a/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.test.tsx b/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.test.tsx new file mode 100644 index 00000000..debc9b8b --- /dev/null +++ b/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.test.tsx @@ -0,0 +1,265 @@ +/** + * Component tests for the Bedrock_Configuration settings section + * (workflow-manager Requirement 10.6): the form is visible and editable + * only for PortalAdmin, loads the stored configuration, validates the + * timeout bound (<= 60 seconds, Requirement 10.7) client-side, and + * offers the model identifier as a dropdown of invokable models (with a + * free-text fallback when the model list is unavailable). + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import createWrapper from '@cloudscape-design/components/test-utils/dom'; +import BedrockConfigurationSettings from './BedrockConfigurationSettings'; + +const { getBedrockConfiguration, getBedrockModels, updateBedrockConfiguration, useAuthMock } = vi.hoisted(() => ({ + getBedrockConfiguration: vi.fn(), + getBedrockModels: vi.fn(), + updateBedrockConfiguration: vi.fn(), + useAuthMock: vi.fn(), +})); + +vi.mock('../services/api', () => ({ + apiService: { getBedrockConfiguration, getBedrockModels, updateBedrockConfiguration }, +})); + +vi.mock('../contexts/AuthContext', () => ({ + useAuth: useAuthMock, +})); + +const STORED_CONFIG = { + model_id: 'anthropic.claude-3-5-sonnet-20240620-v1:0', + region: 'us-east-1', + max_tokens: 4096, + temperature: 0.2, + top_p: 0.9, + timeout_seconds: 60, +}; + +const MODEL_OPTIONS = [ + { id: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', label: 'US Anthropic Claude Sonnet 4.5' }, + { id: 'amazon.titan-text-express-v1', label: 'Titan Text Express' }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + getBedrockConfiguration.mockResolvedValue({ + bedrock_configuration: STORED_CONFIG, + defaults: {}, + max_timeout_seconds: 60, + }); + getBedrockModels.mockResolvedValue({ + models: MODEL_OPTIONS, + region: 'us-east-1', + }); + updateBedrockConfiguration.mockResolvedValue({ + message: 'ok', + bedrock_configuration: STORED_CONFIG, + }); +}); + +function setAuthRole(role: string | null) { + useAuthMock.mockReturnValue({ user: role ? { role } : null }); +} + +describe('BedrockConfigurationSettings', () => { + it('shows the configuration form with stored values for PortalAdmin (Requirement 10.6)', async () => { + setAuthRole('PortalAdmin'); + const { container } = render(); + + // The model dropdown shows the stored model id even though it is not + // in the fetched model list (surfaced as a selectable option). + await waitFor(() => { + const select = createWrapper(container).findSelect(); + expect(select).not.toBeNull(); + expect(select!.findTrigger().getElement()).toHaveTextContent(STORED_CONFIG.model_id); + }); + expect(screen.getByLabelText('Region')).toHaveValue(STORED_CONFIG.region); + expect(screen.getByLabelText('Max tokens')).toHaveValue(4096); + expect(screen.getByLabelText('Temperature')).toHaveValue(0.2); + expect(screen.getByLabelText('Top P')).toHaveValue(0.9); + expect(screen.getByLabelText('Timeout seconds')).toHaveValue(60); + expect(screen.getByRole('button', { name: 'Save Configuration' })).toBeInTheDocument(); + }); + + it.each(['Viewer', 'Operator', 'DataScientist', 'UseCaseAdmin'])( + 'shows an access notice instead of the form for %s (Requirement 10.6)', + (role) => { + setAuthRole(role); + render(); + + expect(screen.getByText('Portal Admin access required')).toBeInTheDocument(); + expect(screen.queryByLabelText('Model identifier')).toBeNull(); + expect(getBedrockConfiguration).not.toHaveBeenCalled(); + expect(getBedrockModels).not.toHaveBeenCalled(); + }, + ); + + it('offers the fetched models in the dropdown and saves the selected one', async () => { + setAuthRole('PortalAdmin'); + const { container } = render(); + + await waitFor(() => { + const wrapper = createWrapper(container).findSelect(); + expect(wrapper).not.toBeNull(); + expect(wrapper!.findTrigger().getElement()).toHaveTextContent(STORED_CONFIG.model_id); + }); + + const select = createWrapper(container).findSelect()!; + await waitFor(() => { + select.openDropdown(); + // Stored (custom) model id + the two fetched options. + expect(select.findDropdown().findOptions()).toHaveLength(3); + }); + expect(select.findDropdown().getElement().textContent).toContain('US Anthropic Claude Sonnet 4.5'); + expect(select.findDropdown().getElement().textContent).toContain('Titan Text Express'); + expect(select.findDropdown().getElement().textContent).toContain(STORED_CONFIG.model_id); + + select.selectOptionByValue('us.anthropic.claude-sonnet-4-5-20250929-v1:0'); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + await waitFor(() => { + expect(updateBedrockConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ + model_id: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + }), + ); + }); + }); + + it('falls back to a free-text model id input when the model list fails to load', async () => { + setAuthRole('PortalAdmin'); + getBedrockModels.mockRejectedValue(new Error('boom')); + const { container } = render(); + + await waitFor(() => { + expect(screen.getByLabelText('Model identifier')).toHaveValue(STORED_CONFIG.model_id); + }); + expect(createWrapper(container).findSelect()).toBeNull(); + + fireEvent.change(screen.getByLabelText('Model identifier'), { + target: { value: 'my.custom-model-v1:0' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + await waitFor(() => { + expect(updateBedrockConfiguration).toHaveBeenCalledWith( + expect.objectContaining({ model_id: 'my.custom-model-v1:0' }), + ); + }); + }); + + it('falls back to a free-text model id input when the backend lacks list permissions', async () => { + setAuthRole('PortalAdmin'); + getBedrockModels.mockResolvedValue({ + models: [], + region: 'us-east-1', + permissions: 'Missing bedrock list permissions', + }); + const { container } = render(); + + await waitFor(() => { + expect(screen.getByLabelText('Model identifier')).toHaveValue(STORED_CONFIG.model_id); + }); + expect(createWrapper(container).findSelect()).toBeNull(); + }); + + it('rejects a timeout above 60 seconds without calling the API (Requirement 10.7)', async () => { + setAuthRole('PortalAdmin'); + render(); + await waitFor(() => { + expect(screen.getByLabelText('Timeout seconds')).toHaveValue(60); + }); + + fireEvent.change(screen.getByLabelText('Timeout seconds'), { target: { value: '61' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + expect( + await screen.findByText('Timeout must be an integer between 1 and 60 seconds'), + ).toBeInTheDocument(); + expect(updateBedrockConfiguration).not.toHaveBeenCalled(); + }); + + it('saves the edited configuration through the API', async () => { + setAuthRole('PortalAdmin'); + render(); + await waitFor(() => { + expect(screen.getByLabelText('Timeout seconds')).toHaveValue(60); + }); + + fireEvent.change(screen.getByLabelText('Timeout seconds'), { target: { value: '45' } }); + fireEvent.change(screen.getByLabelText('Temperature'), { target: { value: '0.5' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + await waitFor(() => { + expect(updateBedrockConfiguration).toHaveBeenCalledWith({ + model_id: STORED_CONFIG.model_id, + region: STORED_CONFIG.region, + max_tokens: 4096, + temperature: 0.5, + top_p: 0.9, + timeout_seconds: 45, + }); + }); + expect(await screen.findByText('Bedrock configuration saved')).toBeInTheDocument(); + }); + + it('loads a stored null temperature and top_p as blank fields (Bugfix Requirement 2.3)', async () => { + setAuthRole('PortalAdmin'); + getBedrockConfiguration.mockResolvedValue({ + bedrock_configuration: { ...STORED_CONFIG, temperature: null, top_p: null }, + defaults: {}, + max_timeout_seconds: 60, + }); + render(); + + await waitFor(() => { + expect(screen.getByLabelText('Timeout seconds')).toHaveValue(60); + }); + // Unset sampling parameters render as blank inputs, not "null". + expect(screen.getByLabelText('Temperature')).toHaveValue(null); + expect(screen.getByLabelText('Top P')).toHaveValue(null); + }); + + it('accepts blank temperature and top_p and saves them as explicit null (Bugfix Requirement 2.3)', async () => { + setAuthRole('PortalAdmin'); + render(); + await waitFor(() => { + expect(screen.getByLabelText('Temperature')).toHaveValue(0.2); + }); + + fireEvent.change(screen.getByLabelText('Temperature'), { target: { value: '' } }); + fireEvent.change(screen.getByLabelText('Top P'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + await waitFor(() => { + expect(updateBedrockConfiguration).toHaveBeenCalledWith({ + model_id: STORED_CONFIG.model_id, + region: STORED_CONFIG.region, + max_tokens: 4096, + temperature: null, + top_p: null, + timeout_seconds: 60, + }); + }); + expect(screen.queryByText('Temperature must be between 0 and 1')).toBeNull(); + expect(screen.queryByText('Top P must be between 0 and 1')).toBeNull(); + expect(await screen.findByText('Bedrock configuration saved')).toBeInTheDocument(); + }); + + it('keeps rejecting a non-blank out-of-range temperature and top_p without calling the API', async () => { + setAuthRole('PortalAdmin'); + render(); + await waitFor(() => { + expect(screen.getByLabelText('Temperature')).toHaveValue(0.2); + }); + + fireEvent.change(screen.getByLabelText('Temperature'), { target: { value: '1.5' } }); + fireEvent.change(screen.getByLabelText('Top P'), { target: { value: '-0.1' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Configuration' })); + + expect(await screen.findByText('Temperature must be between 0 and 1')).toBeInTheDocument(); + expect(await screen.findByText('Top P must be between 0 and 1')).toBeInTheDocument(); + expect(updateBedrockConfiguration).not.toHaveBeenCalled(); + }); +}); diff --git a/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.tsx b/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.tsx new file mode 100644 index 00000000..aa65be24 --- /dev/null +++ b/edge-cv-portal/frontend/src/components/BedrockConfigurationSettings.tsx @@ -0,0 +1,328 @@ +/** + * Bedrock_Configuration settings section (workflow-manager Requirement 10.6). + * + * Lets a PortalAdmin configure the Amazon Bedrock model used by the + * Workflow_Generator: model identifier, region, inference parameters + * (max tokens, temperature, top_p), and the invocation timeout + * (at most 60 seconds, Requirement 10.7). Users without the PortalAdmin + * role see an access notice instead of the form. + */ +import { useEffect, useState } from 'react'; +import { + Alert, + Box, + Button, + Form, + FormField, + Header, + Input, + Select, + SpaceBetween, + Spinner, +} from '@cloudscape-design/components'; +import type { SelectProps } from '@cloudscape-design/components'; +import { apiService } from '../services/api'; +import { useAuth } from '../contexts/AuthContext'; +import { getErrorMessage } from '../utils/errorHandling'; + +export const MAX_BEDROCK_TIMEOUT_SECONDS = 60; + +interface BedrockFormState { + model_id: string; + region: string; + max_tokens: string; + temperature: string; + top_p: string; + timeout_seconds: string; +} + +type FieldErrors = Partial>; + +function validate(form: BedrockFormState): FieldErrors { + const errors: FieldErrors = {}; + + if (!form.model_id.trim()) { + errors.model_id = 'Model identifier is required'; + } + if (!form.region.trim()) { + errors.region = 'Region is required'; + } + + const maxTokens = Number(form.max_tokens); + if (!Number.isInteger(maxTokens) || maxTokens < 1) { + errors.max_tokens = 'Max tokens must be a positive integer'; + } + + // Blank temperature / top_p means "unset": the sampling parameter is + // omitted from Bedrock invocations so the model uses its own default. + if (form.temperature.trim() !== '') { + const temperature = Number(form.temperature); + if (Number.isNaN(temperature) || temperature < 0 || temperature > 1) { + errors.temperature = 'Temperature must be between 0 and 1'; + } + } + + if (form.top_p.trim() !== '') { + const topP = Number(form.top_p); + if (Number.isNaN(topP) || topP < 0 || topP > 1) { + errors.top_p = 'Top P must be between 0 and 1'; + } + } + + const timeout = Number(form.timeout_seconds); + if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_BEDROCK_TIMEOUT_SECONDS) { + errors.timeout_seconds = `Timeout must be an integer between 1 and ${MAX_BEDROCK_TIMEOUT_SECONDS} seconds`; + } + + return errors; +} + +export default function BedrockConfigurationSettings() { + const { user } = useAuth(); + const isPortalAdmin = user?.role === 'PortalAdmin'; + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [fieldErrors, setFieldErrors] = useState({}); + const [form, setForm] = useState({ + model_id: '', + region: '', + max_tokens: '', + temperature: '', + top_p: '', + timeout_seconds: '', + }); + // Invokable model options for the model dropdown. null means the list + // is unavailable (still loading, fetch failed, or missing backend + // permissions) and the form falls back to free-text entry. + const [modelOptions, setModelOptions] = useState(null); + const [modelsLoading, setModelsLoading] = useState(false); + + useEffect(() => { + if (!isPortalAdmin) return; + let cancelled = false; + setModelsLoading(true); + apiService + .getBedrockModels() + .then((response) => { + if (cancelled) return; + if (response.models.length > 0) { + setModelOptions(response.models.map((m) => ({ value: m.id, label: m.label }))); + } + }) + .catch(() => { + // Fall back silently to the free-text model id input. + if (!cancelled) setModelOptions(null); + }) + .finally(() => { + if (!cancelled) setModelsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isPortalAdmin]); + + useEffect(() => { + if (!isPortalAdmin) return; + let cancelled = false; + setLoading(true); + apiService + .getBedrockConfiguration() + .then((response) => { + if (cancelled) return; + const config = response.bedrock_configuration; + setForm({ + model_id: config.model_id, + region: config.region, + max_tokens: String(config.max_tokens), + // A null/undefined sampling parameter is "unset" and renders as + // a blank field (never the literal string "null"). + temperature: config.temperature == null ? '' : String(config.temperature), + top_p: config.top_p == null ? '' : String(config.top_p), + timeout_seconds: String(config.timeout_seconds), + }); + }) + .catch((err: unknown) => { + if (!cancelled) setError(getErrorMessage(err, 'Failed to load Bedrock configuration')); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [isPortalAdmin]); + + if (!isPortalAdmin) { + return ( + + Bedrock configuration can only be viewed and changed by Portal Admins. + + ); + } + + const setField = (field: keyof BedrockFormState) => ({ detail }: { detail: { value: string } }) => { + setForm((current) => ({ ...current, [field]: detail.value })); + }; + + const handleSave = async () => { + setError(''); + setSuccess(''); + const errors = validate(form); + setFieldErrors(errors); + if (Object.keys(errors).length > 0) return; + + setSaving(true); + try { + await apiService.updateBedrockConfiguration({ + model_id: form.model_id.trim(), + region: form.region.trim(), + max_tokens: Number(form.max_tokens), + // A blank field is sent as an explicit null: the backend merges + // provided keys over the current configuration, so omitting the + // key would keep the old value instead of unsetting it. + temperature: form.temperature.trim() === '' ? null : Number(form.temperature), + top_p: form.top_p.trim() === '' ? null : Number(form.top_p), + timeout_seconds: Number(form.timeout_seconds), + }); + setSuccess('Bedrock configuration saved'); + } catch (err: unknown) { + setError(getErrorMessage(err, 'Failed to save Bedrock configuration')); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( + + Loading Bedrock configuration... + + ); + } + + // A stored model id that is not in the fetched list (custom id, or a + // model no longer listed) is surfaced as a selectable option so it is + // never silently dropped. + const displayedModelOptions: SelectProps.Option[] = + modelOptions && form.model_id && !modelOptions.some((option) => option.value === form.model_id) + ? [{ value: form.model_id, label: form.model_id }, ...modelOptions] + : (modelOptions ?? []); + const selectedModelOption = + displayedModelOptions.find((option) => option.value === form.model_id) ?? null; + + return ( + + {error && ( + setError('')}> + {error} + + )} + {success && ( + setSuccess('')}> + {success} + + )} + +
+ Bedrock Configuration + + } + actions={ + + } + > + + + {modelOptions ? ( + + )} + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/edge-cv-portal/frontend/src/components/DeviceCamerasTab.test.tsx b/edge-cv-portal/frontend/src/components/DeviceCamerasTab.test.tsx new file mode 100644 index 00000000..4f26fa93 --- /dev/null +++ b/edge-cv-portal/frontend/src/components/DeviceCamerasTab.test.tsx @@ -0,0 +1,490 @@ +/** + * Component tests for the device detail Cameras tab (camera-registry-sync + * task 8.2): field display of registry entries (Req 1.3), stale badge + * (Req 4.1), absent badge with its timestamp (Req 4.4), device + * disconnected indicator (Req 4.2), explicit never-synced state + * (Req 1.6), discovery-managed edit/delete blocking (Req 5.6), and the + * conflict re-apply flow (Req 6.4), plus unit tests for the exported + * pure helpers. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import createWrapper from '@cloudscape-design/components/test-utils/dom'; +import DeviceCamerasTab, { + formatEpochMs, + summarizeRecord, + summarizeCapabilities, + isDiscoveryManaged, + isDeviceDisconnected, + parseParamsInput, + summarizeConflictVersion, +} from './DeviceCamerasTab'; +import type { + CameraConflictEvent, + CameraSourceEntry, + DeviceCameraConflictsResponse, + DeviceCamerasResponse, +} from '../pages/workflows/cameraReference'; + +const { + getDeviceCameras, + getDeviceCameraConflicts, + createDeviceCamera, + updateDeviceCamera, + deleteDeviceCamera, + reapplyCameraConflict, + refreshDeviceCameras, +} = vi.hoisted(() => ({ + getDeviceCameras: vi.fn(), + getDeviceCameraConflicts: vi.fn(), + createDeviceCamera: vi.fn(), + updateDeviceCamera: vi.fn(), + deleteDeviceCamera: vi.fn(), + reapplyCameraConflict: vi.fn(), + refreshDeviceCameras: vi.fn(), +})); + +vi.mock('../services/api', () => ({ + apiService: { + getDeviceCameras, + getDeviceCameraConflicts, + createDeviceCamera, + updateDeviceCamera, + deleteDeviceCamera, + reapplyCameraConflict, + refreshDeviceCameras, + }, +})); + +// -------------------------------------------------------------------------- +// Fixtures +// -------------------------------------------------------------------------- + +const DEVICE_ID = 'device-1'; +const USECASE_ID = 'usecase-1'; + +const LAST_REPORTED_MS = 1700000000000; +const ABSENT_SINCE_MS = 1700000100000; +const CONFLICT_AT_MS = 1700000200000; + +const PORTAL_CAMERA: CameraSourceEntry = { + camera_source_id: 'cfg-1', + name: 'Line 1 inspection cam', + type: 'Camera', + params: { devicePath: '/dev/video0', gain: 4 }, + capabilities: { + formats: [{ pixelFormat: 'YUYV', resolutions: [[1920, 1080], [1280, 720]] }], + }, + origin: 'portal-created', + version: 3, + last_reported_at: LAST_REPORTED_MS, + sync_status: 'synced', + stale: false, + absent: false, +}; + +const DISCOVERED_CAMERA: CameraSourceEntry = { + camera_source_id: 'disc-abc123', + name: 'USB 2.0 Camera', + type: 'Camera', + params: { devicePath: '/dev/video2' }, + capabilities: { formats: [{ pixelFormat: 'MJPG', resolutions: [[640, 480]] }] }, + origin: 'edge-discovered', + version: 1, + last_reported_at: LAST_REPORTED_MS, + sync_status: 'synced', + stale: false, + absent: false, +}; + +const FAILED_CAMERA: CameraSourceEntry = { + camera_source_id: 'cfg-2', + name: 'RTSP feed', + type: 'RTSP', + params: { url: 'rtsp://example/stream' }, + origin: 'edge-configured', + version: 2, + last_reported_at: LAST_REPORTED_MS, + sync_status: 'failed', + failure_reason: 'schema validation rejected the configuration', + stale: false, + absent: false, +}; + +const CONFLICT: CameraConflictEvent = { + conflict_id: 'conflict-1', + camera_source_id: 'cfg-1', + edge_version: { op: 'update', name: 'Edge name', params: { devicePath: '/dev/video0' } }, + portal_version: { op: 'update', name: 'Portal name', params: { devicePath: '/dev/video9' } }, + resolution: 'edge-retained', + created_at: CONFLICT_AT_MS, +}; + +function camerasResponse(overrides: Partial = {}): DeviceCamerasResponse { + return { + device_id: DEVICE_ID, + usecase_id: USECASE_ID, + state: 'synced', + last_report_at: LAST_REPORTED_MS, + staleness_threshold_hours: 24, + device_status: 'HEALTHY', + cameras: [PORTAL_CAMERA], + ...overrides, + }; +} + +function conflictsResponse( + conflicts: CameraConflictEvent[] = [] +): DeviceCameraConflictsResponse { + return { device_id: DEVICE_ID, usecase_id: USECASE_ID, conflicts, count: conflicts.length }; +} + +function renderTab() { + return render(); +} + +async function waitForLoaded() { + await waitFor(() => { + expect(screen.getByTestId('device-cameras-table')).toBeInTheDocument(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + getDeviceCameras.mockResolvedValue(camerasResponse()); + getDeviceCameraConflicts.mockResolvedValue(conflictsResponse()); +}); + +// -------------------------------------------------------------------------- +// Pure helper unit tests +// -------------------------------------------------------------------------- + +describe('formatEpochMs', () => { + it('renders a human timestamp for valid epoch milliseconds', () => { + expect(formatEpochMs(LAST_REPORTED_MS)).toBe(new Date(LAST_REPORTED_MS).toLocaleString()); + }); + + it('renders "-" for null, undefined, zero, negative, and non-finite values', () => { + expect(formatEpochMs(null)).toBe('-'); + expect(formatEpochMs(undefined)).toBe('-'); + expect(formatEpochMs(0)).toBe('-'); + expect(formatEpochMs(-5)).toBe('-'); + expect(formatEpochMs(Number.NaN)).toBe('-'); + }); +}); + +describe('summarizeRecord', () => { + it('renders "-" for null, undefined, and empty records', () => { + expect(summarizeRecord(null)).toBe('-'); + expect(summarizeRecord(undefined)).toBe('-'); + expect(summarizeRecord({})).toBe('-'); + }); + + it('renders key: value pairs on one line', () => { + expect(summarizeRecord({ devicePath: '/dev/video0', gain: 4 })).toBe( + 'devicePath: /dev/video0, gain: 4' + ); + }); + + it('JSON-stringifies nested object values', () => { + expect(summarizeRecord({ nested: { a: 1 } })).toBe('nested: {"a":1}'); + }); +}); + +describe('summarizeCapabilities', () => { + it('renders "-" for missing or empty capabilities', () => { + expect(summarizeCapabilities(null)).toBe('-'); + expect(summarizeCapabilities({})).toBe('-'); + }); + + it('renders format names with their resolutions', () => { + expect( + summarizeCapabilities({ + formats: [{ pixelFormat: 'YUYV', resolutions: [[1920, 1080], [1280, 720]] }], + }) + ).toBe('YUYV (1920x1080, 1280x720)'); + }); + + it('elides beyond three resolutions and marks truncated capability sets', () => { + expect( + summarizeCapabilities({ + formats: [ + { pixelFormat: 'MJPG', resolutions: [[1, 1], [2, 2], [3, 3], [4, 4]] }, + ], + capabilitiesTruncated: true, + }) + ).toBe('MJPG (1x1, 2x2, 3x3, …) (truncated)'); + }); + + it('falls back to generic record rendering without a formats array', () => { + expect(summarizeCapabilities({ driver: 'uvcvideo' })).toBe('driver: uvcvideo'); + }); +}); + +describe('isDiscoveryManaged', () => { + it('is true only for origin edge-discovered', () => { + expect(isDiscoveryManaged(DISCOVERED_CAMERA)).toBe(true); + expect(isDiscoveryManaged(PORTAL_CAMERA)).toBe(false); + expect(isDiscoveryManaged(FAILED_CAMERA)).toBe(false); + }); +}); + +describe('isDeviceDisconnected', () => { + it('treats DISCONNECTED, OFFLINE, and UNHEALTHY as disconnected, case-insensitively', () => { + expect(isDeviceDisconnected('DISCONNECTED')).toBe(true); + expect(isDeviceDisconnected('offline')).toBe(true); + expect(isDeviceDisconnected('Unhealthy')).toBe(true); + }); + + it('treats healthy and missing statuses as connected', () => { + expect(isDeviceDisconnected('HEALTHY')).toBe(false); + expect(isDeviceDisconnected(undefined)).toBe(false); + expect(isDeviceDisconnected(null)).toBe(false); + expect(isDeviceDisconnected('')).toBe(false); + }); +}); + +describe('parseParamsInput', () => { + it('yields an empty record for empty text', () => { + expect(parseParamsInput('')).toEqual({ params: {} }); + expect(parseParamsInput(' ')).toEqual({ params: {} }); + }); + + it('parses a JSON object', () => { + expect(parseParamsInput('{"devicePath": "/dev/video0"}')).toEqual({ + params: { devicePath: '/dev/video0' }, + }); + }); + + it('rejects non-object JSON and invalid JSON with an error', () => { + expect(parseParamsInput('[1, 2]').error).toBe('Parameters must be a JSON object'); + expect(parseParamsInput('"text"').error).toBe('Parameters must be a JSON object'); + expect(parseParamsInput('null').error).toBe('Parameters must be a JSON object'); + expect(parseParamsInput('{not json').error).toBe('Parameters must be valid JSON'); + }); +}); + +describe('summarizeConflictVersion', () => { + it('renders "-" for missing or empty versions', () => { + expect(summarizeConflictVersion(null)).toBe('-'); + expect(summarizeConflictVersion({})).toBe('-'); + }); + + it('summarizes op, name, type, and params', () => { + expect( + summarizeConflictVersion({ + op: 'update', + name: 'Cam A', + type: 'Camera', + params: { devicePath: '/dev/video1' }, + }) + ).toBe('op: update, name: Cam A, type: Camera, devicePath: /dev/video1'); + }); + + it('falls back to generic record rendering without known fields', () => { + expect(summarizeConflictVersion({ other: 'value' })).toBe('other: value'); + }); +}); + +// -------------------------------------------------------------------------- +// Field display (Req 1.3) +// -------------------------------------------------------------------------- + +describe('DeviceCamerasTab field display', () => { + it('displays name, type, params, capabilities, origin, sync status, and last-reported', async () => { + getDeviceCameras.mockResolvedValue( + camerasResponse({ cameras: [PORTAL_CAMERA, DISCOVERED_CAMERA, FAILED_CAMERA] }) + ); + renderTab(); + await waitForLoaded(); + + // Names + expect(screen.getByText('Line 1 inspection cam')).toBeInTheDocument(); + expect(screen.getByText('USB 2.0 Camera')).toBeInTheDocument(); + expect(screen.getByText('RTSP feed')).toBeInTheDocument(); + + // Params and capabilities summaries + expect(screen.getByText('devicePath: /dev/video0, gain: 4')).toBeInTheDocument(); + expect(screen.getByText('url: rtsp://example/stream')).toBeInTheDocument(); + expect(screen.getByText('YUYV (1920x1080, 1280x720)')).toBeInTheDocument(); + + // Origin badges + expect(screen.getByText('Portal-created')).toBeInTheDocument(); + expect(screen.getByText('Discovery-managed')).toBeInTheDocument(); + expect(screen.getByText('edge-configured')).toBeInTheDocument(); + + // Sync status including the failure reason (Req 5.4 display) + expect(screen.getAllByText('Synced').length).toBeGreaterThan(0); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect( + screen.getByText('schema validation rejected the configuration') + ).toBeInTheDocument(); + + // Last-reported timestamp rendered per row + expect( + screen.getAllByText(new Date(LAST_REPORTED_MS).toLocaleString()).length + ).toBeGreaterThan(0); + }); +}); + +// -------------------------------------------------------------------------- +// Stale / absent / disconnected / never-synced rendering (Reqs 4.1, 4.4, 4.2, 1.6) +// -------------------------------------------------------------------------- + +describe('DeviceCamerasTab state rendering', () => { + it('shows a stale badge for stale camera sources (Req 4.1)', async () => { + getDeviceCameras.mockResolvedValue( + camerasResponse({ cameras: [{ ...PORTAL_CAMERA, stale: true }] }) + ); + renderTab(); + await waitForLoaded(); + expect(screen.getByText('Stale')).toBeInTheDocument(); + }); + + it('does not show a stale badge for fresh camera sources', async () => { + renderTab(); + await waitForLoaded(); + expect(screen.queryByText('Stale')).not.toBeInTheDocument(); + }); + + it('shows an absent badge with the absence timestamp (Req 4.4)', async () => { + getDeviceCameras.mockResolvedValue( + camerasResponse({ + cameras: [{ ...DISCOVERED_CAMERA, absent: true, absent_since: ABSENT_SINCE_MS }], + }) + ); + renderTab(); + await waitForLoaded(); + expect( + screen.getByText(`Absent since ${new Date(ABSENT_SINCE_MS).toLocaleString()}`) + ).toBeInTheDocument(); + }); + + it('indicates disconnected device status alongside the inventory (Req 4.2)', async () => { + getDeviceCameras.mockResolvedValue(camerasResponse({ device_status: 'DISCONNECTED' })); + renderTab(); + await waitForLoaded(); + expect(screen.getByTestId('device-disconnected-indicator')).toBeInTheDocument(); + }); + + it('shows no disconnected indicator for a healthy device', async () => { + renderTab(); + await waitForLoaded(); + expect(screen.queryByTestId('device-disconnected-indicator')).not.toBeInTheDocument(); + }); + + it('renders the explicit never-synced state instead of a bare empty list (Req 1.6)', async () => { + getDeviceCameras.mockResolvedValue( + camerasResponse({ state: 'never-synced', never_synced: true, cameras: [], last_report_at: null }) + ); + renderTab(); + await waitForLoaded(); + expect(screen.getByTestId('never-synced-state')).toBeInTheDocument(); + expect( + screen.getByText( + 'Never synced — no camera inventory has been reported by this device yet' + ) + ).toBeInTheDocument(); + }); + + it('does not render the never-synced state for a synced device', async () => { + renderTab(); + await waitForLoaded(); + expect(screen.queryByTestId('never-synced-state')).not.toBeInTheDocument(); + }); +}); + +// -------------------------------------------------------------------------- +// Discovery-managed edit blocking (Req 5.6) +// -------------------------------------------------------------------------- + +describe('DeviceCamerasTab discovery-managed edit blocking', () => { + it('disables Edit and Delete for discovery-managed rows and enables them for portal-managed rows', async () => { + getDeviceCameras.mockResolvedValue( + camerasResponse({ cameras: [PORTAL_CAMERA, DISCOVERED_CAMERA] }) + ); + const { container } = renderTab(); + await waitForLoaded(); + + const editButton = screen.getByTestId('edit-camera-button'); + const deleteButton = screen.getByTestId('delete-camera-button'); + + // No selection: both disabled + expect(editButton).toBeDisabled(); + expect(deleteButton).toBeDisabled(); + + const table = createWrapper(container).findTable('[data-testid="device-cameras-table"]')!; + + // Select the discovery-managed row (row 2): still disabled (Req 5.6) + table.findRowSelectionArea(2)!.click(); + await waitFor(() => { + expect(editButton).toBeDisabled(); + }); + expect(deleteButton).toBeDisabled(); + + // Select the portal-managed row (row 1): enabled + table.findRowSelectionArea(1)!.click(); + await waitFor(() => { + expect(editButton).not.toBeDisabled(); + }); + expect(deleteButton).not.toBeDisabled(); + }); +}); + +// -------------------------------------------------------------------------- +// Conflict list and re-apply flow (Reqs 6.3, 6.4) +// -------------------------------------------------------------------------- + +describe('DeviceCamerasTab conflicts', () => { + it('renders conflict events with both versions and the resolution (Req 6.3)', async () => { + getDeviceCameraConflicts.mockResolvedValue(conflictsResponse([CONFLICT])); + renderTab(); + await waitForLoaded(); + + expect(screen.getByTestId('camera-conflicts-table')).toBeInTheDocument(); + expect(screen.getByText('Edge retained')).toBeInTheDocument(); + expect( + screen.getByText('op: update, name: Edge name, devicePath: /dev/video0') + ).toBeInTheDocument(); + expect( + screen.getByText('op: update, name: Portal name, devicePath: /dev/video9') + ).toBeInTheDocument(); + expect(screen.getByText(new Date(CONFLICT_AT_MS).toLocaleString())).toBeInTheDocument(); + }); + + it('re-applies the overridden portal version and reloads (Req 6.4)', async () => { + getDeviceCameraConflicts.mockResolvedValue(conflictsResponse([CONFLICT])); + reapplyCameraConflict.mockResolvedValue({ + device_id: DEVICE_ID, + camera_source_id: 'cfg-1', + sync_status: 'pending', + portal_change_id: 'pc-1', + conflict_id: 'conflict-1', + }); + renderTab(); + await waitForLoaded(); + + fireEvent.click(screen.getByText('Re-apply portal version')); + + await waitFor(() => { + expect(reapplyCameraConflict).toHaveBeenCalledWith(DEVICE_ID, 'conflict-1', USECASE_ID); + }); + // The view reloads cameras and conflicts after the re-apply + await waitFor(() => { + expect(getDeviceCameras).toHaveBeenCalledTimes(2); + }); + expect(getDeviceCameraConflicts).toHaveBeenCalledTimes(2); + }); + + it('marks already re-applied conflicts instead of offering the action', async () => { + getDeviceCameraConflicts.mockResolvedValue( + conflictsResponse([{ ...CONFLICT, reapplied_as: 'pc-9' }]) + ); + renderTab(); + await waitForLoaded(); + + expect(screen.getByText('Re-applied')).toBeInTheDocument(); + expect(screen.queryByText('Re-apply portal version')).not.toBeInTheDocument(); + }); +}); diff --git a/edge-cv-portal/frontend/src/components/DeviceCamerasTab.tsx b/edge-cv-portal/frontend/src/components/DeviceCamerasTab.tsx new file mode 100644 index 00000000..3801dfe3 --- /dev/null +++ b/edge-cv-portal/frontend/src/components/DeviceCamerasTab.tsx @@ -0,0 +1,675 @@ +/** + * Device detail Cameras tab (camera-registry-sync task 8.1). + * + * Lists the device's Camera_Registry entries with name, type, parameters, + * capability metadata, origin, sync status (with failure reason), and + * last-reported timestamp (Req 1.3); stale and absent badges with their + * timestamps (Reqs 4.1, 4.4); a device-disconnected indicator (Req 4.2); + * an explicit "never synced" state (Req 1.6); the conflict event list with + * a re-apply action (Reqs 6.3, 6.4); create/edit/delete forms for + * portal-managed sources — discovery-managed sources are read-only + * (Req 5.6) — and a refresh-now button hitting the refresh route. + * + * The small formatting helpers are exported pure functions so the + * component tests (task 8.2) can target them directly. + */ +import { useCallback, useEffect, useState } from 'react'; +import { + Alert, + Badge, + Box, + Button, + Container, + FormField, + Header, + Input, + Modal, + Select, + SpaceBetween, + Spinner, + StatusIndicator, + Table, + Textarea, +} from '@cloudscape-design/components'; +import { apiService } from '../services/api'; +import type { JsonValue } from '../pages/workflows/types'; +import { + cameraDisplayName, + CameraConflictEvent, + CameraSourceEntry, + DeviceCameraConflictsResponse, + DeviceCamerasResponse, +} from '../pages/workflows/cameraReference'; + +// --------------------------------------------------------------------------- +// Pure helpers (exported for the task 8.2 component tests) +// --------------------------------------------------------------------------- + +/** Human timestamp for epoch-milliseconds values; '-' when absent. */ +export function formatEpochMs(value?: number | null): string { + if (value === null || value === undefined) return '-'; + const ms = Number(value); + if (!Number.isFinite(ms) || ms <= 0) return '-'; + return new Date(ms).toLocaleString(); +} + +/** Compact one-line rendering of a params/capabilities style record. */ +export function summarizeRecord(record?: Record | null): string { + if (!record || Object.keys(record).length === 0) return '-'; + return Object.entries(record) + .map(([key, value]) => + `${key}: ${typeof value === 'object' && value !== null ? JSON.stringify(value) : String(value)}`) + .join(', '); +} + +/** + * Compact rendering of capability metadata: format names with their top + * resolutions when the record follows the `{formats: [...]}` shape, + * generic record rendering otherwise. + */ +export function summarizeCapabilities( + capabilities?: Record | null +): string { + if (!capabilities || Object.keys(capabilities).length === 0) return '-'; + const formats = capabilities.formats; + if (Array.isArray(formats) && formats.length > 0) { + const parts = formats.map((format) => { + if (typeof format !== 'object' || format === null || Array.isArray(format)) { + return String(format); + } + const record = format as Record; + const name = record.pixelFormat ?? record.pixel_format ?? '?'; + const resolutions = record.resolutions; + if (Array.isArray(resolutions) && resolutions.length > 0) { + const rendered = resolutions + .slice(0, 3) + .map((r) => (Array.isArray(r) ? r.join('x') : String(r))) + .join(', '); + const suffix = resolutions.length > 3 ? ', …' : ''; + return `${String(name)} (${rendered}${suffix})`; + } + return String(name); + }); + const truncated = capabilities.capabilitiesTruncated === true ? ' (truncated)' : ''; + return parts.join('; ') + truncated; + } + return summarizeRecord(capabilities); +} + +/** Discovery-managed sources are read-only in the Portal (Req 5.6). */ +export function isDiscoveryManaged(camera: CameraSourceEntry): boolean { + return camera.origin === 'edge-discovered'; +} + +/** + * Whether the reported device status counts as disconnected for the + * inventory's disconnected indicator (Req 4.2). The status comes from + * the existing device-status lookup (Greengrass core-device health). + */ +export function isDeviceDisconnected(status?: string | null): boolean { + if (!status) return false; + const normalized = status.toUpperCase(); + return ['DISCONNECTED', 'OFFLINE', 'UNHEALTHY'].includes(normalized); +} + +/** + * Parse the params form input: empty text yields an empty record; any + * non-object or invalid JSON yields an error instead of a record. + */ +export function parseParamsInput( + text: string +): { params?: Record; error?: string } { + const trimmed = text.trim(); + if (trimmed === '') return { params: {} }; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { error: 'Parameters must be a JSON object' }; + } + return { params: parsed as Record }; + } catch { + return { error: 'Parameters must be valid JSON' }; + } +} + +/** One-line summary of a conflict event's recorded version (Req 6.3). */ +export function summarizeConflictVersion( + version?: Record | null +): string { + if (!version || Object.keys(version).length === 0) return '-'; + const parts: string[] = []; + if (version.op !== undefined) parts.push(`op: ${String(version.op)}`); + if (version.name !== undefined) parts.push(`name: ${String(version.name)}`); + if (version.type !== undefined) parts.push(`type: ${String(version.type)}`); + const params = version.params; + if (params && typeof params === 'object' && !Array.isArray(params)) { + parts.push(summarizeRecord(params as Record)); + } + return parts.length > 0 ? parts.join(', ') : summarizeRecord(version); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +const CAMERA_TYPE_OPTIONS = [ + { label: 'Camera (V4L2)', value: 'Camera' }, + { label: 'NVIDIA CSI', value: 'NvidiaCSI' }, + { label: 'RTSP', value: 'RTSP' }, + { label: 'Folder', value: 'Folder' }, + { label: 'ICam', value: 'ICam' }, +]; + +interface CameraFormState { + mode: 'create' | 'edit'; + cameraSourceId?: string; + name: string; + type: string; + paramsText: string; +} + +interface DeviceCamerasTabProps { + deviceId: string; + usecaseId: string; +} + +export default function DeviceCamerasTab({ deviceId, usecaseId }: DeviceCamerasTabProps) { + const [camerasResponse, setCamerasResponse] = useState(null); + const [conflictsResponse, setConflictsResponse] = + useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [actionError, setActionError] = useState(null); + + const [selectedCameras, setSelectedCameras] = useState([]); + const [form, setForm] = useState(null); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const [reapplyingConflictId, setReapplyingConflictId] = useState(null); + + const loadAll = useCallback(async (showLoading = true) => { + if (!deviceId || !usecaseId) return; + try { + if (showLoading) setLoading(true); + setLoadError(null); + const [cameras, conflicts] = await Promise.all([ + apiService.getDeviceCameras(deviceId, usecaseId), + apiService.getDeviceCameraConflicts(deviceId, usecaseId), + ]); + setCamerasResponse(cameras); + setConflictsResponse(conflicts); + setSelectedCameras([]); + } catch (err: any) { + setLoadError(err.message || 'Failed to load camera registry'); + } finally { + if (showLoading) setLoading(false); + } + }, [deviceId, usecaseId]); + + useEffect(() => { + loadAll(); + }, [loadAll]); + + const handleRefreshNow = async () => { + try { + setRefreshing(true); + setActionError(null); + // The refresh route pulls the device shadow through the same + // reducer as the ingest path and returns the refreshed inventory. + const cameras = await apiService.refreshDeviceCameras(deviceId, usecaseId); + setCamerasResponse(cameras); + setSelectedCameras([]); + const conflicts = await apiService.getDeviceCameraConflicts(deviceId, usecaseId); + setConflictsResponse(conflicts); + } catch (err: any) { + setActionError(err.message || 'Failed to refresh from the device'); + } finally { + setRefreshing(false); + } + }; + + const openCreateForm = () => { + setFormError(null); + setForm({ mode: 'create', name: '', type: 'Camera', paramsText: '{\n "devicePath": "/dev/video0"\n}' }); + }; + + const openEditForm = (camera: CameraSourceEntry) => { + setFormError(null); + setForm({ + mode: 'edit', + cameraSourceId: camera.camera_source_id, + name: camera.name ?? '', + type: camera.type ?? 'Camera', + paramsText: JSON.stringify(camera.params ?? {}, null, 2), + }); + }; + + const submitForm = async () => { + if (!form) return; + if (!form.name.trim()) { + setFormError('Name is required'); + return; + } + const parsed = parseParamsInput(form.paramsText); + if (parsed.error) { + setFormError(parsed.error); + return; + } + try { + setSaving(true); + setFormError(null); + const body = { name: form.name.trim(), type: form.type, params: parsed.params }; + if (form.mode === 'create') { + await apiService.createDeviceCamera(deviceId, usecaseId, body); + } else { + await apiService.updateDeviceCamera(deviceId, form.cameraSourceId!, usecaseId, body); + } + setForm(null); + await loadAll(false); + } catch (err: any) { + setFormError(err.message || 'Failed to save the camera source'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + try { + setDeleting(true); + setActionError(null); + await apiService.deleteDeviceCamera(deviceId, deleteTarget.camera_source_id, usecaseId); + setDeleteTarget(null); + await loadAll(false); + } catch (err: any) { + setActionError(err.message || 'Failed to delete the camera source'); + setDeleteTarget(null); + } finally { + setDeleting(false); + } + }; + + const handleReapply = async (conflict: CameraConflictEvent) => { + try { + setReapplyingConflictId(conflict.conflict_id); + setActionError(null); + await apiService.reapplyCameraConflict(deviceId, conflict.conflict_id, usecaseId); + await loadAll(false); + } catch (err: any) { + setActionError(err.message || 'Failed to re-apply the portal version'); + } finally { + setReapplyingConflictId(null); + } + }; + + const getSyncStatusIndicator = (camera: CameraSourceEntry) => { + switch (camera.sync_status) { + case 'synced': + return Synced; + case 'pending': + return Pending; + case 'failed': + return ( + + Failed + {camera.failure_reason && ( + + {camera.failure_reason} + + )} + + ); + default: + return {camera.sync_status || 'Unknown'}; + } + }; + + if (loading) { + return ( + + + + + Loading camera registry... + + + + ); + } + + if (loadError) { + return ( + + {loadError} + + + ); + } + + const neverSynced = camerasResponse?.state === 'never-synced'; + const disconnected = isDeviceDisconnected(camerasResponse?.device_status); + const cameras = camerasResponse?.cameras ?? []; + const conflicts = conflictsResponse?.conflicts ?? []; + const selected = selectedCameras[0]; + const selectedIsDiscoveryManaged = selected !== undefined && isDiscoveryManaged(selected); + + return ( + + {actionError && ( + setActionError(null)}> + {actionError} + + )} + + {/* Device disconnected indicator alongside the inventory (Req 4.2) */} + {disconnected && ( + + This device is currently reported as disconnected. The camera + inventory below reflects the last synchronized state. + + )} + + {/* Explicit never-synced state, never a bare empty list (Req 1.6) */} + {neverSynced && ( + + This device has never completed a camera registry + synchronization. Its camera inventory is not yet known to the + Portal; sources created here are queued as pending changes. + + )} + + + setSelectedCameras(detail.selectedItems as CameraSourceEntry[]) + } + trackBy="camera_source_id" + header={ +
+ + {`Last report: ${formatEpochMs(camerasResponse?.last_report_at)}`} + + + {`Staleness threshold: ${camerasResponse?.staleness_threshold_hours ?? 24}h`} + + {disconnected && ( + Device disconnected + )} + {neverSynced && ( + Never synced + )} + + } + actions={ + + + + + + + } + > + Cameras +
+ } + columnDefinitions={[ + { + id: 'name', + header: 'Name', + cell: (item: CameraSourceEntry) => ( + + {cameraDisplayName(item)} + {item.absent && ( + + {`Absent${item.absent_since ? ` since ${formatEpochMs(item.absent_since)}` : ''}`} + + )} + + ), + sortingField: 'name', + }, + { + id: 'type', + header: 'Type', + cell: (item: CameraSourceEntry) => item.type || '-', + }, + { + id: 'params', + header: 'Parameters', + cell: (item: CameraSourceEntry) => summarizeRecord(item.params), + }, + { + id: 'capabilities', + header: 'Capabilities', + cell: (item: CameraSourceEntry) => summarizeCapabilities(item.capabilities), + }, + { + id: 'origin', + header: 'Origin', + cell: (item: CameraSourceEntry) => + isDiscoveryManaged(item) ? ( + Discovery-managed + ) : item.origin === 'portal-created' ? ( + Portal-created + ) : ( + {item.origin || 'Unknown'} + ), + }, + { + id: 'syncStatus', + header: 'Sync status', + cell: (item: CameraSourceEntry) => getSyncStatusIndicator(item), + }, + { + id: 'lastReported', + header: 'Last reported', + cell: (item: CameraSourceEntry) => ( + + {formatEpochMs(item.last_reported_at)} + {item.stale && Stale} + + ), + }, + ]} + items={cameras} + empty={ + + {neverSynced + ? 'Never synced — no camera inventory has been reported by this device yet' + : 'No camera sources registered for this device'} + + } + /> + + {/* Conflict events (Reqs 6.3, 6.4) */} +
+ Sync conflicts + + } + columnDefinitions={[ + { + id: 'camera', + header: 'Camera source', + cell: (item: CameraConflictEvent) => item.camera_source_id || '-', + }, + { + id: 'resolution', + header: 'Resolution', + cell: (item: CameraConflictEvent) => + item.resolution === 'edge-retained' ? ( + Edge retained + ) : item.resolution === 'deletion-retained' ? ( + Deletion retained + ) : ( + item.resolution || '-' + ), + }, + { + id: 'edgeVersion', + header: 'Edge version (kept)', + cell: (item: CameraConflictEvent) => summarizeConflictVersion(item.edge_version), + }, + { + id: 'portalVersion', + header: 'Portal version (overridden)', + cell: (item: CameraConflictEvent) => summarizeConflictVersion(item.portal_version), + }, + { + id: 'createdAt', + header: 'Occurred', + cell: (item: CameraConflictEvent) => formatEpochMs(item.created_at), + }, + { + id: 'actions', + header: 'Actions', + cell: (item: CameraConflictEvent) => + item.reapplied_as ? ( + Re-applied + ) : ( + + ), + }, + ]} + items={conflicts} + empty={ + + No sync conflicts recorded for this device + + } + /> + + {/* Create/edit form for portal-managed sources (Req 5.1 UI) */} + setForm(null)} + header={form?.mode === 'create' ? 'Create camera source' : `Edit ${form?.cameraSourceId ?? ''}`} + footer={ + + + + + + + } + > + {form && ( + + {formError && {formError}} + + The change is delivered to the device over the sync channel + and stays pending until the device applies it. + + + setForm({ ...form, name: detail.value })} + placeholder="e.g. Line 1 inspection cam" + data-testid="camera-form-name" + /> + + +